블로그로 돌아가기

Refresh Token은 쿠키, Access Token은 메모리에

Marco(마르코)-2025-12-19 06:32:39

Refresh Token은 HttpOnly 쿠키로 보호하고, Access Token은 메모리에만 두어 빠르게 사용하고 폐기한다. 이 구조는 보안과 UX의 균형을 맞추며, 웹과 앱(WebView/모바일) 모두에 적합한 현실적인 인증 전략이다.

웹과 앱을 모두 고려한 현실적인 인증 전략 (fetch 기반 구현)

로그인 이후 토큰을 어디에 저장할 것인가는 단순한 구현 문제가 아닙니다.

이 선택은 보안 모델, 사용자 경험(UX), 그리고 웹과 앱을 함께 운영할 수 있는지 여부까지 결정합니다.

이번 글에서는 우리가 최종적으로 선택한 인증 구조인 Refresh Token은 HttpOnly 쿠키, Access Token은 메모리 라는 전략을, 프론트엔드(fetch 기반) 구현과 앱(WebView) 대응 관점까지 포함해 정리합니다.


1. 왜 다시 토큰 저장 전략을 고민했을까?

전통적으로 많이 사용되던 방식은 다음과 같습니다.

  • Access Token → 쿠키
  • Refresh Token → 쿠키

구현은 간단하지만, 실무에서는 다음과 같은 문제가 반복해서 드러났습니다.

  • Access Token이 쿠키에 있으면 CSRF 공격 대상
  • LocalStorage / SessionStorage는 XSS에 취약
  • 인증 상태 제어가 어려움
  • WebView / 모바일 앱 환경과의 궁합이 애매함

결국 질문은 이렇게 바뀌었습니다.

“웹뿐 아니라 앱까지 함께 가져갈 수 있는 인증 구조는 무엇일까?”


2. 우리가 선택한 구조

최종적으로 선택한 구조는 아래와 같습니다.

구분Refresh TokenAccess Token
저장 위치HttpOnly + Secure Cookie메모리
JS 접근불가가능
XSS 노출매우 낮음낮음
CSRF 영향있음없음
수명짧음
브라우저 종료유지소멸

핵심은 역할 분리입니다.

  • Refresh Token → 유출 시 피해가 크므로 최대한 보호
  • Access Token → 짧은 생명, 빠른 폐기

3. 이 구조가 웹 + 앱에 적합한 이유

1) WebView에서도 자연스럽게 동작한다

  • Refresh Token은 쿠키 기반 → WebView / 모바일 환경에서도 OS 레벨로 안전하게 관리
  • Access Token은 메모리 → 로컬 저장소에 남지 않음

2) Native App 확장에도 유리하다

Access Token을 “메모리에서만 관리한다”는 개념은 모두에서 동일하게 적용할 수 있습니다. 플랫폼이 바뀌어도 인증 모델 자체를 다시 설계할 필요가 없습니다.

3) 인증 실패 UX를 명확하게 만들 수 있다

  • Refresh 성공 → 세션 유지
  • Refresh 실패 → 세션 종료

이 기준이 명확해지면서, 사용자에게도

“세션이 만료되었습니다. 다시 로그인해 주세요.”

라는 일관된 UX를 제공할 수 있습니다.


4. 프론트엔드 구현 원칙 (fetch 기반)

이번 구조에서는 axios interceptor를 사용하지 않고,

fetch 기반 인증 게이트웨이 함수 하나로 모든 인증 흐름을 통합했습니다.

핵심 원칙

  • Access Token은 메모리 전용
  • Authorization 헤더는 요청 시점에만 주입
  • 401 발생 시 Refresh → 재시도
  • Refresh 중복 호출 방지

5. Access Token은 메모리에만 저장한다

// authTokenStore.ts
let accessToken: string | null = null;

export const getAccessToken = () => accessToken;
export const setAccessToken = (token: string | null) => {
  accessToken = token;
};
export const clearAccessToken = () => {
  accessToken = null;
};
  • localStorage ❌
  • sessionStorage ❌
  • cookie ❌

브라우저 탭 종료 시 자동으로 초기화되며,

공격자가 훔쳐갈 “영속 데이터” 자체가 남지 않습니다.


6. fetch 기반 인증 게이트웨이

Authorization 헤더 적용

function applyAuthorization(
  init: RequestInit,
  accessToken: string | null,
): RequestInit {
  const headers = new Headers(init.headers ?? undefined);

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

  return {
    ...init,
    headers,
    credentials: init.credentials ?? 'include', // Refresh 쿠키 전송
  };
}

7. Refresh Token 자동 재발급 (중복 방지 포함)

중요한 포인트

  • 동시에 여러 요청이 401을 받아도 Refresh는 한 번만
  • Refresh 요청은 다시 Refresh하지 않도록 보호
  • Refresh 실패 시 세션 종료로 판단
let refreshingPromise: Promise<string | null> | null = null;
export async function apiFetch(
  input: RequestInfo | URL,
  init: ApiRequestInit = {},
): Promise<Response> {
  const { skipAuth = false, skipRefresh = false, ...requestInit } = init;
  const accessToken = skipAuth ? null : getAccessToken();

  const response = await fetch(
    input,
    applyAuthorization(requestInit, accessToken),
  );

  if (response.status !== 401 || skipRefresh) {
    return response;
  }

  if (!refreshingPromise) {
    refreshingPromise = refreshAccessToken({ skipRefresh: true })
      .then((newToken) => {
        newToken ? setAccessToken(newToken) : clearAccessToken();
        return newToken;
      })
      .finally(() => {
        refreshingPromise = null;
      });
  }

  const refreshedToken = await refreshingPromise;
  if (!refreshedToken) return response;

  return fetch(input, applyAuthorization(requestInit, refreshedToken));
}

이 구조는 axios interceptor와 동일한 안정성을 제공합니다.


8. 새로고침 시 인증 상태 복구

메모리는 새로고침 시 초기화되므로,

앱 부팅 시 Refresh를 한 번 호출합니다.

export async function bootstrapAuth(): Promise<boolean> {
  const token = await refreshAccessToken({
    skipAuth: true,
    skipRefresh: true,
  });

  if (!token) return false;
  setAccessToken(token);
  return true;
}
  • 성공 → 정상 진입
  • 실패 → 로그인 화면 이동

9. CSRF는 어디서만 신경 쓰면 될까?

이 구조의 장점은 CSRF 방어 범위가 매우 좁아진다는 것입니다.

  • Access API → Authorization Header → CSRF 대상 아님
  • Refresh API → 쿠키 사용 → CSRF 방어 적용

필요하다면 Double Submit Cookie 전략을 사용할 수 있습니다.

fetch('/api/v1/users/refresh', {
  method: 'POST',
  headers: {
    'X-CSRF-TOKEN': getCookie('csrfToken'),
  },
  credentials: 'include',
});

10. 트레이드오프

단점

  • 새로고침 시 Access Token 소멸
  • 초기 로딩 시 Refresh 요청 필요

얻는 것

  • 더 작은 공격 표면
  • 더 명확한 책임 분리
  • 웹과 앱을 모두 아우르는 인증 모델

11. 마무리

중요한 것은 “토큰을 어디에 저장했는가”가 아니라 “무엇을 얼마나 오래 신뢰하는가”다.

Refresh Token은 보호하고 Access Token은 빠르게 쓰고 버린다

이 전략은 단순히 웹을 위한 것이 아니라, 웹 + 앱(WebView / 모바일)을 함께 운영하는 팀에게 가장 현실적인 선택지다.