[Next.js] Axios를 사용한 클라이언트/서버 사이드 공통 토큰 재발급 로직 구현

NARARIA03·2025년 9월 6일
0
post-thumbnail

개요


먼저 기본적인 배경부터 정리한 뒤, 토큰 재발급 로직을 클라이언트/서버 사이드 모두에서 깔끔하게 사용할 수 있을지 고민하며 구현했던 Axios 모듈을 소개하고, 마지막으로 구현한 Axios 모듈과 React Query Hydration SSR의 통합 방법을 정리해보려고 한다.

토큰 재발급의 필요성


먼저 토큰 재발급이 필요한 이유에 대해 고민해보자.

현대 웹/앱에서 자주 사용되는 JWT(Json Web Token)인증의 특징은 서버가 클라이언트의 세션 정보를 관리하지 않는 무상태성을 띤다는 것이다.

클라이언트에서 로그인 요청 시 서버는 인증 정보가 담긴 토큰을 생성해서 응답하고, 이후 클라이언트에서는 인증이 필요한 API 요청 시 토큰을 요청 헤더에 포함하여 전달한다. 그러면 서버는 요청 헤더의 토큰을 디코딩하고 서명을 검증하여 유효성을 확인한다. 이로 인해 확장성이 뛰어나다는 장점을 가진다.

하지만 토큰은 클라이언트에게 있기 때문에 공격자가 토큰을 탈취해 서버로 요청을 날리면, 서버는 이를 구분할 수 없다는 위험성이 존재한다. 그렇다고 토큰의 만료 기한을 짧게 설정하면, 일반 사용자들의 인증이 자주 만료되어 UX가 저하된다.

이러한 문제점을 해소하기 위해 로그인 시 서버는 인증에 사용되는 Access token과, 재발급에 사용되는 Refresh token을 각각 생성해서 응답한다. Access token의 만료 기한은 짧게 설정하고 만료 시 Refresh token으로 토큰을 재발급함으로써, 사용자의 로그인 유지 시간을 늘림과 동시에 Access token 탈취 위험성을 비교적 줄일 수 있다.

[참고] Refresh Token Rotation?


만약 Access token과 Refresh token이 모두 탈취되면 어떻게 될까?

Access token을 무효화하는 것은 불가능하지만, Refresh token을 서버의 인메모리 DB 등에 저장해 1회용으로 만들어 개선 가능하다. 이러한 기법을 Refresh Token Rotation이라고 한다.

  1. 로그인 시 생성된 Refresh token을 서버에 저장해둔다.
  2. 토큰 재발급 요청이 들어오면, 저장해두었던 값과 비교 검증한다.
  3. 검증 성공 시 새 토큰을 발급하고, 새 Refresh token으로 업데이트한 뒤 클라이언트에게 전달한다.
  4. 검증 실패 시 토큰을 발급하지 않는다.

결과적으로 사용자가 공격자보다 먼저 토큰을 재발급하거나 로그아웃 후 로그인하면, 공격자의 Refresh token은 만료되지 않았어도 새 토큰을 발급할 수 없게 된다.

다만 Refresh Token Rotation을 사용하면 무상태성이 다소 옅어지고, 클라이언트에서는 재발급 요청 시 Race condition을 신경써야 한다는 트레이드오프가 발생한다.

토큰 재발급 시 발생하는 Race condition 문제


Access token이 만료된 상황에서 3개의 요청을 동시에 날리면, 401 에러가 3번 내려오게 된다. 이 때 토큰 재발급 요청을 3번 날리는 것은 낭비다. 게다가 만약 서버 측에서 Refresh Token Rotation이 적용되어 있다면, 나머지 2개의 재발급 요청은 실패하게 된다.

Race condition을 제어하기 위해 클라이언트 사이드에서는 보통 전역 Axios instance에 interceptor 함수를 등록해 토큰 재발급 로직을 중앙에서 관리한다.

그러나 만약 서버 사이드에서도 토큰 재발급 로직이 필요한 상황이라면 어떻게 해야 할까?

Next.js middleware를 활용한 서버 사이드 토큰 재발급


Stackoverflow, Github issue 등을 돌아다니며 다양한 방법을 조사해본 결과, 가장 안정적인 방법은 Next.js의 middleware를 사용하는 방법이었다.

핵심은 미들웨어가 항상 GSSP(getServerSideProps)보다 먼저 실행된다는 것과, Access token을 쿠키에 저장할 때 maxAge를 토큰의 만료 시점으로 설정해 만료 시 자동으로 삭제되도록 하는 것이다.

하지만 미들웨어로 토큰 재발급을 관리하게 되면, 클라이언트/서버 사이드의 토큰 재발급 흐름이 동일하지 않다는 단점이 생긴다.

  • 클라이언트 사이드: API 요청 -> 401 발생 -> 토큰 재발급 -> 기존 요청 재시도
  • 서버 사이드: 페이지 요청 -> 미들웨어 토큰 검증 -> 토큰 재발급 -> API 요청 -> 렌더링

