
JWT를 구현하면서 처음 고민했던 부분은 Access Token과 Refresh Token을 어디에 저장할 것 인가에 대한 것이었다. 로컬스토리지와 세션 스토리지는 자바스크립트로 토큰 값을 조작할 수 있어 XSS공격에 취약하기 때문에 쿠키를 사용하기로 했다. 물론 쿠키도 CSRF 공격에 활용될 수 있지만 SameSite나 Secure 정책으로 충분히 보완할 수 있다고 판단했다.
결과적으로 두 토큰을 서버(Redis) 에 저장하고, 클라이언트에는 Access Token만 쿠키로 전달하는 방식을 선택했다. 재발급 흐름은 다음과 같다. 만료된 Access Token을 사용해 Redis에서 Refresh Token을 조회하고, Refresh Token이 유효하면 새로운 Access Token을 발급해 Redis에 저장한 뒤 클라이언트에 다시 전달한다. Access Token을 Redis에 저장하는 이유는, 만료된 토큰을 Redis의 키로 관리하여 이미 사용된(혹은 재사용된) 토큰을 즉시 식별·차단함으로써 타인이 해당 토큰을 악용하지 못하도록 하기 위함이다.
토큰의 완전한 무상태(Stateless)를 포기하고, 토큰과 세션의 장점을 결합하려 했다.
이같은 구조를 채택했을 때의 장단점은 다음과 같다.


초기 구현에서는 재발급 로직을 JWT 인증 필터에 위임했다. 필터가 토큰의 유효성 검사와 함께 Access Token이 만료되면 즉시 재발급하도록 했다. Access Token 만료 자체가 토큰 손상이나 위변조를 의미하는 것은 아니므로, “바로 재발급해 응답하면 별도의 재발급 API는 불필요하다”는 판단이었다.
그러나 프론트엔드와 연동해 동시다발적으로 API를 호출해 보니 문제가 드러났다. 어떤 요청은 서버에서 재발급까지 성공했는데도, 다른 동시 요청들이 인증 실패가 발생했다.

문제의 근본 원인은 경쟁 상태(Race Condition) 였다. 프론트엔드에서는 한 페이지에서 다수의 AJAX 요청이 동시에 발생한다. 이 비동기적/동시다발적 요청 중 하나가 재발급 로직을 통해 새로운 Access Token을 발급받는 동안, 나머지 요청들은 여전히 갱신되기 전의 만료된 토큰을 담아 서버로 전송된다. 이로 인해, 서버는 일부 요청에 대해서는 재발급이 성공했음에도 불구하고 다른 요청들은 유효하지 않은 토큰으로 인증을 시도하게되어 인증에 실패하게 된다.

결국 깨달은 건, 재발급을 서버가 알아서 해주길 바라면 Race Condition은 피할 수 없다는 거였다. 그래서 Axois 요청 인터셉터를 사용하여 메인 요청을 보내기 전에 클라이언트가 /auth로 토큰의 유효성을 확인 후 메인 요청을 수행하도록 리펙토링을 진행했다.
지금 생각해보면 정말 멍청한 생각이다. 문제를 제대로 이해하지 못한 상태에서 성급하게 해결책을 도입한 전형적인 임시 방편에 불과했다. 겉으로는 문제를 줄이는 것처럼 보였지만, 오히려 구조는 꼬이고 보안성은 떨어졌다.
결과적으로 이 리팩토링은 구조의 복잡성은 올라가고, 보안성은 후퇴한 실패한 리팩토링이었다.

