트러블 슈팅

Next.js에서의 Cookie와의 전쟁

Cookie로 인증하는게 이렇게나 어려웠나...

2025-04-10 작성

2025-04-12 업데이트

평균 25분 소요

209회 조회

#Next.js#Cookie#JWT#Server Action
cover

이번 포스트는 제가 최근에 겪은 Next.js에서의 Cookie 설정에 관한 포스트 입니다.

친구와 2인 프로젝트를 진행하면서 Next.js 15버전을 사용하기로 했고, 백엔드와 인증은 OAuthJWT를 사용하기로 했어요.
토큰 전달 방식은 쿠키를 사용하기로 했습니다.

구글 OAuth 설정

간단한 서비스였기 때문에 구글 OAuth를 통한 로그인 및 회원가입만 구현하기로 했어요.

구글 OAuth 설정은 다음과 같습니다.

  1. 구글 클라우드 콘솔에서 프로젝트 생성
  2. 프로젝트 설정에서 콜백 URL 설정
  3. 프로젝트 설정에서 클라이언트 ID 및 클라이언트 비밀번호 생성
  4. 로그인 페이지에서 구글 로그인 버튼 클릭
  5. 구글 로그인 페이지로 이동
  6. 구글 로그인 페이지에서 로그인 진행
  7. 로그인 완료 후 콜백 URL로 리다이렉트
  8. 콜백 URL에서 구글 인가 코드 획득
  9. 구글 인가 코드를 통해 구글에서 토큰 발급
  10. 토큰을 받아온 뒤 쿠키에 저장

최초 계획은 다음과 같았어요.

클라이언트에서 구글 로그인 버튼을 클릭하면 구글 로그인 페이지로 이동하고, 구글 로그인 페이지에서 로그인 진행 후 콜백 URL로 리다이렉트 됩니다.
이때, 리다이렉트를 백엔드에서 받고 구글 인가 코드를 획득합니다.

백엔드는 구글 인가 코드를 통해 구글에서 토큰을 발급받고, 토큰을 쿠키에 저장해 프론트엔드에게 전달합니다.

과정만 보면 간단하죠? 하지만 실제로 구현하면서 여러가지 문제가 발생합니다.

문제점

요청과 응답

과정은 완벽했어요. 마지막 쿠키를 받기 전까지는요.

아무리 이런저런 처리를 해봐도 프론트엔드가 쿠키를 받을 수 없었어요.
몇시간쯤 이런저런 설정을 백엔드, 프론트엔드 할 것 없이 건들다가 문득 생각났어요.

프론트엔드에서 백엔드로 요청을 보낸 적이 없잖아?

천천히 저희가 구성한 로직을 되짚어 보니 토큰을 쿠키로 넘겨받는 응답만 있을 뿐 프론트엔드에서 백엔드로 요청을 보낸 적이 없었어요.

이걸 깨닫기까지 한참이 걸렸다...

이 문제를 해결하기 위해 프론트엔드에서 백엔드로 요청을 보내는 과정을 추가하기로 했습니다.
구글 인가 코드를 백엔드가 받는 대신, 프론트엔드가 받아 백엔드로 요청을 보내면, 토큰을 쿠키로 넘겨받는 응답을 받을 수 있을 것 같았어요.

그래서 다음과 같이 과정을 변경했습니다.

  1. 프론트엔드에서 구글 로그인 버튼 클릭 후 구글 로그인 페이지로 이동
  2. 구글 로그인 페이지에서 로그인 진행 후 클라이언트 콜백 URL로 리다이렉트
  3. 클라이언트에서 구글 인가 코드 획득
  4. 클라이언트에서 구글 인가 코드를 백엔드로 전달
  5. 백엔드에서 구글 인가 코드를 통해 구글에서 토큰 발급
  6. 토큰을 쿠키에 저장 후 프론트엔드로 전달
/google/callback.tsx
"use client";
 
import { apiClient } from "@/lib/api-client";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect } from "react";
 
