Next.js Middleware로 안전한 Token Rotation 구현하기

해진·2025년 9월 28일
post-thumbnail

Next.js App Router 환경에서 RSC(React Server Components)와 클라이언트 양측 모두에서 안정적으로 작동하는 인증 시스템을 구축한 경험을 공유해 볼게요.

TL;DR

Next.js Middleware를 활용해 서버/클라이언트 양측에서 동기화된 토큰 관리 시스템을 구현했어요.

핵심 포인트:

  • ✅ HttpOnly Cookie로 클라이언트의 직접적인 토큰 접근 차단
  • ✅ NextRequest/NextResponse 양방향 수정으로 상태 동기화
  • ✅ Custom Header(x-needs-renewal, x-needs-logout)로 서버↔클라이언트 싱크
  • ✅ verify API 기반 인증 상태 관리로 동일 출처 원칙 준수

🤔 문제 상황 - 어떤 고민이 있었냐면요...

Next.js App Router의 RSC 환경에서 JWT 기반 인증을 구현하면서 다음과 같은 고민이 들기 시작했어요.

"클라이언트에서 AccessToken이 만료됐을 때 RefreshToken으로 재발급받는 로직, 도대체 어디서 처리해야 좋지..?"

초기 고민들

일반적인 접근 방법은 두 가지였어요.

1. API 요청 시마다 체크하는 방식

// axios interceptor에서 처리
axios.interceptors.response.use(
  (response) => response,
  async (error) => {
    if (error.response?.status === 401) {
      await refreshToken();
      return axios(error.config);
    }
  },
);

이런 방식은 아래와 같은 아쉬운 점들이 있어요.

  • 서버 컴포넌트와 클라이언트 컴포넌트에서 각각 처리해야 함
  • API를 호출하지 않으면 이미 로그아웃된 상태인데도 로그인으로 보임
  • 코드 중복과 싱크 문제

그래서 생각해본 다음 방식은 Middleware를 활용하는 것이었어요.

2. Middleware에서 처리하는 방식 (선택)

Next.js의 Middleware는 모든 요청이 서버에 도달하기 전 가장 먼저 실행되어 다음과 같은 이점이 있었어요.

  • 서버/클라이언트 요청 모두 공통으로 처리 가능
  • 공식 문서에서도 인증/인가 용도로 권장
  • 단일 지점에서 토큰 관리로 코드 간결화

🤩 왜 Middleware 방식을 선택했나?

제가 구현하고자 했던 요구사항은 다음과 같았어요.

  1. 보안: 클라이언트에서 토큰(access, refresh)에 직접 접근하지 못하게 하자
  2. 일관성: 서버와 클라이언트에서 토큰 rotate 로직이 동일하게 수행되게 하자
  3. 동기화: 서버와 클라이언트 양측의 인가 상태 싱크를 맞추자

Middleware는 이 모든 요구사항을 만족하는 최적의 선택이라고 생각했어요.
그러나 실제 구현은 쉽지 않았어요..😅


🚧 첫 번째 난관: "새 토큰을 받았는데 왜 옛날 토큰이 보이지?"

문제의 발견

Middleware에서 토큰 갱신 API를 호출하고 새로운 토큰을 받아왔어요. 로그를 찍어보니 분명히 새 토큰이 잘 왔었죠.

// middleware.ts
const { token, refreshToken } = await renewalAPI();
console.log('새 토큰:', token); // ✅ 출력됨!

// 그런데...

문제는 이후 서버 컴포넌트나 클라이언트에서 토큰을 확인하면 여전히 옛날 토큰이 사용되고 있는것이었어요. 😱

원인 파악 - 알고보니…

Middleware의 동작 원리를 이해하는 것이 핵심이었어요.

Middleware는 "들어오는 요청(NextRequest) 하나를 받아, 반드시 단 하나의 최종 응답(NextResponse)을 반환하는 함수"

즉, Middleware 내부에서 아무리 토큰을 갱신하고 변수에 저장해도, 그 변화를 NextRequest와 NextResponse에 반영하지 않으면 의미가 없었던거죠.