즉, 클라이언트에서는 일단 요청을 날린 후 401 에러 발생 시 토큰을 재발급하지만, 서버에서는 토큰을 먼저 재발급한 뒤 요청을 날리게 된다.

이로 인한 엣지 케이스 발생과 유지보수의 어려움을 고려해 서버에서도 Axios를 활용해 토큰을 재발급하는 방향으로 결정하게 되었다.

서버 사이드에서 Axios interceptor 사용 시 마주하는 문제


처음 고민하던 당시 서버 사이드에서 Axios interceptor 사용을 깊게 고려하지 않았던 이유는 크게 두 가지다.

먼저 서버 사이드에서는 브라우저의 쿠키에 직접 접근할 수 없고, context.req.cookies 를 통해서 접근할 수 있다. 하지만 토큰 재발급 interceptor는 내부에서 쿠키를 읽고, 업데이트하고, 삭제할 수 있어야 한다.

또 하나의 문제는 서버용 Axios instance를 전역적으로 구현하면 보안 문제가 발생할 수 있다는 것이다. 토큰 재발급 도중 401 에러가 발생한 요청들은 대기 큐에 저장했다가 새 Access token으로 재시도하게 되는데, 만약 대기 큐가 전역적으로 관리된다면 다른 사용자의 Access token으로 요청이 처리될 가능성이 있다.

이런 문제들을 해결하기 위해 결론적으로 실행 컨텍스트 격리와 클로저 개념을 활용하게 되었다.

토큰 재발급 로직 구현


간단한 프로젝트를 구성해서 토큰 재발급 로직을 구현 및 테스트 해볼 예정이다. 아래 스펙은 이미 주어졌다고 가정하고, 토큰 재발급 로직 작성에 초점을 맞추겠다.

프론트엔드 기술 스택:

  • Next.js (page router)
  • Axios
  • cookies-next v4.3.0

백엔드 API:

  • 로그인: POST /api/auth/login
  • 토큰 재발급: POST /api/auth/refresh
  • 인가 데이터 조회: GET /api/awesome-data
  • 로그인 계정 정보 조회: GET /api/users/me

프론트 라우팅 구조:

  • /login?next={nextUrl}: 로그인 페이지, 로그인 성공 시 nextUrl로 리다이렉트
  • /me: 인가 페이지, 인가 데이터/로그인 계정 정보 조회 API 동시 요청 수행

구현하려는 기능:

  • 클라이언트/서버 사이드 모두에서 개발자가 토큰 재발급을 신경 쓰지 않고 API를 호출할 수 있는 설계
    • 클라이언트 사이드: 컴포넌트에서 API 요청 시 401 에러 발생 -> 토큰 재발급 -> 기존 요청 재시도
    • 서버 사이드: GSSP에서 API 요청 시 401 에러 발생 -> 토큰 재발급 -> 기존 요청 재시도
  • 토큰 재발급 Race condition 제어

디렉토리 구조


클라이언트/서버 사이드에서 사용할 axios 관련 로직은 아래와 같이 구현하겠다.

src/module/axios/
├── index.ts             # Barrel export
├── api/getNewToken.ts   # 토큰 재발급 요청 API
├── instances/
│   ├── client.ts        # 클라이언트용 axios 인스턴스
│   └── server.ts        # 서버사이드용 axios 인스턴스 생성 함수
├── interceptors/
│   ├── request/
│   │   ├── client.ts    # 클라이언트 request interceptor
│   │   └── server.ts    # 서버사이드 request interceptor
│   └── response/
│       ├── client.ts    # 클라이언트 response interceptor
│       └── server.ts    # 서버사이드 response interceptor
├── constants/
│   └── index.ts         # 토큰 쿠키 키 상수
├── types/index.ts       # JWT/Error 타입 정의
└── utils/index.ts       # 에러 핸들링 유틸

토큰 재발급 로직 설계


클라이언트/서버 사이드 토큰 재발급 로직은 동일하게 구현된다. 그러므로 먼저 재발급 로직에 대한 의사 코드를 정리해보자.

  1. Axios interceptor 실행
  2. 401 에러가 아니거나, Refresh token이 존재하지 않는 경우 에러를 그대로 반환
    • 토큰 재발급이 필요없는 경우
  3. 토큰 재발급 시도 중이라면, 대기 queue에 요청 추가 후 종료
    • Race condition 방지를 위해
  4. 토큰 재발급 시도 flag를 true로 변경
  5. Refresh token을 사용해 토큰 재발급 요청
    • 성공 시
      • Refresh/Access token 쿠키 업데이트
      • 새 Access token으로 대기중이던 요청 재시도
      • 토큰 재발급을 유발한 요청 재시도
    • 실패 시
      • Refresh/Access token 쿠키 삭제
      • 로그인 페이지로 리다이렉트
  6. 토큰 재발급 시도 flag를 false로 변경
  7. 대기 큐 초기화

