
로그인 풀렸다고 욕먹고 배운 JWT 토큰 관리
서비스 런칭 일주일 만에 '작업하던 거 다 날아갔어요!'라는 항의 메일을 받았습니다. 원인은 JWT 토큰 만료. 보안과 편의성 사이에서 줄타기하며 배운 Refresh Token 전략, Axios Interceptor 구현, 그리고 보안 사고를 막기 위한 저장소 선택 기준을 공유합니다.

서비스 런칭 일주일 만에 '작업하던 거 다 날아갔어요!'라는 항의 메일을 받았습니다. 원인은 JWT 토큰 만료. 보안과 편의성 사이에서 줄타기하며 배운 Refresh Token 전략, Axios Interceptor 구현, 그리고 보안 사고를 막기 위한 저장소 선택 기준을 공유합니다.
프론트엔드 개발자가 알아야 할 4가지 저장소의 차이점과 보안 이슈(XSS, CSRF), 그리고 언제 무엇을 써야 하는지에 대한 명확한 기준.

Debug에선 잘 되는데 Release에서만 죽나요? 범인은 '난독화'입니다. R8의 원리, Mapping 파일 분석, 그리고 Reflection을 사용하는 라이브러리를 지켜내는 방법(@Keep)을 정리해봤습니다.

비트코인은 블록체인의 일부입니다. 이중 지불 문제(Double Spending), 작업 증명(PoW)과 지분 증명(PoS)의 차이, 스마트 컨트랙트, 그리고 Web 3.0이 가져올 미래까지. 개발자 관점에서 본 블록체인의 모든 것.

내 서버가 해킹당하지 않는 이유. 포트와 IP를 검사하는 '패킷 필터링'부터 AWS Security Group까지, 방화벽의 진화 과정.

