[React] React.js 강좌 22. Refresh Token과 세션 만료를 안전하게 관리하는 방법
최근에 작업했던 서비스에서 로그인 유지 기능을 안정적으로 구현해야 하는 일이 있었습니다.
그전까진 단순히 access token만으로 인증을 처리했는데, 며칠 지나면 사용자들이 “갑자기 로그아웃돼요”라고 제보하기 시작하더군요.
처음엔 단순한 버그겠거니 했는데, 알고 보니 토큰 만료 로직과 세션 관리 구조가 제각각이라 발생한 문제였습니다.
그 일을 계기로 React에서 Refresh Token 기반의 인증 흐름을 체계적으로 설계하는 법을 정리하게 되었습니다.
로그인 유지 구조를 단순히 생각했던 시절
초기엔 정말 단순하게 만들었습니다.
백엔드에서 access token을 발급받으면 localStorage에 저장하고,
Axios 요청 시마다 헤더에 붙이는 방식이었습니다.
이 구조의 문제는, access token이 만료되면 사용자가 갑자기 로그아웃된다는 점이었습니다.
한창 결제 페이지나 글 작성 페이지를 이용하다가 로그아웃되면 작성 중이던 내용이 전부 날아가 버렸습니다.
그때 처음 “세션 만료를 제대로 관리해야 한다”는 걸 실감했습니다.
Refresh Token을 도입하면서 생긴 변화
Refresh Token은 말 그대로 새로운 access token을 재발급받기 위한 열쇠입니다.
access token은 유효기간이 짧고, refresh token은 상대적으로 길게 설정됩니다.
이 구조를 React에 적용하려면 토큰을 어디서, 어떻게 갱신할지 명확한 흐름이 필요합니다.
제가 적용했던 구조는 아래와 같습니다.
- access token은 API 요청 시 헤더에 넣어 사용
- refresh token은 서버 전용 쿠키로 관리 (JS에서 접근 불가)
- 401 응답이 오면 Axios 인터셉터에서 refresh 요청 시도
- 새 access token을 발급받은 후 다시 원래 요청을 재시도
결국 사용자는 로그인 만료를 거의 체감하지 못하게 되었습니다.
UX 관점에서도 아주 큰 차이가 났습니다.
Axios 인터셉터에서 자동 갱신하기
이 구조를 실제 코드로 옮기면 아래와 같습니다.
import axios from 'axios';
const api = axios.create({
baseURL: 'https://api.example.com',
withCredentials: true, // 쿠키 포함
});
let isRefreshing = false;
let refreshSubscribers = [];
const onTokenRefreshed = (newToken) => {
refreshSubscribers.forEach((cb) => cb(newToken));
refreshSubscribers = [];
};
const addRefreshSubscriber = (cb) => {
refreshSubscribers.push(cb);
};
api.interceptors.request.use((config) => {
const token = localStorage.getItem('access_token');
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
api.interceptors.response.use(
(response) => response,
async (error) => {
const { response, config } = error;
if (response?.status !== 401) return Promise.reject(error);
if (!isRefreshing) {
isRefreshing = true;
try {
const { data } = await axios.post('/auth/refresh', {}, { withCredentials: true });
localStorage.setItem('access_token', data.access);
isRefreshing = false;
onTokenRefreshed(data.access);
} catch (err) {
isRefreshing = false;
window.location.href = '/login';
return Promise.reject(err);
}
}
return new Promise((resolve) => {
addRefreshSubscriber((newToken) => {
config.headers.Authorization = `Bearer ${newToken}`;
resolve(api(config));
});
});
}
);
export default api;
이 코드는 여러 요청이 동시에 401을 만났을 때도,
한 번만 refresh 요청을 보내고 나머지는 새 토큰을 기다리게 만드는 구조입니다.
즉, 중복된 refresh 요청을 막아주는 셈이죠.
이 패턴을 적용한 뒤로는 세션이 만료되어도 사용자가 로그아웃되지 않았습니다.
화면이 멈추지 않고 자연스럽게 이어졌습니다.
토큰 저장 위치에 대한 고민
처음엔 두 토큰을 모두 localStorage에 저장했는데,
보안적으로 그건 좋지 않은 선택이었습니다.
XSS 공격이 발생하면 토큰이 그대로 노출될 위험이 있기 때문입니다.
그래서 지금은 refresh token은 서버에서만 관리하고,
access token만 클라이언트에서 짧게 유지하는 구조로 바꿨습니다.
즉, refresh token은 HTTPOnly 쿠키에 넣어 JS에서 접근할 수 없게 하고,
access token은 메모리나 localStorage에만 잠시 저장하는 형태입니다.
이렇게 바꾼 뒤로는 보안팀에서도 “이제야 안전한 구조가 됐다”고 하더군요.
사실 이건 프론트엔드 개발자라면 꼭 알아둬야 할 부분이라고 생각합니다.
세션 만료를 사용자 친화적으로 처리하기
예전엔 세션이 끊기면 그냥 로그인 페이지로 보내버렸습니다.
하지만 그렇게 하면 사용자 입장에선 “갑자기 튕겼다”는 느낌을 받게 됩니다.
지금은 만료가 감지되면
“세션이 만료되었습니다. 다시 로그인하시겠습니까?”
라는 모달을 띄워서 직접 선택할 수 있게 했습니다.
이 작은 차이가 UX에 큰 영향을 줍니다.
특히 B2B 서비스처럼 한 번 로그인해서 오래 사용하는 환경에서는
이런 세심한 세션 처리 로직이 필수입니다.
실제 서비스에서 느낀 점
Refresh Token 구조는 단순히 “토큰 자동 갱신” 이상의 의미가 있습니다.
이건 신뢰를 유지하는 시스템이라고 생각합니다.
사용자가 작성 중이던 데이터가 날아가지 않게 해주고,
서비스가 안정적으로 유지된다는 인상을 줍니다.
저 역시 이 구조를 적용한 뒤로는
서버-클라이언트 간 인증 문제로 고생할 일이 거의 없어졌습니다.
특히 Axios 인터셉터와 React Query가 함께 동작하면서
토큰이 새로 발급되어도 데이터를 자연스럽게 갱신해주는 흐름이 만들어졌습니다.
이제는 새로운 프로젝트를 시작할 때 가장 먼저 “인증 구조부터 짜야겠다”는 생각이 듭니다.
초반에 조금 번거롭더라도, 나중에 유지보수 비용이 정말 크게 줄어듭니다.