두 가지를 모두 수정해야 했습니다:

  1. 들어오는 요청: 서버 컴포넌트가 받을 요청에 새 토큰 반영
  2. 나가는 응답: 브라우저에게 새 토큰 전달

해결 방법

// shared/lib/utils/middleware-helpers.ts
export async function createTokenRenewalSuccessResponse(
  req: NextRequest,
  refreshResponse: Response,
): Promise<NextResponse> {
  const { setCookieHeaders, host } = extractTokenRenewalData(refreshResponse, req);

  // 1️⃣ 들어오는 요청의 쿠키 업데이트 (서버 컴포넌트용)
  const updatedRequestHeaders = updateRequestCookieHeader(req, setCookieHeaders);

  // 2️⃣ 새로운 요청 헤더로 응답 생성
  const response = NextResponse.next({
    request: { headers: updatedRequestHeaders },
  });

  // 3️⃣ 나가는 응답에 Set-Cookie 헤더 추가 (브라우저용)
  setBrowserCookies(response, setCookieHeaders, host);

  // 4️⃣ accessTokenExpiresAt 쿠키 설정 (클라이언트 접근 가능)
  setAccessTokenExpiresCookie(response, refreshTokenPairResult);

  // 5️⃣ 갱신 완료 신호
  response.headers.set('x-needs-renewal', 'true');

  return response;
}

핵심은 두 방향 모두 수정:

  • NextResponse.next({ request: { headers: updatedRequestHeaders } }): 서버 컴포넌트가 받을 요청 수정
  • response.headers.append('Set-Cookie', ...): 브라우저가 받을 응답 수정

📊 개선 효과

  • 서버 컴포넌트와 클라이언트 양측에서 즉시 새 토큰 사용
  • 토큰 상태 불일치로 인한 401 에러 해결
  • API 요청마다 토큰 체크하는 로직 불필요

🚧 두 번째 난관: "클라이언트는 바뀐 걸 어떻게 알죠?"

문제의 발견

Middleware에서 토큰을 갱신했어요. 서버 컴포넌트는 새 토큰을 잘 사용했죠.

그런데 클라이언트 컴포넌트(UI)는 여전히 이전 상태를 보고 있었어요.

// 로그인 상태를 표시하는 컴포넌트
export function UserProfile() {
  const { isAuthenticated } = useAuthState();

  // Middleware에서 로그아웃 처리했는데도 여전히 true 😱
  return isAuthenticated ? <Profile /> : <LoginButton />;
}

이건 당연한 결과였어요. 왜냐면 리렌더링을 유발할 트리거가 없었기 때문이죠.

localStorageEvent를 써볼까 했지만, 서버(Middleware)와 클라이언트 사이의 벽을 넘기가 쉽지 않았어요.


그렇게 여러 방법을 고민해 보게 되었어요.

해결 방법: Custom Header + Verify API

middleware에서 flag로 활용할 수 있는 값을 보내보자!라는 방법을 사용해보았어요.

1단계: Middleware에서 Custom Header로 신호 보내기

// middleware-helpers.ts
export async function createTokenRenewalSuccessResponse(
  req: NextRequest,
  refreshResponse: Response,
): Promise<NextResponse> {
  // ... 토큰 갱신 로직

  // 클라이언트에게 "갱신했어!" 신호
  response.headers.set('x-needs-renewal', 'true');
  return response;
}

export function createTokenRenewalFailureResponse(): NextResponse {
  const response = NextResponse.next();
  // 클라이언트에게 "로그아웃해!" 신호
  response.headers.set('x-needs-logout', 'true');
  return response;
}

그리고 클라이언트에서는 신호를 확인하여(헤더를 감지하여) 상태를 업데이트 하는것이죠.

2단계: 클라이언트에서 헤더 확인 후 액션

