프론트엔드 인증 갱신 패턴 비교: Silent Refresh부터 RT Rotation까지

EN

들어가며

참여하고 있는 프로젝트에서 사용자가 글을 쓰다가 "저장"을 눌렀는데 로그인 페이지로 튕겼습니다. AT가 만료된 건데, 토큰 갱신 로직이 제대로 잡혀있었다면 일어나지 않았을 문제였습니다. 이참에 AT 갱신을 처리하는 4가지 패턴을 비교하고, 각각의 구현과 조합 전략을 정리합니다.

기본 전제: AT/RT 구조

  • Access Token (AT): Authorization 헤더에 담기는 짧은 수명의 토큰 (보통 15분~1시간)
  • Refresh Token (RT): AT 만료 시 새 AT를 발급받기 위한 긴 수명의 토큰 (보통 7일~30일)

AT가 만료되면 RT로 새 AT를 받고, RT마저 만료되면 재로그인을 요구합니다. 갱신 패턴의 차이는 **"AT 만료를 언제, 어떻게 감지하고 처리하느냐"**에 있습니다.

1. Silent Refresh

가장 직관적인 패턴입니다. API 요청이 401을 반환하면, 응답 인터셉터에서 RT로 AT를 갱신한 뒤 원래 요청을 재시도합니다. 사용자는 실패가 있었는지조차 모릅니다.

동작 흐름

  1. 만료된 AT로 API를 호출한다
  2. 서버가 401을 반환한다
  3. Axios 응답 인터셉터가 401을 감지한다
  4. RT로 /auth/refresh를 호출해서 새 AT를 받는다
  5. 실패한 요청을 새 AT로 재시도한다

동시 요청 처리가 핵심이였습니다.

대시보드를 열면 사용자 정보, 알림, 통계 등 여러 API가 동시에 나갑니다. AT가 만료된 상태라면 이 요청들이 전부 401을 받는데, 각각이 독립적으로 refresh를 시도하면 문제가 됩니다. 서버 부하도 그렇지만, RT Rotation을 쓰고 있다면 첫 번째 refresh 이후의 요청들은 이미 폐기된 RT를 사용하게 되어 전부 실패합니다.

첫 번째 401이 refresh를 트리거하고, 나머지는 큐에서 대기시키면 됩니다.

interface QueueItem {
  resolve: (token: string) => void;
  reject: (error: unknown) => void;
}
 
let isRefreshing = false;
let failedQueue: QueueItem[] = [];
 
const processQueue = (error: unknown, token: string | null) => {
  failedQueue.forEach((item) => {
    if (token) {
      item.resolve(token);
    } else {
      item.reject(error);
    }
  });
  failedQueue = [];
};
 
api.interceptors.response.use(
  (response) => response,
  async (error: AxiosError) => {
    const originalRequest = error.config as AxiosRequestConfig & { _retry?: boolean };
 
    if (error.response?.status === 401 && !originalRequest._retry) {
      // 이미 refresh 중이면 큐에 넣고 대기
      if (isRefreshing) {
        return new Promise<string>((resolve, reject) => {
          failedQueue.push({ resolve, reject });
        }).then((token) => {
          originalRequest.headers = {
            ...originalRequest.headers,
            Authorization: `Bearer ${token}`,
          };
          return api(originalRequest);
        });
      }
 
      originalRequest._retry = true;
      isRefreshing = true;
 
      try {
        const { data } = await axios.post<{ accessToken: string }>('/auth/refresh', {
          refreshToken: getRefreshToken(),
        });
 
        setAccessToken(data.accessToken);
        processQueue(null, data.accessToken);
 
        originalRequest.headers = {
          ...originalRequest.headers,
          Authorization: `Bearer ${data.accessToken}`,
        };
        return api(originalRequest);
      } catch (refreshError) {
        processQueue(refreshError, null);
        logout();
        return Promise.reject(refreshError);
      } finally {
        isRefreshing = false;
      }
    }
 
    return Promise.reject(error);
  },
);

