JWT, access token, refresh token에 대한 설명은 생략합니다.
평소처럼 비동기 API 콜을 사용하여 데이터를 받아오고 있었습니다.
액세스 토큰이 손상, 만료될 경우를 대비해 axios 인터셉터에 만료 로직을 추가하여 관리하고 있는 상황이었는데..
기존의 재발급 처리 방식
axios.interceptors.response.use(
async (response) =>
response,
async (error) => {
// API 요청 중 401 unauthorized 에러가 발생한 경우 (토큰 만료 등)
if (error.response && error.response.status === 401) {
try {
// reissue 요청을 하고 실패한 요청을 다시 요청한다.
const res = await tokenAxios.post('/v3/jwt/reissue').then((r) => r.data);
console.log('Refreshed token successfully!');
const { accessToken } = res;
const originalRequest = error.config;
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
return await axios(originalRequest);
} catch (err) {
// refresh까지 실패할 경우 인가 쿠키를 모두 지우고 재로그인시킨다.
console.error('Failed to refresh token:', err);
Cookies.remove('accessToken');
Cookies.remove('refreshToken');
window.location.href = '/login';
}
} else if (error.response && error.response.status === 500) {
return Promise.reject(error);
}
return Promise.reject(error);
},
);
이러한 경우 아래와 같은 불필요한 API 콜이 발생하게 됩니다.
만료된 액세스 토큰이 Bearer로 설정되어 비동기적으로 호출된 API 콜들은 모두 401 에러를 발생시키고, 제각각 재발급을 요청합니다.
재발급 할 토큰은 단 1개만 필요하므로, 재발급 API도 단 1번만 보내짐이 바람직합니다.
이를 위한 해결법은 아래와 같습니다.
- 동기적으로 API 콜을 보내 첫 번째로 401이 발생하는 콜에서 재발급을 진행.
(병렬 처리 불가. Waterfall이 늘어납니다..) - 재발급 큐를 만들어 API 콜들을 펜딩시키고 재발급이 된 후 일괄적으로 비동기 호출 (병렬 처리 가능)
2번 방법을 사용해보겠습니다. 아래와 같은 흐름을 만들어야 합니다.
1. 401 발생:
요청 1 → isRefreshing = false → refreshToken() 시작
요청 2 → isRefreshing = true → addSubscriber(callback)
요청 3 → isRefreshing = true → addSubscriber(callback)
2. Refresh 완료:
refreshSubscribers 큐 = [요청 2 콜백, 요청 3 콜백]
onTokenRefreshed 실행 → 큐에 있는 모든 콜백 실행
3. 대기 중인 요청 처리:
요청 1 → 새로운 토큰으로 다시 실행
요청 2 → 새로운 토큰으로 다시 실행
요청 3 → 새로운 토큰으로 다시 실행
재발급 큐를 이용하는 방식 (Promise와 뮤텍스 개념 활용)
아래 방법은 뮤텍스의 개념을 활용했을 뿐, 자바스크립트는 싱글 스레드 언어이기 때문에 C나 Java의 멀티 스레딩 뮤텍스의 원리와는 전혀 다릅니다. 단순 조건문 처리라고 보시는 것이 맞습니다 !
let isRefreshing = false; // 토큰 재발급 중인지. 재발급 API로의 접근을 제한하는 뮤텍스 락의 역할을 한다.
let refreshSubscribers: Array<Function> = []; // 재발급 대기 중인 요청 리스트
// 토큰이 재발급되면 큐에 대기중인 요청들을 순차적으로 실행시킨다.
const onTokenRefreshed = (newToken: string) => {
refreshSubscribers.forEach((callback) => callback(newToken));
refreshSubscribers = [];
};
// 재발급 중 발생한 401 요청은 큐에 등록되어 대기한다.
const addSubscriber = (callback: Function) => {
refreshSubscribers.push(callback);
};
axios.interceptors.response.use(
(response) => response,
async (error) => {
const { config, response } = error;
if (response && response.status === 401) {
if (!isRefreshing) {
// 재발급이 이뤄지고 있지 않으면 재발급 실행
isRefreshing = true; // 락을 걸었다. 아래 구간부터는 임계 구간이다.
try {
const res = await tokenAxios.post('/v3/jwt/reissue').then((r) => r.data);
console.log('Refreshed token successfully!');
const newAccessToken = res;
isRefreshing = false;
// 락 제거.
onTokenRefreshed(newAccessToken);
config.headers.Authorization = `Bearer ${newAccessToken}`;
return await axios(config); // 실패한 첫 요청은 refresh queue에 존재하지 않으므로 이 라인에서 재시도
} catch (err) {
// 위 과정에서 에러가 발생하면 인가 토큰을 모두 제거하고 재로그인시킨다.
console.error('Failed to refresh token:', err);
isRefreshing = false;
Cookies.remove('accessToken');
Cookies.remove('refreshToken');
window.location.href = '/login';
return Promise.reject(err);
}
}
// 재발급 중이라면 refresh queue에 해당 401 요청을 추가한다.
return new Promise((resolve) => {
addSubscriber((newToken: string) => {
config.headers.Authorization = `Bearer ${newToken}`;
resolve(axios(config));
});
});
}
// 오류가 401이 아닌 경우 에러 발생시킴.
return Promise.reject(error);
},
);
Mutex lock과 Critical section의 설정
재발급 API 콜은 첫 번째 401을 일으킨 요청만이 사용하는 자원이 되어야 합니다.
이외 API 콜들의 배제가 필요한 상황이므로 isRefreshing이라는 변수를 선언해 뮤텍스 키의 역할을 하게 했습니다.
isRefreshing = true; // 락 걸기. 이후 해제까지 다른 스레드는 아래 코드 접근 불가.
try {
const res = await tokenAxios.post('/v3/jwt/reissue').then((r) => r.data);
console.log('Refreshed token successfully!');
const newAccessToken = res;
isRefreshing = false; // 락 제거.
onTokenRefreshed(newAccessToken);
config.headers.Authorization = `Bearer ${newAccessToken}`;
return await axios(config); // 실패한 첫 요청은 refresh queue에 존재하지 않으므로 이 라인에서 재시도
} catch (err) {
// 위 과정에서 에러가 발생하면 인가 토큰을 모두 제거하고 재로그인시킨다.
console.error('Failed to refresh token:', err);
isRefreshing = false; // 락 제거
Cookies.remove('accessToken');
Cookies.remove('refreshToken');
window.location.href = '/login';
return Promise.reject(err);
}
}
Promise와 마이크로태스크 큐를 활용한 동기 처리
- refreshToken()
- addSubscriber(callback)
- 재발급 완료
- onTokenRefresh()로 재요청
만약 위 순서가 섞여서 실행되면 지옥이 펼쳐지겠죠? 동기 처리가 필요합니다.
if (!isRefreshing) {
// 재발급이 이뤄지고 있지 않으면 재발급 실행
isRefreshing = true; // 락을 걸었다. 아래 구간부터는 임계 구간이다.
try {
const res = await tokenAxios.post('/v3/jwt/reissue').then((r) => r.data);
console.log('Refreshed token successfully!');
const newAccessToken = res;
isRefreshing = false;
// 락 제거.
onTokenRefreshed(newAccessToken);
config.headers.Authorization = `Bearer ${newAccessToken}`;
return await axios(config); // 실패한 첫 요청은 refresh queue에 존재하지 않으므로 이 라인에서 재시도
} catch (err) {
... // 에러 처리
}
}
// 재발급 중이라면 refresh queue에 해당 401 요청을 추가한다.
return new Promise((resolve) => {
addSubscriber((newToken: string) => {
config.headers.Authorization = `Bearer ${newToken}`;
resolve(axios(config));
});
});
}
위 코드에서 await으로 재발급 로직이 실행되는 동안 발생한 addSubscriber들은 큐에 즉시 추가됩니다.
재발급이 끝나고 onTokenRefreshed가 실행되어 큐에 있는 요청들을 모두 실행합니다.
동기적 실행이 보장되었습니다.
외전 - 완벽한 동기 실행인가?
엄밀히 말하면 race condition에서 자유롭진 않습니다. 아래의 케이스들이 존재하기 때문입니다.
- 토큰 갱신 작업 중 타이밍 문제
- 첫 번째 요청이 401을 받고 refreshToken을 호출하기 직전, 거의 동시에 또 다른 요청이 401 응답을 받을 경우.
- 이 짧은 타이밍 간격에 두 요청이 모두 isRefreshing = false 상태를 확인하여, 두 번의 refreshToken 호출이 이루어질 가능성이 있다.
- 네트워크 지연 및 비정상적인 요청 처리
- 네트워크 지연이 심하거나, 특정 환경에서 이벤트 루프의 순서가 왜곡될 경우(이론적으로)
- JavaScript 실행 환경이 변칙적이거나, 여러 스레드를 사용할 수 있는 환경(Web Workers 등)에서 문제가 발생할 여지.
- 네트워크 지연이 심하거나, 특정 환경에서 이벤트 루프의 순서가 왜곡될 경우(이론적으로)
- 토큰 만료와 동시에 폭발적인 요청 발생
- 서버 또는 클라이언트가 동시에 많은 요청을 보내는 시나리오.
- 이 경우 짧은 시간에 여러 요청이 거의 동시에 401 상태를 반환하여 refreshToken 호출이 중복될 가능성이 높아진다.
위 시나리오들은 거의 일어날 일이 없다고 봐야 하지만, 더욱 엄격한 mutex lock 관리를 통해 불확실성을 좀 더 줄일 수 있습니다. 이를 위해 Promise 기반 lock을 사용하거나, 실제 atomic lock을 만들 수 있는데, 2부로 Promise 기반 lock을 만들어 관리하는 글을 써보겠습니다.
다수의 요청을 처리하는 이벤트루프가 단 하나인 브라우저 환경을 고려하면, 위 상황들에서도 race condition이 발생할 부분이 없다고 보는게 맞겠습니다. goat @donghyk2 님의 제보에 감사를..
'Frontend' 카테고리의 다른 글
[NextJS] 이미지 파일 관리, 최적화 (SVG, SVGO) (3) | 2025.02.09 |
---|---|
[React] 조건부 렌더링 방식 (얼리 리턴, 삼항 연산자, 논리 AND 연산자) (0) | 2025.01.10 |
[React] useMemo 실제 활용해보기 (0) | 2024.11.28 |
[ESLint] ESLint가 vscode에서 적용되지 않을 때 (2) | 2024.11.26 |
[TypeScript] 모듈 임포트 절대경로 설정, 자동완성 (tsconfig, VScode) (2) | 2024.11.12 |