티스토리 뷰
며칠 전 사내 신규 프로젝트 킥오프에서 인증 방식 얘기가 나왔다. 주니어 한 명이 "요즘 누가 세션 쓰나요, JWT 가야죠"라고 했고, 나는 잠깐 고민하다가 "이 프로젝트는 세션이 맞을 것 같은데"라고 답했다. 회의가 끝나고 그 친구가 따로 물어보러 왔다. 왜 JWT가 아니냐고. 그때 했던 얘기를 정리해서 글로 남긴다. 15년 넘게 백엔드를 하면서 JWT로 인증을 구현해 장애도 겪어봤고, 세션으로 돌아간 프로젝트도 있고, 결국 둘 다 쓰는 프로젝트도 있다. 이론은 간단한데 실무는 간단하지 않았다.

세션 인증과 JWT, 교과서 설명은 이렇다
복습부터 하자. 세션 인증은 로그인에 성공하면 서버가 세션 ID를 만들어 저장소(메모리, Redis, DB)에 보관하고, 브라우저에는 그 ID만 쿠키로 내려주는 방식이다. 요청이 올 때마다 서버가 저장소를 조회해서 "이 사람 로그인한 사람 맞네"를 확인한다. 상태가 서버에 있다. 그래서 스테이트풀(stateful)이라고 부른다.
JWT(JSON Web Token)는 반대다. 로그인에 성공하면 서버가 사용자 정보와 만료 시각을 담은 토큰을 서명해서 내려주고, 서버는 아무것도 저장하지 않는다. 요청이 오면 서명만 검증하면 끝이다. 상태가 클라이언트에 있다. 스테이트리스(stateless)다.
교과서적인 장단점 비교는 이렇다. 세션은 서버 저장소 조회가 매 요청마다 필요하니 확장에 불리하고, JWT는 서명 검증만 하면 되니 서버를 수평 확장해도 세션 공유 문제가 없다. 여기까지만 들으면 JWT가 무조건 이기는 게임처럼 보인다. 그 주니어도 여기까지 알고 있었다. 문제는 그다음부터다.
함정 1. JWT는 로그아웃이 안 된다
JWT의 가장 큰 강점인 "서버가 상태를 저장하지 않는다"는 말을 뒤집으면 "서버가 토큰을 무효화할 방법이 없다"가 된다. 사용자가 로그아웃 버튼을 눌러도, 이미 발급된 토큰은 만료 시각까지 유효하다. 클라이언트에서 토큰을 지우는 건 로그아웃처럼 보이게 하는 것뿐이고, 누군가 그 토큰을 복사해뒀다면 만료 전까지 계속 쓸 수 있다.
실제로 겪은 일이다. 몇 년 전 운영하던 서비스에서 퇴사자 계정을 정지시켰는데, 그 계정으로 발급된 토큰이 이틀짜리였다. 계정은 분명 정지 상태인데 API는 이틀 동안 멀쩡히 응답했다. 관리자 입장에서는 "차단했는데 왜 들어와?"가 된다. 보안 감사에서 이 부분을 지적받고 나서야 구조를 바꿨다.
해결책은 다들 안다. 블랙리스트를 만들면 된다. 로그아웃하거나 정지된 토큰을 Redis 같은 곳에 넣어두고, 매 요청마다 "이 토큰 블랙리스트에 있나?"를 확인하는 거다. 그런데 잠깐, 매 요청마다 저장소를 조회한다고? 그건 그냥 세션이다. 스테이트리스라는 JWT의 존재 이유가 사라진다. 이 모순이 JWT 인증 설계의 출발점이고, 그래서 나온 절충안이 "액세스 토큰은 짧게, 리프레시 토큰으로 갱신"하는 구조다.

