
Next.js App Router 환경에서 RSC(React Server Components)와 클라이언트 양측 모두에서 안정적으로 작동하는 인증 시스템을 구축한 경험을 공유해 볼게요.
Next.js Middleware를 활용해 서버/클라이언트 양측에서 동기화된 토큰 관리 시스템을 구현했어요.
핵심 포인트:
x-needs-renewal, x-needs-logout)로 서버↔클라이언트 싱크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);
}
},
);
이런 방식은 아래와 같은 아쉬운 점들이 있어요.
그래서 생각해본 다음 방식은 Middleware를 활용하는 것이었어요.
2. Middleware에서 처리하는 방식 (선택)
Next.js의 Middleware는 모든 요청이 서버에 도달하기 전 가장 먼저 실행되어 다음과 같은 이점이 있었어요.
제가 구현하고자 했던 요구사항은 다음과 같았어요.
Middleware는 이 모든 요구사항을 만족하는 최적의 선택이라고 생각했어요.
그러나 실제 구현은 쉽지 않았어요..😅

Middleware에서 토큰 갱신 API를 호출하고 새로운 토큰을 받아왔어요. 로그를 찍어보니 분명히 새 토큰이 잘 왔었죠.
// middleware.ts
const { token, refreshToken } = await renewalAPI();
console.log('새 토큰:', token); // ✅ 출력됨!
// 그런데...
문제는 이후 서버 컴포넌트나 클라이언트에서 토큰을 확인하면 여전히 옛날 토큰이 사용되고 있는것이었어요. 😱
Middleware의 동작 원리를 이해하는 것이 핵심이었어요.
Middleware는 "들어오는 요청(NextRequest) 하나를 받아, 반드시 단 하나의 최종 응답(NextResponse)을 반환하는 함수"
즉, Middleware 내부에서 아무리 토큰을 갱신하고 변수에 저장해도, 그 변화를 NextRequest와 NextResponse에 반영하지 않으면 의미가 없었던거죠.
두 가지를 모두 수정해야 했습니다:
// 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', ...): 브라우저가 받을 응답 수정📊 개선 효과
Middleware에서 토큰을 갱신했어요. 서버 컴포넌트는 새 토큰을 잘 사용했죠.
그런데 클라이언트 컴포넌트(UI)는 여전히 이전 상태를 보고 있었어요.
// 로그인 상태를 표시하는 컴포넌트
export function UserProfile() {
const { isAuthenticated } = useAuthState();
// Middleware에서 로그아웃 처리했는데도 여전히 true 😱
return isAuthenticated ? <Profile /> : <LoginButton />;
}
이건 당연한 결과였어요. 왜냐면 리렌더링을 유발할 트리거가 없었기 때문이죠.
localStorage나 Event를 써볼까 했지만, 서버(Middleware)와 클라이언트 사이의 벽을 넘기가 쉽지 않았어요.
그렇게 여러 방법을 고민해 보게 되었어요.
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 };
}
이렇게 하면 클라이언트의 인증 상태도 항상 서버가 출처가 되어 신뢰할 수 있습니다.
📊 개선 효과
만능일 줄 알았던 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 에러 발생 😱
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) {
// 에러 처리...
}
}
핵심 로직:
handleTokenRefreshOn401() 호출┌─────────────┐
│ 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)
클라이언트 (TokenRenewalHandler + AuthProvider)
핵심 원칙: 각자 잘하는 걸 한다

결국 Middleware(서버 측)와 API Client(클라이언트 측)가 서로 빈틈을 메워주는 구조가 구현했어요.
처음엔 "Middleware에서 값 바꿨는데 왜 안 바뀌지?" 하면서 삽질도 많이 했지만, 덕분에 Next.js의 요청/응답 흐름을 깊이 이해하게 된 것 같아요.
비슷한 고민을 하고 계신 분들께 이 글이 작은 힌트가 되었으면 좋겠습니다.