const GoogleCallback = () => {
  const router = useRouter();
  const searchParams = useSearchParams();
  const code = searchParams.get("code");
 
  useEffect(() => {
    const handleGoogleAuth = async () => {
      try {
        const res = await apiClient.post("oauth/login", { json: { code } });
 
        if (res.ok) {
          const data = await res.json();
          console.log("인증 성공:", data);
        } else {
          console.error("인증 실패:", await res.text());
        }
      } catch (error) {
        console.error("인증 처리 중 오류:", error);
      } finally {
        router.push("/");
      }
    };
 
    handleGoogleAuth();
  }, [code, router]);
 
  return;
};
 
export default GoogleCallback;

ky 라이브러리를 활용해 apiClient라는 클라이언트 비동기 요청 함수를 만들었습니다. 별도의 구글 콜백 페이지를 구성하고, 쿼리를 통해 전달되는 구글 인가 코드를 추출해 백엔드로 전달합니다.
그럼 백엔드에서는 구글 인가 코드를 통해 구글에서 토큰을 발급받고, 토큰을 쿠키에 저장해 프론트엔드로 전달합니다.

된다!

이렇게 하니 토큰을 쿠키로 넘겨받는 응답을 받을 수 있었어요. 쿠키를 받았으니 이제 프론트엔드에서 API를 호출해봐야겠죠?

서버 컴포넌트와 쿠키?

토큰 인증 테스트를 하기 위해 서버 컴포넌트에서 유저 정보를 조회하는 API를 호출했어요.

/user/settings.tsx
import UserSettingsForm from "@/components/UserSettingsForm";
 
const API_URL = process.env.NEXT_PUBLIC_API_URL as string;
 
const UserSettings = async () => {
  const res = await fetch(`${API_URL}user`, {
    headers: {
      "Content-Type": "application/json",
    },
    credentials: "include",
  });
 
  const data = await res.json();
  console.log(data);
 
  return (
    <>
      <h1 className="text-2xl font-bold">개인정보 설정</h1>
      <UserSettingsForm />
    </>
  );
};
 
// 403 Forbidden 에러 발생

어라? 403 에러가 발생했습니다. 백엔드에서는 쿠키를 전달하지 않아 에러가 발생했다고 하더라고요.
분명 쿠키를 받는 것 까지는 구현을 했는데 왜 전달이 안된걸까 싶어서 이번에는 클라이언트 컴포넌트로 변경하고 API를 호출해봤어요.

"use client";
 
import { useEffect } from "react";
import UserSettingsForm from "@/components/UserSettingsForm";
 
const API_URL = process.env.NEXT_PUBLIC_API_URL as string;
 
const UserSettings = () => {
  useEffect(() => {
    const fetchData = async () => {
      const res = await fetch(`${API_URL}user`, {
        headers: {
          "Content-Type": "application/json",
        },
        credentials: "include",
      });
 
      const userData = await res.json();
      console.log(userData);
    };
 
    fetchData();
  }, []);
 
  return (
    <>
      <h1 className="text-2xl font-bold">개인정보 설정</h1>
      <UserSettingsForm />
    </>
  );
};
 
// {status: 200, data: {}}

이번엔 데이터가 잘 조회됐습니다. 도대체 왜 이럴까요?
여기서 로그인 API 호출을 ky 라이브러리를 사용하는 대신 서버에서 호출하면 어떻게 될까요?
로그인 로직을 Route Handler로 구현해보기로 했어요.

/api/login.ts
import { NextRequest, NextResponse } from "next/server";
 
const API_URL = process.env.NEXT_PUBLIC_API_URL as string;
 
export async function POST(request: NextRequest) {
  const { code } = await request.json();
 
  try {
    await fetch(`${API_URL}oauth/login`, {
      method: "POST",
      body: JSON.stringify({ code }),
      credentials: "include",
      headers: {
        "Content-Type": "application/json",
      },
    });
 
    return NextResponse.json({ success: true });
  } catch (error) {
    console.error("인증 처리 중 오류:", error);
    return NextResponse.json({
      success: false,
      message: "인증 처리 중 오류가 발생했습니다.",
    });
  }
}