클라이언트 Axios instance 구현


위에서 정리한 의사 코드를 기반으로 클라이언트 사이드에서 사용할 전역 Axios instance를 구현하자.

요청 전, 헤더에 Access token을 주입


먼저 Axios instance를 생성하고, 요청 전 Access token을 Authorization 헤더에 주입하는 interceptor 함수를 구현한 뒤 연결해보자.

휴먼 에러를 방지하기 위해 쿠키 키 상수화 작업을 진행한 뒤, 클라이언트 사이드에서 사용할 전역 Axios instance를 생성한다.

// constants/index.ts

export const ACCESS_TOKEN_KEY = "ACCESS_TOKEN_KEY";
export const REFRESH_TOKEN_KEY = "REFRESH_TOKEN_KEY";
// instances/client.ts

import axios from "axios";

export const APIClient = axios.create({
  baseURL: "http://localhost:4000",
  timeout: 5 * 1000,
});

그 다음 요청 전에 브라우저의 쿠키에 저장된 Access token을 가져와 헤더의 Authorization 필드에 추가하는 interceptor를 구현한다.

// interceptors/request/client.ts

import type { InternalAxiosRequestConfig } from "axios";
import { getCookie } from "cookies-next";
import { ACCESS_TOKEN_KEY } from "../../constants";

type Config = InternalAxiosRequestConfig;

export const clientRequestInterceptor = (config: Config) => {
  const accessToken = getCookie(ACCESS_TOKEN_KEY);

  if (accessToken) {
    config.headers.Authorization = `Bearer ${accessToken}`;
  }

  return config;
};

마지막으로 APIClient의 request interceptor 첫 번째 파라미터에 clientRequestInterceptor를 전달해준다.

// instances/client.ts

import axios from "axios";
import { clientRequestInterceptor } from "../interceptors/request/client";

export const APIClient = axios.create({
  baseURL: "http://localhost:4000",
  timeout: 5 * 1000,
});

APIClient.interceptors.request.use(clientRequestInterceptor, undefined);

이제 APIClient를 사용해 요청을 날리면, 브라우저의 쿠키에 Access token이 있을 때 자동으로 헤더에 추가된다.

401 에러 발생 시 토큰 재발급 시도


다음으로 401 에러 발생 시 토큰 재발급 후 재요청을 처리하는 interceptor를 구현해보자. 먼저 현재 토큰 재발급 중인지 관리하는 전역 flag와, 재시도 대기중인 요청들을 모아둘 queue를 추가하자.

대기 큐에 들어가는 원소가 Access token을 파라미터로 받는 함수인 이유는 아래에서 설명할 예정이다.

// types/index.ts

/** 요청 대기큐에 들어가는 원소 */
export type PendingApiCallback = (newAccessToken: string) => void;
// interceptors/response/client.ts

import type { AxiosError } from "axios";
import type { PendingApiCallback } from "../../types";

/** 현재 토큰 재발급 중인지 여부 */
let isRefreshing: boolean = false;

/** 토큰 재발급 도중 401에러가 발생했던 요청 대기큐 */
let pendingApiQueue: PendingApiCallback[] = [];

export const clientResponseErrorInterceptor = async (error: AxiosError) => {};

이제 의사 코드 흐름대로 interceptor를 구현할 차례다.

우선 토큰 만료 에러가 아닌 경우 에러를 그대로 반환해 비즈니스 로직에서 예외처리 할 수 있도록 해준다.

// interceptors/response/client.ts

import type { AxiosError } from "axios";
import type { PendingApiCallback } from "../../types";
import { getCookie } from "cookies-next";
import { REFRESH_TOKEN_KEY } from "../../constants";

...

export const clientResponseErrorInterceptor = async (error: AxiosError) => {
  const requestConfig = error.config;
  const refreshToken = getCookie(REFRESH_TOKEN_KEY);

  /** Refresh token이 존재하지 않거나, 토큰 만료 에러가 아닌 경우 */
  if (!requestConfig || !refreshToken || error.response?.status !== 401) {
    return Promise.reject(error);
  }
};

다음으로 토큰 재발급 시도 중인 경우, 대기 queue에 요청을 대기시키는 부분을 구현한다.

// interceptors/response/client.ts

import type { AxiosError } from "axios";
import type { PendingApiCallback } from "../../types";
import { getCookie } from "cookies-next";
import { REFRESH_TOKEN_KEY } from "../../constants";
import axios from "axios";

...

export const clientResponseErrorInterceptor = async (error: AxiosError) => {
  
  ...
  
  /** 현재 다른 요청에 의해 토큰 재발급 진행중이라면, 대기 큐에 요청 대기 */
  if (isRefreshing) {
    return new Promise((resolve, reject) => {
      pendingApiQueue.push((newAccessToken) => {
        requestConfig.headers.Authorization = `Bearer ${newAccessToken}`;
        axios(requestConfig).then(resolve).catch(reject);
      });
    });
  }
};

