1. 구현 계획1-1. 목표 기능1-2. 구현 환경2. 구현 방법2-1. HTTP에서 Refresh 과정 구현2-1-1. Axios Interceptor 구성2-1-2. 전체 셋업 코드 (Refresh 과정과 Axios 연동)2-2. Refresh 및 Refresh 공유 기능 구현2-2-1. Refresh 과정2-2-2. 전체 구현 코드2-3. WebSocket에서 Refresh 과정 구현2-3-1. STOMP Interceptor와 연동2-3-2. 전체 구현 코드
1. 구현 계획
1-1. 목표 기능
- Authorization header에 Access Token을 할당해 인증
- Access Token 만료 시 Refresh Token을 활용해 재발급
- localStorage를 사용하지 않고, 단순 객체로만 보관
- 최초 탭 접근 시 Refresh를 시도해 신규 AccessToken을 발급
- Refresh 요청은 1회만 진행하도록 Refresh 요청을 공유
- 만약 Refresh에 실패하는 경우 로그아웃
1-2. 구현 환경
- AS-IS
- axios, stompClient를 사용
- localStorage 기반으로 Access Token 기반 인증이 구현된 상태
- TO-BE
- AccessToken은 Authorization 헤더로, RefreshToken은 쿠키로 전달
- HTTP, WebSocket 모두 Access Token을 전송해야 하므로, 둘 모두 Refresh 과정이 필요
2. 구현 방법
2-1. HTTP에서 Refresh 과정 구현
withCredentials: true
옵션을 활용해 CORS 환경에서 쿠키를 전송할 수 있게 설정
- axios의 request handler와 errorHandler를 활용
- 3단계로 구성
- AccessToken을 부착
- Refresh 중인 경우 Refresh 완료까지 대기 후 요청을 실행
- Refresh가 필요한 경우 Refresh를 실행
2-1-1. Axios Interceptor 구성
const apiSetting: AxiosRequestConfig = { baseURL: import.meta.env.VITE_SERVER_URL, timeout: 5000, headers: { Accept: 'application/json', 'Content-Type': 'application/json', }, withCredentials: true, // NOTE: 쿠키를 포함한 크로스 도메인 요청을 위해 필요 }; export const api = axios.create(apiSetting); api.interceptors.request.use(attachAccessToken); api.interceptors.request.use(awaitTokenRefresh); api.interceptors.response.use(null, tryTokenRefresh);
2-1-2. 전체 셋업 코드 (Refresh 과정과 Axios 연동)
export const attachAccessToken = (config: InternalAxiosRequestConfig) => { // NOTE: Refresh 요청 시에도 해당 Interceptor를 통과 if (!accessTokenRepository.isRefreshing()) { config.headers.Authorization = accessTokenRepository.get(); } return config; }; export const awaitTokenRefresh = async (config: InternalAxiosRequestConfig) => { if ( accessTokenRepository.isRefreshing() && config.headers && config.url !== refreshEndpoint ) { try { await accessTokenRepository.refresh(); attachAccessToken(config); } catch (refreshError) { return Promise.reject(refreshError); } } return config; }; export const tryTokenRefresh = async ( error: AxiosError<BaseResponse<unknown>> | Error, ) => { if (!axios.isAxiosError(error)) { console.error(`AxiosError가 아닌 네트워크 오류: ${error}`); return Promise.reject(error); } const config = error?.config; if (!config) { console.error(`Axios Config 객체가 없는 네트워크 오류: ${error}`); return Promise.reject(error); } if (error.response?.status === HttpStatusCode.Unauthorized) { if (config.url === refreshEndpoint) { return Promise.reject('Refresh 요청이 실패'); } try { await accessTokenRepository.refresh(); attachAccessToken(config); } catch (refreshError) { console.error('HTTP 요청 중 토큰 갱신에 실패:', refreshError); forceLogout(); throw refreshError; } return api(config); } console.error('처리하지 못한 네트워크 오류:', error); // NOTE: error 객체를 반환하면 Promise.resolve()로 감싸져 성공 응답으로 처리되므로, // 명시적으로 Promise.reject()로 반환해야 한다. return Promise.reject(error); };
2-2. Refresh 및 Refresh 공유 기능 구현
- HTTP, WebSocket에서 공동으로 사용해야 하므로, Axios 인스턴스에 보관하지 않고 별도의 객체로 구현
- Singleton 객체로 구현
- AccessToken을 보관하고, Refresh 기능과 Refresh 요청을 공유하는 기능을 제공
2-2-1. Refresh 과정
- Refresh 요청 시 공유와 대기를 위해
Promise
기반으로 구현
- 사용처에서는
await refresh();
호출
- 이미 refresh 중인 경우 해당 요청(fetch)을 대기
- refresh 완료 시 사용처에서 새로 발급된 Access Token을 할당하고 재요청 혹은 요청 재개 진행
// 클래스 필드로 구현 #refreshRequest: Promise<void> | null = null; refresh() { if (!this.#refreshRequest) { this.#refreshRequest = (async () => { try { await this.#fetchNewAccessToken(); } finally { this.#refreshRequest = null; } })(); } return this.#refreshRequest; }
2-2-2. 전체 구현 코드
import { api } from './api'; // TODO: /api prefix를 axios 인스턴스에 공통화 export const refreshEndpoint = `${import.meta.env.VITE_SERVER_URL}/api/auth/refresh`; export class AccessTokenRepository { #refreshRequest: Promise<void> | null = null; #accessToken: string | null = null; static #instance = new AccessTokenRepository(); private constructor() {} static getInstance() { if (!AccessTokenRepository.#instance) { AccessTokenRepository.#instance = new AccessTokenRepository(); } return AccessTokenRepository.#instance; } get() { // NOTE: 첫 로그인 시 호출되므로 null 반환 필요 if (!this.#accessToken) { return null; } return this.#accessToken; } isRefreshing() { return this.#refreshRequest !== null; } // NOTE: 로그인 API로 로그인 시 반환되는 AccessToken 활용 onLogin(accessToken: string) { this.#accessToken = `Bearer ${accessToken}`; } isLoggedIn() { return this.#accessToken !== null; } // TODO: Sentry 등으로 오류 모니터링 refresh() { if (!this.#refreshRequest) { this.#refreshRequest = (async () => { try { await this.#fetchNewAccessToken(); } finally { this.#refreshRequest = null; } })(); } return this.#refreshRequest; } async #fetchNewAccessToken() { const response = await api.post( refreshEndpoint, {}, { headers: { Authorization: null, }, }, ); this.#accessToken = `Bearer ${response.data.data.accessToken}`; } } export const accessTokenRepository = AccessTokenRepository.getInstance();
2-3. WebSocket에서 Refresh 과정 구현
2-3-1. STOMP Interceptor와 연동
- StompClient를 사용 중인 코드로,
onStompError
핸들러를 사용해 AccessToken refresh를 진행
- 이미 코드가 공통화되어 있어 쉽게 구현 가능
client.onStompError = async (frame) => { await accessTokenRepository.refresh(); client.connectHeaders.Authorization = accessTokenRepository.get(); }
2-3-2. 전체 구현 코드
export const stompClient = (): Client => { const authorization = accessTokenRepository.get(); if (!authorization) { throw new Error('WebSocket 인증에 필요한 엑세스 토큰이 없습니다.'); } const client = new Client({ brokerURL: `${import.meta.env.VITE_SERVER_URL}/connection`, connectHeaders: { Authorization: authorization, }, reconnectDelay: WEBSOCKET_CONFIG.RECONNECT_DELAY, }); client.onStompError = async (frame) => { if (frame.headers.message?.toLowerCase().includes('unauthorized')) { try { await accessTokenRepository.refresh(); client.connectHeaders.Authorization = accessTokenRepository.get(); if (client.connected) { client.deactivate(); } client.activate(); } catch (error) { console.error('WebSocket 요청 중 토큰 갱신에 실패:', error); forceLogout(); } } else { console.error('STOMP error:', frame.headers.message); } }; client.onDisconnect = () => { console.log('Disconnected from WebSocket'); }; return client; };