개발에 AtoZ까지

세션 vs JWT 인증, 뭘 쓰고 토큰은 어디에 저장하나 본문

프론트엔드

세션 vs JWT 인증, 뭘 쓰고 토큰은 어디에 저장하나

AtoZ 개발자 2026. 7. 21. 22:16
반응형

😀 개요

안녕하세요 😀

로그인 기능을 새로 붙일 때, 검색해 보면 조언이 정반대로 갈립니다.

  • "요즘은 무조건 JWT죠. 서버 확장도 쉽고요."
  • "아니에요, 세션이 더 안전해요. JWT는 로그아웃도 안 되잖아요."

여기에 "토큰은 localStorage에 저장하면 된다"는 글과 "localStorage는 위험하니 쿠키에 넣어라"는 글까지 겹치면, 도대체 뭘 기준으로 골라야 할지 막힙니다.

결론부터 말하면, 이건 하나의 승패 싸움이 아니라 사실 두 개의 다른 결정입니다. ① 로그인 상태를 서버에 둘지(세션) 토큰에 둘지(JWT), 그리고 ② 그 자격증명을 브라우저 어디에 저장할지(쿠키 vs localStorage). 이 둘을 섞어서 설명하니까 계속 헷갈리는 것입니다.

이 글은 예전 CORS 에러, 왜 나고 어떻게 푸나 같은 웹 기초 글과 같은 결로, 이 두 결정을 나눠서 기준을 잡아 드립니다.

🎯 오늘 정리하는 것

이 글을 다 읽으면 아래를 스스로 판단할 수 있습니다.

  • 세션 방식과 JWT 방식이 실제로 무엇이 다른지 한 문장으로 설명할 수 있습니다.
  • 내 서비스에 세션이 맞는지 JWT가 맞는지 조건으로 고를 수 있습니다.
  • 토큰(또는 세션ID)을 localStorage에 둘지 httpOnly 쿠키에 둘지, XSS·CSRF 트레이드오프로 결정할 수 있습니다.
  • "JWT는 왜 로그아웃이 애매한가"의 정체와 짧은 토큰 + 리프레시 토큰 패턴을 이해합니다.

특정 라이브러리 설정법이 아니라, 어떤 구조를 고를지 판단하는 기준을 손에 넣는 것이 목적입니다.

🧭 근거부터 짚고 갑니다

미리 말씀드리면, 이 글은 제 특정 서비스의 운영 경험담이 아니라 표준 문서와 공식 보안 가이드에서 확인해 정리한 것입니다. 근거로 삼은 자료는 다음과 같습니다.

  • RFC 7519 / 7515(JSON Web Token · JSON Web Signature) — JWT가 무엇인지의 규격
  • RFC 6265(HTTP 쿠키) — 세션 쿠키의 동작
  • RFC 9700(OAuth 2.0 보안 Best Current Practice, 2025) — 리프레시 토큰 권고
  • MDN — 쿠키 속성(HttpOnly · Secure · SameSite)
  • OWASP — 세션 관리 / JWT / CSRF 치트시트

🧩 사실은 질문이 두 개입니다

흔히 "세션 vs JWT vs 쿠키 vs localStorage"를 한 줄에 놓고 비교하는데, 이들은 같은 층위의 선택지가 아닙니다.

정리하면 결정은 두 단계로 나뉩니다.

  • Q1. 인증 "상태"를 어디에 둘까?세션(서버가 상태 보관) vs JWT(토큰이 상태를 들고 감)
  • Q2. 그 자격증명(세션ID·토큰)을 브라우저 어디에 저장할까?쿠키 vs localStorage

이 둘은 독립적입니다. 세션ID를 쿠키가 아니라 헤더로 넘길 수도 있고, JWT를 localStorage가 아니라 쿠키에 담을 수도 있습니다. 그래서 "JWT는 localStorage, 세션은 쿠키"라는 공식은 사실이 아닙니다. Q1과 Q2를 따로 결정해야 답이 깔끔해집니다.

🔁 세션과 JWT는 무엇이 다른가

먼저 Q1입니다. 용어부터 풀겠습니다.

세션 방식은 쉽게 말하면 "서버가 로그인 상태를 기억하고, 브라우저에는 열쇠(세션ID)만 쥐여 주는" 방식입니다. HTTP는 원래 요청 사이에 상태가 없는데(stateless), 쿠키로 그 위에 상태를 얹는 것입니다. RFC 6265는 쿠키를 "서버가 사용자 에이전트에 상태를 저장해, 대체로 무상태인 HTTP 위에서 상태 있는 세션을 유지하게 해 주는 것"이라고 설명합니다.

