Published on

Next.js(v14) - 클라이언트/서버에서 쿠키 사용하기 1

2편

👉 Next.js(v14) - 클라이언트/서버에서 쿠키 사용하기 完


현재 구현 중인 투표 상세 페이지는 당연하게 투표 상세 정보 API 요청을 필요로 하게 된다. 요청을 보낼 때, 서버에서 이 페이지에 접속하는 유저의 정보를 식별 및 검증이 필요한데, 이번 프로젝트에서는 JWT를 사용해 인가 과정을 처리하기로 했다.

API 요청 시 토큰 값을 보내면 되는 간단한 작업을 왜 트러블 슈팅까지 하는지 이해하지 못하는 분들도 많으실 것이라 생각한다. 사실 글을 작성하는 나 또한 왜 이 부분에 이렇게 많은 시간을 투자하고 해결하지 못하는지 아직도 이해하지 못했다.

결론부터 말하자면 아직 완벽하게 이 문제를 해결하지 못했다. 제목에 1이라는 시리즈 넘버를 붙인 이유이기도 하다. 계속 해결 방안을 강구할 계획이고, 이번 포스팅에서는 현재까지의 이슈에 대해 알아낸 성과와 고민을 정리하고자 한다.



⚙️ 프로젝트 환경

  • Next14 - app Router
  • tanstack/react-query v5
  • tailwind CSS



🔍 문제 상황

카카오 소셜 로그인 시 서버에서 JWT(accessToken) 값을 응답받고 이 값을 저장하는 방법은 다양하게 존재한다.

  1. localStorage
  2. cookie

하지만 Next.jsSSR 특성상 브라우저에서 제공하는 localStorage를 사용하는 것은 불가능하기 때문에 쿠키를 사용해 JWT 값을 저장하고, 서버에 요청을 보낼 때 값을 꺼내 사용하는 방식으로 방향을 잡게 되었다.

기존 React에서는 쿠키를 사용하기 위해 따로 패키지를 설치해야 했지만, Next에 내장되어 있는 next/headers를 사용하면 간단하게 쿠키 사용이 가능해진다.



🍪 next/headers - cookies

next/headers에서 제공하는 이 cookies는 몇가지 특성이 존재한다.

  1. 서버 구성요소에서만 사용 가능
  2. cookies()는 반환값을 미리 알 수 없는 동적 함수

현재 투표 상세 API 같은 경우 fetch 함수와 react-query에서 제공하는 훅(useSuspenseQuery)을 하나의 훅으로 묶어서 사용하고 있기 때문에 서버 구성요소 환경을 충족하지 못하게 된다. 따라서 투표 상세 API 훅은 next/headers에서 제공하는 쿠키 함수를 사용할 수 없다.

그렇기 때문에 직접적으로 적용하는 방법이 아닌 따로 _util 폴더에 'use server' 키워드를 추가한 cookie.ts 파일을 만들어 쿠키를 저장하고 호출하는 get, set 함수를 만들어 사용하는 방법으로 시도하게 되었다.


cookie.ts

'use server'

import { cookies } from 'next/headers'

export async function setCookie(key: string, value: string) {
  cookies().set(key, value)
}

export async function getCookie(key: string) {
  return cookies().get(key)?.value
}

useVoteDetail.ts ( 투표 상세 데이터 요청 훅 )

import { VoteDetailType } from '@/app/_types/detailVote.type'
import { useSuspenseQuery } from '@tanstack/react-query'
import { getCookie } from '@/app/_utils/cookie'
import { voteKeys } from '.'

async function fetchVoteDetail(voteId: number): Promise<VoteDetailType> {
  const accessToken = await getCookie('accessToken') ⭐️

  const res = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/votes/${voteId}`, {
    method: 'GET',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${accessToken}`,
    },
    cache: 'no-store',
  })

  const data = await res.json()

  if (!res.ok) {
    throw data.message
  }

  return data
}

export const useVoteDetail = (voteId: number) => {
  const {
    data: voteData,
    isError,
    isSuccess,
  } = useSuspenseQuery<VoteDetailType, Error, VoteDetailType>({
    queryKey: voteKeys.detail(voteId).queryKey,
    queryFn: () => fetchVoteDetail(voteId),
    staleTime: 1000 * 60,
  })

  return { voteData, isError, isSuccess }
}

📌 반환값을 미리 알 수 없는 동적 함수이기 때문에 await 키워드가 반드시 필요하다.


결과

  • 📌 만약 Streaming을 사용하지 않는다면, 여기서 더 이상 에러가 발생하지 않는다. 하지만 Streaming을 사용하고 있다면 위와 같은 에러와 경고가 발생하게 된다.

문제의 원인은 쿠키를 호출하는 부분이다. 에러 메시지를 분석해 보면,

server functions cannot be called during initial render

"초기 렌더링 중에는 서버 기능을 호출할 수 없다."는 것이다.


여기서 서버 기능은 cookies().get(key)?.value를 의미하는 것 같다. 결과적으로 봤을 때, 정상적으로 동작하지만 결국 에러와 경고가 발생하고 있기 때문에 다른 방법이 필요했다.