이 부분이 개인적으로 다소 어렵다고 느껴 정리하고 넘어가려고 한다.

먼저 Axios 공식 문서에 따르면, interceptor의 반환값은 일반적으로 Promise여야 한다. 그리고 Axios 코드 일부를 살펴보면, interceptor의 응답 Promise가 settled될 때까지 대기한다는 것을 확인할 수 있다.

추가로 Promise.resolve(), Promise.reject()는 이미 fulfilled/rejected된 Promise를 반환하지만, new Promise(executor)는 executor 함수 내부의 resolve/reject 함수 둘 중 하나가 실행되기 전까지 pending 상태로 유지된다.

위 내용을 기반으로 큐에 요청을 대기시키는 로직을 분석해보자.

토큰 재발급 중인 경우 interceptor는 Promise를 반환하고 종료되지만, Axios는 반환된 Promise가 settled될 때까지 대기한다. 이 때 대기 큐에 push된 재요청 함수는 클로저로써 resolve/reject 함수를 기억하고 있다가 실행되는 시점에 Promise를 settled 상태로 변경시키게 됩니다. 만약 재요청 응답이 fulfilled면 resolve가 실행되고, rejected면 reject가 실행되어 재요청 결과를 최종적인 응답으로 사용하게 된다.

다음으로 토큰 재발급을 위해 DTO와 토큰 재발급 요청 함수를 구현하자. 이 때 해당 interceptor가 연결된 instance를 사용하게 되면, Refresh token이 만료되었을 때 무한 재귀가 발생할 수 있어 주의해야 한다.

// types/index.ts

...

export type RefreshDto = {
  accessToken: string;
  refreshToken: string;
};
// api/getNewToken.ts

import axios from "axios";
import type { RefreshDto } from "../types";

export const getNewToken = async (refreshToken: string) => {
  const { data } = await axios.post<RefreshDto>(
    "http://localhost:4000/api/auth/refresh",
    { refreshToken }
  );
  return data;
};

그리고 interceptor에서 getNewToken 함수를 호출하고, 응답값으로 쿠키를 업데이트한다.

finally 시점에서는 isRefreshing과 pendingApiQueue를 초기화해준다.

// interceptors/response/client.ts

import type { AxiosError } from "axios";
import type { PendingApiCallback } from "../../types";
import { getCookie, setCookie } from "cookies-next";
import { ACCESS_TOKEN_KEY, REFRESH_TOKEN_KEY } from "../../constants";
import axios from "axios";
import { getNewToken } from "../../api/getNewToken";

...

export const clientResponseErrorInterceptor = async (error: AxiosError) => {
  
  ...

  isRefreshing = true;

  try {
    /** 토큰 재발급 수행 */
    const newTokens = await getNewToken(refreshToken);
    /** 쿠키에 새 토큰 업데이트 */
    setCookie(ACCESS_TOKEN_KEY, newTokens.accessToken);
    setCookie(REFRESH_TOKEN_KEY, newTokens.refreshToken);
  } catch (err) {
    /** 에러 케이스 처리 */
  } finally {
    /** 토큰 재발급 관련 상태 초기화 */
    pendingApiQueue = [];
    isRefreshing = false;
  }
};

다음으로 새 Access token을 사용해 대기 큐의 요청과 현재 요청을 재시도하는 로직을 추가한다. 역시 해당 interceptor가 연결된 instance를 사용하면, 문제가 발생할 수 있어 기본 axios를 사용합니다.

// interceptors/response/client.ts

import type { AxiosError } from "axios";
import type { PendingApiCallback } from "../../types";
import { getCookie, setCookie } from "cookies-next";
import { ACCESS_TOKEN_KEY, REFRESH_TOKEN_KEY } from "../../constants";
import axios from "axios";
import { getNewToken } from "../../api/getNewToken";

...

export const clientResponseErrorInterceptor = async (error: AxiosError) => {
  
  ...
  
  try {
    
    ...

    /** 대기중이던 요청 재시도 */
    pendingApiQueue.forEach((pendingApiCallback) =>
      pendingApiCallback(newTokens.accessToken)
    );

    /** 현재 요청 재시도 */
    requestConfig.headers.Authorization = `Bearer ${newTokens.accessToken}`;
    return axios(requestConfig);
  } catch (err) {
    /** 에러 케이스 처리 */
  } finally {
    ...
  }
};

위에서 잠시 다뤘던, Promise가 fulfilled되는 시점이 바로 지금이다.

마지막으로 토큰 재발급 실패 시 쿠키를 제거하고 로그인 페이지로 리다이렉트 시키자.

// interceptors/response/client.ts