함정 2. 리프레시 토큰, 어디에 저장할 것인가
액세스 토큰을 15분짜리로 짧게 만들면 탈취돼도 피해가 15분으로 제한된다. 대신 사용자가 15분마다 재로그인할 수는 없으니, 수명이 긴 리프레시 토큰으로 새 액세스 토큰을 받아온다. 여기까지는 좋다. 그런데 이 리프레시 토큰을 브라우저 어디에 둘 것인가. 이게 두 번째 함정이다.
localStorage에 두면 구현이 제일 쉽다. 자바스크립트로 꺼내서 헤더에 실으면 되니까. 문제는 XSS다. 외부 스크립트 하나, npm 패키지 하나가 오염되면 localStorage는 그대로 털린다. 수명이 2주짜리인 리프레시 토큰이 털리면 공격자는 2주 동안 그 사용자다.
httpOnly 쿠키에 두면 자바스크립트로 접근이 불가능하니 XSS로 토큰 자체를 훔치는 건 막는다. 대신 쿠키는 브라우저가 자동으로 실어 보내니 CSRF 걱정이 생긴다. 요즘은 SameSite=Lax가 기본값이라 과거보다 훨씬 낫지만, 크로스 도메인 API 호출이 필요한 구조(프론트는 app.example.com, API는 api.other.com 같은)에서는 SameSite=None을 써야 하고, 그러면 CSRF 토큰 같은 추가 방어가 다시 필요해진다.
내 결론은 수년째 같다. 리프레시 토큰은 httpOnly + Secure + SameSite 쿠키, 액세스 토큰은 메모리(자바스크립트 변수)에만. 액세스 토큰을 아예 localStorage에도 안 둔다. 새로고침하면 날아가지만, 그때는 쿠키에 있는 리프레시 토큰으로 조용히 한 번 갱신하면 된다. 사용자는 아무것도 못 느낀다. 완벽한 방어는 아니지만(XSS가 뚫리면 공격자가 갱신 요청 자체를 흉내 낼 수 있다), 토큰 원본이 통째로 유출되는 것과는 피해 범위가 다르다.
함정 3. 동시 요청과 리프레시 레이스 컨디션
이건 운영에서 실제로 터져본 사람만 아는 문제다. 액세스 토큰이 만료된 시점에 프론트가 API를 동시에 서너 개 쏘면, 전부 401을 받고 전부 각자 리프레시를 시도한다. 리프레시 토큰 로테이션(갱신할 때마다 리프레시 토큰도 새로 발급하고 이전 것은 폐기)을 적용해둔 상태라면, 첫 번째 갱신 요청이 성공하는 순간 나머지 요청들이 들고 있던 리프레시 토큰은 이미 폐기된 토큰이 된다. 결과는 영문 모를 강제 로그아웃. 사용자 입장에서는 "쓰다가 갑자기 튕겼어요"가 되고, 로그를 봐도 재현이 안 되니 한참 헤매게 된다.
우리도 이 문제로 CS가 간헐적으로 들어왔는데, 원인을 잡는 데 꽤 걸렸다. 해결은 두 갈래로 했다. 프론트에서는 리프레시 요청을 싱글턴으로 만들었다. 갱신이 진행 중이면 뒤에 오는 요청들은 새로 갱신을 시도하지 않고 그 Promise를 같이 기다렸다가, 새 토큰으로 재시도한다. 서버에서는 직전 리프레시 토큰에 수 초 정도의 유예(grace period)를 둬서, 로테이션 직후 아슬아슬하게 도착한 요청까지는 받아줬다. 이 두 개를 넣고 나서야 유령 로그아웃이 사라졌다.
로테이션 얘기가 나온 김에 하나 더. 로테이션을 하는 진짜 이유는 재사용 감지다. 이미 폐기된 리프레시 토큰으로 갱신 요청이 들어온다는 건 둘 중 하나다. 방금 말한 레이스 컨디션이거나, 누군가 토큰을 훔쳐서 쓰고 있거나. 유예 시간을 지난 재사용이 감지되면 그 사용자의 토큰 패밀리 전체를 폐기하고 재로그인을 요구하는 게 정석이다. 이게 돌고 있어야 리프레시 토큰 탈취에 대한 최소한의 안전망이 생긴다.