여기서 브라우저가 들고 있는 세션ID는 의미 없는 임의의 값이어야 합니다. OWASP는 세션ID를 "정보가 담기지 않은 무의미한 값"으로, 추측을 막기 위해 값 길이는 128비트 이상, 엔트로피는 최소 64비트 이상으로 권고합니다. 즉 세션ID 자체에는 사용자 정보가 없고, 서버가 그 값을 자기 저장소에서 조회해 "아, 이건 3번 사용자"라고 알아내는 구조입니다.

JWT 방식은 반대입니다. 쉽게 말하면 "상태를 서버가 아니라 서명된 토큰 안에 담아 클라이언트가 들고 다니는" 방식입니다. 서버는 매 요청마다 DB를 조회하는 대신, 토큰의 서명만 검증해서 위·변조가 없었는지 확인합니다. 저장소 조회가 없으니 서버를 무상태(stateless)로 두기 좋고, 여러 서비스가 같은 토큰을 나눠 검증하기 편합니다.

이 차이에서 장단점이 그대로 따라옵니다. 세션은 서버에 상태가 있으니 로그아웃·강제 차단이 즉시 됩니다. 서버에서 세션을 지우면 그 세션ID는 그 순간 무용지물이 되니까요. 대신 서버 여러 대로 확장하려면 세션 저장소를 공유해야 합니다(예: 요청을 같은 서버로 붙이는 sticky session, 또는 Redis 같은 공용 저장소 — 이건 규격이 아니라 일반적인 아키텍처 관행입니다).

JWT는 확장은 쉽지만, 뒤에서 볼 "즉시 무효화가 어렵다" 는 약점을 안고 갑니다.

🔓 JWT는 "암호화"가 아닙니다

JWT에서 가장 많이 오해하는 지점입니다. JWT는 header.payload.signature 세 부분을 점(.)으로 이은 문자열이고, 각 부분은 base64url로 "인코딩" 되어 있습니다. 인코딩은 암호화가 아니라 누구나 되돌릴 수 있는 변환입니다.

즉 서명된 JWT의 payload는 암호화된 게 아니라 그냥 읽힙니다. jwt.io 공식 소개 문서도 "서명된 토큰의 이 정보는 위·변조로부터는 보호되지만 누구나 읽을 수 있으니, payload나 header에 비밀 정보를 넣지 말라"고 명시합니다. 실제로 토큰을 복사해 base64 디코드만 하면 내용이 그대로 보입니다.

# JWT 예시 (aaaaa.bbbbb.ccccc 형태)
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0Iiwicm9sZSI6ImFkbWluIn0.<서명>

# 앞 두 조각을 base64url 디코드하면 그냥 읽힘 (암호화 아님)
header  → {"alg":"HS256","typ":"JWT"}
payload → {"sub":"1234","role":"admin"}

그럼 서명은 무슨 역할일까요? 서명은 "내용을 숨기는 것"이 아니라 "내용이 바뀌지 않았음을 증명하는 것" 입니다. RFC 7519는 claim을 "JWS(서명) 구조의 payload로, 또는 JWE(암호화) 구조의 평문으로" 담아 "전자서명 또는 무결성 보호, 그리고/또는 암호화"할 수 있다고 정의합니다. 다시 말해 평범한 서명 토큰(JWS)은 무결성만 보장하고, 내용을 숨기려면 별도로 암호화(JWE)해야 합니다.

 

여기서 실무 규칙 두 가지가 나옵니다.

  • payload에 비밀번호·주민번호 같은 민감정보를 넣지 마세요. 누구나 읽힙니다. 넣어야 한다면 값 자체를 따로 암호화하거나 JWE를 씁니다.
  • 서버는 검증할 알고리즘을 코드에 고정하세요. 토큰 header의 alg 값을 그대로 믿으면, 공격자가 algnone으로 바꿔 서명을 무력화하거나(RFC 7515의 "Unsecured JWS"), RS256을 HS256으로 바꿔치기하는 알고리즘 혼동 공격에 노출됩니다. OWASP는 "검증 측은 자체 설정/하드코딩 로직으로 무결성을 검증해야지, 토큰 header의 정보로 검증 알고리즘을 고르면 안 된다"고 못박습니다.