isRefreshing 플래그로 refresh 진행 여부를 추적하고, failedQueue에 대기시킨 요청들을 refresh 완료 후 일괄 재시도합니다.

멀티탭 환경에서의 문제

같은 탭 안의 동시 요청은 큐로 해결됐지만, 탭 간에는 JS 실행 컨텍스트가 분리되어 있어서 isRefreshing 플래그가 공유되지 않습니다. 탭 3개가 열려있으면 3개의 독립적인 refresh가 동시에 발생할 수 있고, RT Rotation 환경에서는 첫 번째 refresh가 완료되는 순간 나머지 탭이 들고 있는 RT가 무효화되어 전체 token family가 revoke됩니다.

해결 방법은 세 가지가 있었습니다.

방법장점단점
Web Locks API브라우저 origin 범위에서 단일 탭만 refresh 실행 보장레거시 브라우저 미지원
BroadcastChannel API탭 간 토큰 갱신 알림 가능Lock 자체를 제공하지 않아 별도 조율 로직 필요
localStorage 이벤트가장 넓은 브라우저 호환성같은 탭에서는 storage 이벤트가 발생하지 않음

실무에서는 Web Locks로 단일 실행을 보장하고, BroadcastChannel로 새 토큰을 다른 탭에 전파하는 조합이 가장 견고했습니다.

async function refreshWithLock(): Promise<string> {
  return navigator.locks.request('token-refresh', async () => {
    const currentToken = getAccessToken();
    // Lock을 기다리는 동안 다른 탭이 이미 갱신했을 수 있음
    if (currentToken && !isTokenExpired(currentToken)) {
      return currentToken;
    }
 
    const { data } = await axios.post<{ accessToken: string }>('/auth/refresh', {
      refreshToken: getRefreshToken(),
    });
 
    setAccessToken(data.accessToken);
 
    // 다른 탭에 새 토큰 전파
    const channel = new BroadcastChannel('auth');
    channel.postMessage({ type: 'TOKEN_REFRESHED', token: data.accessToken });
    channel.close();
 
    return data.accessToken;
  });
}

2. Proactive Refresh

Silent Refresh는 일단 한 번은 실패해야 한다는 한계가 있습니다. 대부분은 사용자가 눈치채지 못하지만, 실패-재시도 사이에 UI가 에러 상태를 잠깐 보여주는 경우가 있습니다.

Proactive Refresh는 이걸 뒤집어서, AT의 exp 클레임을 디코딩하여 만료 전에 선제적으로 갱신합니다. 401 가능성을 크게 줄일 수 있지만, clock skew나 서버 측 강제 만료 같은 변수까지 완전히 없애지는 못합니다.

구현 방식: Request-time 체크 vs Timer 기반

Request-time 체크는 매 API 요청 직전에 exp를 확인합니다. 사용자가 활동하지 않으면 불필요한 refresh가 발생하지 않아 효율적입니다.

function getTokenExpiry(token: string): number {
  const base64Url = token.split('.')[1];
  const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
  const payload: { exp: number } = JSON.parse(atob(base64));
  return payload.exp * 1000;
}
 
const REFRESH_BUFFER_MS = 60_000; // 만료 60초 전
 
api.interceptors.request.use(async (config) => {
  const accessToken = getAccessToken();
 
  if (accessToken) {
    const expiry = getTokenExpiry(accessToken);
    const now = Date.now();
 
    if (expiry - now < REFRESH_BUFFER_MS) {
      const { data } = await axios.post<{ accessToken: string }>('/auth/refresh', {
        refreshToken: getRefreshToken(),
      });
      setAccessToken(data.accessToken);
      config.headers.Authorization = `Bearer ${data.accessToken}`;
    } else {
      config.headers.Authorization = `Bearer ${accessToken}`;
    }
  }
 
  return config;
});

Timer 기반은 토큰 발급 시 setTimeout으로 갱신을 예약합니다. API 호출 여부와 관계없이 항상 유효한 토큰을 유지할 수 있습니다.