이 Route Handler로 로그인을 하면 쿠키가 정상적으로 받아 질까요?
클라이언트 컴포넌트, 서버 컴포넌트 모두에서 쿠키를 받을 수 없었습니다!

정리를 하자면 다음과 같습니다.

  1. ky 라이브러리(클라이언트) API 호출
  • 클라이언트 컴포넌트 : 쿠키 수신 성공
  • 서버 컴포넌트 : 쿠키 수신 실패
  1. Route Handler(서버) 호출
  • 클라이언트 컴포넌트 : 쿠키 수신 실패
  • 서버 컴포넌트 : 쿠키 수신 실패

유일하게 성공하는 방법이 클라이언트 컴포넌트에서 호출하는 방식이지만 이 프로젝트에서는 서버 컴포넌트를 최대한으로 사용하기 위해 서버 호출을 제대로 구현해 보기로 했어요.

쿠키는 어디에?

Next.js에서는 cookies() 를 통해 헤더의 쿠키를 조회하거나 추가할 수 있습니다.
그래서 서버 컴포넌트에서 쿠키를 조회하고 API 요청 헤더에 쿠키를 직접 추가 해보기로 했어요.

Next.js 15부터는 cookies가 비동기로 동작합니다.

import UserSettingsForm from "@/components/UserSettingsForm";
import { cookies } from "next/headers";
 
const API_URL = process.env.NEXT_PUBLIC_API_URL as string;
 
const UserSettings = async () => {
  const cookieStore = await cookies();
 
  const res = await fetch(`${API_URL}user`, {
    headers: {
      "Content-Type": "application/json",
      Cookie: cookieStore.toString(),
    },
    credentials: "include",
  });
 
  const data = await res.json();
  console.log(data);
 
  return (
    <>
      <h1 className="text-2xl font-bold">개인정보 설정</h1>
      <UserSettingsForm />
    </>
  );
};
 
export default UserSettings;
서버 컴포넌트에서 쿠키 직접 적용

이렇게 하면 정상적으로 쿠키가 담길까요? 쿠키가 요청 헤더에 담기긴 합니다.
하지만 조회한 순간에는 쿠키에 토큰이 없었고,그렇기 때문에 토큰 없는 쿠키를 보낸 셈이에요.
백엔드에서는 쿠키는 있지만 토큰이 없다는 에러를 반환했어요.

쿠키에 토큰이 없다

이번엔 문제의 원인이 뭘까요?

앞서 로그인을 하고 쿠키를 받은 수신자는 클라이언트입니다.
그런데 현재 API 요청을 보내는 송신자는 Next.js의 서버에서 실행되는 Route Handler 입니다.
Next.js의 서버는 자체적인 node.js 서버이기 때문에 클라이언트와 실제 백엔드 서버 사이에 존재하면서 서버단에서의 다양한 역할을 수행하고 있죠.

그렇기에 서버가 받은 쿠키를 클라이언트에 추가로 전달을 해 줘야 합니다.

Next.js 서버에서 쿠키를 송수신 하는 과정

그래서 로그인 후 받은 토큰을 직접 서버의 쿠키에 저장하기로 했어요.
우선 클라이언트에서 쿠키를 조회하는 과정이 불필요하다고 생각되어 백엔드에서 accessToken은 헤더에, refreshToken은 바디에 실어 보내기로 했어요.

import { NextRequest, NextResponse } from "next/server";
 
const API_URL = process.env.NEXT_PUBLIC_API_URL as string;
 
