Signleton Promise 기반 JWT 유저 인증 기능 구현
1. 문제 상황 (Problem)
이중 refresh 호출
보호 라우트 진입 시 세션 검증(useViewerSession)에서 refresh를 호출하는 동시에, 자식 컴포넌트의 API 요청이 401을 반환하면 Axios 인터셉터에서도 refresh를 호출합니다. 이로 인해 동일한 refresh API가 두 번 호출되는 Race Condition이 발생합니다.
라우트 전환으로 인한 재호출
토큰이 만료된 상태에서 보호 라우트 접근 시 refresh가 실패하면 /login으로 리다이렉트됩니다. 이때 게스트 레이아웃이 마운트되며 동일한 useViewerSession 훅이 다시 실행됩니다. 스토어가 비어 있어 refresh가 한 번 더 호출되는데, 사용자 체감상 **"첫 호출 후 짧은 간격을 두고 발생하는 두 번째 호출"**이 문제가 됩니다.
서버 부하 및 토큰 경합
Refresh Token Rotation(RTR) 정책(한 번 사용한 토큰은 무효화)을 사용하는 백엔드 환경일 경우, 동시에 두 번 호출되면 한 쪽 요청이 실패 처리되어 사용자가 강제로 로그아웃되는 불상사가 생길 수 있습니다.
2. 해결 과정 (Approach)
Step 1: Singleton Promise 패턴 도입
JavaScript에서 권장되는 Singleton Promise 패턴을 적용했습니다. 진행 중인 refresh 요청의 Promise 인스턴스를 모듈 레벨 변수에 보관하고, 이미 진행 중인 요청이 있다면 새 요청을 생성하지 않고 기존의 Promise를 반환합니다.
Note: 단순히
boolean플래그(isRefreshing)만 사용하면 첫 요청이 끝나기 전 두 번째 요청이 들어왔을 때 제어하기 어렵습니다. Promise 자체를 캐시하여 모든 호출자가 동일한 결과를await하도록 만드는 것이 핵심입니다.
Step 2: 단일 진입점으로 캡슐화
세션 검증 가드와 401 인터셉터가 모두 **refreshViewer()**라는 동일한 함수만 바라보도록 설계했습니다. 내부 로직(중복 방지)은 캡슐화하여 외부에서는 복잡한 구현 체계를 신경 쓰지 않고 함수만 호출하면 됩니다.
Step 3: 보호 라우트 실패 후 이중 호출 방지
refresh 실패 직후 /login 페이지 진입 시 발생하는 즉시 재호출을 막기 위해, 응답 데이터에 refreshToken 만료 기한을 포함했습니다. useViewerSession 내부에서 최근 refresh 시도 기록이 있다면 일정 시간 동안은 재요청을 스킵하도록 처리했습니다.
3. 최종 코드 (Final Solution)
로우 레벨 API와 Singleton Promise가 적용된 공개 API를 분리하여 구현했습니다. finally 블록에서 refreshPromise를 초기화하여, 작업 완료 후에는 새로운 요청을 수행할 수 있도록 설계했습니다.
typescript// src/entities/viewer/api/refresh-viewer.ts async function fetchRefresh(): Promise<TRefreshViewerResponse> { const response = await axiosInstance.post<TRefreshViewerResponse>("/auth/refresh"); return response.data; } /** * 진행 중인 refresh 요청의 Promise. * Singleton Promise 패턴으로 중복 호출 방지 */ let refreshPromise: Promise<TRefreshViewerResponse> | null = null; export function refreshViewer(): Promise<TRefreshViewerResponse> { // 1. 이미 진행 중인 요청이 있다면 해당 Promise를 반환 if (refreshPromise) return refreshPromise; // 2. 요청 시작: Promise를 변수에 할당 refreshPromise = fetchRefresh().finally(() => { // 3. 완료(성공/실패) 시 변수 초기화 refreshPromise = null; }); return refreshPromise; }
4. 결과 (Result)
- 동시 호출 단일화: 여러 곳에서 동시에
refreshViewer()를 호출해도 실제 API는 단 1회만 발생하며, 모든 호출자가 동일한 결과를 공유합니다. - 라우트 전환 시 이중 호출 제거: 리다이렉트 과정에서 발생하는 불필요한 서버 요청과 토큰 경합 문제를 해결하여 서비스 안정성을 높였습니다.
- 관례 준수: Go 언어의
singleflight나 JavaScript의 Singleton 패턴 등 표준적인 설계를 참고하여 유지보수가 용이한 구조를 확립했습니다.
아직 댓글이 없습니다
첫 번째 댓글을 작성해보세요!