nextjs server Action을 적극 도입하게 되었다.

|

우리 회사의 기존 방식은 이러했다.

msa 구조였고, 레일웨이에 서버 컨테이너들을 올리고, 게이트웨이 api를 통해 개발을 했는데, 레일웨이의 공용 config 환경을 dev로 해놨지만 레일웨이 게이트웨이 주소가 https다보니까 nestjs 백엔드 서버에서 보내는 cookie가 프론트 및 브라우저에 저장이 안됬다.

즉 레일웨이 주소는 https고 프론트는 http localhost 환경이고 도메인이 다르니 sameSite:none, secure는 false로 해줬어야 했는데 문제는, sameSite:none 이라면 secure는 무조건 true여야 브라우저가 에러를 안던진다.

그래서 기존 프로젝트의 구조를 바꿨다.

바꾼 구조 1

회사 백엔드 환경을 그냥 회사 백엔드라 칭하겠다.

  • 모든 요청은 Route Handler를 경유한다.

Route Handler도 서버지만 nextjs위에서 돌아가기 때문에 모든 요청을 Route Handler를 경유하게 했다. 즉, 회사 서버 레일웨이 요청을 Router Handler가 담당하게 해줬다.

  • Route Handler에서 Cookie를 설정한다.

jwt token을 요청해서 return 값으로 받고 그대로 다시 setCookies(accessToken)을 해주었다.

회사 백엔드에서 jwt token의 payload 및 유효기간을 설정해놓은 token을 그대로 return해주고있는데, 그 값을 그대로 Route Handler에서 받아서 setCookies를 해준다.

결과적으로 이렇게 Route Handler를 경유하게 해주니까 Route Handler를 오케스트라 형식으로도 쓸 수 있다는 장점이 생겼다.

바꾼 구조 1의 문제점

  • f12 네트워크탭에서의 디버깅이 어려웠다.

Route Handler는 브라우저와 백엔드 사이의 독립된 서버레이어로 동작하기 때문에 네트워크 탭에 요청이 안떠서 디버깅이 힘들었다.

  • Route Handler → lib/api → 프론트 및 SSR → 다시 Route Handler 로 이어지는 어떻게 보면 ‘순환’에 가까운 구조가 되어버렸다.

이렇게 된 가장 큰 배경은, 에러 처리를 프론트에서 직접 제어해야 하는 상황 때문이었다.

  • 프론트에서 에러 처리를 따로해주어야했다.

Route Hanlder에서 에러 처리를 보통 다음과 같이 해준다고 치자