import type { AxiosError } from "axios";
import type { PendingApiCallback } from "../../types";
import { deleteCookie, getCookie, setCookie } from "cookies-next";
import { ACCESS_TOKEN_KEY, REFRESH_TOKEN_KEY } from "../../constants";
import axios from "axios";
import { getNewToken } from "../../api/getNewToken";
import Router from "next/router";

...

export const clientResponseErrorInterceptor = async (error: AxiosError) => {
  ...

  try {
    ...
  } catch (err) {
    /** Access/Refresh token 삭제 */
    deleteCookie(ACCESS_TOKEN_KEY);
    deleteCookie(REFRESH_TOKEN_KEY);
    
    /** 로그인 페이지로 리다이렉트 수행 */
    Router.replace(`/login?next=${encodeURIComponent(Router.asPath)}`);
    /** 비즈니스 로직에서 예외처리 할 수 있도록 에러 반환 */
    return Promise.reject(err);
  } finally {
    ...
  }
};

[참고] next/router는 default export 형태로 React 라이프사이클 외부에서 사용 가능한 라우터 객체를 제공한다!

이제 구현된 interceptor를 instance에 연결해주자.

// instances/client.ts

import axios from "axios";
import { clientRequestInterceptor } from "../interceptors/request/client";
import { clientResponseErrorInterceptor } from "../interceptors/response/client";

export const APIClient = axios.create({
  baseURL: "http://localhost:4000",
  timeout: 5 * 1000,
});

APIClient.interceptors.request.use(clientRequestInterceptor, undefined);
APIClient.interceptors.response.use(undefined, clientResponseErrorInterceptor);

서버 사이드 Axios instance 구현


서버 사이드 Axios instance는 클로저를 활용해서 구현해야 한다. interceptor가 쿠키 값에 자유롭게 접근하기 위해서는 GSSP의 context가 필요하기 때문에, context를 파라미터로 받아서 interceptor가 등록된 Axios instance를 반환하는 형태로 구현할 예정이다.

이렇게 구현하게 되면, Axios instance의 생명 주기가 GSSP에 격리되기 때문에 요청이 섞이는 보안 이슈도 발생하지 않는다.

클로저 구조와 instance의 생명 주기를 다이어그램으로 표현하면 아래와 같다.

이러한 구조의 차이를 제외하면, 로직은 클라이언트 사이드와 동일하기 때문에 구조적인 부분만 정리하겠다.


요청 전, 헤더에 Access token을 주입

우선 Axios instance를 반환하는 함수를 구현한다.

클라이언트 사이드용 instance는 axios.create()가 전역 스코프에 위치했지만, 이번에는 getServerAxios 함수 내부 스코프에 위치시킨다. GSSP에서 참조가 해제되면 instance를 GC 대상에 포함시키기 위해서다.

// instances/server.ts

import axios from "axios";
import type { GetServerSidePropsContext } from "next";

export const getServerAxios = (context: GetServerSidePropsContext) => {
  const APIServer = axios.create({
    baseURL: "http://localhost:4000",
    timeout: 5 * 1000,
  });

  return APIServer;
};

그 다음 요청 전에 context 객체에서 Access token을 가져와 헤더의 Authorization 필드에 추가하는 interceptor 클로저를 반환하는 함수를 구현한다.

// interceptors/request/server.ts

import type { InternalAxiosRequestConfig } from "axios";
import { getCookie } from "cookies-next";
import type { GetServerSidePropsContext } from "next";
import { ACCESS_TOKEN_KEY } from "../../constants";

export const getServerRequestInterceptor = (
  context: GetServerSidePropsContext
) => {
  const { req, res } = context;

  /**
   * cookies-next 사용 시 option에 req, res 객체 전달
   * 그 외에는 client side와 로직 동일
   */
  return (config: InternalAxiosRequestConfig) => {
    const accessToken = getCookie(ACCESS_TOKEN_KEY, { req, res });

    if (accessToken) {
      config.headers.Authorization = `Bearer ${accessToken}`;
    }

    return config;
  };
};

[참고]
아래와 같은 형태로 작성할 수도 있으나, 개인적으로 헷갈릴 수 있는 것 같아 위와 같이 구현했다.

export const interceptor = (context) => (config) => { ... }

이제 구현된 interceptor 클로저를 반환하는 함수를 getServerAxios 내부에서 연결하자.

// instances/server.ts

import axios from "axios";
import type { GetServerSidePropsContext } from "next";
import { getServerRequestInterceptor } from "../interceptors/request/server";

export const getServerAxios = (context: GetServerSidePropsContext) => {
  const APIServer = axios.create({
    baseURL: "http://localhost:4000",
    timeout: 5 * 1000,
  });
  
  const serverRequestInterceptor = getServerRequestInterceptor(context);

  APIServer.interceptors.request.use(serverRequestInterceptor, undefined);

  return APIServer;
};