지금까지의 JWT 인증필터는 너무 많은 책임이 부여되어 토큰의 검증과 만료된 토큰을 재발급까지 수행했다. 모든 요청은 JWT 인증필터를 거치기 때문에 이곳에서 재발급이 이루어 진다면 경쟁상태(Race Condition)이 발생할 가능성이 너무 높았다. 또한 서로 다른 책임을 하나의 필터가 담당하므로 단일 책임 원칙에 반하며 이는 코드 복잡도 증가와 유지보수성이 저하되게 했다. 애초에 인증필터는 요청을 가로채 인증을 확인하는 역할인데 왜 이렇게 만들었는지 모르겠다.
그래서 JWT 인증필터에서는 토큰의 인증만 확인하도록 리펙토링했다. 이제 만료가 되었든 이상한 토큰이든 Valid한 토큰이 아니면 401을 던지도록 설계했다. 재발급은 별도의 API(/token/refresh)를 구현하여 재발급을 수행하도록 했다. 인증과 재발급 로직을 분리함으로써 각 컴포넌트가 단일 책임을 가지게 되어 클라이언트가 토큰 상태를 명확히 제어할 수 있게 되었다.
(JWT인증필터, JWT Provider, 재발급 API)
서버의 설계는 끝났다. 그렇다면 이제 클라이언트 쪽에서는 토큰을 어떻게 관리해야 할까? 이전 시도에서처럼 요청 인터셉터를 사용해 /auth를 매번 호출하는 방식은 결국 서버 부하만 늘리고 근본적인 문제를 해결하지 못했다. 결국 내가 내린 결론은 “서버는 알아서 재발급을 처리할 수 없고 재발급의 주도권은 반드시 클라이언트가 가져야 한다.” 였다. 그렇다면 가장 자연스러운 방법은, 서버가 만료된 토큰에 대해 401을 던지면 클라이언트는 이 응답을 토대로 토큰이 만료되었다는 판단 후 재발급을 요청하는 것 이다.
이때 사용할 수 있는 것이 Axios의 응답 인터셉터(Response Interceptor) 이다. 응답 인터셉터는 요청 인터셉터와 비슷하게 서버의 응답을 가로채 공통 로직을 실행할 수 있는 훅이다. 이를 통해 “401이 발생하면 /token/refresh로 재발급을 요청하고, 재발급이 완료되면 원래의 요청을 다시 시도한다”라는 흐름을 구현할 수 있다. 하지만 여기까지만 구현하면 경쟁 상태가 다시 발생할 수 밖에 없다. 전의 경우와 동일하게 여러 API를 동시에 호출하고, 그 모든 요청의 토큰이 만료된 상태라면, 각 요청의 인터셉터가 거의 동시에 /token/refresh를 호출하게 된다. 경쟁상태가 다른 형태로 반복되는 것 이다.
이를 해결하기 위해 Axios 인터셉터 기반의 단일 재발급(Single-Flight) 패턴을 도입했다.
Single-Flight란 같은 키에 대한 중복 작업을 한 번만 실행하고, 나머지 경쟁자들은 그 결과를 공유하도록 하는 중복 억제(deduplication) 기법이다. Go에서 널리 쓰이는 듯 하다.
이를 응답 인터셉터와 조합하면 다음과 같은 흐름이 가능하다.
즉, 한 번의 재발급 요청만 수행하고, 그 사이 들어온 다른 요청들은 잠시 큐에 대기시킨 뒤, 재발급이 완료되면 새 토큰으로 모두 다시 시도하도록 설계했다.
좀 더 자세한 로직은 다음과 같다.
let isRefreshing = false;
let refreshSubscribers = [];
const onRefreshed = () => {
refreshSubscribers.forEach(callback => callback());
refreshSubscribers = [];
};
const addRefreshSubscriber = (callback) => {
refreshSubscribers.push(callback);
};
결과적으로 재발급은 오직 한 번만 수행되고, 모든 요청이 새 토큰으로 정상 재시도되는 안정적인 구조가 완성되었다. 이제 서버는 토큰 검증에만 집중하고, 클라이언트가 토큰 라이프사이클을 완전히 제어할 수 있게 되었다. 결과적으로 JWT 인증 구조 전반이 훨씬 단순하고 안정적으로 작동하게 되었다.

