동시 요청에도 Refresh는 한 번만, 어떻게 구현할까?
Access Token 만료 시, 동시에 발생한 API 요청이 모두 Refresh를 호출하는 문제를 해결해보자
2025.10.29
20분 소요
글을 시작하며
프로젝트를 진행하며 Access Token과 Refresh Token 방식으로 인증을 구현했습니다.
Access Token이 만료되었을 때, 동시에 발생한 여러 API 요청이 모두 Refresh를 호출하는 문제가 발생했으며 이는 불필요한 서버 부하 증가, 네트워크 리소스 낭비 등 다양한 문제를 발생시킬 수 있습니다.
이번 글에서는 왜 이러한 현상이 발생했는지, 또 어떻게 해결했는지에 대해 공유해보고자 합니다.
Access Token & Refresh Token 전략은 왜 필요할까?
사용자가 로그인을 하면 Access Token과 Refresh Token을 함께 발급받고, Access Token이 만료되었을 때 Refresh Token을 사용해 새로운 Access Token을 발급받아 사용합니다.
| Token | 역할 | 유효 기간 |
|---|---|---|
| Access Token | API 인증 처리 | 짧게 (분 단위) |
| Refresh Token | Access Token 재발급 | 길게 (일~주 단위) |
Access Token은 접근에 관여, Refresh Token은 재발급에 관여합니다.
Access Token이 탈취되면 토큰이 만료되기 전까지 계속해서 접근이 가능하다는 문제점이 있습니다.
따라서 Access Token의 유효 기간을 짧게 설정하게 되는데, 유효 기간이 짧을 경우 그만큼 사용자가 자주 로그인을 해야하는 번거로움이 있기 때문에 Refresh Token을 함께 사용합니다.
이러한 방식은 보안성과 사용자 경험을 동시에 향상시킬 수 있습니다. Access Token이 노출되더라도 짧은 유효 기간 덕분에 피해를 최소화할 수 있으며, 사용자는 Refresh Token을 통해 원활하게 인증을 유지할 수 있습니다.
axios interceptor로 토큰 갱신 로직 구현하기
axios interceptor를 사용하면 모든 API 요청과 응답을 가로채어 토큰 갱신 로직을 쉽게 처리할 수 있습니다.
axios.interceptors.request.use((config) => {
const token = localStorage.getItem('accessToken');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// 401 에러 발생 시 자동으로 토큰 갱신
axios.interceptors.response.use(
(response) => response,
async (error) => {
const { config, response } = error;
if (response.status === 401) {
try {
// 새로운 access Token을 발급 받는 요청 전송
const response = await axios.post('/auth/refresh');
config.headers.Authorization = `Bearer ${response.data.accessToken}`;
localStorage.setItem('accessToken', response.data.accessToken);
return axios(config); // 실패한 기존 요청을 재시도
} catch (refreshError) {
// 새로운 Access Token을 발급받다 실패한 경우 로직 처리
}
}
return Promise.reject(error);
},
);
이렇게 response interceptors를 사용해서 Access Token이 만료된 경우 Refresh Token을 사용하여 새로운 Access Token을 발급받고, 기존의 요청을 다시 실행시켜 Access Token 만료를 신경 쓰지 않고 개발을 할 수 있습니다.
이는 토큰 처리 로직을 한 곳에서 관리할 수 있으며, 모든 API 호출에 동일한 인증 로직을 적용시킬 수 있습니다.
발생한 문제
Access Token이 만료된 상황에서 동시에 3개의 요청을 서버로 보낸다고 가정하겠습니다.
서버는 모두 401 Unauthorized 반환하게 되고, Axios Interceptor는 각 요청마다 토큰 갱신 로직을 처리하게 됩니다.
요청 A가 먼저 토큰을 갱신하고 로컬스토리지에 새 토큰이 저장되었더라도, 이미 서버로 전송된 B, C 요청들은 이전의 만료된 토큰을 사용하고 있기 때문에 401 에러가 발생하게 됩니다.
이는 다음과 같은 문제를 발생시킵니다.
- 불필요한 중복 리프레시 요청으로 서버 부하 증가
- 네트워크 리소스 낭비
- 동시성 문제로 인한 예기치 않은 에러 발생 가능성
axios Interceptor가 여러 번 실행되더라도, 새로운 Access Token을 발급받는 요청은 한 번만 실행되도록 해야 합니다.
어떻게 이 문제를 해결 할 수 있을지 고민하고, 검색해본 결과 다음과 같은 방법을 찾을 수 있었습니다.
해결 방법
Refresh Token 재발급 중임을 표시하는 isRefreshing Flag와
재발급 완료 후 다시 수행할 요청을 담아둘 failedQueue를 도입했습니다.
| 장치 | 기능 |
|---|---|
isRefreshing | Refresh API 중복 요청 방지 |
failedQueue | Refresh 완료까지 API 요청 보류 |
| Interceptor | 전체 흐름 자동 제어 |
아래는 구현 코드입니다.
let isRefreshing = false;
let failedQueue = [];
axios.interceptors.response.use(
(response) => response,
async (error) => {
const { config, response } = error;
// 401 에러이고 재시도하지 않은 요청인 경우
if (response.status === 401 && !config._retry) {
// 토큰 갱신이 이미 진행 중인 경우 큐에 추가
if (isRefreshing) {
return new Promise((resolve, reject) => {
failedQueue.push((token, error) => {
if (error) {
return reject(error);
} else {
config.headers.Authorization = `Bearer ${token}`;
resolve(axios(config));
}
});
});
}
config._retry = true;
isRefreshing = true;
try {
// 새로운 access Token을 발급 받는 요청 전송
const refreshResponse = await axios.post('/auth/refresh');
const newAccessToken = refreshResponse.data.accessToken;
config.headers.Authorization = `Bearer ${newAccessToken}`;
localStorage.setItem('accessToken', newAccessToken);
// 큐에 대기 중인 요청들에게 새로운 토큰 전달
failedQueue.forEach((callback) => {
callback(newAccessToken, null); // 에러 없음을 명시
});
failedQueue = [];
isRefreshing = false;
return axios(config); // 실패한 기존 요청을 재시도
} catch (refreshError) {
// 대기 중인 모든 요청 실패 처리
failedQueue.forEach((callback) => {
callback(null, refreshError);
});
failedQueue = [];
isRefreshing = false;
// 추가적인 에러 처리 로직
}
}
return Promise.reject(error);
},
);
이 코드는 다음과 같은 흐름으로 동작합니다.
- 첫 번째 401 에러 발생 →
isRefreshing = true설정, 토큰 갱신 시작 - 후속 401 에러들 →
isRefreshing = true확인,failedQueue에 대기 - 토큰 갱신 완료 → 큐에 있는 모든 요청에게 새 토큰 전달, 일괄 재시도
- 토큰 갱신 실패 → 큐에 있는 모든 요청 실패 처리, 로그아웃
isRefreshing 플래그로 중복 요청을 방지하고, failedQueue로 대기 중인 요청들을 관리하여 단 한 번의 토큰 갱신으로 모든 요청을 안전하게 처리할 수 있습니다.