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

|

2편에서는 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를 사용하기 위해
clientApiserverApi 두 가지 래퍼를 만들어 사용했다.

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 인증 복구 사이클이 완성되었다.