[TYPESCRIPT] 인증 토큰 처리(Authorization 헤더), Refresh 토큰 전략, 공통 에러 핸들링 - 프론트/백엔드 모두에서 흔들리지 않는 기준

 

실무에서 인증이 한번 꼬이기 시작하면 문제가 연쇄적으로 발생합니다. 401이 뜰 때마다 화면이 튕기고, 토큰이 만료되면 무한 재시도가 걸리고, 어느 페이지에서는 정상인데 다른 페이지에서는 갑자기 로그아웃되는 식이죠.

이런 문제는 대체로 “기능 구현”이 아니라 정책(Policy) 설계의 문제입니다.

다음 3가지를 하나의 흐름으로 정리합니다.

  • Authorization 헤더로 Access Token 자동 주입
  • Refresh Token으로 Access Token 재발급
  • 공통 에러 핸들링(401/403/5xx) 표준화

예시는 RTK Query + TypeScript 기준으로 작성합니다. (Axios에도 거의 동일하게 적용됩니다.)

 

 

토큰 모델링: Access vs Refresh

실무에서 가장 흔한 구조는 다음과 같습니다.

  • Access Token: 짧은 만료(예: 5~30분), API 호출에 사용
  • Refresh Token: 긴 만료(예: 수일~수주), Access 재발급에 사용

중요한 원칙은 하나입니다. Refresh Token은 가능한 한 JS 런타임에서 직접 만지지 않게 가져가는 것이 안전합니다.

권장 조합(가장 흔한 실무 선택):

  • Access Token: 메모리(또는 Redux) / 필요 시 localStorage(리스크 감수)
  • Refresh Token: HttpOnly Cookie로 서버가 관리

 

Authorization 헤더 자동 주입 (RTK Query: prepareHeaders)

RTK Query에서는 fetchBaseQueryprepareHeaders를 통해 모든 요청에 Authorization 헤더를 자동으로 붙일 수 있습니다.

import { fetchBaseQuery } from "@reduxjs/toolkit/query/react";
import type { RootState } from "../store";

export const baseQuery = fetchBaseQuery({
  baseUrl: "/api",
  credentials: "include", // Refresh Token을 쿠키로 쓰는 경우 필수
  prepareHeaders: (headers, { getState }) => {
    const state = getState() as RootState;
    const token = state.auth.accessToken; // 예: auth slice에 저장

    if (token) {
      headers.set("Authorization", `Bearer ${token}`);
    }
    return headers;
  },
});

핵심 포인트:

  • credentials: "include"로 쿠키 전송 허용
  • Access Token이 있을 때만 Authorization 헤더 주입
  • 토큰 저장 위치는 프로젝트 정책에 따라 결정

 

Refresh 토큰 전략의 기본 흐름

“Access가 만료되면 Refresh로 재발급 받고 재시도”가 기본입니다.

  • API 요청 → 401 발생
  • /auth/refresh 호출 (Refresh Token 쿠키 기반)
  • 새 Access Token 발급
  • 원래 요청을 한 번만 재시도
  • Refresh도 실패(401)하면 로그아웃 처리

문제는 동시 요청입니다. 여러 API가 동시에 401을 맞으면 refresh 요청이 폭발하거나, 토큰 갱신이 꼬일 수 있습니다. 실무에서는 refresh 단일화(싱글 플라이트) 처리가 사실상 필수입니다.

 

RTK Query에서 401 공통 처리: baseQueryWithReauth

RTK Query에서는 baseQuery를 감싸서 401을 공통 처리하는 방식이 표준입니다.

import type { BaseQueryFn } from "@reduxjs/toolkit/query";
import type { FetchArgs, FetchBaseQueryError } from "@reduxjs/toolkit/query";
import { fetchBaseQuery } from "@reduxjs/toolkit/query/react";
import type { RootState } from "../store";
import { authActions } from "../store/authSlice";

const rawBaseQuery = fetchBaseQuery({
  baseUrl: "/api",
  credentials: "include",
  prepareHeaders: (headers, { getState }) => {
    const token = (getState() as RootState).auth.accessToken;
    if (token) headers.set("Authorization", `Bearer ${token}`);
    return headers;
  },
});

// 간단한 싱글 플라이트(동시 refresh 방지)용 전역 Promise
let refreshPromise: Promise<string | null> | null = null;

async function refreshAccessToken(
  api: any
): Promise<string | null> {
  try {
    const res = await rawBaseQuery(
      { url: "/auth/refresh", method: "POST" },
      api,
      {}
    );

    if (res.error) return null;

    // 서버 응답 예: { ok: true, data: { accessToken: "..." } }
    const body = res.data as any;
    const token = body?.data?.accessToken ?? null;

    if (token) {
      api.dispatch(authActions.setAccessToken(token));
      return token;
    }
    return null;
  } catch {
    return null;
  }
}

