App Router SSR JWT Cookie 관리 전략 (1) - Redirect

JT·2025년 4월 13일
post-thumbnail

들어가기전...

🔒 App Router에서 인증이 필요한 페이지를 SSR로 구성할 때, 클라이언트에 저장된 JWT 토큰(Cookie)을 어떻게 처리해야 할까요?
App Router에서 SSR의 경우 클라이언트에 쿠키를 보낼 수 없습니다.
그럼 왜 App Router에서는 SSR은 쿠키를 클라이언트로 전달할 수 없으며, 어떤 방식으로 SSR에서 클라이언트로 쿠키를 전달 할 수 있을까요?


클라이언트와 서버 사이 JWT Cookie 관리 흐름에 대해 살펴보겠습니다.
예시로, 사용자가 프로필 페이지를 요청하는 상황을 가정합니다.

  1. 클라이언트는 서버에 프로필 정보 조회 API를 요청합니다. 서버는 요청에 포함된 쿠키에서 accessToken을 꺼내 유효성을 검사합니다.

  2. 유효하다면 프로필 정보를 응답합니다.

  3. 만약 accessToken이 만료되었다면, 서버는 인증 에러를 응답합니다.

  4. 클라이언트는 이를 감지하고 refreshToken을 사용해 새로운 accessToken을 요청합니다.

  5. 서버는 쿠키에서 refreshToken을 확인하고, 유효하다면 새 accessToken을 쿠키에 담아 응답합니다.

  6. 이후 클라이언트는 새로 받은 토큰으로 다시 요청을 보냅니다.

  7. 만약 refreshToken도 만료되었다면, 서버는 에러를 응답하며, 클라이언트는 모든 토큰을 삭제하고 로그인 만료 메시지를 사용자에게 보여줍니다.


App Router의 Streaming

Page Router의 경우엔 응답을 Streaming하지 않기 때문에 렌더링 중에 발견된 헤더를 응답 본문 전송 전에 설정할수 있습니다.
그러나 App Router는 React의 서버 사이드 렌더링(SSR)과 React 서버 컴포넌트(RSC)를 위해 Streaming을 사용합니다.
HTTP 응답에서 헤더는 항상 응답 본문(body)보다 먼저 전송이되어야 합니다.
Streaming 환경에서는 응답 본문이 서버에서 렌더링되는 즉시 클라이언트로 전송이 됩니다.
따라서 스트리밍 도중에 컴포넌트가 헤더를 설정하려 하면, 이미 본문 일부가 전송된 후일 수 있기 때문에 쿠키 세팅이 어렵습니다.
이로 인해 서버에서 브라우저에 쿠키를 설정하는 데 사용되는 Set-Cookie 응답 헤더를 설정할 수 없습니다.

💡 Streaming 이란?
Streaming은 Next.js App Router에서 페이지를 점진적으로 렌더링할 수 있게 해주는 기능입니다. 즉, 서버에서 모든 데이터를 다 가져올 때까지 기다리지 않고, 일부 컴포넌트를 먼저 보여주고, 나머지는 준비되는 대로 차례대로 보여주는 방식입니다.
Streaming중인 컴포넌트는 Suspense를 통해 fallback UI를 보여줍니다.

SSR을 통해 프로필 페이지를 렌더링한다고 가정해보겠습니다.

  1. SSR 측 서버에서 프로필 API를 요청할 때, 서버는 accessToken을 쿠키에서 확인합니다.

  2. 유효하다면 데이터를 응답받아 페이지를 렌더링합니다.

  3. accessToken이 만료되었다면, SSR은 이를 인지하고 refreshToken을 통해 토큰 재발급을 시도합니다.

  4. App Router는 React Server Component(RSC)Streaming 렌더링 방식을 사용합니다. App Router의 SSR은 React의 Streaming 특성상, 일부 UI가 렌더링되자마자 본문 전송이 시작됩니다. 이로 인해 서버에서 accessToken을 재발급하고 쿠키에 담아 응답하려 해도, 이미 본문 전송이 시작된 이후에는 응답 헤더(예: Set-Cookie)를 설정할 수 없습니다.
    따라서 SSR 단계에서 설정한 쿠키는 클라이언트 브라우저에 전달되지 않으며, 클라이언트는 새로운 accessToken을 받아올 수 없습니다.

  5. 클라이언트는 새로운 accessToken을 알지 못하고 인증 문제가 지속됩니다.

  6. refreshToken마저 유효하지 않다면, SSR 단계에서 로그인 만료 처리(쿠키에 저장된 토큰 삭제)를 해야 하지만, 서버는 브라우저의 쿠키에 직접 접근할 수 없기 때문에 클라이언트 측 상태를 즉시 조작하거나 강제로 로그아웃 처리할 수 없습니다.


