nextjs 401 에러 재인증 처리하기 3편
22 Oct 2025 | memo nextjs authentication middleware app-router2편에서는 MSA 구조에서 Next.js 미들웨어의 한계와 race condition 문제를 다루었고,
그 해결책으로 리프레시 주도권을 클라이언트로 옮기는 구조를 채택했다.
이번 3편에서는 그 구조를 실제로 어떻게 구현했는지를 간단히 정리해본다.
서술
리프레시 주도권을 클라이언트로 옮기는 방법은 생각보다 다양했다.
대표적으로는 두 가지 방식이 있었다.
결론부터 말하자면, 나는 React의
cache로 감싸 함수를 반복 호출하는 방법을 선택했다.
-
클라이언트 측에서 컴포넌트를 감싸
AuthBoundary형태로 사용하는 방법
→ 서버에서 401이 발생하면error.tsx에서AuthRestore를 렌더 -
React의
cache를 이용해 서버 함수(fetchCurrentUser)를 반복 호출해도
한 번만 실행되게 하는 방법
Next.js의 401 관련 사례들을 찾아보면 대부분 미들웨어 중심으로 처리하고 있었다.
하지만 지금 프로젝트는 msa구조고 그로인해 클라이언트에서 서버에 여러 요청을 짧은 시간 내에 보내게 되면 각 미들웨어가 각자 리프레시를 시도하므로 하나를 제외한 모든 요청이 실패하고 리다이렉트 루프에 빠지게된다. 이 루프에서는 동시에 들어온 요청들 중 처음 빠져나가는 하나만 탈출할 수 있기 때문에, 이론상 최악의 경우 요청 n개가 O(n^2)개쯤으로 증식해 서버를 터트릴수 있었다.
지금 프로젝트는 분산 시스템으로서 동시에 여러 서버 액션을 호출한다던가 하는 일이 잦을 수 있어 이 문제가 걱정스러운 수준까지 커질 수 있다고 생각한다는 팀장님의 의견이있었고,
여러방안을 모색하던중 일단은 이 방법을 채택하여 구현하게되었다.
여담이지만, 팀장님이 하신 말씀이 아직도 기억난다.
“이런 문제는 정답이 없다. 여러 솔루션의 장단점을 모두 고려하고,
장점이 의미없어지거나 단점이 극복 불가능해지면 유연하게 바꾸면 된다.”
그 말 덕분에, “지금은 최선이지만 영원한 정답은 아니다”라는 명언을 가슴속에 품고 코딩을 하게 되었다..
구현
serverAPI
서버와 클라이언트 양쪽에서 동일한 fetch 기반 API를 사용하기 위해
clientApi와 serverApi 두 가지 래퍼를 만들어 사용했다.
clientApi는 흔히 알려진,
“401 발생 시 이벤트 큐에 요청을 넣고 토큰 재발급 후 재시도하는 구조”라서 생략하고,
여기서는 SSR용 serverApi만 다룬다.
import "server-only";
import { cookies as nextCookies } from "next/headers";
import { ApiAuthError, ApiError } from "./api-error";
if (!process.env.NEXT_PUBLIC_BACKEND_URL) {
throw new Error("NEXT_PUBLIC_BACKEND_URL is not defined");
}
export async function serverApi<T = any>(
endpoint: string,
options?: RequestInit
): Promise<T> {
try {
const cookies = await nextCookies();
const res = await fetch(
`${process.env.NEXT_PUBLIC_BACKEND_URL}${endpoint}`,
{
...options,
signal: options?.signal || AbortSignal.timeout(35000),
headers: {
"Content-Type": "application/json",
Cookie: cookies.toString(),
...options?.headers,
},
}
);
if (res.status === 401) {
throw new ApiAuthError();
}
const body = await res.json();
if (!res.ok) {
throw new ApiError(
body.message || `요청 실패: ${res.status}`,
res.status,
body
);
}
return body.data || body;
} catch (error) {
if (error instanceof ApiError) throw error;
if (
error instanceof Error &&
(error.name === "AbortError" || error.name === "TimeoutError")
) {
throw new ApiError(
"요청 시간이 초과되었습니다. 잠시 후 다시 시도해주세요.",
408,
"Request Timeout"
);
}
if (error instanceof TypeError && error.message.includes("fetch failed")) {
throw new ApiError("Network Error", 500, "Network Error");
}
throw error;
}
}
ApiError와 ApiAuthError를 별도로 둔 이유는, 401 에러를 좀 더 명확하게 구분해 클라이언트 측에서 재인증(AuthRestore)을 트리거하기 위함이다.
즉, serverAPI는 서버에서 401을 감지해 클라이언트로 넘기는 첫 관문이 된다.
ProtectedRoute
import AuthRestore from "@components/common/auth-restore";
import { ApiAuthError } from "@lib/api-error";
import { fetchCurrentUser } from "@lib/api/users/me";
export default async function ProtectedRoute({
children,
}: {
children: React.ReactNode;
}) {
try {
await fetchCurrentUser();
return <>{children}</>;
} catch (e) {
if (e instanceof ApiAuthError) {
return <AuthRestore />;
}
throw e; // 다른 에러는 error.tsx로 전달
}
}
로그인하지 않은 사용자나 리프레시 토큰이 만료된 사용자를 걸러내는 관문이다.
여기서 중요한 포인트는 fetchCurrentUser()와 AuthRestore이다.
-
fetchCurrentUser()-
로그인 여부를 판별하기 위함
-
자식 컴포넌트의 SSR 단계에서 fetchCurrentUser()를 다시 호출할 때 발생할 수 있는 401을 선제적으로 처리하기 위함
-
만약 ProtectedRoute에서 fetchCurrentUser()를 호출하지 않고 자식 컴포넌트에서만 호출한다면, Next.js의 bottom-up 렌더링 구조상 ProtectedRoute가 그 에러를 잡지 못해 그대로 페이지가 깨지게 된다.
AuthRestore
서버에서 던진 401을 클라이언트로 넘겨주는 역할이다. serverApi에서 401을 ApiAuthError로 래핑한 이유도 바로 이것 때문이다.
AuthRestore
"use client";
import { clientApi } from "@lib/client-api";
import { signout } from "@lib/data/customer";
import { useRouter } from "next/navigation";
import { useEffect, useRef } from "react";
const MAX_ATTEMPTS = 2;
export default function AuthRestore() {
const router = useRouter();
const attemptsRef = useRef(0);
const hasRunRef = useRef(false);
useEffect(() => {
if (hasRunRef.current) return;
if (attemptsRef.current >= MAX_ATTEMPTS) {
handleAuthFail();
return;
}
hasRunRef.current = true;
const restore = async () => {
try {
await clientApi(
"/auth/restore-token",
{ method: "POST" },
{ signal: AbortSignal.timeout(3000) }
);
window.location.reload();
} catch {
attemptsRef.current++;
if (attemptsRef.current < MAX_ATTEMPTS) {
setTimeout(restore, 1000);
} else {
handleAuthFail();
}
}
};
restore();
}, []);
const handleAuthFail = async () => {
const currentPath = window.location.pathname;
const countryCode = currentPath.split("/")[1] || "kr";
if (currentPath === "/" || currentPath === `/${countryCode}/`) {
await signout(countryCode);
} else {
router.replace(`/${countryCode}/auth/login`);
}
};
return (
<div className="flex min-h-screen items-center justify-center">
<p className="text-gray-500">인증 상태를 복구 중입니다...</p>
</div>
);
}
MAX_ATTEMPTS, attemptsRef, hasRunRef로
AuthRestore가 여러 번 렌더되더라도
리프레시 요청이 한 번만 실행되도록 안전장치를 추가했다.
Context API로 관리하는 방법도 고려했지만, Provider가 하나 더 생긴다는 점이 구조적으로 부담스럽다고 판단해 단일 컴포넌트 레벨의 방어 로직으로 마무리했다.
또 한 가지 중요한 부분은,
/auth/restore-token 요청에 clientApi를 사용했다는 점이다.
이유는 간단하다.
clientApi는 401 응답 시 이벤트 큐에 요청을 쌓고 토큰을 재발급한 뒤 쿠키를 브라우저에 반영하는 로직이 내장되어 있다.
즉, 단순한 fetch()보다 쿠키 동기화가 훨씬 안정적이다. 이를 통해 무한 리다이렉트 문제를 원천 차단할 수 있다.
fetchCurrentUser
마지막으로, 개인적으로 가장 뿌듯했던 부분이다. React 19에서 새롭게 추가된 cache() API를 직접 적용했다. 덕분에 같은 SSR 요청 안에서는 여러 컴포넌트가 fetchCurrentUser()를 호출해도 한 번만 실행되게 만들 수 있었다.
게다가 fetch의 cache: ‘no-store’ 옵션을 함께 사용해 CDN이나 Next.js의 fetch 캐시가 개입하지 않도록 막았고, 요청마다 항상 최신 유저 데이터를 받아오게 했다.
"use server"
import { ApiError } from "@lib/api-error"
import { serverApi } from "@lib/server-api"
import { UserDetailsResponseDto } from "@lib/types/dto/user"
import { UserBasicInfo as User } from "@lib/types/ui/user"
import { toUserBasicInfo } from "@lib/utils/transformers/user.transformer"
import { cookies, headers } from "next/headers"
import { cache } from "react"
export const fetchCurrentUser = cache(async (): Promise<User | null> => {
const cookieStore = await cookies()
const headersList = await headers()
const pathname = headersList.get("x-pathname") || ""
const countryCode = pathname.split("/")[1] || "kr"
try {
const dto: UserDetailsResponseDto = await serverApi("/users/detail", {
cache: "no-store",
})
return toUserBasicInfo(dto)
} catch (error) {
if (error instanceof ApiError) {
if (error.message === "Network Error") {
throw new Error("Network Error")
}
throw error
}
})
이 구조 덕분에 SSR 렌더링 중에 동일한 유저 정보가 여러 번 필요한 경우에도 API 호출은 한 번으로 끝나고, 서버 리소스 낭비 없이 React가 동일 Promise를 재사용한다. 그리고 요청이 끝나면 캐시는 자동으로 해제되어 다음 요청에서는 항상 새로운 유저 정보를 불러온다.
마무리
전체 흐름을 정리하면 이렇다.
serverApi — 서버에서 401을 감지하고, 이를 ApiAuthError로 감싼 커스텀 에러 객체로 변환해 전달한다.
ProtectedRoute — SSR 단계에서 인증 상태를 판별
AuthRestore — CSR 단계에서 토큰 복구 및 재요청
fetchCurrentUser — React 19의 cache()로 중복 호출을 줄이고, no-store로 최신 상태를 보장
이 네 가지가 톱니처럼 맞물리며, Next.js App Router 환경에서 안정적인 401 인증 복구 사이클이 완성되었다.
앉았으니, 해보는 거죠