WebSocket 연결은 new WebSocket(url)로 시작되는 HTTP 업그레이드(handshake) 요청이고, 이 경로는 Axios가 만드는 HTTP 요청이 아니다. 당연히 Axios 인터셉터는 Axios가 만든 요청/응답에만 개입할 수 있으니, 웹소켓 핸드셰이크에는 애초에 끼어들 수 없다. 그래서 분명 재발급이 이루어 졌음에도 웹소켓 연결이 어떤 이유로 연결이 끊겨 재연결을 시도하면 인증문제로 실패했다.
결국 웹소켓 핸드쉐이크와 Axios요청 모두 하나의 재발급 요청 후 이를 공유해야한다. 이를위해 refreshManager라는 중간 매개체를 만들었다. refreshManager는 safeRefreshToken() 함수를 가지는데 이 함수는 실제 재발급 요청을 날리는 함수다.
// refreshManager.js
import axios from 'axios';
let isRefreshing = false;
let refreshPromise = null;
export const safeRefreshToken = async () => {
if (isRefreshing) return refreshPromise;
isRefreshing = true;
refreshPromise = axios.get(`${process.env.REACT_APP_API_URL}/token/refresh`, {
withCredentials: true,
})
.catch((err) => {
throw err;
})
.finally(() => {
isRefreshing = false;
});
return refreshPromise;
};
axiosInstance는 내부에서 safeRefreshToken()를 호출하여 재발급을 처리한다.
// axiosInstance.js
import axios from 'axios';
import { safeRefreshToken } from './refreshManager'; //
const instance = axios.create({
baseURL: process.env.REACT_APP_API_URL,
withCredentials: true,
});
let refreshSubscribers = [];
const onRefreshed = () => {
refreshSubscribers.forEach(callback => callback());
refreshSubscribers = [];
};
const addRefreshSubscriber = (callback) => {
refreshSubscribers.push(callback);
};
// 응답 인터셉터
instance.interceptors.response.use(
(response) => response,
async (error) => {
const { config, response } = error;
const originalRequest = config;
if (response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
return new Promise((resolve, reject) => {
addRefreshSubscriber(() => {
resolve(instance(originalRequest)); // 재요청 실행
});
// 단 한 번만 refresh 실행
safeRefreshToken()
.then(() => {
onRefreshed(); // 대기 중인 요청들 처리
})
.catch((err) => {
window.location.replace('/login');
reject(err);
});
});
}
return Promise.reject(error);
}
);
export default instance;
그리고 웹소켓은 연결이 끊어졌을 때(onWebSocketClose) 즉시 재연결하지 않고, 먼저 await safeRefreshToken()으로 최신 쿠키/토큰 확보 후 재연결한다. 이렇게 하면 한번의 재발급 요청으로 Axios와 웹소켓이 같은 Promise(같은 결과)를 공유하게 되어 갱신된 토큰으로 모든 요청이 처리될 수 있다.
🚶 axios 요청1 ┐
🚶 axios 요청2 ┼─────┐
🧍 websocket ┘ │
⏳ [safeRefreshToken 호출]
↓
🔄 /token/refresh
↓
☑️ 성공 → 대기하던 요청들 다 실행
❌ 실패 → 로그인 페이지로 이동
Race Condition은 이 프로젝트를 진행하면서 나를 계속 괴롭게 했던 문제였다. JWT토큰은 물론이고 인증구현이 처음이라 안그래도 하나하나 알아가며 구현했어야 했는데, 동시성 문제까지 닥치니 매우 힘들었던 기억이 생생하다. 심지어 프론트 쪽 코드도 처음 작성해봤기에 배로 힘들었다. 그러나 이러한 경험이 없었다면 오랜 기간 Race Condition을 알거나 마주치기 어려웠을 것 이다. Axios 인터셉터라는 백엔드 개발자에게는 조금은 생소한 개념도 알게되었다. 결과적으로 인증관련 흐름을 깨지고 부서지며 체득할 수 있었던 경험이다. 또한, 설계의 중요성도 깨닫게 된것 같다. 무작정 구현만 일단 달려들면 더 큰 시련에 부딪힐 수 있다는 것을 몸으로 직접 깨달았다. 뭔가를 빨리 구현하고싶은 마음은 여전하지만 이때에 비해 설계에 훨씬 많은 시간을 쏟고 있다.