function scheduleTokenRefresh(accessToken: string) {
  const expiry = getTokenExpiry(accessToken);
  const now = Date.now();
  const delay = Math.max(expiry - now - REFRESH_BUFFER_MS, 0);
 
  setTimeout(async () => {
    try {
      const { data } = await axios.post<{ accessToken: string }>('/auth/refresh', {
        refreshToken: getRefreshToken(),
      });
      setAccessToken(data.accessToken);
      scheduleTokenRefresh(data.accessToken);
    } catch {
      logout();
    }
  }, delay);
}

다만 Timer 기반에는 주의점이 있습니다. Chrome 계열 브라우저는 백그라운드 탭의 setTimeout을 조건에 따라 강하게 throttle합니다. 일반적으로는 체크 주기가 1초 단위로 느려지고, 장시간 백그라운드 상태 등 특정 조건에서는 분 단위 throttling이 적용될 수도 있습니다. 탭이 비활성 상태에서 타이머가 제때 실행되지 않을 수 있으므로, visibilitychange 이벤트로 보완해야 합니다.

document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'visible') {
    const token = getAccessToken();
    if (token && isTokenExpired(token)) {
      refreshTokenImmediately();
    }
  }
});

Clock Skew 문제

exp서버 시계 기준의 Unix timestamp입니다. 클라이언트 시계가 서버보다 2분 느리면, 클라이언트는 토큰이 아직 유효하다고 판단하지만 서버는 이미 만료로 처리합니다.

서버 응답의 Date 헤더와 Date.now()를 비교해서 offset을 계산할 수도 있지만, 실무에서는 refresh buffer를 넉넉하게 잡는 것만으로 충분합니다. AT 수명의 10~20%(최소 30초, 최대 5분) 정도면 대부분의 clock skew를 커버할 수 있습니다.

참고로 클라이언트에서 JWT payload를 디코딩하는 것 자체는 보안 문제가 아닙니다. JWT는 서명되었지 암호화된 것이 아니니까요. 다만 클라이언트의 exp 체크는 UX 최적화를 위한 힌트일 뿐이고, 서버는 반드시 자체적으로 서명 검증과 exp 체크를 해야 합니다.

3. Sliding Session

사용자가 활동할 때마다 세션 만료를 뒤로 밀어주는 패턴입니다. 전통적인 서버 세션에서 쿠키 maxAge를 갱신하는 방식과 동일한 UX를 토큰 기반 인증에서 구현한 것입니다.

토큰 기반 인증에서의 구현

가장 일반적인 방법은 RT에 sliding expiration을 적용하는 것입니다. AT는 고정 만료(15분)로 두고, RT를 사용할 때마다 새 RT를 발급하면서 만료 시간을 리셋합니다.

전통적 세션토큰 기반 매핑
쿠키 maxAge 리셋RT 사용 시 새 RT 발급 + 만료 리셋
서버 세션 TTL 갱신RT의 만료를 활동 시마다 연장
lastActivity 타임스탬프서버 DB에 lastUsedAt 저장

보안 약점: 무한 세션의 가능성

Sliding Session의 가장 큰 문제는 세션이 이론적으로 무한히 연장될 수 있다는 점입니다. 공격자가 토큰을 탈취하면, 주기적으로 refresh만 해주는 것으로 영원히 유효한 세션을 유지할 수 있습니다. 자동화 스크립트 한 줄이면 되니까요.

OWASP도 이 지점을 경고하며 absolute timeout을 반드시 함께 둘 것을 권장합니다. 사용자가 계속 활동 중이더라도 일정 시간이 지나면 재인증을 강제하는 상한선이 필요하다는 뜻입니다.

  • 고위험 서비스(금융, 의료): idle timeout 2~5분, absolute timeout 수 시간
  • 저위험 서비스(콘텐츠, 소셜): idle timeout 1530분, absolute timeout 48시간
interface RefreshTokenPayload {
  userId: string;
  family: string;
  createdAt: number; // absolute timeout 기준
  lastUsedAt: number; // idle timeout 기준
}
 
