왜 이 글을 쓰나
JWT 기본 구조, Node.js/Python/Go에서의 기본 발급과 검증은 이전 글에서 다뤘다. 그런데 "서비스에 붙여보자"는 순간 기본 구현의 한계가 바로 드러난다.
토큰 만료 시간을 짧게 잡으면 사용자가 자꾸 로그아웃된다. 길게 잡으면 탈취됐을 때 대응할 방법이 없다. 로그아웃 처리도 애매하다. JWT는 stateless라 서버 쪽에서 무효화가 안 된다.
이 글은 그 문제들을 실전에서 어떻게 해결하는지 정리한다. Refresh Token 이중 구조, Token Rotation, 블랙리스트, Redis 연동까지 전부 다룬다.
Access Token만 쓰면 생기는 문제
단일 토큰 구조의 딜레마는 단순하다.
만료 시간을 길게 잡으면:
- 토큰이 탈취됐을 때 만료될 때까지 공격자가 계속 사용 가능
- 로그아웃 처리가 의미 없다. 서버 세션이 없어서 무효화할 수 없음
- 계정 탈취 사고 대응 시 피해 시간이 길어짐
만료 시간을 짧게 잡으면:
- 사용자가 자주 재로그인해야 함
- UX가 나빠짐. 30분 글 쓰다가 저장 시 세션 만료
해결책이 Refresh Token이다. 역할을 분리한다.
| 토큰 | 역할 | 만료 시간 | 저장 위치 |
|---|---|---|---|
| Access Token | API 요청 인증 | 15분 ~ 1시간 | 메모리 (또는 localStorage) |
| Refresh Token | Access Token 재발급 | 7일 ~ 30일 | HttpOnly Cookie |
Access Token은 짧게 유지해서 보안을 높이고, Refresh Token으로 자동 갱신해서 UX를 유지한다.
Access Token + Refresh Token 이중 구조 설계
토큰 만료 시간 전략
| 서비스 유형 | Access Token | Refresh Token |
|---|---|---|
| 일반 웹 서비스 | 15분 | 7일 |
| 금융/보안 중요 서비스 | 5분 | 1일 |
| 소비자 앱 (Remember Me) | 1시간 | 30일 |
| 관리자 대시보드 | 15분 | 8시간 |
Access Token을 1시간 이상 주면 토큰 탈취 시 피해가 크다. 15분이 실무적으로 가장 흔한 선택이다.
저장 위치 비교
| 저장 위치 | XSS 공격 | CSRF 공격 | 접근성 | 권장 |
|---|---|---|---|---|
| localStorage | 취약 (JS로 접근 가능) | 안전 | 간편 | X |
| sessionStorage | 취약 | 안전 | 탭 닫으면 삭제 | X |
| 메모리 (JS 변수) | 안전 | 안전 | 새로고침 시 삭제 | Access Token ○ |
| HttpOnly Cookie | 안전 (JS 접근 불가) | 취약 (SameSite로 완화) | 자동 전송 | Refresh Token ○ |
결론: Access Token은 메모리에, Refresh Token은 HttpOnly Cookie에 저장하는 것이 현재 베스트 프랙티스다.
localStorage에 Refresh Token을 저장하는 코드를 자주 본다. XSS 취약점이 하나만 있어도 토큰 전체가 탈취된다. 금융 서비스나 사용자 개인정보를 다루는 서비스에서는 절대로 쓰면 안 된다.
Refresh Token 구현 실전 (Node.js/Express)
전체 플로우
1. 로그인 → Access Token + Refresh Token 발급
2. API 요청 → Authorization: Bearer {accessToken}
3. Access Token 만료 → 401 응답
4. 프론트엔드 → POST /auth/refresh (Refresh Token은 Cookie로 자동 전송)
5. 서버 → Refresh Token 검증 → 새 Access Token 발급
6. 프론트엔드 → 원래 요청 재시도
로그인 시 두 토큰 발급
// utils/jwt.js
const jwt = require('jsonwebtoken');
const ACCESS_SECRET = process.env.JWT_ACCESS_SECRET;
const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET;
function generateAccessToken(payload) {
return jwt.sign(payload, ACCESS_SECRET, { expiresIn: '15m' });
}
function generateRefreshToken(payload) {
return jwt.sign(payload, REFRESH_SECRET, { expiresIn: '7d' });
}
module.exports = { generateAccessToken, generateRefreshToken };
Access Token과 Refresh Token의 시크릿 키는 반드시 분리해야 한다. 키 하나가 노출됐을 때 피해 범위를 줄이기 위해서다.
// routes/auth.js
const { generateAccessToken, generateRefreshToken } = require('../utils/jwt');
router.post('/login', async (req, res) => {
const { email, password } = req.body;
// 사용자 검증
const user = await User.findOne({ email });
if (!user || !await user.comparePassword(password)) {
return res.status(401).json({ message: '이메일 또는 비밀번호가 잘못됐다' });
}
const payload = { userId: user.id, email: user.email };
const accessToken = generateAccessToken(payload);
const refreshToken = generateRefreshToken(payload);
// Refresh Token을 DB에 저장 (나중에 검증 및 무효화에 사용)
await RefreshToken.create({
userId: user.id,
token: refreshToken,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
});
// Refresh Token은 HttpOnly Cookie로
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production', // HTTPS에서만
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7일
});
// Access Token은 응답 바디로
res.json({ accessToken });
});
Access Token 만료 시 자동 갱신 엔드포인트
// routes/auth.js
const jwt = require('jsonwebtoken');
router.post('/refresh', async (req, res) => {
const refreshToken = req.cookies.refreshToken;
if (!refreshToken) {
return res.status(401).json({ message: 'Refresh Token이 없다' });
}
try {
// 1. 서명 검증
const decoded = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
// 2. DB에 유효한 토큰인지 확인 (블랙리스트 or 저장된 토큰 비교)
const storedToken = await RefreshToken.findOne({
userId: decoded.userId,
token: refreshToken,
});
if (!storedToken) {
return res.status(401).json({ message: '유효하지 않은 Refresh Token이다' });
}
// 3. 새 Access Token 발급
const payload = { userId: decoded.userId, email: decoded.email };
const newAccessToken = generateAccessToken(payload);
res.json({ accessToken: newAccessToken });
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ message: 'Refresh Token이 만료됐다. 재로그인이 필요하다' });
}
return res.status(401).json({ message: '유효하지 않은 토큰이다' });
}
});
프론트엔드 Axios 인터셉터로 자동 갱신 처리
// api/axios.js
import axios from 'axios';
const api = axios.create({
baseURL: process.env.REACT_APP_API_URL,
withCredentials: true, // Cookie 자동 전송
});
// 요청 인터셉터: Access Token을 헤더에 추가
api.interceptors.request.use((config) => {
const accessToken = localStorage.getItem('accessToken'); // 또는 메모리 변수
if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`;
}
return config;
});
// 응답 인터셉터: 401 시 자동 갱신
let isRefreshing = false;
let failedQueue = [];
const processQueue = (error, token = null) => {
failedQueue.forEach((prom) => {
if (error) {
prom.reject(error);
} else {
prom.resolve(token);
}
});
failedQueue = [];
};
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) {
if (isRefreshing) {
// 갱신 중이면 대기열에 추가
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
}).then((token) => {
originalRequest.headers.Authorization = `Bearer ${token}`;
return api(originalRequest);
});
}
originalRequest._retry = true;
isRefreshing = true;
try {
const { data } = await axios.post('/auth/refresh', {}, { withCredentials: true });
const newToken = data.accessToken;
localStorage.setItem('accessToken', newToken); // 또는 메모리 변수 업데이트
api.defaults.headers.common['Authorization'] = `Bearer ${newToken}`;
processQueue(null, newToken);
return api(originalRequest);
} catch (refreshError) {
processQueue(refreshError, null);
// Refresh Token도 만료됐으면 로그인 페이지로
localStorage.removeItem('accessToken');
window.location.href = '/login';
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
return Promise.reject(error);
}
);
export default api;
isRefreshing 플래그와 failedQueue가 핵심이다. 갱신 중에 다른 요청이 동시에 401을 받으면 대기열에 쌓았다가 토큰 갱신이 완료되면 한꺼번에 재시도한다. 이게 없으면 동시 요청 레이스컨디션이 생긴다. 뒤에서 자세히 다룬다.
Refresh Token 보안 강화
Token Rotation
Refresh Token을 갱신할 때 Access Token만 새로 발급하면 Refresh Token이 계속 같은 값을 유지한다. 탈취된 Refresh Token이 오래 유효한 문제가 있다.
Token Rotation은 Access Token을 갱신할 때 Refresh Token도 함께 교체하는 전략이다.
router.post('/refresh', async (req, res) => {
const refreshToken = req.cookies.refreshToken;
try {
const decoded = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
const storedToken = await RefreshToken.findOne({
userId: decoded.userId,
token: refreshToken,
});
if (!storedToken) {
// 이미 사용된 토큰으로 접근 시 → 토큰 탈취 의심
// 해당 사용자의 모든 Refresh Token 무효화
await RefreshToken.deleteMany({ userId: decoded.userId });
return res.status(401).json({ message: '비정상적인 접근이다. 재로그인이 필요하다' });
}
const payload = { userId: decoded.userId, email: decoded.email };
// 새 토큰 발급
const newAccessToken = generateAccessToken(payload);
const newRefreshToken = generateRefreshToken(payload);
// 기존 Refresh Token 교체
storedToken.token = newRefreshToken;
storedToken.expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
await storedToken.save();
// 새 Refresh Token을 Cookie로
res.cookie('refreshToken', newRefreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000,
});
res.json({ accessToken: newAccessToken });
} catch (err) {
return res.status(401).json({ message: '유효하지 않은 토큰이다' });
}
});
이미 사용된 Refresh Token으로 재요청이 오면 탈취 시나리오로 판단한다. 해당 사용자의 토큰을 전부 무효화하고 재로그인을 요구한다.
Refresh Token 블랙리스트
로그아웃 시 Refresh Token을 DB에서 삭제하는 것만으로도 기본적인 무효화는 된다. 추가로 블랙리스트를 운용하면 탈취된 토큰의 즉시 차단이 가능하다.
// 로그아웃
router.post('/logout', async (req, res) => {
const refreshToken = req.cookies.refreshToken;
if (refreshToken) {
// DB에서 삭제 (Token Rotation 방식)
await RefreshToken.deleteOne({ token: refreshToken });
// 또는 Redis 블랙리스트에 추가 (만료 시간까지 유지)
const decoded = jwt.decode(refreshToken);
if (decoded?.exp) {
const ttl = decoded.exp - Math.floor(Date.now() / 1000);
if (ttl > 0) {
await redis.setex(`blacklist:${refreshToken}`, ttl, '1');
}
}
}
res.clearCookie('refreshToken');
res.json({ message: '로그아웃 됐다' });
});
검증 단계에서 블랙리스트 확인을 추가한다:
// /auth/refresh 검증 로직에 추가
const isBlacklisted = await redis.get(`blacklist:${refreshToken}`);
if (isBlacklisted) {
return res.status(401).json({ message: '무효화된 토큰이다' });
}
Redis로 토큰 관리하기
DB 조회 대신 Redis를 쓰면 성능이 크게 올라간다. Refresh Token 검증이 API 요청마다 발생하는 게 아니라 토큰 갱신 시점에만 발생하긴 하지만, 사용자가 많아지면 차이가 생긴다.
// utils/redis.js
const { createClient } = require('redis');
const redis = createClient({
url: process.env.REDIS_URL || 'redis://localhost:6379',
});
redis.on('error', (err) => console.error('Redis 연결 오류:', err));
(async () => {
await redis.connect();
})();
module.exports = redis;
// Refresh Token을 Redis에 저장 (로그인 시)
async function storeRefreshToken(userId, token) {
const ttl = 7 * 24 * 60 * 60; // 7일 (초 단위)
await redis.setex(`refresh:${userId}:${token}`, ttl, JSON.stringify({
userId,
createdAt: new Date().toISOString(),
}));
}
// 검증
async function validateRefreshToken(userId, token) {
const data = await redis.get(`refresh:${userId}:${token}`);
return data !== null;
}
// 토큰 교체 (Token Rotation)
async function rotateRefreshToken(userId, oldToken, newToken) {
await redis.del(`refresh:${userId}:${oldToken}`);
await storeRefreshToken(userId, newToken);
}
// 사용자의 모든 Refresh Token 무효화 (탈취 대응)
async function revokeAllTokens(userId) {
const keys = await redis.keys(`refresh:${userId}:*`);
if (keys.length > 0) {
await redis.del(keys);
}
}
Redis는 TTL을 네이티브로 지원해서 만료 처리가 자동이다. DB처럼 만료된 레코드를 별도로 정리하는 스케줄 작업이 필요 없다.
DB vs Redis 비교
| 항목 | DB (MySQL/PostgreSQL) | Redis |
|---|---|---|
| 조회 속도 | 느림 (디스크 I/O) | 빠름 (메모리) |
| TTL 자동 관리 | 별도 스케줄 필요 | 네이티브 지원 |
| 토큰 만료 후 정리 | 수동 배치 작업 필요 | 자동 삭제 |
| 영속성 | 영구 저장 | 재시작 시 삭제 (RDB/AOF 설정 시 유지) |
| 설정 복잡도 | 낮음 | 중간 |
| 적합한 규모 | 소~중규모 | 중~대규모 |
토큰 무효화 방법 비교
| 방법 | 구현 복잡도 | 보안 수준 | 탈취 대응 속도 | 비고 |
|---|---|---|---|---|
| Short Expiry만 사용 | 낮음 | 중간 | 만료 후 자동 | UX 희생 |
| DB 저장 + 삭제 | 중간 | 높음 | 즉시 | 조회 오버헤드 |
| Redis 블랙리스트 | 중간 | 높음 | 즉시 | 인프라 추가 필요 |
| Token Rotation | 중간 | 매우 높음 | 즉시 (재사용 감지) | 동시 요청 처리 필요 |
| Token Rotation + Redis | 높음 | 최고 | 즉시 | 프로덕션 권장 |
삽질 기록
Refresh Token 탈취 시나리오와 대응
실제로 발생하는 케이스다. 공격자가 XSS나 네트워크 스니핑으로 Refresh Token을 탈취했다고 가정한다.
단순 DB 저장 방식: 공격자가 Refresh Token으로 Access Token을 계속 갱신할 수 있다. 피해자가 로그아웃하면 DB에서 삭제되지만, 그 전까지는 막을 방법이 없다.
Token Rotation 방식: 공격자가 Refresh Token으로 갱신 요청을 보내면 토큰이 교체된다. 이후 피해자의 갱신 요청이 들어오면 DB에 없는 토큰으로 판단되어 전체 토큰 무효화가 트리거된다. 피해자는 재로그인을 해야 하지만, 공격자의 접근도 차단된다.
핵심은 이미 사용된 Refresh Token으로 갱신 요청이 오면 탈취로 간주하고 전체 세션을 종료하는 것이다.
동시 요청 시 토큰 갱신 레이스컨디션
Token Rotation을 구현하면 반드시 마주치는 문제다.
T=0: Access Token 만료
T=1ms: 요청 A → 401
T=1ms: 요청 B → 401 (동시에)
T=2ms: 요청 A → POST /auth/refresh → 성공, 새 Refresh Token X 발급
T=3ms: 요청 B → POST /auth/refresh → 이미 교체된 토큰으로 요청 → 실패, 전체 로그아웃
해결책은 두 가지다.
1. 프론트엔드에서 갱신 요청 직렬화 (앞서 소개한 isRefreshing + failedQueue 패턴)
갱신 요청이 이미 진행 중이면 새로 보내지 않고 대기열에 쌓는다. 갱신 완료 후 새 토큰으로 대기열을 한꺼번에 처리한다.
2. 백엔드에서 짧은 시간 동안 이전 토큰도 허용
// 이전 토큰을 5초간 유효한 것으로 처리
async function validateRefreshTokenWithGrace(userId, token) {
const current = await redis.get(`refresh:${userId}:${token}`);
if (current) return true;
// 5초 유예 기간 동안 이전 토큰도 허용
const previous = await redis.get(`refresh:prev:${userId}`);
if (previous === token) return true;
return false;
}
실무에서는 1번(프론트 직렬화)으로 대부분 해결된다. 2번은 보안이 약간 느슨해지는 트레이드오프가 있다.
토큰 갱신 무한루프
/auth/refresh 자체가 실패하면 프론트엔드 인터셉터가 다시 401을 받고, 또 갱신을 시도하는 무한루프가 생길 수 있다.
// 잘못된 패턴
api.interceptors.response.use(null, async (error) => {
if (error.response?.status === 401) {
await refreshToken(); // /auth/refresh도 401이면 다시 여기로
return api(error.config); // 무한 반복
}
});
해결은 _retry 플래그와 갱신 요청 자체를 별도 axios 인스턴스로 분리하는 것이다.
// 갱신 요청은 인터셉터가 없는 별도 인스턴스를 사용
const refreshAxios = axios.create({ withCredentials: true });
api.interceptors.response.use(null, async (error) => {
const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true; // 재시도 표시
try {
const { data } = await refreshAxios.post('/auth/refresh'); // 인터셉터 없는 인스턴스
// ...
} catch {
window.location.href = '/login'; // 실패하면 로그인으로
return Promise.reject(error);
}
}
return Promise.reject(error);
});
originalRequest._retry = true를 반드시 설정해야 한다. /auth/refresh가 실패해서 401이 반환되면 인터셉터가 다시 트리거되지만 _retry가 이미 true라 갱신을 시도하지 않고 로그인 페이지로 넘어간다.
마무리
JWT를 프로덕션에 올릴 때 필요한 것들을 한 줄로 정리하면 이렇다.
Access Token (15분, 메모리) + Refresh Token (7일, HttpOnly Cookie)
+ Token Rotation + Redis 블랙리스트 + 프론트엔드 인터셉터 직렬화
한 번에 다 구현하려면 부담이 크다. 단계별로 가는 게 현실적이다.
- 1단계: Access Token + Refresh Token 분리, DB에 저장
- 2단계: HttpOnly Cookie 적용, 프론트엔드 인터셉터 추가
- 3단계: Token Rotation 추가
- 4단계: Redis 마이그레이션, 블랙리스트 운용
서비스 규모와 보안 요구사항에 맞게 어디서 멈출지 판단하면 된다. 개인 프로젝트라면 1단계로도 충분하다. 실사용자가 있는 서비스라면 최소 3단계까지는 가야 한다.
기술 스택: Node.js, Express, JWT, Redis, Axios
참고: 이전 글 — JWT 기본 구현
'개발 팁' 카테고리의 다른 글
| 터미널 생산성 세팅 — zsh + CLI 도구 실사용 정리 (0) | 2026.03.17 |
|---|---|
| Chrome DevTools 실전 활용 — F12 열고 Console만 쓰고 있다면 (0) | 2026.03.17 |
| Git 실전 명령어 모음 — rebase, stash, cherry-pick, fixup 실무 활용 (0) | 2026.03.17 |
| 풀스택 앱 무료 배포 완전 가이드 — Vercel + Railway + MongoDB Atlas (0) | 2026.02.25 |
| JWT 인증 구현 — Node.js, Python, Go 비교 (0) | 2026.02.25 |