쿠키·세션·JWT 비교: 로그인 상태를 두는 곳과 회수 방법
들어가며
로그인 기능을 만들 때 가장 먼저 정하는 것이 "로그인 상태를 어디에 둘 것인가" 입니다. 서버가 기억하게 할 수도 있고, 클라이언트가 증명서를 들고 다니게 할 수도 있습니다. 이 선택에 따라 서버를 늘리는 방법도, 탈취된 계정을 막는 방법도 달라집니다.
이번 글에서는 쿠키·세션·JWT 가 각각 무엇인지 정리하고, 두 로그인 방식의 차이와 JWT 를 회수하기 어려운 이유, 그리고 리프레시 토큰으로 이를 보완하는 방법을 설명합니다.
1. 쿠키는 저장 방식이 아니라 운반 수단
쿠키는 서버가 Set-Cookie 헤더로 내려 주면 브라우저가 저장해 두었다가, 같은 사이트로 보내는 요청마다 자동으로 붙이는 작은 값입니다. 세션 ID 를 담을 수도 있고 JWT 를 담을 수도 있습니다.
그래서 "쿠키냐 JWT 냐" 는 맞지 않는 비교입니다. 쿠키는 값을 실어 나르는 주머니이고, 비교해야 할 것은 세션 방식과 토큰 방식입니다.
Set-Cookie: sid=a8f3c1; HttpOnly; Secure; SameSite=Lax; Path=/HttpOnly 는 자바스크립트가 쿠키를 읽지 못하게 막고, Secure 는 HTTPS 에서만 보내게 합니다. SameSite 는 다른 사이트에서 시작된 요청에 쿠키를 붙일지 정하는 속성으로, CSRF 를 줄이는 데 씁니다.
2. 세션 로그인과 JWT 로그인
세션 방식에서는 서버가 로그인 상태를 저장소에 기록하고, 클라이언트에는 그 기록을 찾을 번호(세션 ID)만 줍니다. 요청이 올 때마다 서버는 번호로 저장소를 조회합니다.
JWT(JSON Web Token) 방식에서는 서버가 사용자 ID 와 만료 시각을 담은 토큰에 서명해 건넵니다. 서버는 아무것도 저장하지 않고, 요청이 오면 서명과 만료 시각만 검증합니다. 이를 무상태(stateless)라고 부릅니다.
세션은 번호표를 주고 장부를 보는 방식, JWT는 도장 찍힌 자유이용권 밴드를 보는 방식이에요.
쿠키에는 번호만 있다. 상태는 저장소에 있다.
저장소를 거치지 않는다. 토큰 안에 사용자와 만료 시각이 있다.
JWT 는 header.payload.signature 세 부분을 점으로 이은 문자열입니다. 앞의 두 부분은 Base64URL 로 인코딩했을 뿐 암호화가 아니므로 누구나 디코딩해 읽을 수 있습니다. 서명은 내용이 바뀌지 않았음을 보장할 뿐이라, 비밀번호나 개인정보는 넣지 않습니다.
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI0MiIsImV4cCI6MTc5MDAwMDkwMH0.Q2x...3. 무엇을 얻고 무엇을 잃나
세션은 서버가 상태를 쥐고 있으므로 저장소에서 지우기만 하면 즉시 로그아웃됩니다. 대신 서버가 여러 대면 모든 서버가 같은 세션 저장소를 봐야 하고, 요청마다 조회 비용이 듭니다.
JWT 는 저장소 없이 어느 서버에서나 검증할 수 있어 서버를 늘리거나 여러 서비스가 같은 토큰을 믿게 하기 쉽습니다. 대신 서명이 맞고 만료 전이면 통과하므로, 한 번 발급한 토큰을 서버가 되돌리기 어렵습니다.
장부식은 내쫓기 쉽고, 밴드식은 입구를 늘리기 쉬워요.
| 방식 | 서버 저장 | 즉시 회수 | 서버 증설 |
|---|---|---|---|
| 세션번호표 + 장부 | 필요 | 저장소 공유 필요 | |
| JWT 단독긴 만료 | 없음 | ||
| JWT + 리프레시짧은 액세스 | 리프레시만 | 액세스 만료까지 지연 |
단일 서비스라면 세션이 더 단순하고 회수도 쉽습니다. JWT 의 이점은 여러 서비스가 저장소를 공유하지 않고 각자 검증해야 할 때 커집니다.
4. 리프레시 토큰으로 회수 문제 줄이기
JWT 의 회수 문제는 보통 토큰을 둘로 나눠 해결합니다. 요청마다 쓰는 액세스 토큰은 수명을 5~15분 정도로 짧게 두고, 새 액세스 토큰을 받을 때만 쓰는 리프레시 토큰은 길게 두되 서버에 저장합니다.
로그아웃이나 계정 차단은 서버에 저장한 리프레시 토큰을 폐기하는 것으로 합니다. 이미 발급된 액세스 토큰은 남은 수명 동안 통하지만, 그 시간이 짧으므로 피해 범위도 짧아집니다.
액세스 토큰은 15분짜리 입장 팔찌, 리프레시 토큰은 매표소에서 팔찌를 새로 받는 회원증이에요.
리프레시 토큰은 쓸 때마다 새 것으로 바꾸고 옛 것은 폐기하는 회전(rotation) 을 함께 씁니다. 이미 폐기된 토큰이 다시 들어오면 누군가 훔쳐 쓴 것으로 보고 그 사용자의 토큰을 모두 끊습니다. OAuth 2.0 보안 권고에 있는 방식입니다.
def renew(old_refresh: str):
row = db.find_refresh(sha256(old_refresh))
if row is None:
raise Unauthorized() # 401
if row.revoked:
db.revoke_family(row.user_id) # 재사용 탐지: 전부 끊기
raise Unauthorized()
db.revoke(row) # 한 번 쓰면 폐기
return issue_pair(row.user_id) # 새 액세스 + 새 리프레시리프레시 토큰은 유출돼도 원문을 알 수 없도록 해시로 저장합니다. 즉시 차단이 꼭 필요하면 요청마다 차단 목록을 확인할 수도 있지만, 그만큼 JWT 의 무상태 장점은 줄어듭니다.
헷갈리기 쉬운 점
- JWT 안의 정보는 암호화돼 있다는 오해. 인코딩과 서명뿐이라 누구나 읽을 수 있습니다.
- 로그아웃하면 JWT 가 바로 무효가 된다는 오해. 클라이언트가 토큰을 지울 뿐, 서버가 차단 목록을 보지 않으면 만료 전까지 계속 통합니다.
- 쿠키를 쓰면 세션 방식이라는 오해. 쿠키는 운반 수단이라 JWT 도 쿠키에 담을 수 있습니다.
면접에서 자주 받는 질문
브라우저에서 JWT 는 어디에 저장하나요?
localStorage 는 자바스크립트로 읽을 수 있어 XSS 가 생기면 토큰이 그대로 유출됩니다. HttpOnly 쿠키에 두면 스크립트로 읽을 수 없지만 브라우저가 자동으로 붙이므로 SameSite 설정이나 CSRF 토큰으로 대비해야 합니다. 어느 쪽이든 XSS 자체를 막는 것이 먼저입니다.
리프레시 토큰 회전과 재사용 탐지는 무엇인가요?
리프레시 토큰을 쓸 때마다 새 토큰을 발급하고 옛 토큰은 폐기합니다. 폐기된 토큰이 다시 오면 정상 사용자와 공격자 중 한쪽이 훔친 토큰을 쓴 것이므로, 그 사용자의 토큰 묶음을 모두 끊고 다시 로그인하게 합니다.
그럼 처음부터 세션을 쓰면 되지 않나요?
단일 서비스라면 세션이 더 단순하고 강제 로그아웃도 쉽습니다. JWT 는 여러 서비스가 저장소를 공유하지 않고 각자 검증해야 할 때 이점이 큽니다. 회수의 쉬움과 확장의 쉬움 중 무엇이 더 중요한지에 따라 고릅니다.
정리
- 쿠키는 브라우저가 자동으로 실어 보내는 운반 수단이고, 비교 대상은 세션 방식과 토큰 방식입니다.
- 세션은 서버가 상태를 기억해 회수가 쉽고, JWT 는 서명만 검증해 확장이 쉽지만 회수가 어렵습니다.
- JWT 는 액세스 토큰을 짧게 두고, 서버에 저장한 리프레시 토큰을 회전·폐기하는 방식으로 회수 문제를 줄입니다.
세션은 서버가 기억하고, JWT 는 토큰이 증명합니다. 그래서 JWT 는 짧게 주고, 회수는 리프레시 토큰에서 합니다.