배경
Next.js 프로젝트에서 백엔드로 API 요청을 보내기 전, Route Handler를 통해 클라이언트의 토큰 정보를 헤더에 포함하고 백엔드로 요청을 전송하는 BFF를 구축했다. 클라이언트의 쿠키를 다루기 위해 Next.js에서 제공하는 cookies 함수를 활용한 유틸 서버 액션을 구현했으며, Route Handler에서 해당 서버 액션을 활용하여 헤더를 설정하도록 했다. 하지만 이 과정에서 헤더에 토큰 정보가 undefined로 설정되는 현상이 발생하여, 원인을 분석하고 해결한 과정을 공유하고자 한다.
기술 스택
해당 프로젝트는 Next.js 16.0.3 버전을 기준으로 작성되었다.
문제 발생
문제 상황을 언급하기 전에 API 라우트와 cookies 관련 유틸 서버 액션은 다음과 같이 작성되었다.
cookies 유틸 서버 액션
나는 Next.js 프로젝트에서 cookies를 간편하게 다루고자 다음과 같은 유틸 서버 액션을 만들어서 사용하곤 한다.
export async function setCookie(
name: string,
value: string,
options: CookieOptions = {}
): Promise<void> {
const defaultOptions: CookieOptions = {
path: '/',
maxAge: 24 * 60 * 60,
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
};
const cookieOptions = { ...defaultOptions, ...options };
const cookieStore = await cookies();
cookieStore.set(name, value, cookieOptions);
}
export async function getCookie(name: string): Promise<string | undefined> {
const cookieStore = await cookies();
const value = cookieStore.get(name)?.value;
return value;
}
export async function deleteCookie(
name: string,
options: Pick<CookieOptions, 'path' | 'domain'> = {}
): Promise<void> {
try {
const cookieStore = await cookies();
cookieStore.delete({ name, ...options });
} catch (error) {
console.error(`Error deleting cookie ${name}:`, error);
}
}
export async function getAllCookies(): Promise<Record<string, string>> {
const cookieStore = await cookies();
return Object.fromEntries(cookieStore.getAll().map((cookie) => [cookie.name, cookie.value]));
}
export async function hasCookie(name: string): Promise<boolean> {
const cookieStore = await cookies();
return cookieStore.has(name);
}Route Handler
유틸 서버 액션에서 구현한 함수 중 getCookie를 사용하여 다음과 같이 코드를 작성하였다.
export async function GET(req: NextRequest) {
const path = RemoveApiPath(req.nextUrl.pathname); // 요청 url에서 /api를 제거한 경로
const search = req.nextUrl.search; // 요청 url의 serchParams
const accessToken = await getCookie('accessToken'); // getCookie 유틸 서버 액션을 통한 accessToken 반환
const headers = getHeaders(accessToken); // { Authorization: `Bearer ${accessToken}` }을 반환하는 유틸 함수
const { body, newAccessToken, newRefreshToken } = await requestWithRefresh({
url: `${API_BASE_URL}${path}${search}`,
method: 'GET',
headers: { ...headers }, // accessToken에 undefined가 반환되어서 정상적으로 토큰 정보가 담기지 않는다 !
});
const res = NextResponse.json(body);
if (newAccessToken && newRefreshToken) {
setResponseTokenCookie(res, newAccessToken, newRefreshToken);
}
return res;
}문제 발견
문제가 발생하는 부분을 정확하게 확인하기 위해, accessToken을 반환하고 바로 console.log를 사용해봤다. 참고로, 브라우저의 쿠키에는 accessToken이 제대로 담겨있었다.
export async function GET(req: NextRequest) {
const accessToken = await getCookie('accessToken');
console.log(accessToken);
// ...
}accessToken을 출력해본 결과 undefined로 출력되었다.

그러면 여기서 질문. getCookie에서 토큰 정보를 가져오지 못하는 것인가?
확인해보기 위해 getCookie에도 console.log를 사용해봤다.
export async function getCookie(name: string): Promise<string | undefined> {
const cookieStore = await cookies();
const value = cookieStore.get(name)?.value;
console.log(value);
return value;
}하지만 출력 결과는 정상적으로 토큰 정보가 출력되었다. 정말 충격적이었다.

분명 getCookie에서 정상적으로 출력된 토큰 정보를 그대로 반환해줬는데, Route Handler에서 반환결과를 그대로 출력하면 undefined가 나온다. 아주 혼란스러운 현상이었고, 이를 이해하기 위해서는 Next.js 프레임워크의 동작을 좀 더 자세히 이해할 필요가 있었다.
해결 과정
Next.js의 공식문서에서 이 문제를 해결하기 위한 직접적인 내용을 확인할 수는 없었다. cookies 함수는 서버 컴포넌트, 서버 액션, Route Handler에서 사용할 수 있는 함수인데, Route Handler에서 사용하고 있으니 문제가 없다. 또한 Route Handler에서 서버 액션을 사용할 수 없는 것도 아니었다. 원인을 명확히 알 수 없어, 유틸 서버 액션을 포기하고 대안을 생각하기로 했다. 그래도 나름 추측을 해보자면, 서버 액션에서 fetch를 호출했기 때문인 것 같다. 본 시스템은 브라우저에서 API Route를 거치고 백엔드 서버로 요청을 보내는 구조이다. 그러나 브라우저 코드에는 서버 액션을 통해 API 호출하는 로직도 존재한다. 서버 액션은 Next 서버에서 동작하므로 브라우저와는 다른 컨텍스트이다. 그러므로 서버 액션을 통해 fetch를 호출하면 Next 서버에서 새로운 HTTP 요청을 생성하여, 브라우저의 쿠키 정보가 담기지 않는 것이다. API Route 내에서 호출한 getCookie 내부에서 토큰 값이 출력되는 이유는 알 수 없지만, 서버 액션을 통해 fetch를 실행하는 요청이 모두 실패했기 때문에, 이것이 원인인 것 같다.

클라이언트의 Request를 활용
Route Handler는 첫 번째 매개변수로 NextRequest 타입의 클라이언트의 Request 객체를 사용할 수 있다.
export async function GET(req: NextRequest) {
// ...
}req를 직접 출력해보면, 아래와 같이 클라이언트의 요청 헤더 정보가 포함되어 있는 모습을 볼 수 있다. 요청 헤더에서 토큰 정보를 추출하면 정상적으로 작동할 것이라고 확신했다.

개선된 Route Handler
req.headers.get('authorization')을 통해 authorization 키에 저장되어 있는 값을 추출하고 앞에 불필요한 Bearer를 날려줬다.
사실 불필요하진 않은데, 내가 따로 구현한 getHeaders 함수에는 불필요했다.
export async function GET(req: NextRequest) {
const path = RemoveApiPath(req.nextUrl.pathname);
const search = req.nextUrl.search;
const authHeader = req.headers.get('authorization');
const accessToken = authHeader?.startsWith('Bearer ')
? authHeader.slice('Bearer '.length)
: undefined;
const headers = getHeaders(accessToken);
const { body, newAccessToken, newRefreshToken } = await requestWithRefresh({
url: `${API_BASE_URL}${path}${search}`,
method: 'GET',
headers: { ...headers },
});
const res = NextResponse.json(body);
if (newAccessToken && newRefreshToken) {
setResponseTokenCookie(res, newAccessToken, newRefreshToken);
}
return res;
}결과
아래와 같이 토큰 정보가 필요한 유저 정보를 반환하는 요청이 정상적으로 수행된 것을 확인할 수 있다.