export async function GET(req: NextRequest) {

...

const res =  await fetch('/somthingsAPI')
const data = await res.json()
if (!res.ok) {
  return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
...

}

이걸 프론트에서 호출하면 아래와 같이 호출하게 될텐데

const res = await fetch('/api/somthingRouteHandler');

라우트핸들러에서 던지는 에러를 다시 감지해줘야하는 번거로움이 있었다.

const res = await fetch('/api/somthingRouteHandler');

if (!res.ok) {
  // 에러처리
}

라우트핸들러는 서버고, 프론트에서 그걸 호출하는것이기 때문에 에러 처리를 해줘야하는건 당연하지만 뭔가 아름답지못한 구조임에는 분명했다.

그래서 현 개발팀장이면서 CTO님께서 주신 의견(또는 조언..) 끝에 구조를 변경하게되었다.

바꾼 구조 2

Route Handler를 거치지 않고 Server Action을 직접 도입하는 방식으로 구조를 재정비했다. Server Action 역시 서버 환경에서 실행되기 때문에, 쿠키 처리와 같은 브라우저 제약에서 비교적 자유롭다.

물론, 이 선택이 개발 측면에서 무조건적인 이득만 있는 것은 아니다. 개발팀장님께 배운 것 중 하나는, 어떤 기술 선택에도 트레이드오프가 존재하며 그 손익을 비교했을 때 전체적으로 이득이 크다면 과감하게 선택해도 된다는 것이었다.

이번 변화로 얻은 점은 다음과 같다.

  • 순환 구조에 가까웠던 요청 흐름을 완전히 끊을 수 있었다.

  • 프론트에서 별도로 에러 처리를 구현할 필요가 없어졌다. (Server Action 내부에서 예외를 제어하며 보다 일관적인 흐름 유지 가능)

반면, 잃은 점도 분명히 존재한다.

  • 고통스러운 리팩토링 과정 (기존 lib/api 구조와 사용 패턴을 대거 수정해야 함)

  • 빠듯한 일정 속에서 추가적인 작업 부담이 발생했다.

마무리

이 문제를 덮어두고 점진적으로 리펙토링하는 방식을 따를 수 있겠지만, 현실적으로 바라봤을때 msa 구조고 그걸 호출하는 service 폴더가 연쇄적으로 묶여있다보니 지금 그나마 코드가 쌓여있지 않을 때 확실하게 잡고가는것이 낫다는 판단을했다.

railway api gateway rewrite 설정으로인한 404 error

|

서버 로컬 환경과 프론트 로컬 환경에서는 말끔히 동작하던것들이 레일웨이로 옮겨서 레일웨이와 통신하면서 잡다한 에러를 마주쳤다.

cors에러부터 시작해서.. 도메인이 달라 쿠키가 브라우저에 전달안되는 현상, 기타 등등..

그중에서 가장 어이없던 에러는 레일웨이 api 게이트웨이 주소 짤림 현상과 rewrite 현상이다.

레일웨이 게이트웨이 주소 짤림 현상

레일웨이 api 게이트를 사용할 때, 레일웨이 컨테이너의 이름이 길면 주소가 짤리는 현상이 발생한다.

예를들면 레일웨이 컨테이너의 이름이 file-service라면 https://file-servce-development.up.railway.ap/** 이런식으로 저장이 되어버린다. (원래 라면 https://file-service-development.up.railway.app/** 이렇게 저장이 되어야함 )

rewrite 현상

레일웨이 게이트웨이 주소 짤림 현상 때문에 file-service를 files로 바꿨던적이 있다. 이렇게했을때 통신할때의 주소는 만약 파일의 업로드를 POST해야한다면, https://railwayURL/files/api/v1/files/upload가 되는데, 어이없게도.. files를 레일웨이가 rewrite해버리면서 중간에 files가 사라져버리는 현상이 있었다.

즉 최종 통신은 https://railwayURL/files/api/v1/upload 가 되어버렸고 404를 띄웠다.

이거를 발견하기까지 수많은 콘솔로그를 찍고 api/v1 이것이 문제인것인가를 찾아봤으며 내가 담당하지않은 file-service였기에 file-service의 코드들을 하나하나 훑어보며 api/v1를 없애는 프리픽스를 했어야했나?? 내가 임의로 수정을 해도되는걸까? 담당하는 사람이 지금 많이 바쁜데 내가 채팅을 걸어도될까?? 를 수없이 고민했다.

지금은 레일웨이 이름을 files->fs로 변경해줬고 정상적으로 동작하는거를 확인했다.

마무리

개발자를 하기를 잘했다고 생각이 드는 순간은 에러를 마주칠때이고, 내가 정말 개발자가 적성이 맞는건가? 의심이 되는 순간 또한 에러를 마주쳤을때이다.

(사실은 사용자의 문제를 해결하는 서비스를 개발했을때가 정말 뿌뜻하고 개발자가 되기를 잘한것같다는 생각을 함미다…)

무튼 오늘 또 하나의 에러를 잡으면서 성장아닌 성장을 한 것 같아.. 다행이다

추가 - api/v1의 이유

라우트에 /api/v1을 붙이는 이유가 뭘까를 찾아봤는데,

API 서버 버전 관리 이 링크에서 알 수 있게 됬다.

한 번 버전이 정해진 이후에는 라우터를 함부로 수정하면 안 되기 때문이다. 왜냐하면 다른 사람이나 서비스가 기존 API를 쓰고 있음을 항상 염두에 두어야 하기 때문이라고한다.

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

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

|

1편에서는 Next.js App Router 환경에서 SSR 렌더링이 시작되기 전에 토큰을 리프레시하는 미들웨어 방식을 다뤘다.
이번 글에서는 그 구조가 가진 race condition 문제와, 이를 해결하기 위한 클라이언트 주도형 리프레시 접근을 정리한다.


미들웨어 사전 리프레시의 한계

1편에서 사용한 방식은 렌더링 전에 토큰을 갱신해 SSR을 안정적으로 보장하는 구조였다.
하지만 이 방식은 여러 요청이 동시에 들어오는 상황에서 심각한 부작용을 일으킬 수 있다.

팀장님께선 이 문제를 아래와같이 설명해주셨다.

“간단한 서비스는 이게 큰 문제가 되지는 않아서 사용자 경험이나 SEO를 생각해 사전리프레시를 하기도 하는 것 같은데, 우리가 개발하려는 사이트는 분산 시스템으로서 동시에 여러 서버 액션을 호출한다던가 하는 일이 잦을 수 있어 이 문제가 걱정스러운 수준까지 커질 수 있다고 생각합니다.”

문제 상황: race condition

race condition = 여러 요청이 동시에 동일한 자원(여기서는 refreshToken)에 접근해, 실행 순서에 따라 결과가 달라지는 불안정한 상태

예시

  • /dashboard, /me, /notifications 페이지를 동시에 요청
  • accessToken이 만료되어 있음
  • 각 요청의 미들웨어가 각자 동시에 /auth/restore-token 호출
  • 첫 번째 요청만 성공하고, 나머지는 “이미 사용된 refreshToken”으로 실패
  • 실패한 요청들이 /login으로 리다이렉트
  • 브라우저는 리다이렉트 루프에 빠짐 🔁
  • 심한 경우 요청이 O(n²) 수준으로 증식 → 서버 과부하

단일 서버에서는 눈에 잘 안 띄지만, 분산 시스템(예: 지금 프로젝트의 사이트 구조)에서는 쉽게 재현되는 문제다.


클라이언트 주도형 리프레시 (벨로그의 어떤분이 제안한 방식)

이 문제를 해결하기 위해 리프레시 주도권을 클라이언트로 옮긴 구조가 현재 시간을 아끼는 방법중 가장 베스트로 채택되었다. 즉, 401을 받은 후 클라이언트가 재요청하는 구조다.

흐름 요약

  1. 서버에서 401 발생
  2. 클라이언트의 axios interceptor가 이를 감지
  3. /auth/restore-token 요청
  4. 새 accessToken 발급 후 원래 요청을 재시도
  5. 성공 시 쿠키 동기화 완료

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

|

Next.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 서버 간 쿠키 싱크 문제 해결

단점

  1. 401 발생 시 요청 2번 (성능 이슈)

  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로 새 요청 시작 이게 현재까지 가장 깔끔한 구조.

참고자료