참고로 서명 방식은 하나의 비밀키를 공유하는 HMAC(HS256) 과, 개인키로 서명하고 공개키로 검증하는 비대칭키(RS256·ES256) 로 나뉩니다. 여러 서비스가 토큰을 검증한다면 비대칭키가 안전합니다. HMAC은 검증할 수 있는 쪽이면 새 토큰도 만들 수 있어서, 한 서비스가 뚫리면 키를 공유한 전체가 뚫리기 때문입니다.

🗄️ 토큰을 어디에 저장할까 — localStorage vs httpOnly 쿠키

이제 Q2입니다. 세션ID든 JWT든, 그 값을 브라우저 어딘가에 보관해야 합니다. 후보는 크게 localStorage쿠키이고, 둘은 공격 표면이 정반대입니다.

localStorage는 페이지의 모든 자바스크립트가 읽을 수 있습니다. 그래서 XSS(쉽게 말하면 내 페이지에 남의 스크립트가 실행되는 취약점)가 하나라도 뚫리면 저장된 토큰이 통째로 유출됩니다. OWASP는 이 점을 매우 강하게 경고합니다. 요지는 인증 토큰·세션ID·JWT·리프레시 토큰 같은 자격증명을 localStorage나 sessionStorage에 저장하지 말라는 것입니다. 이 저장소는 해당 출처의 모든 자바스크립트가 접근할 수 있어, XSS 하나로 전부 노출되기 때문입니다. 대신 localStorage는 브라우저가 요청에 자동으로 실어 보내지 않으므로, 뒤에 볼 CSRF에는 상대적으로 덜 노출됩니다.

httpOnly 쿠키는 반대입니다. HttpOnly 속성이 붙은 쿠키는 자바스크립트가 읽을 수 없어서(예: document.cookie로 접근 불가), XSS가 나도 토큰 값 자체를 훔쳐 가긴 어렵습니다.

 

그런데 쿠키에는 대신 다른 약점이 있습니다. 쿠키는 브라우저가 목적지 도메인에 맞춰 자동으로 실어 보냅니다. 사용자가 의도했는지와 무관하게요. 바로 이 "자동 전송" 때문에 CSRF(크로스 사이트 요청 위조) 가 성립합니다. 악성 사이트가 내 은행 사이트로 요청을 유도하면, 브라우저가 알아서 로그인 쿠키를 붙여 보내는 것이죠. 그래서 쿠키 인증은 CSRF 방어를 따로 해야 합니다.

두 저장소의 트레이드오프를 표로 보면 이렇습니다. 어느 쪽도 XSS 앞에서 "안전"한 게 아니라, 뚫렸을 때의 실패 방식이 다릅니다.

저장 위치 XSS(스크립트 주입) CSRF(요청 위조) 비고
localStorage ⚠️ JS로 읽혀 탈취됨 상대적으로 덜함(자동 전송 아님) OWASP는 토큰 저장을 권장하지 않음
httpOnly 쿠키 값 읽기 차단(탈취 방어) ⚠️ 자동 전송이라 노출 SameSite+CSRF 토큰으로 방어 필요

 

한 가지 오해를 더 풀면, httpOnly가 XSS를 막아 주는 건 아닙니다. 토큰 "값"을 훔쳐 다른 곳에서 재사용하는 걸 막을 뿐, XSS 스크립트가 그 페이지 안에서 자동 전송되는 쿠키를 타고 요청을 보내는 것까지는 못 막습니다. 그래서 진짜 방어는 저장 위치 선택이 아니라 XSS 자체를 없애는 것(출력 인코딩, 입력 검증, 엄격한 CSP)입니다. 저장 위치는 그다음의 피해 최소화 장치입니다.

쿠키 쪽 CSRF 방어의 핵심은 SameSite 속성입니다. SameSite는 쿠키를 크로스 사이트 요청에 보낼지를 제어합니다.

  • Strict — 같은 사이트 요청에만 전송(가장 엄격, OWASP가 세션 쿠키에 권장)
  • Lax — 같은 사이트 + 안전한 메서드(GET)로 상위 이동(링크 클릭)할 때까지 전송, 크로스 사이트 POST에는 안 보냄
  • None — 크로스 사이트에도 전송하되 반드시 Secure 필요

