로그인 기능을 만들다 보면 결국 이 질문에 도착해요. "액세스 토큰은 몇 분, 리프레시 토큰은 며칠로 하지?" 결론부터 말하면 정답은 없지만 흔한 출발점은 액세스 토큰 5~15분, 리프레시 토큰 1~14일이에요. 액세스 토큰 만료는 "탈취됐을 때 최대 피해 시간", 리프레시 토큰 만료는 "사용자가 다시 로그인해야 하는 주기"로 생각하고, 리프레시 토큰은 쓸 때마다 새 것으로 바꾸는 회전(rotation)을 함께 쓰면 돼요.
이 글에서는 그 숫자를 고르는 기준을 계산으로 보여 드리고, 실제로 사고가 나는 지점들 — 여러 요청이 동시에 갱신하는 경합, 서버 간 시계 오차, exp 에 밀리초를 넣는 실수, 로그아웃 처리 — 을 Node.js 22와 jsonwebtoken 9.x로 직접 재현해 봤어요.
핵심 요약
액세스 토큰은 서명만으로 검증돼 폐기가 어렵기 때문에 짧게, 리프레시 토큰은 서버가 저장·폐기할 수 있으니 길게 가져가요.
만료를 짧게 할수록 탈취 피해 시간은 줄지만 갱신 요청이 늘어요. 동시 접속 10만 명이면 5분 만료는 초당 약 333회, 15분은 약 111회 갱신이에요.
리프레시 토큰은 쓸 때마다 새 것으로 교체하고, 이미 쓴 토큰이 다시 오면 탈취로 보고 그 계열 전체를 폐기해요.
동시 401이 여러 번 갱신을 부르지 않게 갱신 요청을 하나로 묶고, 서버 간 시계 오차는 30~60초 정도 허용하세요.
토큰을 왜 두 개나 쓸까
JWT 같은 자체 포함형(self-contained) 토큰은 서버가 DB를 조회하지 않고 서명만 확인해서 검증해요. 빠르고 확장하기 쉽지만, 한번 발급한 토큰을 중간에 무효화하기 어렵다는 약점이 있어요. 탈취돼도 만료될 때까지는 유효하죠. 토큰 구조와 서명 원리는 JWT 구조와 서명 원리에 정리해 뒀어요.
그래서 역할을 나눠요.
| 구분 | 액세스 토큰 | 리프레시 토큰 |
|---|---|---|
| 용도 | 매 API 요청의 인증 | 새 액세스 토큰 발급에만 사용 |
| 형태 | 보통 JWT (서명 검증) | 랜덤 문자열 또는 JWT, 서버에 저장 |
| 수명 | 짧게 (분 단위) | 길게 (일 단위) |
| 전송 빈도 | 모든 요청 | 갱신할 때만 |
| 폐기 | 어려움 (만료까지 유효) | 서버 저장소에서 삭제하면 즉시 |
| 저장 위치 예 | 메모리 | HttpOnly·Secure·SameSite 쿠키 |
액세스 토큰은 자주 오가니 노출 기회가 많지만 수명이 짧아 피해가 제한되고, 리프레시 토큰은 수명이 길지만 갱신 엔드포인트에만 가서 노출 기회가 적어요. 둘을 합치면 "자주 로그인하지 않아도 되면서 탈취 피해는 짧은" 구조가 돼요.
만료 시간은 몇 분이 정답일까 — 계산으로 보기
액세스 토큰 만료를 정할 때의 트레이드오프는 단순해요. 짧을수록 탈취 시 피해 시간이 줄고, 대신 갱신 요청이 늘어요. 사용자 한 명이 8시간 동안 계속 서비스를 쓴다고 하면 갱신 횟수는 480분 ÷ 만료 시간 이에요.
동시 접속 10만 명 서비스에서 5분 만료면 인증 서버가 초당 333회 갱신을 처리해야 해요. 리프레시 토큰을 DB에서 조회하고 회전까지 하면 무시할 수 없는 부하예요. 15분이면 111회로 줄어요. 그래서 많은 서비스가 10~15분 근처에서 시작해요. 상황별로 정리하면 이래요.
| 서비스 성격 | 액세스 토큰 | 리프레시 토큰 | 이유 |
|---|---|---|---|
| 금융·관리자 콘솔 | 5분 안팎 | 수 시간~1일, 비활동 시 만료 | 탈취 피해가 큼 |
| 일반 웹 서비스 | 10~15분 | 7~14일 | 부하와 보안의 균형 |
| 모바일 앱 | 15~60분 | 30~90일 (회전 필수) | 재로그인 마찰이 큼 |
| 내부 서비스 간 통신 | 5~15분 | 없음 (자격 증명으로 재발급) | 사람이 개입하지 않음 |
표의 숫자는 업계에서 흔히 쓰는 범위일 뿐 규정이 아니에요. 리프레시 토큰은 "마지막 사용 후 N일"(슬라이딩)과 "최초 로그인 후 최대 M일"(절대 만료)을 함께 두면, 매일 쓰는 사용자는 편하고 오래 방치된 세션은 확실히 끊겨요.
갱신 흐름: 언제, 어떻게 갱신하나
갱신 시점은 두 가지 방식이 있어요.
- 401을 받고 갱신: 요청이
jwt expired로 실패하면 갱신하고 원래 요청을 재시도해요. 구현이 단순해요. - 만료 직전 선제 갱신: 토큰의
exp를 읽어 만료 1~2분 전에 미리 갱신해요. 실패한 요청이 생기지 않아 사용자 경험이 매끄러워요.
실무에서는 둘을 같이 써요. 선제 갱신을 기본으로 하고, 그래도 401이 오면(시계 오차, 백그라운드 탭 복귀 등) 갱신 후 재시도해요.
선제 갱신을 setTimeout 으로만 구현하면 함정이 있어요. 브라우저는 백그라운드 탭의 타이머를 늦추고, 노트북이 절전 모드에 들어가면 타이머가 멈춰요. 한 시간 뒤 사용자가 돌아오면 타이머는 아직 안 울렸는데 토큰은 이미 만료된 상태죠. 그래서 visibilitychange 이벤트로 탭이 다시 보일 때 exp 를 확인하고, 만료가 가까우면 바로 갱신하는 처리를 같이 넣어요. 모바일 앱도 마찬가지로 포그라운드 복귀 시점에 한 번 확인하는 게 안전해요. 그리고 재시도는 딱 한 번만 하세요. 갱신 후에도 401이면 로그인 화면으로 보내야지, 무한 재시도 루프에 빠지면 서버에 요청 폭탄을 던지게 돼요.
동시 갱신 경합 — 갱신 요청은 하나로 묶기
페이지를 열 때 API 다섯 개가 동시에 나가는데 액세스 토큰이 막 만료됐다면? 다섯 요청이 모두 401을 받고 각자 갱신을 시도해요. 리프레시 토큰 회전을 쓰는 서버라면 첫 갱신이 옛 리프레시 토큰을 "사용됨"으로 바꾸니, 나머지 네 개는 재사용으로 감지돼 사용자가 강제 로그아웃돼요. 실제로 꽤 흔한 버그예요.
해결책은 진행 중인 갱신 Promise를 공유하는 거예요.
let refreshing = null;
function getFreshToken() {
if (!refreshing) {
refreshing = fetch('/token/refresh', { method: 'POST', credentials: 'include' })
.then(r => (r.ok ? r.json() : Promise.reject(new Error('refresh failed'))))
.then(d => d.accessToken)
.finally(() => { refreshing = null; });
}
return refreshing; // 동시에 불러도 같은 Promise를 받는다
}
세 요청이 동시에 getFreshToken() 을 불러도 실제 갱신 호출은 1번만 나가는 걸 Node에서 확인했어요. 탭이 여러 개라면 탭끼리도 경합하니, BroadcastChannel 로 갱신 결과를 공유하거나, 서버에서 짧은 유예 시간(예: 옛 토큰을 10초간은 같은 결과로 응답) 을 두는 방법도 써요.
리프레시 토큰 회전과 재사용 감지
회전(rotation)은 리프레시 토큰을 쓸 때마다 새 리프레시 토큰을 발급하고 옛 것은 무효화하는 방식이에요. 핵심은 이미 쓴 토큰이 다시 오면 누군가 토큰을 복사해 갔다는 신호로 보고, 그 토큰 계열(family) 전체를 폐기하는 거예요.
const crypto = require('node:crypto');
const store = new Map(); // 실제로는 DB/Redis — 토큰 원문 대신 해시를 저장
const hash = t => crypto.createHash('sha256').update(t).digest('hex');
function issueRefresh(userId, family = crypto.randomUUID()) {
const token = crypto.randomBytes(32).toString('base64url');
store.set(hash(token), { userId, family, used: false, expiresAt: Date.now() + 14 * 864e5 });
return token;
}
function rotate(oldToken) {
const rec = store.get(hash(oldToken));
if (!rec || rec.expiresAt < Date.now()) throw new Error('invalid_grant');
if (rec.used) { // 재사용 = 탈취 의심
for (const r of store.values()) if (r.family === rec.family) r.used = true;
throw new Error('reuse_detected: family revoked');
}
rec.used = true;
return issueRefresh(rec.userId, rec.family);
}
const r1 = issueRefresh('user_123');
const r2 = rotate(r1); // 정상 갱신
rotate(r1); // Error: reuse_detected: family revoked ← 공격자가 옛 토큰 사용
rotate(r2); // Error: reuse_detected: family revoked ← 정상 사용자도 재로그인
공격자가 옛 토큰을 쓰는 순간 계열 전체가 막혀서, 정상 사용자도 재로그인해야 해요. 불편하지만 "누가 내 세션을 복사했다"는 걸 서버가 알아채고 피해를 끊는 거예요. 리프레시 토큰은 DB에 해시로 저장하세요. DB가 유출돼도 원문 토큰을 바로 쓸 수 없어요.
exp 날짜가 이상할 때 — 초와 밀리초
토큰을 JWT 디코더로 열어 exp 를 날짜로 바꿨더니 5만 년 뒤가 나왔다면, 밀리초를 넣은 거예요. JWT의 exp, iat, nbf 는 초 단위 유닉스 시간이에요.
const jwt = require('jsonwebtoken');
const bad = jwt.sign({ sub: '1', exp: Date.now() + 15 * 60 * 1000 }, secret);
new Date(jwt.decode(bad).exp * 1000).toISOString(); // '+058729-07-27T04:14:06.000Z'
const good = jwt.sign({ sub: '1' }, secret, { expiresIn: '15m' }); // 라이브러리에 맡기기
이 토큰은 사실상 영원히 만료되지 않아요. 반대로 초 단위 값을 JS Date 에 그대로 넣으면 1970년 1월로 나와서 "이미 만료됐다"고 판단하는 버그가 생기고요. 숫자를 날짜로 바꿔 확인할 땐 타임스탬프 변환기가 자릿수로 초·밀리초를 판별해 줘요. 이 유형의 버그는 초와 밀리초 단위 버그에서 더 다뤘어요.
서버마다 시계가 조금씩 다르면
토큰을 발급한 서버와 검증하는 서버의 시계가 몇 초만 달라도, 갓 발급된 토큰이 nbf 때문에 jwt not active 로 거절되거나 막 만료된 토큰 처리가 들쭉날쭉해져요. 대부분의 라이브러리에 허용 오차 옵션이 있어요.
// exp가 20초 지난 토큰
jwt.verify(token, secret); // TokenExpiredError: jwt expired
jwt.verify(token, secret, { clockTolerance: 30 }); // 통과 (30초까지 허용)
30~60초 정도면 충분해요. 몇 분 단위로 크게 잡으면 짧은 만료의 의미가 사라져요. 근본적으로는 모든 서버가 NTP로 시간을 맞추고 있는지 확인하는 게 먼저예요.
로그아웃은 어떻게 처리할까
- 리프레시 토큰: 서버 저장소에서 삭제(또는 폐기 표시)하고 쿠키를 지워요. 이건 즉시 효과가 있어요.
- 액세스 토큰: 만료까지 유효해요. 대부분은 짧은 수명에 맡기고, 즉시 차단이 필요한 경우(비밀번호 변경, 계정 정지)에만
jti를 폐기 목록(denylist)에 넣고 만료 시각까지 Redis 등에 보관해 검증 때 확인해요. - 전체 기기 로그아웃: 사용자별 "토큰 버전" 숫자를 두고 토큰에 넣은 뒤, 버전을 올리면 이전 토큰을 모두 거절하는 방식도 많이 써요.
폐기 목록을 매 요청 조회하면 결국 세션 조회와 비슷한 비용이 들어요. 그래서 "모든 요청을 즉시 차단할 수 있어야 한다"는 요구가 강하다면 JWT 대신 서버 세션이 더 맞을 수도 있어요. 토큰을 어디에 저장할지는 JWT 보안 실수 체크리스트에서 쿠키와 localStorage를 비교해 뒀어요.
자주 묻는 질문
액세스 토큰 만료 시간은 몇 분이 적당한가요?
일반 웹 서비스라면 10~15분이 흔한 출발점이에요. 짧을수록 탈취 피해 시간이 줄지만 갱신 요청이 늘어요. 동시 접속 10만 명 기준으로 5분이면 초당 약 333회, 15분이면 약 111회 갱신이 발생해요.
리프레시 토큰도 JWT여야 하나요?
꼭 그럴 필요는 없어요. 리프레시 토큰은 어차피 서버에 저장하고 조회하니, 충분히 긴 랜덤 문자열로 만들고 해시로 저장하는 방식이 단순하고 안전해요. JWT로 만들더라도 서버 저장소에서 유효성을 확인해야 회전과 폐기가 가능해요.
여러 API가 동시에 401을 받으면 어떻게 하나요?
갱신 요청을 하나의 Promise로 공유해서 실제 갱신은 한 번만 나가게 하세요. 각 요청이 따로 갱신하면 리프레시 토큰 회전 환경에서 재사용으로 감지돼 강제 로그아웃될 수 있어요. 탭이 여러 개라면 BroadcastChannel로 결과를 공유하는 방법도 있어요.
jwt expired 에러가 발급 직후에 나요.
서버 간 시계 차이나, exp 에 초 대신 밀리초 또는 그 반대를 넣은 경우가 대부분이에요. 토큰을 디코딩해 iat 와 exp 를 날짜로 바꿔 확인하고, 서버 시간이 NTP로 동기화돼 있는지 보세요. 검증 옵션에 30~60초의 clockTolerance를 주는 것도 도움이 돼요.
로그아웃하면 액세스 토큰도 바로 무효가 되나요?
기본적으로는 아니에요. 액세스 토큰은 서명만으로 검증되니 만료될 때까지 유효해요. 즉시 무효화가 필요하면 jti 를 폐기 목록에 넣어 검증 때 확인하거나, 사용자별 토큰 버전을 올리는 방식을 써야 해요.
정리
액세스 토큰은 짧게, 리프레시 토큰은 길게 하되 회전과 재사용 감지를 붙이는 게 기본 구조예요. 만료 시간은 "탈취 시 최대 피해 시간"과 "갱신 부하"의 트레이드오프라서, 10~15분에서 시작해 서비스 성격에 맞게 조정하면 돼요. 그리고 동시 갱신은 하나로 묶고, exp 는 초 단위로, 서버 시계는 NTP로 맞추기 — 이 세 가지가 운영에서 터지는 토큰 버그의 대부분을 막아요. 숫자를 정한 뒤에는 실제 로그에서 갱신 요청 수와 재사용 감지 건수를 지켜보며 조정하세요.