RESTful API를 사용하는 앱을 개발하다 보면 JWT 토큰 기반 인증은 거의 필수처럼 사용됩니다. 특히 액세스 토큰과 리프레시 토큰의 이중 토큰 시스템은 보안과 사용자 경험 사이의 균형을 맞추는 좋은 방법입니다. 하지만 이런 시스템을 구현하다 보면 예상치 못한 상황이 발생할 수 있습니다.
최근 개발 중인 앱에서 토큰 리프레시 로직이 작동하지 않는 문제가 발생했습니다. 사용자가 일정 시간 앱을 사용한 후, API 요청이 실패하며 로그아웃되는 현상이 나타났습니다.
로그를 분석해보니 액세스 토큰이 만료되었을 때 서버에서 다음과 같은 응답을 반환하고 있었습니다:
JWT expired 9051682 milliseconds ago at 2025-03-04T13:43:46.000Z. Current time: 2025-03-04T16:14:37.682Z. Allowed clock skew: 0 milliseconds.
문제는 이 응답이 HTTP 상태 코드 500(Server Error)으로 오고 있다는 점이었습니다.
기존 코드는 일반적인 JWT 토큰 갱신 패턴에 따라 401(Unauthorized) 혹은 400(Bad Request) 상태 코드를 기대하고 작성되어 있었습니다:
// 응답 인터셉터
axiosInstance.interceptors.response.use(
(response) => {
return response;
},
async (error) => {
const originalRequest = error.config;
if (
error.response?.status === 401 ||
(error.response?.status === 400 &&
error.response?.data?.message?.includes("토큰이 만료") &&
!originalRequest._retry)
) {
// 리프레시 토큰을 사용하여 액세스 토큰 갱신 로직
// ...
}
return Promise.reject(error);
}
);
서버 응답의 실제 패턴에 맞게 인터셉터 코드를 수정했습니다. 500 에러이면서 "JWT expired" 메시지가 포함된 경우에도 토큰 갱신을 시도하도록 조건을 추가했습니다:
// 수정된 응답 인터셉터
axiosInstance.interceptors.response.use(
(response) => {
return response;
},
async (error) => {
const originalRequest = error.config;
if (
error.response?.status === 401 ||
(error.response?.status === 400 &&
error.response?.data?.message?.includes("토큰이 만료") &&
!originalRequest._retry) ||
(error.response?.status === 500 &&
error.response?.data?.message?.includes("JWT expired") &&
!originalRequest._retry)
) {
// 리프레시 토큰을 사용하여 액세스 토큰 갱신 로직
// ...
}
return Promise.reject(error);
}
);
이 경험을 통해 몇 가지 중요한 점을 배웠습니다:
API 응답 형식 사전 합의의 중요성: 백엔드와 프론트엔드 팀은 오류 상태 코드와 응답 형식에 대해 명확하게 합의해야 합니다.
방어적 코딩의 필요성: 서버 응답이 예상과 다를 수 있으므로, 다양한 상황을 처리할 수 있는 유연한 코드를 작성해야 합니다.
로깅의 중요성: 자세한 로깅이 없었다면 이 문제를 파악하기 훨씬 어려웠을 것입니다.
장기적으로는 백엔드 팀과 협력하여 HTTP 상태 코드를 REST 표준에 맞게 사용하는 것이 좋습니다. JWT 토큰 만료는 일반적으로 클라이언트 측 오류로 간주되므로 401 또는 403 상태 코드가 더 적절합니다.