401 에러 발생 시 토큰 재발급 시도


위에서와 동일하게 401 에러 발생 시 토큰 재발급 후 재요청을 처리하는 interceptor 클로저를 반환하는 함수를 구현하자.

다만, isRefreshing과 pendingApiQueue가 interceptor 클로저의 부모 함수에서 정의되어야 한다는 점이 중요하다. 그래야만 GSSP 생명 주기에 맞게 생성/소멸되면서, Axios instance로 여러 요청을 날렸을 때 pendingApiQueue가 공유된다.

// interceptors/response/server.ts

import type { AxiosError } from "axios";
import type { GetServerSidePropsContext } from "next";
import { PendingApiCallback } from "../../types";
import { deleteCookie, getCookie, setCookie } from "cookies-next";
import { ACCESS_TOKEN_KEY, REFRESH_TOKEN_KEY } from "../../constants";
import axios from "axios";
import { getNewToken } from "../../api/getNewToken";

export const getServerResponseErrorInterceptor = (
  context: GetServerSidePropsContext
) => {
  const { req, res } = context;
  /** 부모 함수에 flag와 대기 queue 정의 */
  let isRefreshing: boolean = false;
  let pendingApiQueue: PendingApiCallback[] = [];

  /**
   * Router.replace()는 사용할 수 없으므로 제거
   * cookies-next 사용 시 option에 req, res 객체 전달
   * 그 외에는 client side와 로직 동일
   */
  return async (error: AxiosError) => {
    const requestConfig = error.config;
    const refreshToken = getCookie(REFRESH_TOKEN_KEY, { req, res });

    if (!requestConfig || !refreshToken || error.response?.status !== 401) {
      return Promise.reject(error);
    }

    if (isRefreshing) {
      return new Promise((resolve, reject) => {
        pendingApiQueue.push((newAccessToken) => {
          requestConfig.headers.Authorization = `Bearer ${newAccessToken}`;
          axios(requestConfig).then(resolve).catch(reject);
        });
      });
    }

    isRefreshing = true;

    try {
      const newTokens = await getNewToken(refreshToken);

      setCookie(ACCESS_TOKEN_KEY, newTokens.accessToken, { req, res });
      setCookie(REFRESH_TOKEN_KEY, newTokens.refreshToken, { req, res });

      pendingApiQueue.forEach((pendingApiCallback) =>
        pendingApiCallback(newTokens.accessToken)
      );

      requestConfig.headers.Authorization = `Bearer ${newTokens.accessToken}`;
      return axios(requestConfig);
    } catch (err) {
      deleteCookie(ACCESS_TOKEN_KEY, { req, res });
      deleteCookie(REFRESH_TOKEN_KEY, { req, res });
      return Promise.reject(err);
    } finally {
      pendingApiQueue = [];
      isRefreshing = false;
    }
  };
};

이제 구현된 interceptor 클로저를 반환하는 함수를 getServerAxios 내부에서 연결하자.

// instances/server.ts

import axios from "axios";
import type { GetServerSidePropsContext } from "next";
import { getServerRequestInterceptor } from "../interceptors/request/server";
import { getServerResponseErrorInterceptor } from "../interceptors/response/server";

export const getServerAxios = (context: GetServerSidePropsContext) => {
  const APIServer = axios.create({
    baseURL: "http://localhost:4000",
    timeout: 5 * 1000,
  });

  const serverRequestInterceptor = getServerRequestInterceptor(context);
  const serverResponseInterceptor = getServerResponseErrorInterceptor(context);

  APIServer.interceptors.request.use(serverRequestInterceptor, undefined);
  APIServer.interceptors.response.use(undefined, serverResponseInterceptor);

  return APIServer;
};

테스트


이제 구현된 클라이언트/서버 사이드 Axios instance가 정말 토큰 만료 시 Race condition을 고려하면서 재발급을 수행하는지 확인해보자.

클라이언트 사이드


다음 페이지로 테스트를 진행하겠다. 클라이언트 사이드에서 useEffect로 두 GET요청을 동시에 보내는 상황이다.

// pages/me.tsx

import { APIClient } from "@/module/axios";
import { useEffect, useState } from "react";

const Me = () => {
  const [me, setMe] = useState<any>(null);
  const [awesomeData, setAwesomeData] = useState<any>(null);

  useEffect(() => {
    (async () => {
      try {
        const [me, awesomeData] = await Promise.all([
          APIClient.get("/api/users/me"),
          APIClient.get("/api/awesome-data"),
        ]);
        setMe(me.data);
        setAwesomeData(awesomeData.data);
      } catch (error) {
        console.log(error);
      }
    })();
  }, []);

  return (
    <div>
      <p>여기가 보인다면 토큰 검증이 된 것입니다</p>
      <p>{JSON.stringify(me)}</p>
      <p>{JSON.stringify(awesomeData)}</p>
    </div>
  );
};

export default Me;