요즘 브라우저는 SameSite를 지정하지 않으면 대체로 Lax로 취급합니다. 다만 이건 규격이 아니라 브라우저 동작이고(크로미엄 계열 기준, 파이어폭스·사파리는 조금 다릅니다), 명시적으로 지정한 Lax와 미묘한 차이도 있습니다. 확실히 하려면 직접 지정하세요. 그리고 SameSite만으로 CSRF가 완전히 막히는 건 아닙니다 — OWASP는 이를 심층 방어의 한 겹으로 보고, CSRF 토큰(동기화 토큰) 을 기본 방어로 함께 쓰라고 권합니다.

CSRF 토큰은 쉽게 말하면, 서버가 요청(폼)마다 예측 불가능한 값을 하나 심어 두고, 요청이 올 때 그 값이 함께 왔는지 대조하는 장치입니다. 악성 사이트는 이 값을 미리 알 수 없으므로 위조 요청이 걸러집니다.

# 세션ID/토큰을 쿠키에 담는 안전한 기본형 (OWASP 권장)
Set-Cookie: session=<임의의 긴 값>; HttpOnly; Secure; SameSite=Strict; Path=/

🚪 JWT는 왜 로그아웃이 애매한가

JWT를 세션처럼 쓰려다 가장 먼저 부딪히는 벽이 "로그아웃(무효화)" 입니다.

세션은 서버에 상태가 있으니 지우면 끝입니다. 하지만 서명된 JWT는 exp(만료 시각)까지 그 자체로 유효합니다. OWASP JWT 치트시트도 "토큰은 만료될 때에만 무효가 되며, 사용자가 토큰을 명시적으로 무효화할 내장 기능이 없다"고 설명합니다. 클라이언트에서 토큰을 지워도, 그 토큰이 어디선가 복사돼 있으면 만료 전까지는 계속 통합니다.

그럼 강제로 무효화하려면? 서버가 "이 토큰은 폐기됨" 목록(deny list) 을 들고 매번 대조해야 합니다. 그런데 이러면 다시 서버에 상태가 생겨서, JWT의 장점인 무상태성이 사라집니다. OWASP도 "이 경우 세션이 완전히 무상태가 아니게 되어 JWT의 이점을 상쇄할 수 있다"고 지적합니다.

그래서 현실적인 정석은 "짧은 access 토큰 + 리프레시 토큰" 조합입니다(RFC 9700 권고).

  • access 토큰은 수명을 짧게(예: 몇 분~수십 분) 둡니다. 유출돼도 금방 만료되니 피해 창이 좁아집니다.
  • 리프레시 토큰은 수명이 길고, access 토큰이 만료되면 새 토큰을 받아오는 데 씁니다. 이 리프레시 토큰이 서버에서 무효화할 수 있는 진짜 열쇠입니다.
  • 리프레시 토큰은 httpOnly · Secure 쿠키에 담아 JS가 못 읽게 하고(XSS 방어), 사용할 때마다 새것으로 교체(회전, rotation)합니다. 이러면 탈취된 옛 토큰이 재사용될 때 이를 감지해 토큰 계열 전체를 무효화할 수 있습니다.
  • access 토큰은 아예 저장하지 않고 메모리(JS 변수) 에만 두는 패턴도 널리 씁니다. 새로고침하면 리프레시 토큰으로 다시 받으면 되니까요.

즉 "JWT는 로그아웃이 안 된다"가 아니라, 무효화 지점을 리프레시 토큰으로 옮기고 access 토큰은 짧게 굴리는 것이 답입니다.

🧭 그래서 뭘 고르나

이제 두 결정을 순서대로 내리면 됩니다.

그림 4. 먼저 상태를 어디 둘지(Q1) 정하고, 그다음 자격증명을 어디 저장할지(Q2) 정합니다. 두 결정은 독립적입니다.

Q1. 상태를 어디에 둘까 — 세션 vs JWT

  • 세션이 맞는 경우 — 단일 서버·모놀리스거나 세션 저장소를 두기 쉽고, 로그아웃·강제 차단이 즉시 되어야 하며, 결제·관리자처럼 민감한 세션일 때. 단순하고 무효화가 확실합니다.
  • JWT가 맞는 경우 — 여러 서비스·MSA에서 서버 조회 없이 검증하고 싶거나, 외부 클라이언트가 토큰을 소비하거나, 수명이 짧은 access 토큰으로 쓸 때. 확장성이 강점입니다.

한 문장 기준: "즉시 끊는 것"이 중요하면 세션, "무상태 확장"이 중요하면 JWT 입니다. 그리고 JWT를 오래 유지해야 하면 앞의 리프레시 토큰 패턴을 함께 씁니다.