function validateRefreshToken(payload: RefreshTokenPayload): boolean {
  const now = Date.now();
  const IDLE_TIMEOUT = 30 * 60 * 1000; // 30분
  const ABSOLUTE_TIMEOUT = 8 * 60 * 60 * 1000; // 8시간
 
  if (now - payload.lastUsedAt > IDLE_TIMEOUT) return false;
  if (now - payload.createdAt > ABSOLUTE_TIMEOUT) return false;
 
  return true;
}

Sliding Session이 적절한 경우와 부적절한 경우를 구분하는 것이 중요합니다.

적절한 경우부적절한 경우
소셜 미디어, 콘텐츠 소비 앱금융 거래, 은행 앱
내부 업무 도구 (하루 종일 사용)의료 정보 시스템
E-commerce 브라우징관리자 패널

4. RT Rotation

지금까지의 패턴들에는 공통적인 취약점이 있습니다. RT가 탈취되면 공격자가 마음대로 새 AT를 계속 발급받을 수 있다는 점입니다. RT가 유효한 동안에는 서버 입장에서 정당한 사용자인지 공격자인지 구분할 방법이 없습니다.

RT Rotation은 RT를 사용할 때마다 새 RT를 발급하고, 이전 RT를 즉시 폐기하는 패턴입니다. OAuth 2.0 BCP(RFC 9700)에서도 브라우저 같은 Public 클라이언트에서는 이 방식을 반드시 사용하라고 명시하고 있습니다.

Token Family로 탈취를 감지합니다

RT Rotation의 진짜 핵심은 단순한 교체가 아니라 탈취 감지에 있습니다.

한 사용자의 RT 체인을 하나의 token family로 묶어서 관리합니다.

  1. 최초 인증 시 RT-1을 발급합니다. 이것이 family의 root입니다.
  2. RT-1을 사용하면 RT-2를 발급하고, RT-1은 무효화됩니다.
  3. RT-2를 사용하면 RT-3를 발급하고... (계속)

여기서 핵심은 이미 무효화된 RT-1이 다시 사용되는 경우입니다. 정상적인 사용자는 이미 RT-2를 가지고 있으므로 RT-1을 쓸 이유가 없습니다. RT-1이 재사용됐다는 건 누군가가 탈취해뒀다는 뜻이고, 서버는 해당 family의 모든 토큰을 즉시 revoke합니다. 정당한 사용자도 로그아웃되지만, 탈취된 상태에서 접근을 계속 허용하는 것보다는 안전합니다.

async function handleRefresh(oldRT: string): Promise<TokenPair> {
  const record = await db.refreshTokens.findOne({
    token: oldRT,
  });
 
  if (!record) {
    throw new UnauthorizedError('Invalid refresh token');
  }
 
  // 이미 사용된 토큰이 다시 나타남 → 탈취 의심
  if (record.used) {
    await db.refreshTokens.updateMany({ family: record.family }, { revoked: true });
    throw new UnauthorizedError('Token reuse detected');
  }
 
  await db.refreshTokens.updateOne({ token: oldRT }, { used: true });
 
  const newAT = generateAccessToken(record.userId);
  const newRT = generateRefreshToken({
    userId: record.userId,
    family: record.family,
  });
 
  return { accessToken: newAT, refreshToken: newRT };
}

네트워크 실패 시 RT 유실 문제

RT Rotation의 가장 큰 실무적 문제는 네트워크 실패입니다.

  1. 클라이언트가 RT-1로 refresh를 요청한다
  2. 서버는 RT-2를 발급하고 RT-1을 무효화한다
  3. 네트워크 실패로 응답이 클라이언트에 도착하지 않는다
  4. 클라이언트는 여전히 RT-1만 가지고 있다
  5. RT-1로 다시 요청 → reuse 감지 → 전체 family revoke → 강제 로그아웃