export async function POST(request: NextRequest) {
  const { code } = await request.json();
 
  try {
    const res = await fetch(`${API_URL}oauth/login`, {
      method: "POST",
      body: JSON.stringify({ code }),
      credentials: "include",
      headers: {
        "Content-Type": "application/json",
      },
    });
 
    const { data } = await res.json();
 
    const accessToken = res.headers.get("Authorization")?.split(" ")[1];
    const refreshToken = data.refreshToken;
 
    if (!accessToken) {
      return NextResponse.json({
        success: false,
        message: "인증에 실패했습니다.",
      });
    }
 
    // 쿠키 설정
    const response = NextResponse.json({ success: true });
 
    const cookieOptions = {
      httpOnly: true,
      secure: true,
      sameSite: "none" as const,
      path: "/",
    };
 
    response.cookies.set("accessToken", accessToken, cookieOptions);
    response.cookies.set("refreshToken", refreshToken, cookieOptions);
 
    return response;
  } catch (error) {
    console.error("인증 처리 중 오류:", error);
    return NextResponse.json({
      success: false,
      message: "인증 처리 중 오류가 발생했습니다.",
    });
  }
}
쿠키 전달 로직을 포함한 로그인 Route Handler

이렇게 Route Handler를 활용해 토큰을 Next.js의 서버 쿠키에 저장한 후, 서버 컴포넌트에서 조회를 하면?

서버에서 토큰이 확인됨

토큰이 제대로 확인됩니다. 이제 서버 컴포넌트에서 API를 호출해보면 정상적으로 호출이 되는 모습을 볼 수 있어요.

임시 데이터 도착

와 그럼 이제 토큰 만료에 따른 토큰 갱신 로직만 만들면 끝이겠죠? 사실 여기부터가 진짜 문제였어요.

서버액션? 서버함수?

토큰 갱신 로직은 다음과 같은 순서로 이루어집니다.

  1. 만료된 accessToken을 사용해 API 호출
  2. 401 Unauthorized 에러 발생
  3. 401 에러 발생 시 refreshToken을 사용해 새로운 accessToken을 발급받는 API 호출
  4. 새로운 accessToken을 쿠키에 저장
  5. 새로운 accessToken으로 다시 API 호출

이 로직을 모든 API에 공통적으로 적용시키기 위해 모듈화 된 API 호출 함수를 만들었어요.

"use server";
 
import { cookies } from "next/headers";
 
// ... 생략
 
const makeRequest = async <T>(
  url: string,
  options: RequestInit,
  cookieString: string
): Promise<{ data: T; status: number }> => {
  let responseData: T;
  let responseStatus: number;
 
  try {
    const response = await fetch(`${API_URL}${url}`, {
      ...options,
      headers: {
        ...options.headers,
        Cookie: cookieString,
      },
    });
 
    responseStatus = response.status;
 
    // 401에러 발생 시 토큰 갱신
    if (responseStatus === 401) {
      const refreshResponse = await fetch(`${API_URL}oauth/refresh`, {
        method: "POST",
        credentials: "include",
        headers: {
          "Content-Type": "application/json",
          Cookie: cookieString,
        },
      });
 
      if (refreshResponse.ok) {
        const newAccessToken = refreshResponse.headers
          .get("accessToken")
          ?.split(" ")[1];
 
        const cookieStore = await cookies();
 
        // 새 토큰 서버 쿠키에 저장
        if (newAccessToken) {
          cookieStore.set("accessToken", newAccessToken, {
            httpOnly: true,
            secure: true,
            sameSite: "none" as const,
            path: "/",
          });
        }
 
        // 새 토큰으로 API 재실행
        const newResponse = await fetch(`${API_URL}${url}`, {
          ...options,
          headers: {
            ...options.headers,
            Cookie: `${cookieString}; accessToken=${newAccessToken}`,
          },
        });
 
        responseData = await newResponse.json();
        responseStatus = newResponse.status;
      } else {
        responseData = await response.json();
      }
    } else {
      responseData = await response.json();
    }
  } catch (error) {
    console.error("API 요청 실패:", error);
    throw error;
  }
 
  return { data: responseData, status: responseStatus };
};
 
export const get = async <T>(url: string, cookieString: string) => {
  return makeRequest<T>(
    url,
    {
      method: "GET",
      headers: {
        ...serverApiConfig.headers,
      },
    },
    cookieString
  );
};
 
/// post, put, delete 등...
토큰 갱신 로직을 포함한 API 호출 함수

이 함수를 활용해 서버 컴포넌트에서의 API 호출을 구현하면 다음과 같습니다.

