최근 "JWT 사용을 중단하라"는 글이 다시 돌면서 댓글창이 또 한 번 불타올랐다. 매번 반복되는 논쟁인데, 핵심 메시지는 늘 같다. "브라우저 사용자 로그인 세션 유지에 JWT를 쓰지 마라." 서비스 간 통신(M2M)이나 SSO 전송에는 JWT가 멀쩡히 잘 쓰이는데, 정작 한국 백엔드 실무에서 가장 흔하게 보이는 패턴은 "로그인하면 access token + refresh token을 발급해서 localStorage에 박아두는" 그 패턴이다. 이 글은 그게 왜 잘못된 선택인지, 그리고 어떻게 정리할지를 실무자 관점에서 풀어본다.
1. 왜 지금 또 이 얘기가 나오나
이 논쟁의 출처는 GeekNews에 올라온 gist 글이다. 주장은 간단하다. JWT는 무상태(stateless) 인증을 약속하지만, 정작 안전하게 운영하려면 어차피 서버 측 상태가 필요하고, 그럴 거면 그냥 평범한 쿠키 세션이 더 단순하고 안전하다는 것이다.
실무에서 이 패턴을 만나는 순간은 거의 정해져 있다. 신규 프로젝트 킥오프 때 누군가 "요즘은 무상태로 가야죠, JWT 박읍시다"라고 말하면서 시작된다. MSA를 한다는 명분, 모바일 앱이 붙는다는 명분, 서버 메모리에 세션 안 들고 싶다는 명분. 그런데 1년쯤 지나서 "강제 로그아웃 기능 넣어주세요", "탈취된 토큰 즉시 무효화해주세요", "이 사용자만 전체 기기에서 로그아웃시켜주세요" 같은 요구가 들어오면 그제야 무상태의 환상이 깨진다.
한 가지 짚고 넘어갈 것. 원문 발췌에 나온 "JWT 스펙은 5분 이하 짧은 수명 토큰만을 위해 설계됐다"는 주장은 HN 댓글에서도 반박이 나온다. RFC 7519를 봐도 그런 명시적 규정은 없다는 지적이다. 그러니 이 부분은 글쓴이의 강한 의견 정도로 받아들이고, "JWT는 짧은 수명일 때 가장 안전하게 동작한다"는 실무적 경험칙으로 이해하는 게 맞다. 스펙 레벨의 단정은 공식 문서 확인이 필요하다.
2. JWT는 어떻게 동작하고, 세션은 어떻게 동작하나
비유부터. JWT는 위조 방지 도장이 찍힌 출입증이다. 한 번 발급하면 서버가 명부를 보지 않아도 도장만 검증해서 통과시킨다. 빠르고 편하다. 문제는 이미 발급한 출입증을 회수할 방법이 없다는 것. 도장은 유효 기간 끝날 때까지 살아있다.
반면 쿠키 세션은 코인 보관함 번호표다. 번호표 자체엔 아무 정보가 없고, 서버가 보관함(세션 스토어)을 열어봐야 누군지 안다. 번호표를 회수(세션 삭제)하면 그 즉시 무효다.
실제 JWT 구조를 디코드해보자. JWT는 그냥 base64url로 인코딩된 세 덩어리(header.payload.signature)다. 암호화가 아니다. 누구나 디코드해서 내용을 읽을 수 있다.
# JWT의 payload 부분만 디코드해보기 (서명 검증 아님, 단순 디코드)
echo "eyJzdWIiOiIxMjM0IiwibmFtZSI6IkhvbmciLCJpYXQiOjE3MDAwMDAwMDB9" | base64 -d
# 출력:
{"sub":"1234","name":"Hong","iat":1700000000}
여기서 첫 번째 함정이 보인다. localStorage에 JWT를 넣어두면 XSS 한 방에 이 토큰이 통째로 털린다. 그리고 payload는 평문이나 다름없으니, 민감 정보(권한, 이메일 등)를 넣었다면 그것도 같이 노출된다. 그래서 원문도 강조한다. "인증 자격 증명은 localStorage나 sessionStorage에 저장하지 말 것."
그럼 쿠키 세션은? 제대로 설정한 세션 쿠키는 이렇게 생긴다.
# 로그인 응답의 Set-Cookie 헤더 확인
curl -i -X POST https://example.com/login \
-d 'username=hong&password=secret'
# 출력 (헤더 일부):
HTTP/2 200
set-cookie: sid=8f3a9c1e-...; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=86400
HttpOnly가 붙으면 자바스크립트(document.cookie)로 접근이 막힌다. 즉 XSS가 터져도 세션 ID는 못 빼간다. 이게 localStorage JWT와의 결정적 차이다.
3. 실무 관점 — 트레이드오프, 흔한 함정, 대안
무상태의 함정: 로그아웃과 탈취 대응
JWT 세션의 가장 큰 거짓말은 "무상태"다. 실무에서 진짜로 무상태로 운영되는 인증 시스템은 거의 없다. 왜냐하면:
- 강제 로그아웃: 토큰을 무효화하려면 결국 서버 어딘가에 폐기 목록(blocklist)이나
minimum_issued_at같은 상태를 둬야 한다. - 실시간 권한 변경: 사용자 권한을 회수했는데 JWT에 박힌 권한은 만료 전까지 그대로 살아있다. 그래서 요청마다 DB에서 사용자 객체를 다시 조회하게 된다.
- 탈취 대응: 토큰이 털린 걸 알아도 만료 전까지 막을 수 없다. blocklist에 넣어야 한다.
HN 댓글의 한 사람이 정곡을 찌른다. "요청마다 사용자 객체를 조회하는 순간, JWT의 핵심 장점은 사라진다. 그럴 거면 그냥 불투명한 세션 ID 쓰고 DB 조회하면 된다." 세션 조회는 인덱스 탄 SELECT 한 번, 0~1행 반환이다. 대부분의 서비스에서 이건 병목이 아니다.
흔한 함정 — 실제로 만나는 에러들
JWT를 세션으로 굴리다 보면 거의 반드시 만나는 에러들이 있다. 검색 유입 차원에서 그대로 박아둔다.
(1) access token 만료 시점 처리 실패
JsonWebTokenError: jwt expired
at /app/node_modules/jsonwebtoken/verify.js:152:21
name: 'TokenExpiredError',
expiredAt: 2024-01-15T03:22:11.000Z
refresh 로직을 프론트에 제대로 안 깔아두면, 사용자가 작업 중에 갑자기 401을 맞고 튕긴다. silent refresh 구현이 생각보다 까다롭고, 동시 요청이 여러 개 날아갈 때 refresh가 race condition으로 중복 호출되는 버그가 흔하다.
(2) 서명 알고리즘 혼동 — 역사적 취약점
JsonWebTokenError: invalid algorithm
at /app/node_modules/jsonwebtoken/verify.js:104:19
과거 일부 라이브러리는 alg: none을 받아들이거나(서명 검증 없이 통과), 공개 키를 HMAC 공유 비밀로 오인해 토큰 위조를 허용하는 결함이 있었다(원문에서 언급된 CVE-2022-23540, CVE-2024-54150 계열). 요즘 주요 라이브러리는 기본값이 정상화됐지만, 검증 시 algorithms 옵션을 명시하지 않으면 여전히 위험하다.
// 위험: 알고리즘 미지정
jwt.verify(token, secret);
// 안전: 허용 알고리즘 명시
jwt.verify(token, publicKey, { algorithms: ['RS256'] });
(3) 쿠키에 JWT 넣었는데 헤더 크기 초과
upstream sent too big header while reading response header from upstream
431 Request Header Fields Too Large
payload에 권한, 클레임을 잔뜩 넣다 보면 토큰이 비대해지고, Nginx 기본 헤더 버퍼 한도를 넘긴다. proxy_buffer_size 튜닝을 하게 되는데, 이쯤 되면 "그냥 세션 ID 한 줄 쓸걸"이라는 생각이 든다.
그럼 JWT는 언제 쓰나 — 올바른 적용
JWT를 버리라는 게 아니다. 맞는 자리에 쓰라는 거다. 원문과 댓글에서 합의되는 정당한 사용처:
- 서비스 간 통신(M2M): 인증 서비스가 RS256으로 서명한 짧은 수명 토큰을 발급하면, 하위 서비스는 공개 키로 검증만 한다. 인증 DB에 접근할 필요가 없다. 하위 서비스가 털려도 인증 DB까지 노출되진 않는다.
- SSO 전송 수단: 한 호스트의 세션을 다른 호스트로 넘길 때. 구글이 JWT를 쓰는 방식도 브라우저 세션이 아니라 이쪽이다.
- OIDC ID 토큰: OIDC 토큰은 전부 JWT다. 다만 이건 "한 번 검증하고 자기 세션으로 교환"하는 용도지, 그 자체를 세션 쿠키처럼 매 요청 굴리는 게 아니다.
그리고 짧은 수명 서명 토큰이 정말 필요하면, 원문은 JWT 대신 PASETO를 권한다. 알고리즘 협상 같은 footgun을 설계 단계에서 제거한 사양이다. 단, PASETO도 세션 용도로 쓰지 말라는 건 동일하다.
4. 정리 — 한 줄 요약과 선택 기준
한 줄 요약: 브라우저 로그인 세션은 HttpOnly 쿠키 세션으로, JWT는 서비스 간 통신·SSO·OIDC 같은 짧은 수명 신원 전달에만.
- 쿠키 세션을 써라: 일반적인 웹/모바일 백엔드, 강제 로그아웃·실시간 권한 회수가 필요한 모든 서비스. 즉 거의 대부분.
- JWT를 써도 된다: MSA 내부 서비스 간 인증, SSO 토큰 전송, OIDC 연동. 단 RS256 + 짧은 수명 + audience 설정 + 검증 시 알고리즘 명시는 기본.
- 절대 하지 마라: JWT를 localStorage에 저장, payload에 민감정보 적재, 검증 시 알고리즘 미지정, "무상태니까 무효화 안 해도 돼"라는 가정.
참고로 댓글 중엔 "잘 구현하면 JWT도 충분히 안전하다"는 반론도 강하게 나온다. 맞는 말이다. 다만 "올바르게 잡고 쓰면 안전하다"는 건 좋은 기본값이 아니라는 뜻이기도 하다. 평범한 세션 쿠키는 프레임워크 기본값만 따라가도 안전한데, JWT는 매번 footgun을 피해 다녀야 한다. 팀 규모와 보안 리소스가 구글급이 아니라면, 단순한 도구를 고르는 게 5년 굴려본 입장에서의 권고다.
전환 가이드 — 기존 JWT 세션을 쿠키 세션으로
한 번에 갈아엎지 말고 점진적으로 가는 게 안전하다. 대략 이런 순서다.
- 세션 스토어 준비: Redis나 RDB(Postgres/MySQL). 원문은 Express 기준
express-session+ 적절한 store connector(예:connect-session-knex)를 권한다. - 병행 인증 기간: 신규 로그인은 쿠키 세션 발급, 기존 JWT는 만료될 때까지 검증만 허용. 듀얼 검증 미들웨어를 둔다.
- 저장 위치 이전: 프론트의 localStorage 토큰 읽기 코드를 제거하고, 쿠키는 브라우저가 알아서 싣게 한다.
- CSRF 대비: 쿠키 인증으로 가면 CSRF가 새 과제가 된다.
SameSite=Lax/Strict를 기본으로 깔고, 상태 변경 요청엔 CSRF 토큰을 추가한다. (원문도 CSRF는 별도 학습이 필요하다고 명시한다.)
// Express 기준 최소 세션 설정 예시
const session = require('express-session');
app.use(session({
store: /* connect-session-knex 등 영속 스토어 */ ,
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true, // JS 접근 차단 (XSS 방어)
secure: true, // HTTPS 전용
sameSite: 'lax', // CSRF 1차 방어
maxAge: 1000 * 60 * 60 * 24 // 24시간
}
}));
// "모든 기기 로그아웃"이 필요하면 해당 유저 세션을 스토어에서 일괄 삭제하면 끝.
마지막으로 현실적인 조언. 이미 JWT로 잘 돌아가고 큰 보안 요구가 없는 서비스를 무리해서 갈아엎을 필요는 없다. 다만 신규 프로젝트에서 관성적으로 "일단 JWT"를 외치는 건 멈추자. "우리가 진짜 무상태가 필요한가? 강제 로그아웃 요구가 들어올까?"를 먼저 물어보면, 대부분의 답은 평범한 쿠키 세션이다.
참고 자료
'Tech_News' 카테고리의 다른 글
| AI 에이전트가 메일을 읽는 시대, SPF·DKIM·DMARC를 다시 점검해야 하는 이유 (0) | 2026.06.16 |
|---|---|
| AUR 패키지 408개 감염 사태로 다시 보는 공급망 보안: maintainer 사칭부터 eBPF 루트킷까지 (0) | 2026.06.15 |
| 하드웨어 증설 없이 스캔 처리량 10배: Cloudflare가 Kafka·Postgres·API만 손봐서 해낸 스케일링 분석 (0) | 2026.06.14 |
| Claude Desktop이 채팅만 써도 1.8GB Hyper-V VM을 띄우는 이유와 인프라 엔지니어의 대응법 (0) | 2026.06.13 |
| 무료 체험인데 $1,000 청구서? Blacksmith 사례로 보는 CI 과금 함정과 비용 방어 전략 (1) | 2026.06.12 |