상상해보세요. 카페에서 공용 와이파이를 쓰고 화장실을 다녀왔는데, 누군가 제 노트북으로 제 카드를 긁었습니다. 비밀번호를 바꿨지만, 범인은 여전히 제 카드를 쓰고 있습니다. 이게 바로 토큰 탈취(Token Hijacking)의 공포입니다. JWT는 한 번 발급되면 서버가 강제로 회수할 수 없습니다(Stateless). 그래서 유효기간(Expiration)과 갱신(Refresh) 전략이 보안의 핵심입니다.
서비스를 런칭하고 일주일 뒤, 고객센터로 격한 항의 메일이 왔습니다. 열심히 글을 쓰고 '저장' 버튼을 눌렀는데, 갑자기 로그인 화면으로 튕기면서 모든 내용이 사라졌다는 겁니다.
로그를 확인해보니 원인은 "Access Token 만료"였습니다. 저는 보안을 철저히 하겠답시고 토큰 유효기간을 30분으로 설정해 뒀거든요. 사용자가 글을 쓰는 데 31분이 걸렸으니, 저장 요청을 보낼 때는 이미 토큰이 죽어있었던 거죠.
"아, 보안 챙기려다 사용자 다 떠나보내겠구나."
이 사건을 계기로 저는 "사용자는 절대 로그아웃되었다는 사실을 몰라야 한다"는 목표를 세우고 JWT 갱신 전략을 다시 짰습니다.
JWT를 공부하면서 가장 헷갈렸던 건 Access Token과 Refresh Token의 관계였습니다. 처음엔 "그냥 Access Token을 30일로 길게 주면 안 되나?"라고 생각했습니다.
하지만 이건 "집 열쇠를 잃어버리는 것"과 같습니다. 만약 해커가 30일짜리 토큰을 훔쳐가면? 제가 비밀번호를 바꿔도 해커는 30일 동안 제 행세를 할 수 있습니다. (JWT는 서버에서 강제로 만료시킬 수 없으니까요!)
그래서 이중 열쇠 전략이 필요합니다.
핵심은 "편의점 출입증이 만료되면, 금고 열쇠로 몰래 새 출입증을 발급받아 오기"입니다. 사용자가 눈치채지 못하게요.
로그인 화면에 있는 "로그인 상태 유지" 체크박스, 이게 기술적으로 뭘까요? 바로 Refresh Token의 수명을 결정하는 겁니다.
단순해보이는 체크박스 하나에도 이런 디테일이 숨어 있습니다.
사용자가 "어? 로그인 풀렸네?"라고 느끼는 순간 실패입니다. 백그라운드에서 조용히 토큰을 갈아 끼워야 합니다.
저는 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 플래그입니다.
이 플래그가 없으면 무한 루프에 빠질 위험이 있습니다.
interceptor가 갱신 시도interceptor가 동작하여 또 갱신 시도... (무한 반복)이를 방지하기 위해, 한 번 갱신을 시도한 요청에는 _retry = true를 표시하여, "너는 이미 기회를 줬어"라고 알려주는 것입니다.
또한, window.location.href = '/login'을 통해 Refresh Token마저 만료된 경우에는 가차 없이 로그인 페이지로 튕겨내야 보안상 안전합니다.
토큰을 어디에 저장할지는 개발자들의 영원한 논쟁거리입니다. 저도 처음엔 "당연히 LocalStorage 아니야?" 했는데, 보안 문서를 읽고 식겁했습니다.
해커가 제 사이트에 악성 스크립트(alert(localStorage.getItem('token')))를 심으면, 토큰이 바로 털립니다.
Access Token은 어차피 수명이 짧으니 털려도 15분만 위험하지만, Refresh Token이 털리면 계정이 영구적으로 넘어갑니다.
그래서 저는 타협했습니다.
Authorization 헤더에 실어 보내기 편하니까요)httpOnly: 자바스크립트(document.cookie)로 접근 불가. XSS 공격 방어.Secure: HTTPS에서만 전송. 네트워크 스니핑 방지.SameSite=Strict: CSRF 공격 방지. 외부 사이트에서 요청 시 쿠키 전송 차단.이렇게 하면 해커가 XSS 공격을 해도 Access Token만 가져갈 수 있고, Refresh Token은 안전합니다.
쿠키를 쓰면 XSS는 막을 수 있지만, CSRF(Cross-Site Request Forgery)라는 새로운 적이 나타납니다. 해커가 만든 가짜 사이트에서 제 은행 사이트로 "송금 요청"을 보낼 수 있습니다. 브라우저는 쿠키를 자동으로 실어 보내기 때문이죠.
하지만 걱정 마세요. SameSite 속성이 우리를 구원합니다.
SameSite=Strict: 쿠키가 같은 도메인에서만 전송됩니다. (가장 안전)SameSite=Lax: 링크를 타고 들어올 때는 허용하지만, 그 외에는 차단합니다. (로그인 유지에 적합)
그러니 쿠키 설정할 때 httpOnly; Secure; SameSite=Lax 세트는 필수입니다.| 공격 유형 | 설명 | 방어 전략 |
|---|---|---|
| XSS | 자바스크립트 실행 공격 | httpOnly 쿠키 (JS 접근 불가) |
| CSRF | 가짜 요청 전송 공격 | SameSite=Strict/Lax 쿠키 |
Refresh Token을 httpOnly 쿠키에 넣는다고 끝이 아닙니다.
만약 해커가 어떻게든 그 쿠키를 탈취하면? (브라우저 취약점이나 악성코드 등으로)
해커는 그 쿠키로 영원히 Access Token을 발급받을 수 있습니다.
이 악몽을 막기 위해 Refresh Token Rotation을 도입했습니다. 개념은 간단합니다: "Refresh Token도 한 번 쓰면 버린다."
RefreshToken (A)로 새 Access Token을 요청합니다.A를 확인하고, Access Token (B)와 새로운 RefreshToken (C)를 발급합니다.RefreshToken (A)를 삭제합니다.A를 버리고 C를 저장합니다.만약 해커가 훔친 RefreshToken (A)를 사용하려고 하면 어떻게 될까요? (이미 진짜 사용자는 C로 갈아탄 상태)
A는 이미 사용된(삭제된) 토큰인데?"A를 훔쳐서 썼다는 뜻입니다.이 전략 덕분에 "영구적인 계정 탈취"가 "일시적인 불편함(재로그인)"으로 바뀝니다.
마지막 보스는 "중복 갱신(Duplicate Renewals)" 문제였습니다. 대시보드에 진입하면 API 5개를 동시에 호출하는데, 토큰이 만료된 상태라면?
순식간에 갱신 요청 5개가 서버로 날아갑니다. 서버는 "방금 갱신해줬잖아! 왜 또 달래?" 하며 에러를 뱉고(특히 Rotation 전략을 쓴다면 보안 경고로 인식됨), 결국 로그아웃됩니다.
"교통정리"가 필요합니다. 누군가 먼저 갱신하러 갔다면, 나머지 요청들은 대기열(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);
}
);
이 코드는 단 한 번의 갱신 요청만 서버로 보내고, 나머지는 새 토큰이 올 때까지 기다리게 만듭니다.
Next.js App Router가 등장하면서 상황이 복잡해졌습니다. 서버 컴포넌트는 LocalStorage에 접근할 수 없기 때문입니다. 어쩔 수 없이 쿠키에 의존해야 합니다.
그래서 새로운 패턴이 등장했습니다:
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)에서 서버(미들웨어)로 옮겨갔을 뿐, 핵심 원칙은 같습니다: "사용자는 이 복잡한 과정을 몰라야 한다."
"보안 때문에 어쩔 수 없습니다"는 개발자의 핑계입니다. 진정한 보안은 사용자가 불편함을 느끼지 않는 선에서 지켜져야 합니다.
이제 제 서비스의 사용자는 글을 쓰다가 화장실을 다녀와도, 밥을 먹고 와도 로그인이 풀리지 않습니다. 하지만 뒤에서는 15분마다 치열하게 토큰을 검사하고 갱신하고 있죠. 백조가 물 아래에서 발을 구르듯이 말입니다.
여러분의 서비스는 어떤가요? 혹시 지금도 사용자를 로그아웃시키고 있지는 않나요?
가끔 토큰에 email, address, 심지어 phoneNumber까지 넣는 분들이 있습니다.
제발 그러지 마세요.
토큰은 Base64로 인코딩될 뿐, 암호화되지 않습니다. 누구나 jwt.io에서 내용을 볼 수 있습니다.
userId, role, exp (만료시간)password, phoneNumber, address (개인정보)