그래서 결국 세션은 뭐가 되는가
여기까지 하고 나면 묘한 기분이 든다. 짧은 액세스 토큰, 서버에 저장되는 리프레시 토큰, 로테이션, 재사용 감지, 블랙리스트까지. 서버가 저장하고 관리하는 게 한둘이 아니다. 스테이트리스라서 선택한 JWT인데, 제대로 만들수록 점점 스테이트풀이 되어간다. 반면 세션은 어떤가. Redis에 세션 넣고 쿠키 하나 내려주면 로그아웃 즉시 반영, 강제 만료 즉시 반영, 기기별 세션 관리까지 전부 공짜로 따라온다. Spring Session이든 Laravel이든 express-session이든 프레임워크가 다 해준다.
"세션은 확장이 안 된다"는 말도 이제는 반쯤 옛날 얘기다. 그 말이 나왔던 시절은 세션을 서버 메모리에 들고 있어서 로드밸런서에 스티키 세션을 걸어야 했던 때다. 지금은 Redis 하나 띄우면 서버 10대가 세션을 공유한다. Redis 조회는 1ms 안쪽이다. 물론 매 요청마다 네트워크 왕복이 하나 추가되는 건 사실이고, 초당 수십만 요청을 받는 규모라면 이것도 부담이 된다. 하지만 국내 서비스 대부분은 그 규모가 아니다. 우리 회사도 아니다.
내가 실무에서 고르는 기준
그래서 요즘 나는 이렇게 가른다.
세션을 고르는 경우. 프론트와 백엔드가 같은 도메인(서브도메인 포함)에 있고, 웹 브라우저가 주 클라이언트인 서비스. 사내 어드민, 모놀리식 웹 서비스, 전형적인 B2B SaaS가 여기 해당한다. 이런 데서 JWT를 고르면 위에서 말한 함정들을 전부 직접 구현해서 통과해야 하는데, 얻는 건 거의 없다. 킥오프에서 세션을 밀었던 그 프로젝트도 어드민 성격의 웹 서비스였다.
JWT(또는 토큰 기반)를 고르는 경우. 모바일 앱이 주 클라이언트일 때(앱에는 쿠키 개념이 어색하다), 서드파티에게 API를 열어줄 때, 마이크로서비스 사이에서 서비스 간 인증이 필요할 때, 그리고 인증 서버와 자원 서버가 조직적으로 분리되어 있을 때다. 특히 MSA에서 게이트웨이가 토큰을 한 번 검증하고 내부 서비스들은 서명만 믿고 가는 구조는 JWT가 아니면 만들기 어렵다.
그리고 현실적인 세 번째 선택지. 직접 만들지 않는 것. Keycloak 같은 셀프호스팅 솔루션이든 Auth0, Cognito, Firebase Auth 같은 매니지드 서비스든, 인증을 통째로 위임하는 선택지가 요즘은 꽤 성숙했다. 위에서 말한 로테이션, 재사용 감지, 토큰 폐기 같은 걸 전부 알아서 해준다. 인증은 틀리면 보안 사고로 직결되는 영역이라, 작은 팀일수록 오히려 기성품을 쓰는 게 맞다고 본다. 나도 사이드 프로젝트에서는 직접 구현 안 한 지 오래됐다.
설계 체크리스트로 정리
글이 길어졌으니 실무 체크리스트로 요약한다. JWT 기반으로 간다면 최소한 이건 확인하자.
- 액세스 토큰 수명은 짧게(5~15분). 피해 반경을 시간으로 제한하는 게 1원칙.
- 리프레시 토큰은 httpOnly + Secure + SameSite 쿠키로. localStorage에 두지 않는다.
- 액세스 토큰은 메모리에만 보관, 새로고침 시 리프레시로 복구.
- 리프레시 토큰 로테이션 + 재사용 감지(패밀리 전체 폐기)를 구현한다.
- 동시 요청 레이스 컨디션 대비: 프론트 리프레시 싱글턴 + 서버 유예 시간.
- JWT 페이로드에 민감 정보를 넣지 않는다. 서명이지 암호화가 아니다. 디코딩은 누구나 한다.
- 알고리즘은 서버가 강제한다. 헤더의 alg를 믿고 따라가면 안 된다(none 알고리즘 공격은 고전이지만 여전히 나온다).
- 계정 정지·비밀번호 변경 시 해당 사용자의 리프레시 토큰을 전부 폐기하는 경로를 처음부터 만든다.
세션으로 간다면 체크리스트가 훨씬 짧다. 세션 저장소를 Redis로 분리하고, 쿠키에 httpOnly/Secure/SameSite 걸고, 세션 고정(session fixation) 방지로 로그인 성공 시 세션 ID를 재발급하는 것. 프레임워크 기본 설정에서 크게 벗어날 일이 없다. 이 짧음 자체가 세션의 경쟁력이다.
마무리: 기술 선택은 유행이 아니라 제약 조건이다
그 주니어에게 마지막으로 해준 말은 이거였다. JWT가 나쁜 게 아니라, "왜 쓰는지 모르고 쓰는 것"이 나쁜 거라고. 세션이 구식인 게 아니라, 우리 제약 조건에서 가장 싸고 안전한 답인 경우가 생각보다 많은 것뿐이라고. 인증 방식은 이력서에 쓰는 기술 스택이 아니라 서비스의 현관문이다. 문은 화려할 필요가 없다. 잘 잠기고, 필요할 때 바로 잠글 수 있으면 된다.
다음에 기회가 되면 이 글에서 미뤄둔 주제, MSA 환경에서 게이트웨이 토큰 검증과 서비스 간 인증(mTLS vs 토큰 릴레이) 얘기를 따로 정리해볼 생각이다.
이미지 출처: Unsplash (키보드 위 자물쇠 © Towfiqu Barbhuiya, 자물쇠와 키보드 키 © FlyD, 자바스크립트 코드 화면 © Markus Spiske)
'개발 > Etc' 카테고리의 다른 글
| OpenAPI 문서와 실제 API가 어긋날 때 먼저 잡을 기준 (0) | 2026.08.06 |
|---|---|
| Watchtower로 도커 컨테이너 자동 업데이트하기, 그리고 운영에서 조심할 점 (0) | 2024.11.05 |
| JetBrains IDE 한글 언어팩이 안 먹을 때, 설정 위치가 바뀌었다 (0) | 2024.09.26 |
| 맥에서 사진 EXIF 날짜·위치 일괄 변경하기 (ExifTool) (0) | 2024.08.24 |
| 헷갈리는 HTML 태그 정리, em과 i는 무엇이 다른가 (0) | 2024.03.30 |