/user/settings.tsx
const UserSettings = async () => {
  const cookieStore = await cookies();
  const { data } = await get("user", cookieStore.toString());
 
  console.log(data);
 
  return (
    <>
      <h1 className="text-2xl font-bold">개인정보 설정</h1>
      <UserSettingsForm />
    </>
  );
};

새 토큰도 발급받았고, 발급받은 새 토큰을 쿠키에도 저장했고, 새 토큰으로 API도 재실행 할 수 있게 됐어요.
그럼 이 호출의 결과는 어떨까요?

서버액션에서 호출하라고? 호출했잖아?!

분명 use server 키워드를 사용하고 있는데 왜 이런 문제가 발생했을까요?
여기서 '서버 함수'와 '서버 액션'의 차이를 알아야 합니다.

서버 함수 : 서버에서 실행되는 일반적인 함수.
서버 액션 : 서버에서 실행되는 함수 중, 클라이언트의 form 기반 post 요청을 서버에서 처리하기 위한 함수

즉, 서버 액션은 Route Handler와 같이 클라이언트의 요청을 Next.js의 서버에서 처리하기 위한 함수입니다. 여러 조건을 만족한다고 해도 클라이언트가 아닌 서버에서 호출하면 서버 액션으로 동작하지 않는다는 셈이죠.

토큰 갱신 로직이 포함된 API 호출 함수는 서버 컴포넌트에서 호출된 서버 함수입니다.
따라서 토큰 갱신 로직에 있는 cookieStore.set() 에서 에러가 발생한 것이죠.

여기까지 오니 뒷통수가 얼큰해졌습니다...

서버 쿠키 저장

서버 쿠키에 set 하는 방법은 여러가지가 있습니다.

  • 서버 액션에서 cookieStore.set() 사용
  • Route Handler에서 cookieStore.set() 사용
  • 미들웨어에서 cookieStore.set() 사용

이 중, 첫 번째는 위에서 실패했죠. 그럼 두 번째인 Route Handler는 어떨까요?
아쉽게도 Vercel의 공식 유튜브 영상에서는 Route Handler의 서버 호출을 지양하고 있습니다.

그럼 미들웨어에서는 어떨까요?

미들웨어의 기본적인 역할은 요청과 응답을 가로채는 것 입니다.
유저가 해당 페이지에 접근할 권한이 있는지 확인하는 등의 작업을 하는데 쓰이곤 하죠.
이를 이용해서 서버 컴포넌트의 API 요청을 가로채 토큰 갱신 로직을 구성해 보려고 했습니다.

결과적으로 미들웨어에서는 서버 컴포넌트에서 호출된 개별 API 요청을 가로챌 수 없었습니다.

서버 컴포넌트는 서버에서 렌더링을 요청하고, 렌더링이 완료된 후 응답하는 하나의 흐름을 가집니다. 그 사이에서 몇 개의 API 호출이 있든, 렌더링에 문제가 있지 않으면 중간에서 멈추지 않죠.

클라이언트와 서버 컴포넌트의 요청,응답 흐름
미들웨어의 입장에서 서버 컴포넌트는 도중에 API 호출이 있든 없든 렌더링이 완료된, 응답 상태값이 200인 서버 호출인 셈입니다.

따라서 도중에 발생한 401 에러를 감지하는 미들웨어를 구성해도 이를 감지할 방법을 찾을 수 없었습니다.

미들웨어로 해결 할 수 있는 방법을 아시면 알려주세요!

해결?방법

결국 좋은 방법을 찾지 못한 채 타협을 해야 했습니다.

  • 모든 API 호출을 클라이언트에서 직접 하기
  • 토큰 갱신이 필요한 경우만 별도의 클라이언트 페이지로 리다이렉트
  • 별도의 토큰 갱신용 클라이언트 레이어 구성하기

솔직히 첫 번째 방법은 서버 컴포넌트의 장점을 모두 잃는 것이라 생각하여 두 번째 방법을 선택했어요.
세 번째 방법은 좋은 구현 방법이 있으면 추후 구현해 볼 생각입니다.

