1. 문제 상황
프로젝트를 진행하면서, React.js 프론트엔드와 Node.js 백엔드를 사용해 JWT 기반 로그인을 구현했다. refreshtoken은 7일로 설정된 jwt을 통해 로그인을 구현했다.
accessToken
은 15분, refreshToken
은 7일로 설정되어 있으며, FE에서 accessToken이 만료된 경우 refreshToken이 BE와 일치하는지 검증을 한 후, BE에서 새로운 accessToken과 refreshToken을 재발급하여 FE에게 전달해 주고, FE에서는 localStorage에 저장하는 방식으로 구현했다.
필자의 경우 컴퓨터에서 사용할 수 있는 웹과 웹뷰로 동작하는 부분이 둘 다 존재하기 때문에, 앱 사용자의 경우 잦은 로그인이 필요없기 때문에 refreshToken의 만료 기간을 7일로 길게 설정했다.
이러한 경우 한번 로그인을 하게 되면 로그아웃을 하지 않는다면, 7일 동안 로그인을 할 일이 없어야 한다.
하지만 사용자들에게 가끔 로그인이 풀리거나 웹에서 데이터 업로드 시 엄청 오래 걸리는 작업을 할 때 새로고침을 하면 로그인이 풀린다는 피드백을 받게 되었다.
도대체 왜 로그인이 풀릴까??
가끔 로그인이 풀리는 것은 웹과 앱을 번갈아서 사용하는 사용자의 경우 refreshToken으로 인해 동시 로그인이 안 되기 때문에 그럴 수 있다고 생각했지만, 그냥 웹을 사용할 때 어떤 특정 경우에는 확률적으로 로그인이 풀리는 경우가 발생하는 것은 로직적으로 문제가 있다고 판단하고 분석에 들어갔다.
export const api = axios.create({
baseURL: `${import.meta.env.VITE_SERVER_ADDRESS}`,
headers: {
'Content-Type': 'application/json',
},
withCredentials: true,
});
필자의 경우 axios의 인스턴스를 만들어서 공통된 설정을 적용했고,
// api.js - Axios Response Interceptor
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
// 401 에러이고, 재시도하지 않은 요청인 경우
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
const refreshToken = localStorage.getItem('refreshToken');
if (!refreshToken) {
throw new Error('No refresh token available');
}
// Refresh Token으로 새로운 Access Token 요청
const response = await axios.post(
`${import.meta.env.VITE_SERVER_ADDRESS}/auth/refresh`,
{},
{
headers: {
Authorization: `Bearer ${refreshToken}`,
'Content-Type': 'application/json',
},
}
);
// 새로운 토큰들 저장
const { accessToken, refreshToken: newRefreshToken } = response.data;
localStorage.setItem('accessToken', accessToken);
if (newRefreshToken) {
localStorage.setItem('refreshToken', newRefreshToken);
}
// 원래 요청에 새로운 Access Token 적용하여 재시도
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
return api(originalRequest);
} catch (refreshError) {
// Refresh Token도 만료되었거나 오류 발생
console.error('Token refresh failed:', refreshError);
// 모든 토큰 정보 삭제 (아이디 저장 기능은 유지)
const rememberedPhone = localStorage.getItem('rememberedPhone');
const rememberPhoneChecked = localStorage.getItem(
'rememberPhoneChecked'
);
localStorage.clear();
// 아이디 저장이 체크되어 있었다면 복원
if (rememberPhoneChecked === 'true' && rememberedPhone) {
localStorage.setItem('rememberedPhone', rememberedPhone);
localStorage.setItem('rememberPhoneChecked', 'true');
}
// 로그인 페이지로 리다이렉트
window.location.href = '/';
return Promise.reject(refreshError);
}
}
return Promise.reject(error);
}
);
요청 인터셉터를 활용하여 요청을 보낼 때마다 refreshToken을 검증하고 토큰 재할당을 받는 방법도 있지만, 이런 경우 서버에 부하가 심할 수 있다고 생각하여 응답 인터셉터에서 토큰을 재발급받는 로직을 수행할 수 있도록 구현했다.
응답 인터셉터에서는 originalRequest._retry
가 false인 경우와 API 요청 시 401 에러가 발생한 경우, refreshToken이 일치한다면 accessToken과 refreshToken을 재발급받는 로직이다.
originalRequest._retry = true;
처럼 _retry
플래그를 설정해 401 에러가 발생했을 때 다시 요청을 날리는데, 이때에도 401 에러가 반복적으로 나타나는 경우를 차단했다. refreshToken이 만료된 경우 localStorage.clear();
를 통해 토큰 정보를 모두 초기화하고, 로그인 페이지로 리다이렉트 하도록 구현했다.
- axios와 axios인스턴스에 대해서는 아래 링크를 참고
Axios 인스턴스 | Axios Docs
Axios 인스턴스 인스턴스 만들기 사용자 지정 config로 새로운 Axios 인스턴스를 만들수 있습니다. axios.create([config]) const instance = axios.create({ baseURL: 'https://some-domain.com/api/', timeout: 1000, headers: {'X-Custom-
axios-http.com
2. 테스트 및 원인 분석
// App.tsx
import { api } from './apis/api';
useEffect(() => {
if (import.meta.env.DEV) {
// 전역으로 api 노출 (개발 환경에서만)
(window as any).api = api;
}
}, []);
디버깅을 위해 위와 같이 App.tsx에서 axios인스턴스를 전역으로 설정하고,
localStorage.setItem('accessToken', 'expired_access_token');
정상적으로 로그인이 되어있는 상태로 개발자도구 콘솔을 통해 accessToken을 만료를 시켰다.
const test = async () => {
const promises = [
window.api.get('/aaa/bbb'),
window.api.get('/aaa/bbb/ccc'),
window.api.get('/aaa/bbbb/cccccc')
];
const results = await Promise.allSettled(promises);
console.log('📊 결과:', results.map(r => r.status));
console.log('💾 토큰 상태:', localStorage.getItem('accessToken'));
};
test();
// 결과: ['fulfilled', 'rejected', 'rejected'] - 로그인 풀림!
이후 테스트 시 영향이 없도록 최대한 get요청들을 위주로 개발자 도구 콘솔에 위 test코드를 입력하여 실제로 api요청을 실행하였다.
이때 여러 요청이 동시에 BE로 전달되는 경우 로그인이 풀린다는 것을 알게 되었다.
여러 API요청이 동시에 같은 refreshToken을 사용하여 갱신을 시도할 때 Race Condition(경쟁상태) 이 발생하게 되는 것이다.
// 서버에서 일어나는 일:
// 요청 A의 refresh (0.001초)
1. storedToken = await RefreshToken.findOne({ token: "abc123" }) // ✅ 찾음
2. await RefreshToken.deleteOne({ token: "abc123" }); // 🗑️ 삭제
3. await RefreshToken.create({ token: "xyz789" }); // ✅ 새로 생성
4. return { accessToken: "new1", refreshToken: "xyz789" } // ✅ 성공
// 요청 B의 refresh (0.002초 - 거의 동시)
1. storedToken = await RefreshToken.findOne({ token: "abc123" }) // ❌ 이미 삭제됨!
2. return 401 "유효하지 않은 refresh token입니다." // 실패 → 로그아웃 처리
즉 서버의 refresh 로직에서 "기존 토큰 삭제 → 새 토큰 생성" 과정 사이에 클라이언트에서는 이전 refreshToken으로 요청을 보내기 때문에 로그아웃 처리가 되게 되는 것이다.
3. 대기열 큐를 통한 해결
token 재발급 과정에서 발생하는 Race Condition 문제를 해결하기 위해서는 API 요청을 큐에 담아 순차적으로 처리하는 방식을 사용해야 한다.
위 그림처럼 api요청시 기본적으로 큐를 사용하고, 락을 걸어 token 재발급을 받게 된다면 RaceCondition 문제를 해결할 수 있다.
3-1. 대기열 큐 및 큐에 추가되는 과정
interface QueueItem {
resolve: (token: string) => void;
reject: (error: any) => void;
}
// Race Condition 방지를 위한 전역 변수들
let isRefreshing = false;
let failedQueue: QueueItem[] = [];
isRefreshing
을 통해 락을 걸어 동시에 여러 토큰 재발급 요청이 발생하는 것을 방지하고, failedQueue
는 토큰 재발급이 진행되는 동안 대기해야 하는 API 요청들을 저장하는 큐다.
const processQueue = (error: any, token: string | null = null): void => {
failedQueue.forEach((prom) => {
if (error) {
prom.reject(error);
} else {
prom.resolve(token!);
}
});
failedQueue = [];
};
processQueue`는 대기열에 있는 모든 요청들을 처리하는 함수로써 토큰 재발급이 성공하면 새로운 토큰을 전달하고, 실패하면 에러를 전파하는 역할을 한다.
// 401 에러이고, 재시도하지 않은 요청인 경우
if (error.response?.status === 401 && !originalRequest._retry) {
// 이미 refresh가 진행 중인 경우
if (isRefreshing) {
// 대기열에 추가하고 결과를 기다림
return new Promise((resolve, reject) => {
failedQueue.push({
resolve: (token: string) => {
if (originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${token}`;
}
resolve(api(originalRequest));
},
reject,
});
});
}
기존 token재발급 조건에서 isRefreshing이 true인 경우 다른 api요청이 token 재발급 중이기 때문에, 대기열 큐에 해당 요청을 추가해 준다.
3-2. 토큰 재발급 과정
originalRequest._retry = true;
isRefreshing = true;
try {
const refreshToken = localStorage.getItem('refreshToken');
if (!refreshToken) {
throw new Error('No refresh token available');
}
// Refresh Token으로 새로운 Access Token 요청
const response = await axios.post<RefreshTokenResponse>(
`${import.meta.env.VITE_SERVER_ADDRESS}/auth/refresh`,
{},
{
headers: {
Authorization: `Bearer ${refreshToken}`,
'Content-Type': 'application/json',
},
}
);
// 새로운 토큰들 저장
const { accessToken, refreshToken: newRefreshToken } = response.data;
localStorage.setItem('accessToken', accessToken);
if (newRefreshToken) {
localStorage.setItem('refreshToken', newRefreshToken);
}
// 대기 중인 모든 요청들에게 새 토큰 전달
processQueue(null, accessToken);
// 원래 요청에 새로운 Access Token 적용하여 재시도
if (originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
}
return api(originalRequest);
실제 토큰 재발급을 수행하는 핵심 부분입니다. originalRequest._retry = true
로 재시도 플래그를 설정하여 무한 루프를 방지하고, isRefreshing = true
로 락을 걸어 동시 재발급을 차단한다.
토큰 재발급이 성공하면 processQueue(null, accessToken)
를 통해 대기 중인 모든 요청들에게 새로운 토큰을 전달한다. 이렇게 하나의 토큰 재발급으로 여러 대기 중인 요청들을 모두 처리할 수 있어 서버 부하를 줄이고 Race Condition을 방지할 수 있다.
주의) 여기서 중요한 점은 기존 api
인스턴스가 아닌 새로운 axios
요청을 사용한다는 것입니다. 만약 api
인스턴스를 사용하면 이 요청도 인터셉터를 거치게 되어 무한 루프에 빠질 수 있다.
3-3. 에러 처리 및 락 해제
} catch (refreshError) {
// 대기 중인 모든 요청들을 reject
processQueue(refreshError, null);
// 인증 만료 시 로그아웃 처리
if (axiosRefreshError.response?.status === 401) {
localStorage.clear();
window.location.href = '/';
}
} finally {
isRefreshing = false;
}
토큰 재발급이 실패하면 대기 중인 모든 요청들에게 에러를 전파하고, 메모리 누수를 방지하기 위해 finally
블록에서 반드시 isRefreshing
락을 해제한다.
이 방식을 통해 여러 API 요청이 동시에 401 에러를 받아도 첫 번째 요청만 토큰 재발급을 수행하고, 나머지 요청들은 큐에서 대기하다가 새로운 토큰을 받아 순차적으로 처리되므로 Race Condition 문제를 해결할 수 있지 않을까? 마찬가지로 직접 콘솔을 통한 테스트로 확인해 볼 수 있다.
3-4. 테스트
이후 axios인스턴스가 있는 파일인 api.tsx에
export const getRefreshStatus = (): {
isRefreshing: boolean;
queueLength: number;
} => ({
isRefreshing,
queueLength: failedQueue.length,
});
// 강제로 refresh 상태를 리셋하는 함수
export const resetRefreshState = (): void => {
isRefreshing = false;
processQueue(new Error('Refresh state reset'), null);
console.log('Refresh state has been reset');
};
와 같은 디버깅을 위한 메서드들을 추가해 주고, App.tsx에 아래와 같이 전역에서 사용할 수 있는 전역함수를 등록해 주었다.
useEffect(() => {
(window as any).checkRefreshStatus = getRefreshStatus;
(window as any).resetRefresh = resetRefreshState;
(window as any).api = api;
const interval = setInterval(() => {
const status = getRefreshStatus();
if (status.isRefreshing || status.queueLength > 0) {
console.log('🔄 Refresh Status:', status);
}
}, 1000);
return () => clearInterval(interval);
}, []);
이후 초기 테스트방법과 같이 개발자 도구 콘솔에 아래와 같이 입력하게 되면
localStorage.setItem('accessToken', 'expired_access_token');
//여러 요청 동시 실행
const testRealRaceCondition = async () => {
console.log('테스트 전:', window.checkRefreshStatus());
const promises = [
window.api.get('/aaa/bbb'),
window.api.get('/aaa/bbb/ccc'),
window.api.get('/aaa/bbbb/cccccc')
];
// 즉시 상태 확인
setTimeout(() => {
console.log('🔄 처리 중:', window.checkRefreshStatus());
}, 10);
const results = await Promise.allSettled(promises);
console.log('📊 결과:', results.map(r => r.status));
console.log('최종 상태:', window.checkRefreshStatus());
};
testRealRaceCondition();
// 결과: ['fulfilled', 'fulfilled', 'fulfilled']
동시에 여러 api요청을 날려도 정상적으로 token이 재발급되는 것을 확인할 수 있다!
3-5. 결론
필자의 경우 처음에 이 문제점을 찾는데 꽤 많은 시간이 들었다. 로직상 문제 될 것이 없는데 사용자들이 간헐적으로 로그인이 풀린다는 피드백을 받았을 때, 단순히 refreshToken 만료나 웹-앱 간 동시 로그인 문제로만 생각했었다. 하지만 개발자 도구에서 동시 API 요청 테스트를 통해 Race Condition 문제를 재현할 수 있었고, 이를 통해 근본적인 원인을 파악할 수 있었다.
결국 JWT 토큰 재발급 시 발생하는 Race Condition 문제는 큐와 락 메커니즘을 활용하여 해결할 수 했지만, 사용자의 피드백이 없었다면 이런 확률적 문제를 발견하기 어려웠을 것이다. 이로써 개발자가 예상하지 못한 실제 사용 환경에서 발생하는 오류들이 분명 존재하며, 사용자 피드백의 중요성과 더불어 콘솔을 통한 간단한 시나리오 테스트만으로도 핵심 문제를 찾아낼 수 있다는 것을 느끼게 되었다.
'React' 카테고리의 다른 글
[React] BottomSheet 드래그 이벤트 스로틀링 최적화 (requestAnimationFrame) (1) | 2025.06.08 |
---|---|
[Vite] vite.config.js 설정 (proxy, console제거) (0) | 2025.05.26 |
[React] 사진 슬라이드 쉽게 직접 구현하기 (0) | 2025.05.18 |
[React] State(상태) 끌어올리기로 리렌더링 문제 해결하기 (0) | 2025.04.24 |
[React] GlobalStyle 폰트 적용 안됨, 폰트 깜빡임 현상 원인 및 해결방법 (0) | 2025.04.14 |