gofillus 기술·운영 저널

Axios에서 access token과 refresh token을 함께 다루는 방법

Axios instance에 인증을 붙일 때 토큰을 어디에 보관하고, 여러 요청이 동시에 만료되면 어떻게 한 번만 갱신할지 정리했다. 웹과 React Native에서 달라지는 저장소 선택과 재시도 중단 조건도 함께 다룬다.

배경 — 요청마다 토큰을 직접 붙이기 시작하면

로그인 뒤 API를 호출하는 가장 단순한 방법은 요청을 보낼 때마다 Authorization 헤더를 직접 넣는 것이다.

axios.get('/me', {
  headers: { Authorization: `Bearer ${accessToken}` },
});

처음에는 문제가 없어 보인다. 하지만 화면이 늘어나면 토큰을 읽는 위치와 만료 처리 방식이 요청마다 달라진다.

  • 어떤 요청은 오래된 access token을 사용한다.
  • 401 응답을 받은 화면마다 refresh 요청을 따로 보낸다.
  • 갱신이 끝나기 전에 실패한 요청은 그대로 오류로 끝난다.
  • refresh token도 일반 저장소에 넣어 두면 탈취됐을 때 피해가 커진다.

그래서 요청을 보내는 Axios 인스턴스와 토큰 수명주기를 한 곳에 모았다. 컴포넌트는 api.get('/me')만 호출하고, 토큰을 언제 읽고 갱신하는지는 인증 모듈이 맡도록 했다.

선택 — 토큰을 어디에 둘 것인가

두 토큰의 역할은 다르다. access token은 API 요청에 자주 사용하고, refresh token은 새 access token을 발급할 때만 사용한다. 둘을 같은 방식으로 저장할 이유가 없다.

환경access tokenrefresh token
웹메모리 우선HttpOnly, Secure, SameSite 쿠키
React Native메모리 또는 짧은 수명의 상태 저장Keychain·Keystore 기반 보안 저장소

웹에서 refresh token을 localStorage에 넣으면 자바스크립트에서 읽을 수 있다. XSS가 발생했을 때 장기간 사용할 수 있는 자격까지 함께 노출될 수 있으므로 기본 선택으로 삼지 않았다. 서버가 refresh token을 HttpOnly 쿠키로 관리하면 브라우저 자바스크립트가 그 값을 직접 읽을 수 없다.

React Native에는 브라우저의 HttpOnly 쿠키가 없다. 이 경우 refresh token은 expo-secure-store나 Keychain·Keystore를 사용하는 저장소에 넣는다. 일반 AsyncStorage는 편리하지만 장기간 사용할 인증 비밀을 보관하는 곳으로는 적합하지 않다.

access token은 메모리에 두는 쪽을 우선했다. 앱을 다시 열 때 refresh token으로 새 access token을 발급하면 된다. access token이 유출되는 시간을 줄이고, 로그아웃할 때 메모리 값도 즉시 비울 수 있다.

적용 — 하나의 instance와 하나의 refresh 요청

먼저 토큰 저장소의 동작을 Axios 코드와 분리했다. 웹과 React Native는 저장 방식이 다르지만, 아래 인터페이스는 같다.

type TokenPair = {
  accessToken: string;
  refreshToken?: string;
};

interface TokenStore {
  getAccessToken(): string | null;
  getRefreshToken(): Promise<string | null>;
  save(pair: TokenPair): Promise<void>;
  clear(): Promise<void>;
}

웹에서는 getRefreshToken이 토큰을 반환하지 않고 쿠키를 사용하도록 만들 수 있다. React Native에서는 보안 저장소에서 refresh token을 읽는다. 인증 요청에는 쿠키를 보내야 하므로 웹 Axios 인스턴스에는 withCredentials: true를 설정한다.

import axios from 'axios';

export const api = axios.create({
  baseURL: import.meta.env.VITE_API_URL,
  withCredentials: true,
});

const authApi = axios.create({
  baseURL: import.meta.env.VITE_API_URL,
  withCredentials: true,
});

refresh 요청에 api를 다시 사용하지 않은 점이 중요하다. refresh 자체가 401을 받으면 같은 응답 처리기가 다시 refresh를 시도할 수 있다. 인증 갱신에는 별도의 인스턴스를 사용해 이 경로를 분리했다.

요청을 보내기 전에 access token 붙이기

토큰은 요청 직전에 읽는다. 인스턴스를 만들 때 한 번 읽어 두면 로그인·갱신 뒤에도 이전 값이 남기 때문이다.

api.interceptors.request.use(async (config) => {
  const bearer = tokenStore.getAccessToken();

  if (bearer) {
    config.headers.set('Authorization', `Bearer ${bearer}`);
  }

  return config;
});

동시에 들어온 401은 한 번만 갱신하기

화면을 처음 열 때 API 5개가 동시에 호출될 수 있다. access token이 같은 시점에 만료되어 5개 요청이 모두 401을 받으면, refresh도 5번 보내면 안 된다.

진행 중인 refresh 작업을 refreshPromise 하나로 공유했다. 첫 번째 요청만 실제 갱신을 시작하고, 나머지는 같은 Promise가 끝날 때까지 기다린다.

let refreshPromise: Promise<string> | null = null;

async function refreshAccessToken(): Promise<string> {
  if (!refreshPromise) {
    refreshPromise = (async () => {
      const refreshToken = await tokenStore.getRefreshToken();
      const response = await authApi.post('/auth/refresh', { refreshToken });
      const pair = response.data as TokenPair;

      await tokenStore.save(pair);
      return pair.accessToken;
    })().finally(() => {
      refreshPromise = null;
    });
  }

  return refreshPromise;
}