Access token 만료 시


정상적으로 토큰 재발급을 1번 수행한 뒤, 재요청을 날리는 것을 확인할 수 있다.

Refresh token 만료 시


토큰 재발급을 1번 시도한 뒤, 재발급에 실패하면 로그인 페이지로 이동하는 것을 확인할 수 있다.

서버 사이드


이번에는 서버 사이드에서의 토큰 재발급을 테스트하기 위해 GSSP에서 두 GET 요청을 동시에 보내보자.

// pages/me.tsx

import { getServerAxios } from "@/module/axios";
import type { GetServerSideProps, InferGetServerSidePropsType } from "next";

type Props = InferGetServerSidePropsType<typeof getServerSideProps>;

const Me = ({ me, awesomeData }: Props) => {
  return (
    <div>
      <p>여기가 보인다면 토큰 검증이 된 것입니다</p>
      <p>{JSON.stringify(me)}</p>
      <p>{JSON.stringify(awesomeData)}</p>
    </div>
  );
};

export default Me;

export const getServerSideProps = (async (context) => {
  const APIServer = getServerAxios(context);

  try {
    const [me, awesomeData] = await Promise.all([
      APIServer.get("/api/users/me"),
      APIServer.get("/api/awesome-data"),
    ]);

    return {
      props: {
        me: me.data,
        awesomeData: awesomeData.data,
      },
    };
  } catch {
    return {
      redirect: {
        destination: `/login?next=${encodeURIComponent(context.resolvedUrl)}`,
        permanent: false,
      },
    };
  }
}) satisfies GetServerSideProps;

Access token 만료 시


브라우저에서 Access token을 잘못된 값으로 변경해서 401 에러를 유발하니 재발급이 정상적으로 되는 것을 확인할 수 있다.

Refresh token 만료 시


Refresh token이 만료된 이후 새로고침하자 로그인 페이지로 redirect 되는 것을 확인할 수 있다.

React Query SSR과의 통합


대부분의 Next.js page router + REST API 환경에서는 React Query를 사용하게 된다. React Query는 서버 상태 관리의 복잡도를 크게 낮춰주고, Hydration을 통해 GSSP부터 시작되는 props drilling 문제를 해결해주기 때문이다.

지금까지 구현했던 토큰 재발급 Axios 모듈을 React Query Hydration SSR과 함께 사용할 때 prefetch 함수에서 ESLint 에러가 발생하는 상황을 마주하게 되었고, 이를 해결하기 위한 컨벤션을 하나 소개하려고 한다.

문제 상황


보통 React Query를 커스텀 훅으로 wrapping해서 사용하므로, 일반적으로 훅은 아래와 같은 형태가 된다.

// hooks/useGetPublicData.ts

import { type QueryClient, useQuery } from "@tanstack/react-query";
import axios from "axios";

const getPublicData = async () => {
  const { data } = await axios.get(".../api/public/data");
  return data;
};

export const PUBLIC_DATA_QUERY_KEY = "PUBLIC_DATA_QUERY_KEY";

export const useGetPublicData = () => {
  return useQuery({
    queryFn: getPublicData,
    queryKey: [PUBLIC_DATA_QUERY_KEY],
  });
};

export const prefetchPublicData = (queryClient: QueryClient) => {
  return queryClient.prefetchQuery({
    queryFn: getPublicData,
    queryKey: [PUBLIC_DATA_QUERY_KEY],
  });
};

그런데 GSSP에서 지금까지 구현했던 getServerAxios 함수로 생성한 instance를 prefetch 함수에서 사용하려 하니 딜레마가 발생했다.

  • instance는 GSSP에서 생성되므로, prefetch 함수가 파라미터로 받아와야 한다.
    -> queryFn의 파라미터로 instance가 들어가게 된다.
  • queryFn의 파라미터는 queryKey에 포함되어야 한다.
    -> instance는 querykey에 포함되면 안 된다.

React Query의 queryKey는 queryFn이 필요로 하는 모든 파라미터를 포함해야 한다. 이는 캐싱/무효화를 정확하게 처리하기 위함이다.

// hooks/useGetPrivateData.ts

import { APIClient } from "@/module/axios";
import { type QueryClient, useQuery } from "@tanstack/react-query";
import type { AxiosInstance } from "axios";

const getPrivateData = async (axiosInstance?: AxiosInstance) => {
  const instance = axiosInstance ?? APIClient;

  const { data } = await instance.get("api/private/data");
  return data;
};

export const PRIVATE_DATA_QUERY_KEY = "PRIVATE_DATA_QUERY_KEY";

export const useGetPrivateData = () => {
  return useQuery({
    queryFn: () => getPrivateData(),
    queryKey: [PRIVATE_DATA_QUERY_KEY],
  });
};