우선 토큰을 갱신하는 서버 액션을 만들어야하죠.

"use server";
 
export const refreshAccessToken = async () => {
  const cookieStore = await cookies();
  const refreshTokenCookie = cookieStore.get("refreshToken");
 
  if (!refreshTokenCookie?.value) {
    throw new Error("리프레시 토큰이 없습니다.");
  }
 
  try {
    const response = await fetch(`${API_URL}oauth/refresh`, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Cookie: `refreshToken=${refreshTokenCookie.value}`,
      },
      credentials: "include",
    });
 
    const newAccessToken = response.headers.get("Authorization")?.split(" ")[1];
 
    if (!newAccessToken) {
      throw new Error("토큰 갱신에 실패했습니다.");
    }
 
    cookieStore.set("accessToken", newAccessToken);
 
    return { success: true };
  } catch (error) {
    console.error("토큰 갱신 중 오류 발생:", error);
    throw error;
  }
};
클라이언트에서 호출하기 위한 서버 액션

그리고 별도의 클라이언트 페이지를 만들어 토큰 갱신을 요청합니다.

/token-refresh/page.tsx
"use client";
 
import { useEffect } from "react";
import { refreshAccessToken } from "@/app/actions/auth";
import { useRouter, useSearchParams } from "next/navigation";
import Inner from "@/components/Inner";
 
const RefreshTokenPage = () => {
  const router = useRouter();
  const searchParams = useSearchParams();
  const redirectUrl = searchParams.get("redirect") || "/";
 
  useEffect(() => {
    const handleRefreshToken = async () => {
      try {
        const result = await refreshAccessToken();
        if (result.success) {
          router.push(redirectUrl);
        }
      } catch (error) {
        console.error("토큰 갱신 실패:", error);
        router.push("/");
      }
    };
 
    handleRefreshToken();
  }, [router, redirectUrl]);
 
  return (
    <Inner className="flex h-screen items-center justify-center">
      <h1 className="text-2xl font-bold">토큰 갱신 중...</h1>
    </Inner>
  );
};
/user/settings.tsx
const UserSettings = async () => {
  const cookieStore = await cookies();
  const { data, status } = await get("user", cookieStore.toString());
 
  if (status === 401) {
    const currentPath = "/user/settings";
    redirect(`/refresh-token?redirect=${encodeURIComponent(currentPath)}`);
  }
 
  console.log(data);
 
  return (
    <>
      <h1 className="text-2xl font-bold">개인정보 설정</h1>
      <UserSettingsForm />
    </>
  );
};

이렇게 토큰 갱신을 요청하는 페이지로 리다이렉트 하면 토큰 갱신이 완료된 후 원래 페이지로 리다이렉트 됩니다.

정말 별로다...

이 방법은 유저가 토큰 갱신 과정을 봐야 하고, 각 서버 컴포넌트에서 각 API마다 리다이렉트 로직을 구성해야 하는 치명적인 단점이 있습니다.
아직은 이 방법이 스스로 구현할 수 있는 방법중 최선이라고 생각하고 있지만 만족스럽냐 하면 전혀 그렇지 않네요.

더 나은 방법을 찾기 위해 앞으로도 계속 고민해 볼 생각이에요.

마치며...

정말 오랜만에 쓰는 포스트입니다. 그동안 많은 일이 있었어요.
다양한 곳에 지원하고 떨어지고, 프리랜서로 일해보기도 하고... 그리고 지금은 2인 프로젝트를 하고 있습니다.

이런 이야기를 모두 블로그에 담고 싶은데 블로그 한 편 한 편을 제대로 써야겠다는 생각에 계속해서 미루게 되더라고요...

앞으로는 더 자주 쓰려고 노력해보려고 합니다.

참조

route handler로 고통받기
Nextjs 앱라우터 서버컴포넌트에서 쿠키 세팅이 안되는 이유
App Router Custom Header Use Cases
nextjs 13 route handlers vs fetch directly from server component
[NEXT-1126] Cookies set in middleware missing on Server Component render pass
[next js] app directory에서 token 저장하기 - (2)