실무에서 인증이 한번 꼬이기 시작하면 문제가 연쇄적으로 발생합니다. 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에서는 fetchBaseQuery의 prepareHeaders를 통해 모든 요청에 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)