export const prefetchPrivateData = (
  queryClient: QueryClient,
  APIServer: AxiosInstance
) => {
  return queryClient.prefetchQuery({
    queryFn: () => getPrivateData(APIServer),
    // The following dependencies are missing in your queryKey: APIServer
    queryKey: [PRIVATE_DATA_QUERY_KEY], 
  });
};

해결 방법


ESLint를 끄거나 prefetch 함수에서만 disabled 시킬 수도 있지만, 이는 코드 관리 측면에서 고려 대상이 아니었다. 실수로 들어가야 할 querykey를 누락시킬 수 있기 때문이다.

여러 방법을 테스트하던 중 고차함수를 활용해 해결할 수 있었다. 핵심은 고차함수를 통해 instance를 미리 파라미터로 받은 뒤, queryFn이 클로저로써 부모에 바인딩된 instance를 참조하도록 하는 것이다. 이렇게 하면 반환되는 queryFn은 instance를 파라미터로 가지지 않으므로 ESLint 검사 대상에서 제외되고, queryFn 자체의 파라미터만 ESLint가 검사한다.

id라는 파라미터가 필요한 GET /api/data/:id 엔드포인트로 요청을 날린다고 가정하면 아래와 같이 구현해서 사용 가능하다.

// hooks/useGetDataById.ts

import { APIClient } from "@/module/axios";
import { type QueryClient, useQuery } from "@tanstack/react-query";
import type { AxiosInstance } from "axios";

const getQueryFn = (instance: AxiosInstance) => {
  // 클로저로써 instance를 캡쳐해 사용하고, 파라미터로는 id만 받는다
  return async (id: number) => {
    const { data } = await instance.get(`/api/data/${id}`);
    return data;
  };
};

export const DATA_BY_ID_QUERY_KEY = "DATA_BY_ID_QUERY_KEY";

export const useGetDataById = (id: number) => {
  const getDataById = getQueryFn(APIClient);

  return useQuery({
    queryFn: () => getDataById(id),
    queryKey: [DATA_BY_ID_QUERY_KEY, id],
  });
};

export const prefetchDataById = (
  queryClient: QueryClient,
  APIServer: AxiosInstance,
  id: number
) => {
  const getDataById = getQueryFn(APIServer);

  return queryClient.prefetchQuery({
    queryFn: () => getDataById(id),
    // id를 queryKey에서 제거 시 ESLint 에러 발생
    // 그러나 APIServer 관련 에러는 발생 X
    queryKey: [DATA_BY_ID_QUERY_KEY, id],
  });
};

정리


누군가는 Next.js가 단순한 프론트엔드 프레임워크라고 생각할 수도 있지만, SSR이라는 특징 때문에 클라이언트/서버 사이드를 모두 고려해야 하고, 이로 인한 엣지 케이스를 해결하는 것은 험난하다는 생각이 들었다.

사내 프론트엔드팀 동료분들의 많은 도움을 받아 구현 방향을 결정하고 문제가 발생했을 때 비교적 쉽게 해결할 수 있었지만, 서버 사이드에서 Axios interceptor를 활용하려는 기존 시도나 예시를 찾아보기 어려워 많은 고생을 했던 기억이 난다.

관련 사례를 조사하다 발견했던 Stackoverflow 코멘트가 하나 생각난다. "SSR을 위해 필요한 API는 인증이 필요하면 안 된다"는 내용이었다. 그러나 이는 서비스의 목적과 상황마다 다르다고 생각한다. 서버 사이드에서 인증이 필요한 API 요청이 필요한 상황은 기획에 따라 얼마든지 생길 수 있다.

토큰 재발급 로직을 구현하면서 많은 공부를 할 수 있었고, 이 포스트를 정리하면서 한 번 더 깊게 이해할 수 있는 계기가 되어 좋은 경험이었다고 생각한다.

글 읽어주셔서 감사합니다.

서버 사이드에서 Axios interceptor를 활용해 토큰 재발급을 처리하는 방법에 대해 고민하고 계셨거나, React Query의 queryKey에 들어가면 안 되는 값이 ESLint 에러를 발생시키는 문제를 겪고 계셨다면, 이 포스트가 도움이 되었기를 바랍니다.

이 글에 대한 가독성, 오탈자/오개념, 코드 오타 등 다양한 지적을 환영합니다!

profile
신입 프론트엔드 개발자입니다. React와 RN 생태계를 좋아합니다.

9개의 댓글

comment-user-thumbnail
2025년 9월 24일

👍👍👍

1개의 답글
comment-user-thumbnail
4일 전

안녕하세요 혹시 nextjs의 server를 안쓰고 따로 서버가있다면
deleteCookie(ACCESS_TOKEN_KEY);
deleteCookie(REFRESH_TOKEN_KEY);
부분은 빼는게 당연히 맞나요? 서버부분에서 리프레시토큰 api라우터가 실패하면 쿠키 삭제해주는 로직을 적어주면 좋겠다는 생각이 들어서요!

1개의 답글