3. SSR에서 Client 쿠키 전달 전략

물론 SSR를 사용하지 않고 CSR을 사용하여 인증이 필요한 페이지를 관리할 수 있습니다.
하지만 만약, 반드시 SSR를 통해 인증이 필요한 페이지를 관리해야한다면 어떻게 해야할까요?

👉 해결 방법은 SSR에서 클라이언트 측 처리 페이지로 redirect를 시켜 토큰 재발급을 클라이언트가 처리하도록 위임하는 것입니다.

위 사항을 반영해서 SSR 측에서 Cookie JWT 관리 흐름을 살펴보겠습니다.

  1. SSR은 프로필 API를 요청하고 accessToken의 유효성을 확인합니다.

  2. 유효하지 않다면, 토큰 재발급용 클라이언트 페이지로 리디렉션합니다. (ex : /refresh-token)

  3. 클라이언트는 해당 페이지에서 refreshToken을 통해 서버에 토큰 재발급 요청을 합니다.

  4. 서버는 새 accessToken을 응답하며, 클라이언트 쿠키에 저장됩니다.

  5. 이후 클라이언트는 원래 페이지로 돌아가 다시 요청을 시도합니다.

  6. 만약 refreshToken도 만료되었다면, 클라이언트는 쿠키를 삭제하고 로그인 만료 알림을 표시합니다.


4. 예시 코드

Profile 페이지 (SSR)

import { cookies } from "next/headers";

export default async function Profile() {
	const fetchProfile = async () => {
		// 클라이언트 쿠키 가져오기
    	const cookieStore = cookies();
      	const cookie = (await cookieStore).getAll().map(({ name, value }) => `${name}=${value}`).join("; ");
      
     	try {
    	  const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/profile`, {
      	  headers: {
            // SSR 측에 클라이언트 쿠키가 없기 때문에 요청을 보낼때 쿠키를 포함시켜 보내야함
            Cookie: cookie,
          },
          credentials: "include",
         });
          
          // accessToken이 만료된 경우 redirect
		  if(response.status === 401) {
            redirect("/refresh-token?next=profile");
          }
          
          const data = await response.json();
          return data;
        } catch (error) {
          throw error;
        }
 	}
    
    const userProfile = await fetchProfile();
  
 
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <div>
        <p>{userProfile.email}</p>
        <p>{userProfile.name}</p>
      </div>
    </Suspense>
  )
}

Refresh-token 페이지 (Client)

"use client";

import { useRouter, useSearchParams } from "next/navigation";
import React, { useEffect } from "react";

export default function RefreshToken() {
  const router = useRouter();
  
  // searchParams를 통해 토큰 발급 후 이동할 페이지 확인
  const searchParams = useSearchParams();
  const next = searchParams.get("next");
  
  useEffect(() => {
    const fetchAccessToken = async () => {
      try {
      	const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/auth/refresh-token`, {
          method: "POST",
          credentials: "include",
        });
        
        if (!response.ok) {
          throw response;
        }
        
        // SSR 재요청을 위한 리다이렉트
        window.locatioin.replace(`/${next}`);
      } catch(error) {
        // refreshToken 만료시 쿠키에 저장된 토큰 삭제 및 사용자에게 로그인 만료 알림
        if(error.status===401) {
          // 토큰 쿠키 삭제 API 요청
          await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/delete-token`, {
            method: "DELETE",
            credentials: "include",
          });
          alert("세션이 만료되었습니다. 다시 로그인해 주세요.");
          router.replace("/");
        } else {
          throw error;
        }
      }
    };

    fetchAccessToken();
  }, [next, router]);

  return <div>Loading...</div>;
}

정리

App Router의 SSR은 React의 Streaming 특성상, 일부 UI가 렌더링되자마자 본문 전송이 시작됩니다. 이로 인해 서버에서 accessToken을 재발급하고 쿠키에 담아 응답하려 해도, 이미 본문 전송이 시작된 이후에는 응답 헤더(예: Set-Cookie)를 설정할 수 없습니다.
따라서 쿠키에 저장된 토큰을 클라이언트로 전달하기 위해서는 별도의 추가적인 과정이 필요합니다.
SSR 측에서 redirect하여 페이지를 이동시켜 토큰 재발급을 클라이언트가 처리하도록 위임하여 해결할 수 있습니다.
다음에는 middleWare를 사용하여 헤더 쿠기를 설정하는 전략을 알아보겠습니다.


참고 자료

https://ianlog.me/blog/2024/server-component-cookie

profile
함께 개선하는 프론트엔드 개발자

0개의 댓글