0. "당신의 계정이 해킹당했습니다" (Pre-reading)
상상해보세요. 카페에서 공용 와이파이를 쓰고 화장실을 다녀왔는데, 누군가 제 노트북으로 제 카드를 긁었습니다. 비밀번호를 바꿨지만, 범인은 여전히 제 카드를 쓰고 있습니다. 이게 바로 토큰 탈취(Token Hijacking)의 공포입니다. JWT는 한 번 발급되면 서버가 강제로 회수할 수 없습니다(Stateless). 그래서 유효기간(Expiration)과 갱신(Refresh) 전략이 보안의 핵심입니다.
1. "사장님, 글 쓰던 거 다 날아갔어요"
서비스를 런칭하고 일주일 뒤, 고객센터로 격한 항의 메일이 왔습니다. 열심히 글을 쓰고 '저장' 버튼을 눌렀는데, 갑자기 로그인 화면으로 튕기면서 모든 내용이 사라졌다는 겁니다.
로그를 확인해보니 원인은 "Access Token 만료"였습니다. 저는 보안을 철저히 하겠답시고 토큰 유효기간을 30분으로 설정해 뒀거든요. 사용자가 글을 쓰는 데 31분이 걸렸으니, 저장 요청을 보낼 때는 이미 토큰이 죽어있었던 거죠.
"아, 보안 챙기려다 사용자 다 떠나보내겠구나."
이 사건을 계기로 저는 "사용자는 절대 로그아웃되었다는 사실을 몰라야 한다"는 목표를 세우고 JWT 갱신 전략을 다시 짰습니다.
2. 딜레마 - 보안인가 편의성인가?
JWT를 공부하면서 가장 헷갈렸던 건 Access Token과 Refresh Token의 관계였습니다. 처음엔 "그냥 Access Token을 30일로 길게 주면 안 되나?"라고 생각했습니다.
하지만 이건 "집 열쇠를 잃어버리는 것"과 같습니다. 만약 해커가 30일짜리 토큰을 훔쳐가면? 제가 비밀번호를 바꿔도 해커는 30일 동안 제 행세를 할 수 있습니다. (JWT는 서버에서 강제로 만료시킬 수 없으니까요!)
그래서 이중 열쇠 전략이 필요합니다.
- Access Token (편의점 출입증): 유효기간 15분. 훔쳐가도 금방 못 쓰게 됨.
- Refresh Token (금고 열쇠): 유효기간 14일. 아주 안전한 곳(httpOnly Cookie)에 보관.
핵심은 "편의점 출입증이 만료되면, 금고 열쇠로 몰래 새 출입증을 발급받아 오기"입니다. 사용자가 눈치채지 못하게요.
2.5. '로그인 유지' (Remember Me) 체크박스의 비밀
로그인 화면에 있는 "로그인 상태 유지" 체크박스, 이게 기술적으로 뭘까요? 바로 Refresh Token의 수명을 결정하는 겁니다.
- 체크 안 함: Refresh Token을 세션 쿠키(Session Cookie)로 설정. 브라우저 끄면 사라짐.
- 체크 함: Refresh Token을 영구 쿠키(Persistent Cookie)로 설정 (예: 14일). 브라우저 꺼도 유지.
단순해보이는 체크박스 하나에도 이런 디테일이 숨어 있습니다.
3. 구현 - 사용자를 속이는 기술 (Axios Interceptor)
사용자가 "어? 로그인 풀렸네?"라고 느끼는 순간 실패입니다. 백그라운드에서 조용히 토큰을 갈아 끼워야 합니다.
저는 Axios Interceptor를 사용해서 이 과정을 자동화했습니다. 마치 은행 창구 직원이 "잠시만요, 본인 확인 좀 다시 할게요"라고 하고 뒤에서 신분증을 복사해 오는 것과 같습니다.
// axios.ts
api.interceptors.response.use(
(response) => response, // 성공하면 그냥 통과
async (error) => {
const originalRequest = error.config;
// "어? 토큰 만료됐네(401)?" && "아직 재시도 안 해봤지?"
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true; // 무한 루프 방지용 플래그
try {
// 1. Refresh Token으로 새 Access Token 달라고 조르기
const { data } = await axios.post('/api/auth/refresh');
// 2. 새 토큰 갈아 끼우기
localStorage.setItem('accessToken', data.accessToken);
originalRequest.headers.Authorization = `Bearer ${data.accessToken}`;
// 3. 실패했던 요청 다시 시도 (감쪽같이!)
return api(originalRequest);
} catch (refreshError) {
// Refresh Token까지 만료됐으면... 그땐 진짜 이별(로그아웃)
window.location.href = '/login';
return Promise.reject(refreshError);
}
}
return Promise.reject(error);
}
);
이 코드를 넣고 나니, 사용자는 토큰이 만료되든 말든 끊김 없이 서비스를 이용할 수 있게 되었습니다.
코드 상세 설명
위 코드에서 주목해야 할 점은 originalRequest._retry 플래그입니다.
이 플래그가 없으면 무한 루프에 빠질 위험이 있습니다.
- Access Token 만료됨 (401)
interceptor가 갱신 시도- 갱신 요청도 실패 (401)
- 다시
interceptor가 동작하여 또 갱신 시도... (무한 반복)
이를 방지하기 위해, 한 번 갱신을 시도한 요청에는 _retry = true를 표시하여, "너는 이미 기회를 줬어"라고 알려주는 것입니다.
또한, window.location.href = '/login'을 통해 Refresh Token마저 만료된 경우에는 가차 없이 로그인 페이지로 튕겨내야 보안상 안전합니다.
4. 토큰 저장소 전쟁: LocalStorage vs Cookie
토큰을 어디에 저장할지는 개발자들의 영원한 논쟁거리입니다. 저도 처음엔 "당연히 LocalStorage 아니야?" 했는데, 보안 문서를 읽고 식겁했습니다.
LocalStorage의 치명적 약점: XSS
해커가 제 사이트에 악성 스크립트(alert(localStorage.getItem('token')))를 심으면, 토큰이 바로 털립니다.
Access Token은 어차피 수명이 짧으니 털려도 15분만 위험하지만, Refresh Token이 털리면 계정이 영구적으로 넘어갑니다.
반반 전략
그래서 저는 타협했습니다.
- Access Token → LocalStorage에 저장합니다. (자바스크립트에서
Authorization헤더에 실어 보내기 편하니까요) - Refresh Token → httpOnly Cookie에 저장합니다.
httpOnly: 자바스크립트(document.cookie)로 접근 불가. XSS 공격 방어.Secure: HTTPS에서만 전송. 네트워크 스니핑 방지.SameSite=Strict: CSRF 공격 방지. 외부 사이트에서 요청 시 쿠키 전송 차단.
이렇게 하면 해커가 XSS 공격을 해도 Access Token만 가져갈 수 있고, Refresh Token은 안전합니다.
4.1. 쿠키의 대가 - CSRF 공격
쿠키를 쓰면 XSS는 막을 수 있지만, CSRF(Cross-Site Request Forgery)라는 새로운 적이 나타납니다. 해커가 만든 가짜 사이트에서 제 은행 사이트로 "송금 요청"을 보낼 수 있습니다. 브라우저는 쿠키를 자동으로 실어 보내기 때문이죠.
하지만 걱정 마세요. SameSite 속성이 우리를 구원합니다.
SameSite=Strict: 쿠키가 같은 도메인에서만 전송됩니다. (가장 안전)SameSite=Lax: 링크를 타고 들어올 때는 허용하지만, 그 외에는 차단합니다. (로그인 유지에 적합) 그러니 쿠키 설정할 때httpOnly; Secure; SameSite=Lax세트는 필수입니다.
4.2. 보안 요약 (XSS vs CSRF)
| 공격 유형 | 설명 | 방어 전략 |
|---|---|---|
| XSS | 자바스크립트 실행 공격 | httpOnly 쿠키 (JS 접근 불가) |
| CSRF | 가짜 요청 전송 공격 | SameSite=Strict/Lax 쿠키 |
4.5. 고급 보안 - Refresh Token Rotation (회전 전략)
Refresh Token을 httpOnly 쿠키에 넣는다고 끝이 아닙니다.
만약 해커가 어떻게든 그 쿠키를 탈취하면? (브라우저 취약점이나 악성코드 등으로)
해커는 그 쿠키로 영원히 Access Token을 발급받을 수 있습니다.
이 악몽을 막기 위해 Refresh Token Rotation을 도입했습니다. 개념은 간단합니다: "Refresh Token도 한 번 쓰면 버린다."
- 클라이언트가
RefreshToken (A)로 새 Access Token을 요청합니다. - 서버는
A를 확인하고,Access Token (B)와 새로운RefreshToken (C)를 발급합니다. - 서버는 DB에서
RefreshToken (A)를 삭제합니다. - 클라이언트는
A를 버리고C를 저장합니다.
탈취 감지 시나리오
만약 해커가 훔친 RefreshToken (A)를 사용하려고 하면 어떻게 될까요? (이미 진짜 사용자는 C로 갈아탄 상태)
- 서버 DB 확인: "어?
A는 이미 사용된(삭제된) 토큰인데?" - 보안 경고!: 누군가
A를 훔쳐서 썼다는 뜻입니다. - 서버는 즉시 해당 사용자의 모든 토큰(C 포함)을 무효화시킵니다.
- 해커와 진짜 사용자 모두 로그아웃됩니다.
이 전략 덕분에 "영구적인 계정 탈취"가 "일시적인 불편함(재로그인)"으로 바뀝니다.
5. 동시성 문제 - "새치기 하지 마세요"
마지막 보스는 "중복 갱신(Duplicate Renewals)" 문제였습니다. 대시보드에 진입하면 API 5개를 동시에 호출하는데, 토큰이 만료된 상태라면?
- API 1 → 401 에러 → 갱신 요청
- API 2 → 401 에러 → 갱신 요청...
순식간에 갱신 요청 5개가 서버로 날아갑니다. 서버는 "방금 갱신해줬잖아! 왜 또 달래?" 하며 에러를 뱉고(특히 Rotation 전략을 쓴다면 보안 경고로 인식됨), 결국 로그아웃됩니다.
해결책 - 큐(Queue) 시스템
"교통정리"가 필요합니다. 누군가 먼저 갱신하러 갔다면, 나머지 요청들은 대기열(Queue)에 서서 기다려야 합니다.
sequenceDiagram
participant 앱
participant 인터셉터
participant 서버
앱->>인터셉터: API 요청 1 (토큰 만료)
앱->>인터셉터: API 요청 2 (토큰 만료)
인터셉터->>서버: 토큰 갱신 요청 (요청 1을 위해)
Note over 인터셉터: 요청 2는 큐에서 대기! ⏳
서버-->>인터셉터: 새 Access Token 발급
인터셉터->>앱: 요청 1 재시도 (성공)
인터셉터->>앱: 요청 2 재시도 (성공)
아래는 좀 더 견고하게 구현된 큐 시스템 코드입니다:
let isRefreshing = false;
let failedQueue: ((token: string) => void)[] = [];
const processQueue = (error: any, token: string | null = null) => {
failedQueue.forEach((prom) => {
if (error) {
prom(Promise.reject(error));
} else {
prom(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((token) => {
if (token instanceof Error) {
reject(token);
} else {
originalRequest.headers.Authorization = `Bearer ${token}`;
resolve(api(originalRequest));
}
});
});
}
originalRequest._retry = true;
isRefreshing = true;
try {
const { data } = await axios.post('/api/auth/refresh');
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); // 대기열 모두 실패 처리
window.location.href = '/login';
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
return Promise.reject(error);
}
);
이 코드는 단 한 번의 갱신 요청만 서버로 보내고, 나머지는 새 토큰이 올 때까지 기다리게 만듭니다.
6. Next.js 서버 컴포넌트는요?
Next.js App Router가 등장하면서 상황이 복잡해졌습니다. 서버 컴포넌트는 LocalStorage에 접근할 수 없기 때문입니다. 어쩔 수 없이 쿠키에 의존해야 합니다.
그래서 새로운 패턴이 등장했습니다:
- 미들웨어(Middleware)가
refreshToken쿠키를 확인합니다. accessToken이 만료되었지만refreshToken이 살아있다면, 페이지를 렌더링하기 전에 미들웨어가 토큰을 갱신해줍니다.- 서버 컴포넌트는 "토큰은 이미 유효하다"고 가정하고 데이터를 가져옵니다.
sequenceDiagram
participant 브라우저
participant 미들웨어
participant 서버컴포넌트
participant 인증서버
브라우저->>미들웨어: /dashboard 요청 (쿠키: RefreshToken)
미들웨어->>미들웨어: AccessToken 확인 (만료됨?)
alt AccessToken 만료
미들웨어->>인증서버: 갱신 요청
인증서버-->>미들웨어: 새 AccessToken
미들웨어->>미들웨어: Set-Cookie: 새 AccessToken
end
미들웨어->>서버컴포넌트: 요청 전달 (헤더: Authorization)
서버컴포넌트->>서버컴포넌트: 데이터 렌더링
서버컴포넌트-->>브라우저: HTML 응답
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export async function middleware(request: NextRequest) {
const accessToken = request.cookies.get('accessToken');
const refreshToken = request.cookies.get('refreshToken');
if (!accessToken && refreshToken) {
// 1. 서버 사이드에서 갱신
const newTokens = await fetch('https://api.myapp.com/refresh', {
method: 'POST',
headers: { Cookie: `refreshToken=${refreshToken.value}` }
});
if (newTokens.ok) {
const data = await newTokens.json();
const response = NextResponse.next();
// 2. 클라이언트를 위해 쿠키 업데이트
response.cookies.set('accessToken', data.accessToken);
return response;
}
}
return NextResponse.next();
}
복잡도가 클라이언트(Axios)에서 서버(미들웨어)로 옮겨갔을 뿐, 핵심 원칙은 같습니다: "사용자는 이 복잡한 과정을 몰라야 한다."
7. 마치며 - 보안은 사용자 눈에 보이지 않아야 한다
"보안 때문에 어쩔 수 없습니다"는 개발자의 핑계입니다. 진정한 보안은 사용자가 불편함을 느끼지 않는 선에서 지켜져야 합니다.
이제 제 서비스의 사용자는 글을 쓰다가 화장실을 다녀와도, 밥을 먹고 와도 로그인이 풀리지 않습니다. 하지만 뒤에서는 15분마다 치열하게 토큰을 검사하고 갱신하고 있죠. 백조가 물 아래에서 발을 구르듯이 말입니다.
여러분의 서비스는 어떤가요? 혹시 지금도 사용자를 로그아웃시키고 있지는 않나요?
🎁 부록 - 토큰 Payload에 무엇을 넣을까?
가끔 토큰에 email, address, 심지어 phoneNumber까지 넣는 분들이 있습니다.
제발 그러지 마세요.
토큰은 Base64로 인코딩될 뿐, 암호화되지 않습니다. 누구나 jwt.io에서 내용을 볼 수 있습니다.
- 넣어도 되는 것:
userId,role,exp(만료시간) - 절대 안 되는 것:
password,phoneNumber,address(개인정보)