서버가 refresh token을 매번 새로 발급하는 방식이라면 응답의 새 refreshToken도 함께 저장해야 한다. 이전 값을 유지하면 다음 갱신에서 이미 폐기된 토큰을 보내게 된다.

401 응답을 한 번만 재시도하기

응답 처리기에서는 모든 오류를 refresh 대상으로 보지 않는다. 실제로 access token이 만료된 경우인 401만 처리하고, 로그인·refresh 요청은 제외한다. 원래 요청에도 _retry 표시를 붙여 같은 요청이 계속 반복되지 않게 했다.

api.interceptors.response.use(
  (response) => response,
  async (error) => {
    const original = error.config;
    const status = error.response?.status;
    const isAuthRequest = original?.url?.includes('/auth/');

    if (status !== 401 || !original || original._retry || isAuthRequest) {
      return Promise.reject(error);
    }

    original._retry = true;

    try {
      const accessToken = await refreshAccessToken();
      original.headers.set('Authorization', `Bearer ${accessToken}`);
      return api(original);
    } catch (refreshError) {
      await tokenStore.clear();
      onAuthenticationExpired();
      return Promise.reject(refreshError);
    }
  },
);

실제 TypeScript 프로젝트에서는 Axios의 요청 설정 타입에 _retry를 추가한다.

declare module 'axios' {
  interface InternalAxiosRequestConfig {
    _retry?: boolean;
  }
}

refresh가 실패하면 조용히 원래 요청을 계속 보내지 않는다. refresh token이 만료됐거나 서버에서 폐기된 상태일 수 있으므로 저장된 인증 정보를 지우고 로그인 화면으로 보낸다. 네트워크가 잠깐 끊긴 경우까지 바로 로그아웃할지는 서비스의 재시도 정책에 따라 따로 정한다.

실수한 것들

401을 받은 요청마다 refresh를 보냈다

첫 구현에서는 응답 인터셉터 안에서 바로 /auth/refresh를 호출했다. 화면 진입 시 401이 5개 나오자 refresh도 5개가 나갔다. refresh token을 한 번만 사용할 수 있게 만든 서버에서는 일부 요청이 새 토큰을 받기 전에 실패했다.

진행 중인 Promise를 공유하는 방식으로 바꾸고, refresh 응답에 새 refresh token이 있으면 함께 저장하도록 수정했다.

refresh 요청에도 같은 Axios instance를 사용했다

refresh token이 만료된 상황에서 /auth/refresh도 401을 반환했다. 응답 인터셉터가 다시 refresh를 호출하면서 요청이 끝나지 않았다.

refresh에는 별도 Axios 인스턴스를 사용하고, 원래 요청의 _retry와 인증 URL 제외 조건을 함께 적용했다.

React Native에서 AsyncStorage에 refresh token을 넣었다

로그인 상태를 복원하기 쉽다는 이유로 처음에는 모든 토큰을 AsyncStorage에 저장했다. access token과 장기 인증 수단을 같은 곳에 둔 셈이었다.

이후 access token은 메모리에서만 관리하고, refresh token은 운영체제 보안 저장소를 사용하는 구조로 나눴다. 앱을 다시 열 때만 refresh token을 읽어 새 access token을 만들도록 했다.

모든 401을 로그인 만료로 처리했다

401이 항상 access token 만료를 뜻하는 것은 아니다. 서버가 권한 부족을 401로 표현하는 경우도 있고, 로그인·refresh API 자체의 인증 실패도 있다.

API 계약을 확인해 갱신 대상 응답을 정했다. 서버가 구분된 오류 코드를 내려 주면 상태 코드만 보지 않고 그 코드까지 확인하는 편이 안전하다.

결과

항목요청마다 직접 처리Axios 인증 모듈 적용 뒤
access token 주입화면·API 호출부마다 처리request interceptor 한 곳
동시 401 처리요청 수만큼 refresh 가능진행 중인 refresh 1개 공유
원래 요청 재시도호출부별로 제각각요청당 최대 1회
refresh 실패원래 오류가 남을 수 있음저장 정보 삭제 후 로그인 흐름
React Native refresh token일반 저장소Keychain·Keystore 기반 저장소

토큰 처리는 컴포넌트에서 사라졌고, 화면은 API 성공·실패만 다루게 됐다. 특히 동시에 여러 요청이 실패하는 첫 화면에서 refresh 요청이 하나만 나가는지 로그로 확인할 수 있었다.

남는 것

Axios interceptor는 인증 흐름을 모으는 도구이지, 보안 문제를 자동으로 해결해 주는 기능은 아니다. 저장소 선택은 실행 환경에 맞춰야 하고, 서버는 refresh token 재사용·폐기·회전 정책을 명확히 해야 한다.

다음 단계에서는 다음 항목을 함께 정해야 한다.

  • 여러 브라우저 탭이 동시에 갱신할 때의 조정 방식
  • 로그아웃 시 서버의 refresh token 폐기 범위
  • 네트워크 오류와 인증 만료를 다시 시도하는 기준
  • React Native 앱이 백그라운드에서 돌아온 뒤 토큰을 확인하는 시점

핵심은 토큰을 어디에 저장했는지가 아니라, 토큰의 수명과 실패 경로를 한 곳에서 설명할 수 있게 만드는 것이다. access token은 짧게 쓰고, refresh token은 더 조심스럽게 보관하고, 갱신은 동시에 한 번만 실행한다. 이 세 가지 기준이 있으면 React와 React Native의 구현 세부가 달라도 같은 인증 구조를 유지할 수 있다.


← 목록으로