export const baseQueryWithReauth: BaseQueryFn<
  string | FetchArgs,
  unknown,
  FetchBaseQueryError
> = async (args, api, extraOptions) => {
  const result = await rawBaseQuery(args, api, extraOptions);

  if (result.error?.status === 401) {
    if (!refreshPromise) {
      refreshPromise = refreshAccessToken(api).finally(() => {
        refreshPromise = null;
      });
    }

    const newToken = await refreshPromise;

    if (newToken) {
      // 새 토큰으로 원 요청 1회 재시도
      return rawBaseQuery(args, api, extraOptions);
    }

    // refresh 실패: 세션 만료로 간주
    api.dispatch(authActions.logout());
  }

  return result;
};

여기서 중요한 설계 포인트는 다음입니다.

  • 401이 오면 refresh를 시도하되, 동시 refresh를 하나로 합침
  • refresh 성공 시 원 요청을 한 번만 재시도
  • refresh 실패 시 로그아웃/세션 만료 처리

 

createApi에 baseQueryWithReauth 적용

import { createApi } from "@reduxjs/toolkit/query/react";
import { baseQueryWithReauth } from "./baseQueryWithReauth";

export const api = createApi({
  reducerPath: "api",
  baseQuery: baseQueryWithReauth,
  tagTypes: ["User", "Order"],
  endpoints: (builder) => ({
    // ...
  }),
});

이제 모든 endpoint가 401 처리 정책을 자동으로 따르게 됩니다.

 

Refresh API 설계(백엔드 관점) 체크

프론트만 잘해도 서버 정책이 흔들리면 결국 문제가 납니다. 서버에서 다음을 지키는 것이 중요합니다.

  • Refresh Token은 HttpOnly/Secure/SameSite 정책을 명확히
  • Refresh 요청 시 토큰 재사용 감지(회전 방식이라면)
  • Access Token 재발급은 짧고 예측 가능한 응답 형식 유지
  • 만료/유효성 실패 시 401로 통일

실무에서 가장 많이 쓰는 방식은 Refresh Token Rotation입니다. (Refresh 호출 시 Refresh 토큰도 새로 발급하고 이전 토큰은 폐기)

 

공통 에러 핸들링 표준화 (401/403/5xx)

에러는 “화면마다 따로 처리”하면 반드시 흔들립니다. 다음처럼 최소한의 정책을 정해두면 안정적입니다.

  • 401: 재발급 시도 후 실패하면 로그아웃 + 로그인 유도
  • 403: 권한 없음(로그인은 되어있음) → 권한 안내 페이지 또는 토스트
  • 5xx: 서버 장애 → 공통 장애 UI/재시도 버튼
  • 네트워크 오류: 오프라인/타임아웃 → 네트워크 안내

RTK Query에서 에러는 FetchBaseQueryError로 들어오므로 필요하면 유틸로 분기합니다.

import type { FetchBaseQueryError } from "@reduxjs/toolkit/query";

export function getErrorMessage(error: unknown): string {
  const e = error as FetchBaseQueryError;

  if (!e) return "Unknown error";

  if (typeof e.status === "number") {
    if (e.status === 403) return "권한이 없습니다.";
    if (e.status === 404) return "요청한 자원을 찾을 수 없습니다.";
    if (e.status >= 500) return "서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.";
    return "요청 처리 중 오류가 발생했습니다.";
  }

  // e.status === "FETCH_ERROR" 등
  return "네트워크 상태를 확인해주세요.";
}

이 유틸을 공통 토스트/에러 컴포넌트에서 사용하면 프로젝트 전체 에러 메시지가 일관되게 유지됩니다.

 

무한 재시도/무한 루프 방지 팁

인증 처리에서 가장 위험한 버그는 “무한 루프”입니다.

  • refresh 자체가 401을 받는데 또 refresh를 시도
  • 원 요청 재시도가 계속 반복
  • 여러 요청이 동시에 refresh를 때려 서버 부하 증가

방지 원칙:

  • 원 요청 재시도는 정확히 1회
  • refresh 요청에는 재인증 로직을 적용하지 않기(위 코드처럼 rawBaseQuery로 호출)
  • 싱글 플라이트로 refresh 요청을 하나로 합치기

 

실무 권장 체크리스트

  • Access는 Authorization 헤더로 자동 주입
  • Refresh는 HttpOnly Cookie 기반 + credentials include
  • 401 시 refresh → 원 요청 1회 재시도
  • 동시 refresh는 싱글 플라이트로 합치기
  • 403/5xx/네트워크 오류 메시지 정책을 공통화

 


 

 

인증 토큰 처리와 에러 핸들링은 기능 구현이라기보다 프로젝트 전체의 운영 정책입니다. 한 번 기준을 잡아두면, 이후 개발 속도와 장애 대응이 크게 달라집니다.

  • Authorization 헤더 자동 주입
  • Refresh로 재발급 + 재시도 1회
  • 에러 정책 공통화(401/403/5xx)