mermaid-diagram-2024-11-17-004537.svg

1. TokenRefreshManager 클래스

class TokenRefreshManager {
  // 현재 토큰 재발급이 진행 중인지 확인하는 플래그
  private isRefreshing = false;

  // 토큰 재발급을 기다리는 요청들의 콜백 함수 배열
  private refreshSubscribers: Array<() => void> = [];

  // 대기열에 콜백 추가
  public addSubscriber(callback: () => void) {
    this.refreshSubscribers.push(callback);
  }

  // 토큰 재발급 완료 후 대기 중인 모든 요청 실행
  private onRefreshed() {
    this.refreshSubscribers.forEach((callback) => callback());
    this.refreshSubscribers = [];  // 대기열 초기화
  }

  async refreshToken(): Promise<boolean> {
    // 이미 재발급 진행 중이면 새로운 Promise 반환
    if (this.isRefreshing) {
      return new Promise<boolean>((resolve) => {
        // 현재 요청을 대기열에 추가
        this.addSubscriber(() => resolve(true));
      });
    }

    this.isRefreshing = true;  // 재발급 시작

    try {
      const response = await fetch('<http://localhost:8080/token/reissue>', {
        method: 'POST',
        credentials: 'include',  // 쿠키 포함하여 요청
      });

      if (!response.ok) {
        throw new Error('토큰 갱신 실패');
      }

      this.isRefreshing = false;
      this.onRefreshed();  // 대기 중인 모든 요청 실행
      return true;
    } catch (error) {
      this.isRefreshing = false;
      this.refreshSubscribers = [];  // 에러 발생 시 대기열 초기화
      return false;
    }
  }
}

2. fetchWithToken 함수

export async function fetchWithToken(
  url: string,
  options: RequestInit = {}
): Promise<Response> {
  // 기본 헤더 설정
  const headers = {
    ...options.headers,
  };

  // 첫 번째 API 요청 시도
  let response = await fetch(url, {
    ...options,
    headers,
    credentials: 'include',  // 모든 요청에 쿠키 포함
  });

  // 401 에러(토큰 만료) 처리
  if (response.status === 401) {
    // 토큰 재발급 시도
    const isRefreshSuccess = await TokenRefreshManager.refreshToken();

    if (isRefreshSuccess) {
      // 재발급 성공 시 원래 요청 재시도
      response = await fetch(url, {
        ...options,
        headers,
        credentials: 'include',
      });
    } else {
      // 재발급 실패 시 로그인 페이지로 강제 이동
      alert('세션이 만료되었습니다. 다시 로그인해주세요.');
      window.location.href = '/login';
      throw new Error('인증 실패');
    }
  }

  // 기타 에러 처리
  if (!response.ok) {
    throw new Error(`요청에 실패했습니다: ${response.status}`);
  }

  return response;
}

상세 동작 설명

  1. 초기 요청 처리

  2. 토큰 만료 감지 (401 에러)

    if (response.status === 401) {
      const isRefreshSuccess = await TokenRefreshManager.refreshToken();
      // ...
    }
    
    
  3. 토큰 재발급 프로세스

    async refreshToken(): Promise<boolean> {
      if (this.isRefreshing) {
        return new Promise<boolean>((resolve) => {
          this.addSubscriber(() => resolve(true));
        });
      }
      // ...
    }
    
    
  4. 동시성 처리

    private refreshSubscribers: Array<() => void> = [];
    
    
  5. 재발급 완료 후 처리

    private onRefreshed() {
      this.refreshSubscribers.forEach((callback) => callback());
      this.refreshSubscribers = [];
    }
    
    
  6. 에러 처리

주요 이점

  1. Race Condition 방지
  2. 보안성