사용자 잘못이 아닌데 로그아웃되는 상황입니다. 이 문제 때문에 Auth0, Okta 같은 서비스들은 Grace Period를 제공합니다. 무효화된 RT가 짧은 시간(보통 30초) 내에 다시 사용되면 reuse로 간주하지 않고 새 토큰을 발급합니다.

Grace Period 동안에는 탈취 감지가 작동하지 않으므로, 가능한 짧게 설정하는 것이 중요합니다.

갱신 패턴 비교

패턴동작장점단점
Silent Refresh401 감지 → RT로 AT 갱신 → 재시도구현 직관적, 대부분의 프레임워크에서 표준최초 1회 401 실패 비용
Proactive Refresh만료 전 선제 갱신401 없이 seamless 경험JWT 디코딩 로직 필요, 시간 동기화 이슈
Sliding Session활동 시마다 세션 연장기존 웹 세션과 유사한 UX보안 약화 (영구 세션 가능성)
RT RotationRT 사용 시마다 새 RT 발급, 이전 RT 폐기OAuth 2.0 BCP 권장, 탈취 감지 가능네트워크 실패 시 RT 유실 위험

이 4가지는 배타적이지 않습니다. 실무에서는 조합해서 씁니다.

실무에서의 조합 전략

Silent Refresh + RT Rotation

가장 일반적인 프로덕션 설정입니다. 401이 발생하면 인터셉터가 refresh를 하고, 서버는 매 refresh마다 RT를 교체합니다. 구현이 단순하면서도 탈취 감지까지 가능합니다.

다만 멀티탭 환경에서 Web Locks API로 동시 refresh를 반드시 방지해야 합니다.

Proactive Refresh + Silent Refresh (Fallback)

가장 견고한 조합입니다.

  • 1차 방어선 (Proactive): 요청 인터셉터에서 exp를 확인해서, 만료가 임박하면 미리 갱신
  • 2차 방어선 (Silent): 시계 차이나 네트워크 지연으로 proactive가 실패해서 401이 발생하면, 응답 인터셉터가 catch

99%의 경우 proactive가 작동해서 사용자는 401을 전혀 경험하지 않고, 나머지 1%에서도 silent refresh가 안전망 역할을 합니다.

여기에 RT Rotation까지 더하면 Proactive + Silent + RT Rotation 3중 조합이 됩니다.

피해야 할 안티패턴

1. RT Rotation + Sliding Session인데 absolute timeout이 없는 경우

RT가 사용될 때마다 새 RT의 만료가 리셋되면, 공격자가 탈취한 토큰으로 무한히 갱신할 수 있습니다. 반드시 absolute timeout을 함께 설정해야 합니다.

2. 여러 탭에서 동기화 없이 RT Rotation 사용

탭마다 독립적으로 refresh를 시도하면, token family가 한꺼번에 폐기되어 사용자가 갑자기 로그아웃됩니다.

3. Refresh 실패 시 무한 재시도

RT가 만료되었거나 폐기된 경우, 재시도는 무의미하고 서버에 부하만 줍니다. 실패하면 즉시 재로그인 플로우로 보내야 합니다.

4. Timer 기반 proactive refresh만 단독 사용

브라우저가 백그라운드 탭의 타이머를 늦추거나, 기기가 sleep 모드에 들어가면 타이머가 실행되지 않을 수 있습니다. 반드시 401 기반 silent refresh를 fallback으로 둬야 합니다.

선택 기준 정리

제가 현재 쓰는 기준은 단순합니다.

  • 일반적인 웹 서비스: Proactive Refresh + Silent Refresh(fallback) + RT Rotation
  • 보안 민감한 서비스: 위 조합 + 짧은 AT 수명(5분) + absolute timeout 필수
  • 내부 도구/어드민: Silent Refresh + Sliding Session + absolute timeout

어떤 조합을 선택하든, 멀티탭 동기화refresh 실패 시 재로그인 플로우는 반드시 챙겨야 합니다. 이 두 가지를 빠뜨리면 프로덕션에서 사용자가 갑자기 로그아웃되는 문제가 반복됩니다.