대안2 - cookies-next

cookies-next 라이브러리는 서버 구성 요소가 아닌 클라이언트 구성 요소에서 사용이 가능하다. 따라서 기존에 서버 기능을 호출해서 발생하는 에러 방지를 기대할 수 있게 되었다.

추가적으로 기존 next/headers에서 쿠키에 accessToken 값을 저장(set) 하고 cookies-next 라이브러리로 쿠키를 호출(get)하는 호환도 가능하다.

cookies-next 적용 code

import { VoteDetailType } from '@/app/_types/detailVote.type'
import { useSuspenseQuery } from '@tanstack/react-query'
import { getCookie } from 'cookies-next'
import { voteKeys } from '.'

async function fetchVoteDetail(voteId: number): Promise<VoteDetailType> {
  const accessToken = getCookie('accessToken') ⭐️

  const res = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/votes/${voteId}`, {
    method: 'GET',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${accessToken}`,
    },
    cache: 'no-store',
  })

  const data = await res.json()

  if (!res.ok) {
    throw data.message
  }

  return data
}

export const useVoteDetail = (voteId: number) => {
  const {
    data: voteData,
    isError,
    isSuccess,
  } = useSuspenseQuery<VoteDetailType, Error, VoteDetailType>({
    queryKey: voteKeys.detail(voteId).queryKey,
    queryFn: () => fetchVoteDetail(voteId),
    staleTime: 1000 * 60,
  })

  return { voteData, isError, isSuccess }
}

최근에 이 방법으로 그동안에 고민을 해결한 줄 알고 PR을 올려 수정 사항을 반영했고 그동안 묵은 때를 벗긴 것 같은 기분에 정말 기뻤지만, 우연히 페이지 새로고침을 한 순간 다시 문제점이 있다는 것을 발견하게 되었다.

새로고침을 진행하니 갑자기 토큰이 없다는 에러가 발생했다. 토큰이 없다는 에러는 현재 상황을 해결하는 결정적인 단서가 아니기 때문에 관련 레퍼런스를 찾아봤지만 결국 이렇다할 해결 방법을 찾아내지 못했다.


그나마 stack overflow에서 내 상황과 가장 유사한 질문 글을 발견했지만, 명확한 해결 방법을 찾을 수는 없었다. 정확한 내용이라고 신뢰할 수는 없지만 가장 보고 싶은 단서를 하나 찾아냈다.

클라이언트 쿠키는 클라이언트 구성 요소에서 사용할 수 있고 서버 쿠키는 서버 구성 요소에서 사용할 수 있습니다. 이러한 서로 다른 쿠키는 서로 접근할 수 없습니다. 이것이 NextJS의 또 다른 결함인 것 같습니다.


사실 이 내용에 크게 공감이 됐던 이유가 있었는데, console.log(accessToken)으로 토큰 값을 조회했을 때 서로 다른 두 개로그를 발견했기 때문이다.

  • 이 로그는 브라우저 개발자 도구 콘솔이 아닌 VS CODE 터미널에 출력된 내용이다. (SSR 실행 중)
  • 이후에 클라이언트에서 실행될 때는 정상적으로 accessToken 값이 출력된다.

🤔 cookies-next 라이브러리 쿠키는 클라이언트에서 실행되기 때문에 초기 SSR 시 undefined가 출력되는 것 같다는 나름 합리적 추론을 할 수 있게 되었다.



마치며

서버에서 렌더링 될 때, 서버에서 동작하는 쿠키 관련 기능을 사용할 때는 서버 기능 호출이 불가능하다는 에러가 발생하고 다른 방법을 사용하면 아예 서버에서 렌더링 될 때 동작을 안 한다. 이번에 이슈를 해결해 나가며 쿠키를 많이 다루게 되었는데, 이렇게 막힐 때면 React 환경이었다면 이런 고민조차 하지 않고 해결했을 것 같다는 그리운 마음이 들고는 한다.


이번 기회를 계기로 말로만 듣고 글로만 보던 브라우저의 역할을 몸소 체감할 수 있었고 SSR의 동작과 원리를 이해하는 데 많은 도움이 되었다. 글을 쓰는 지금까지도 도무지 해결 방법을 찾아내지 못했는데, 만약 이 부분이 현재 해결할 수 없는 것이라면 안도감과 동시에 현재 구현한 코드를 수정해야 되는 상황이 되기 때문에 불안한 마음도 들 것 같다는 생각이 든다.

이 문제를 완벽히 해결하고 글을 작성하고 싶었으나 단기간 안에 끝날 사안은 아니라는 생각이 들었기 때문에 시리즈 형태로 글을 작성하기로 결심했다. 노션에 레퍼런스와 메모를 남겨두었지만, 지금의 생각과 정리한 내용을 종합하는 게 추후 문제 해결에 있어서 더 도움이 될 것 같다. 다음 포스팅 때는 꼭! 이 문제의 해결 방법을 제시하는 글을 쓸 수 있으면 좋겠다. 🧐




Reference