// app/_components/TokenRenewalHandler.tsx
export function TokenRenewalHandler() {
  const { logout, refreshAuthStatus } = useAuthState();
  const router = useRouter();

  const checkMiddlewareHeaders = useCallback(async () => {
    const response = await fetch(window.location.href, {
      method: 'HEAD',
      credentials: 'include',
    });

    const needsLogout = response.headers.get('x-needs-logout');
    const needsRenewal = response.headers.get('x-needs-renewal');

    if (needsLogout === 'true') {
      // Middleware가 로그아웃 처리함 → 클라이언트도 로그아웃
      logout();
      router.push('/');
      return;
    }

    if (needsRenewal === 'true') {
      // Middleware가 토큰 갱신함 → 클라이언트도 상태 갱신
      await refreshAuthStatus();
      return;
    }
  }, [logout, refreshAuthStatus, router]);

  useEffect(() => {
    const timer = setTimeout(() => {
      checkMiddlewareHeaders();
    }, 100);

    return () => clearTimeout(timer);
  }, [checkMiddlewareHeaders]);

  return null;
}

그러나 단순하게 flag값으로만 판단하기는 조금 불안하기도 해요. 클라이언트가 자체적으로 상태를 판단하기보다 "지금 내 토큰 유효해?"라고 서버(Verify API)에 물어보는 방식을 택했어요. 이렇게 하면 동일 출처 원칙도 지키면서 훨씬 신뢰성 있는 상태 관리가 가능하거든요.

3단계: Verify API로 동일 출처 원칙 준수

클라이언트의 인증 상태도 서버에 물어보기:

// features/auth/lib/hooks/use-auth-status.ts
export function useAuthStatus(cookie?: string, initialAuthStatus?: boolean) {
  const [isAuthenticated, setIsAuthenticated] = useState<boolean | undefined>(initialAuthStatus);

  const checkAuthStatus = useCallback(async () => {
    // ✅ 서버에게 "내 토큰 유효해?" 물어봄
    const response = await verifyAccessToken(cookie);

    if (response?.isValid) {
      setIsAuthenticated(true);
    } else {
      setIsAuthenticated(false);
    }
  }, [cookie]);

  const refreshAuthStatus = useCallback(async () => {
    await checkAuthStatus();
  }, [checkAuthStatus]);

  return { isAuthenticated, refreshAuthStatus };
}

이렇게 하면 클라이언트의 인증 상태도 항상 서버가 출처가 되어 신뢰할 수 있습니다.

📊 개선 효과

  • Middleware의 토큰 변경사항이 즉시 클라이언트에 반영
  • 서버와 클라이언트 인증 상태 100% 동기화
  • 단순 boolean state보다 신뢰성 높은 인증 상태 관리

🚧 세 번째 난관: "Middleware가 실행 안 될 땐 어떡해요?"

만능일 줄 알았던 middlewware에는 중요한 사실이 하나 있었어요..

"Middleware는 페이지 이동(Navigation)이 일어날 때 주로 실행된다"는 거죠.

이 말은 즉, 한 페이지 안에서 fetch로 데이터를 가져올 땐 Middleware를 거치지 않는 경우가 많아요. 만약 그 순간에 토큰이 만료됐다면? 사용자는 영문도 모른 채 에러를 보게 되겠죠. 😱

// ❌ Middleware가 실행되지 않는 케이스
export function UserDashboard() {
  const [data, setData] = useState(null);

  useEffect(() => {
    // 이 fetch는 Middleware를 거치지 않음!
    fetch('/api/user/profile', { credentials: 'include' })
      .then((res) => res.json())
      .then(setData);
  }, []);

  return <div>{/* ... */}</div>;
}

이 경우 페이지는 그대로인데 API 요청만 보내는 것이므로, Middleware가 개입할 기회가 없습니다.

만약 이 순간에 토큰이 만료되었다면? → 401 에러 발생 😱

해결 방법: fetch함수에 안전 장치 달기(401 Interceptor 추가)

Middleware가 커버하지 못하는 영역을 처리하기 위해, 클라이언트 측 API 호출 함수에도 '401 에러가 나면 토큰 갱신을 시도하라'는 로직(Interceptor)을 추가했어요.

