nextjs 401 에러 재인증 처리하기 1편

|

Next.js App Router 환경에서 401 에러를 어떻게 처리할지 정리한 메모.
서버사이드에서 쿠키가 갱신되지 않는 문제를 여러 방식으로 실험하면서, 결국 미들웨어가 유일하게 일관된 해법이라는 결론에 도달했다.
아래는 그 과정에서 시도해봄직한 여러 방법들과 정리이다.

401 에러 처리 흐름

App Router 기반의 SSR 환경에서는 accessToken이 만료되었을 때
백엔드(NestJS)에서 refreshToken으로 재발급하더라도
Next 서버가 그걸 브라우저 쿠키로 전달하지 못함.

즉, Nest에서는 새 쿠키를 Set-Cookie로 내려보냈는데
Next.js는 이미 스트리밍이 시작되어 cookies()가 고정된 상태라
새 쿠키를 반영할 수 없음.

결과적으로 백엔드에서는 재발급 성공 → Next 서버는 이전 쿠키로 요청 → 또 401.


원인 요약

  • App Router는 서버컴포넌트 렌더링과 동시에 스트리밍이 시작됨
  • cookies()read-only라 한 번 읽으면 수정 불가능
  • SSR 렌더링 도중 쿠키 갱신이 일어나도 반영 불가

결국 렌더링 전에(스트리밍 시작 전) 토큰을 갱신해야 함.
이걸 할 수 있는 위치는 middleware밖에 없음.


미들웨어 방식 (결론)

렌더링 시작 전에 accessToken이 만료되어 있으면 refreshToken으로 새로 발급받고
그걸 response.cookies에 세팅한 다음 redirect로 새 요청 사이클 시작시킴.
즉, SSR 이전에 쿠키 교체 완료 후 렌더링이 시작되도록 하는 구조.

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { jwtVerify } from 'jose';
import { USER_SERVICE_BASE_URL } from './const';

const JWT_SECRET = new TextEncoder().encode(process.env.AUTH_SECRET);

export async function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;

  if (
    pathname.startsWith('/_next/') ||
    pathname.startsWith('/api/') ||
    pathname.startsWith('/favicon.ico') ||
    pathname.includes('.')
  ) {
    return NextResponse.next();
  }

  const accessToken = request.cookies.get('accessToken')?.value;
  const refreshToken = request.cookies.get('refreshToken')?.value;

  if (!accessToken && !refreshToken) {
    return NextResponse.redirect(new URL('/login', request.url));
  }

  try {
    if (accessToken) await jwtVerify(accessToken, JWT_SECRET);
  } catch {
    if (!refreshToken) {
      return NextResponse.redirect(new URL('/login', request.url));
    }

    const res = await fetch(`${USER_SERVICE_BASE_URL}/auth/restore-token`, {
      method: 'POST',
      credentials: 'include',
      headers: { Cookie: request.cookies.toString() },
    });

    if (!res.ok) return NextResponse.redirect(new URL('/login', request.url));

    const data = await res.json();
    const newResponse = NextResponse.redirect(request.url);
    newResponse.cookies.set('accessToken', data.accessToken);

    // 새 요청 사이클로 교체
    return newResponse;
  }

  return NextResponse.next();
}

export const config = {
  matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};

특징

  • 렌더링 전에 새 토큰 확보 가능

  • serverUserApi.getMe()에서 401이 더이상 안터짐

  • 브라우저와 Next 서버 간 쿠키 싱크 문제 해결

단점

  1. 401 발생 시 요청 2번 (성능 이슈)

  2. 무한 리다이렉트 위험 있음

→ 해결책

  • 401 자체가 자주 안 나오는 구조로 설계

  • 재시도 횟수를 쿠키나 헤더에 기록해서 제한


server-api 쪽에서 해볼만한 시도들

  • 현재 일하고있는 회사의 개발팀장님께서 제시해준 대안과

  • Velog의 NARARIA03 님께서 다듬어준 App Router의 구조상 완벽하진 않지만 참고할 만한 접근들임


1. axios 내부에서 1회성 캐싱 토큰으로 해결 시도

렌더링 사이클 안에서만 유효한 newAccessToken 변수를 둠.
401 → refresh → newAccessToken 저장 → 이후 요청부터 이 값 사용.

import axios from 'axios';
import { cookies } from 'next/headers';

export const serverApi = () => {
  let newAccessToken: string | null = null;

  const instance = axios.create({
    baseURL: process.env.NEXT_PUBLIC_USER_SERVICE_URL,
  });

  instance.interceptors.request.use(async (config) => {
    const store = await cookies();
    const oldAccessToken = store.get('accessToken')?.value;
    const token = newAccessToken ?? oldAccessToken;
    if (token) config.headers.Authorization = `Bearer ${token}`;
    return config;
  });

  instance.interceptors.response.use(
    (res) => res,
    async (error) => {
      if (error.response?.status === 401) {
        const res = await fetch(
          `${process.env.NEXT_PUBLIC_USER_SERVICE_URL}/auth/restore-token`,
          { method: 'POST', credentials: 'include' }
        );
        if (res.ok) {
          const data = await res.json();
          newAccessToken = data.accessToken;
          return instance(error.config);
        }
      }
      throw error;
    }
  );

  return instance;
};

2. 에러 바운더리에서 401 처리 (클라이언트 위임)

이건 NARARIA03 님이 제안한 실험적인 방식.
서버 컴포넌트에서 401이 발생하면 ErrorBoundary로 던지고 클라이언트에서 refresh → router.refresh()로 SSR 재요청.

// app/error.tsx
'use client';
import { useRouter } from 'next/navigation';
import { useEffect, startTransition } from 'react';

export default function Error({ error, reset }) {
  const router = useRouter();

  useEffect(() => {
    const tokenRefresh = async () => {
      if (error.digest === 'UNAUTHORIZED' || error.message === 'UNAUTHORIZED') {
        const res = await fetch('/auth/refresh', { method: 'POST' });
        if (res.ok) {
          startTransition(() => {
            router.refresh();
            reset();
          });
        }
      }
    };
    tokenRefresh();
  }, [error, reset, router]);

  return null;
}
  • 서버는 단순히 401 던짐

  • 클라이언트가 토큰 갱신 후 페이지 새로 렌더

  • 자연스러운 UI 흐름 가능

  • 단, 공식적인 방법은 아니고 트릭에 가까움

결론

결론적으로 App Router 기반 SSR에서는 미들웨어에서 리프레시 처리 → redirect로 새 요청 시작 이게 현재까지 가장 깔끔한 구조.

참고자료