Q2. 자격증명을 어디 저장할까 — 쿠키 vs localStorage

  • 기본값은 HttpOnly; Secure; SameSite 쿠키입니다(OWASP 권장). 토큰 값이 JS에 노출되지 않고, SameSite+CSRF 토큰으로 CSRF도 막습니다.
  • localStorage에 토큰을 두는 것은 OWASP가 권장하지 않습니다. 굳이 Bearer 헤더 방식을 써야 한다면, 긴 수명 토큰 대신 짧은 access 토큰은 메모리에, 리프레시 토큰은 httpOnly 쿠키에 두는 쪽이 안전합니다.
  • 저장 위치는 사고가 났을 때 피해를 줄이는 장치일 뿐, 그 자체로 앱을 안전하게 만들어 주지는 않습니다.

대부분의 소규모·단일 서버 사이드 프로젝트라면 세션 + HttpOnly 쿠키면 충분합니다. 리프레시 토큰 회전이나 access 토큰 메모리 보관 같은 장치는, 서비스가 여러 개로 나뉘거나 외부 클라이언트가 토큰을 쓰게 될 때 붙이면 됩니다. 처음부터 전부 갖추려 하지 않아도 됩니다.

✅ 인증 방식 정할 때 체크리스트

새 로그인 기능을 설계할 때 이 순서로 짚으면 됩니다. 필요하면 복사해서 쓰세요.

[Q1 — 상태를 어디에 둘까]
- [ ] 로그아웃/강제 차단이 "즉시" 되어야 하나? → 예: 세션 유리
- [ ] 여러 서비스가 서버 조회 없이 검증해야 하나? → 예: JWT 유리
- [ ] JWT로 간다면 access 토큰 수명을 짧게 잡았나? (분 단위)
- [ ] 오래 유지가 필요하면 "리프레시 토큰(회전)"을 별도로 뒀나?

[Q2 — 자격증명을 어디 저장할까]
- [ ] 기본은 HttpOnly + Secure + SameSite 쿠키로 두었나?
- [ ] 토큰/세션ID를 localStorage에 저장하고 있지 않나? (OWASP 권장 아님)
- [ ] 쿠키를 쓰면 CSRF 방어(SameSite + CSRF 토큰)를 넣었나?

[JWT 위생 — 서명/내용]
- [ ] payload에 비밀번호·개인정보 같은 민감정보를 넣지 않았나? (그대로 읽힘)
- [ ] 서버가 검증 알고리즘을 코드에 고정했나? (alg:none / 알고리즘 혼동 방지)
- [ ] 여러 서비스가 검증하면 비대칭키(RS256/ES256)를 썼나?

[모두 공통]
- [ ] XSS 자체를 막는 방어(출력 인코딩·CSP)를 먼저 갖췄나?

결정을 내렸다면 다음 한 발은 이렇습니다. 세션으로 간다면 쓰는 프레임워크의 세션 미들웨어부터, 쿠키 방식이면 Set-Cookie 옵션(HttpOnly·Secure·SameSite) 설정부터, JWT면 검증 라이브러리의 알고리즘 고정 옵션부터 찾아보시면 자연스럽게 이어집니다.

🫡 마무리

정리하면, "세션 vs JWT"는 이기고 지는 문제가 아니라 두 개의 다른 결정입니다. 상태를 서버에 둘지 토큰에 둘지(Q1), 그리고 그 자격증명을 어디에 저장할지(Q2). 즉시 무효화가 중요하면 세션이, 무상태 확장이 중요하면 JWT가 어울리고, 저장은 httpOnly 쿠키가 기본입니다.

그리고 JWT의 payload는 암호화가 아니라 그냥 읽힌다는 것, 어떤 저장 위치를 골라도 진짜 방어선은 XSS를 막는 것이라는 두 가지만 기억해도 대부분의 사고를 피할 수 있습니다.

같은 웹 기초 묶음으로, 통신 방식 자체가 궁금하다면 Socket 통신과 HTTP 통신 비교를, 응답 상태 코드를 다루는 법은 HTTP Status Code 제어를 함께 보셔도 좋습니다.

다음 글에서는 여기서 짧게 짚은 OAuth 2.0 / 소셜 로그인 흐름이 실제로 어떻게 돌아가는지 이어서 정리해 보겠습니다.

읽어 주셔서 감사합니다 🙇

참고 자료

반응형
Comments