nextjs 401 에러 재인증 처리하기 1편
22 Oct 2025 | memo nextjs authentication middleware app-routerNext.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 서버 간 쿠키 싱크 문제 해결
단점
-
401 발생 시 요청 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로 새 요청 시작 이게 현재까지 가장 깔끔한 구조.
앉았으니, 해보는 거죠