// src/shared/lib/utils/fetch-api-client.ts
export async function fetchApiClient<TRequestData, TResponseData>(
  props: FetchApiClientProps<TRequestData>,
): Promise<TResponseData | undefined> {
  const { endPoint, skipTokenRenewal = false, ...restOptions } = props;

  try {
    const response = await fetch(apiUrl, defaultOptions);

    try {
      return await parseResponse<TResponseData>(response, endPoint);
    } catch (error) {
      // 401 에러 발생 시 토큰 갱신 시도
      const isUnauthorized = response.status === 401;

      // 클라이언트에서만 동작하고, 토큰 갱신 요청이 아니며, skipTokenRenewal이 false인 경우
      if (isUnauthorized && typeof window !== 'undefined' && !skipTokenRenewal) {
        const refreshSuccess = await handleTokenRefreshOn401(endPoint);

        if (refreshSuccess) {
          // ✅ 토큰 갱신 성공 시 원래 요청 재시도 (한 번만)
          const retryResponse = await fetch(apiUrl, defaultOptions);
          return await parseResponse<TResponseData>(retryResponse, endPoint);
        }
      }

      throw error;
    }
  } catch (error) {
    // 에러 처리...
  }
}

핵심 로직:

  1. API 요청 시 401 에러 발생
  2. 클라이언트 측에서 handleTokenRefreshOn401() 호출
  3. 토큰 갱신 성공 시 원래 요청을 자동으로 재시도
  4. 사용자는 에러를 인지하지 못하고 자연스럽게 데이터를 받음

📝 전체 흐름도

┌─────────────┐
│   Browser   │
│   Request   │
└──────┬──────┘
       │
       ▼
┌─────────────────────────────────────────┐
│         Middleware (Edge Runtime)       │
│                                         │
│  1. accessTokenExpiresAt 쿠키 확인        │
│  2. 만료되었다면 verify API 호출             │
│  3. 실제로 만료 → renewal API 호출          │
│  4. 새 토큰으로 Request 헤더 수정            │
│  5. 새 토큰으로 Response 쿠키 설정           │
│  6. x-needs-renewal 헤더 추가             │
└──────┬──────────────────────────────────┘
       │
       ▼
┌─────────────────────┐
│  Server Component   │
│  (새 토큰 사용)        │
└──────┬──────────────┘
       │
       ▼
┌─────────────────────────────────────────┐
│      Client: TokenRenewalHandler        │
│                                         │
│  1. x-needs-renewal 헤더 체크             │
│  2. true면 refreshAuthStatus() 호출       │
│  3. verify API로 서버에 인증 상태 확인        │
│  4. AuthProvider 상태 업데이트              │
└─────────────────────────────────────────┘

서버 (Middleware - Edge Runtime)

  • 토큰 만료 체크 (verify API)
  • 토큰 갱신 (renewal API)
  • 요청/응답 쿠키 수정
  • Custom Header로 클라이언트에 신호

클라이언트 (TokenRenewalHandler + AuthProvider)

  • Custom Header 감지
  • 인증 상태 확인 (verify API)
  • UI 상태 관리 (로그인/로그아웃)

핵심 원칙: 각자 잘하는 걸 한다


🚀 정리하며

결국 Middleware(서버 측)와 API Client(클라이언트 측)가 서로 빈틈을 메워주는 구조가 구현했어요.

  • 서버(Middleware): 페이지 이동할 때 토큰 체크하고 갱신해주고, 쿠키도 구움.
  • 클라이언트(Handler & Fetcher): 헤더 신호를 감지해서 상태를 업데이트하고, 페이지 이동 없는 요청에서도 안전하게 토큰을 지켜줌.

처음엔 "Middleware에서 값 바꿨는데 왜 안 바뀌지?" 하면서 삽질도 많이 했지만, 덕분에 Next.js의 요청/응답 흐름을 깊이 이해하게 된 것 같아요.

비슷한 고민을 하고 계신 분들께 이 글이 작은 힌트가 되었으면 좋겠습니다.

참고 자료

profile
안녕하세요, Frontend 개발자 윤해진입니다.

0개의 댓글