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 감지

ProtectedRoute → SSR에서 인증 상태 판별

AuthRestore → CSR에서 토큰 복구 및 재요청

fetchCurrentUser → React 19 cache()로 중복 호출 방지 + no-store로 최신성 보장

이 네 가지가 맞물리면서 Next.js App Router에서 안정적으로 401을 처리하는 인증 복구 사이클이 완성됐다.

..3편에서 끝나지않고 4편이 나올것같은 건 기분탓일까?