나의 첫 JWT 도입은 참사였다
JWT의 stateless함에 매료돼 도입했다가 로그아웃이 안 되는 사실을 배포 직전에 깨달은 적이 있는가. access token 만료 시간을 15분으로 설정하면 UX가 나빠지고, 길게 잡으면 보안이 걱정된다. refresh token을 회전시키면 탈취 위험은 줄지만 구현 복잡도가 급상승한다. 세션 기반 인증으로 회귀할 수도 없는 노릇. 이 글은 실전에서 부딪힌 이 모든 트레이드오프를 사례별로 풀어낸다.
DevInsight 편집팀 발행
AI 보조 초안과 편집 검수를 거쳐 발행했습니다.
1년 전, 서비스 인증을 JWT로 전환했다. access token에 사용자 정보를 넣고 서버는 시크릿만 검증하면 끝. DB 조회도, 세션 저장소도 필요 없으니 트래픽이 늘어도 인프라 부담이 없다는 말에 홀딱 넘어갔다. 배포 일주일 전, 로그아웃이 안 된다는 사실을 깨달았다. JWT는 stateless니까 서버에서 강제로 만료시킬 방법이 없다. 블랙리스트를 레디스에 저장하는 순간, 도입 이유였던 stateless가 사라진다.
가장 짧은 만료 시간이 가장 좋은 건 아니다
토큰 만료 시간을 두고 팀 내에서 설전이 벌어졌다. 보안팀은 5분을 요구했고, 기획팀은 적어도 하루는 돼야 사용자가 불편하지 않다고 맞섰다. 실제로 15분짜리 access token을 쓰던 서비스에서 사용자가 에디터에 10분간 글을 쓰고 저장 버튼을 누르는 순간 401이 떨어지는 경우가 발생한다. 401을 받으면 클라이언트는 refresh token으로 새 access token을 발급받고 요청을 재시도한다. 문제는 이 재시도 로직이 모든 API 호출에 비동기로 얽히면서 레이스 컨디션을 일으킨다는 점이다. refresh token이 만료된 상태에서 여러 API가 동시에 401을 받으면, 각각이 refresh를 시도하고 첫 번째 요청이 refresh token을 소진한 뒤 나머지는 전부 실패한다.
이를 해결하려면 refresh 요청 자체에 락을 걸거나, refresh token을 한 번만 사용할 수 있게 한 후 실패 시 클라이언트를 강제 로그아웃시키는 방식이 필요하다. 결국 '5분 vs 하루'라는 이분법적 논쟁이 아니라, 토큰 갱신 중첩을 얼마나 우아하게 처리할 수 있느냐가 핵심으로 바뀐다.
refresh token 회전이 만능은 아니었다
refresh token을 요청할 때마다 새 refresh token도 함께 발급하고 이전 것은 폐기하는 회전(rotation) 기법은 탈취된 refresh token을 무력화하는 가장 확실한 방법으로 알려져 있다. 탈취자가 쓰기 전에 정상 사용자가 먼저 회전시키면 공격자의 토큰은 무효가 된다. 하지만 반대로, 정상 사용자보다 탈취자가 먼저 회전시켜 버리면 정상 사용자가 다음 요청에서 refresh에 실패하고 서비스에서 쫓겨난다. 즉, refresh token 회전은 완벽한 방어가 아니라 누가 먼저 도착하느냐의 경쟁으로 문제를 환원시킨다.
이 경쟁에서 정상 사용자가 불리한 경우는 생각보다 흔하다. 모바일 환경에서 네트워크가 불안정하면 refresh 요청이 중복으로 전송되고, 사용자가 탭을 여러 개 띄워 놓으면 각 탭이 서로 다른 refresh token을 가지게 된다. 특히 서드파티 쿠키 제한 이후 iframe 내에서 인증을 처리해야 하는 환경이라면 회전 충돌이 주간 단위로 보고된다.
RFC 6749의 refresh token에 대한 명세는 '선택 사항'이라는 표현이 붙어 있을 정도로 느슨하다. 실제로 구글, 애플, 마이크로소프트는 자신들의 OAuth 구현에서 각각 다른 회전 정책을 쓴다. 구글은 refresh token을 특정 조건(7일 이상 미사용 등)에서만 폐기하고, 애플은 각 refresh마다 이전 토큰을 즉시 무효화한다. 어느 쪽이 더 안전하다고 단정할 수 없는 이유는, 회전 정책의 안전성이 서비스의 사용 패턴에 따라 완전히 달라지기 때문이다. 사용자가 하루에 한 번만 접속하는 서비스라면 구글 방식이, 매초 요청을 주고받는 서비스라면 애플 방식이 더 적합하다.
세션으로 돌아갈 수도 없고
JWT가 불편하니 전통적인 세션 기반 인증을 다시 검토해 보았다. 세션은 서버가 만료 시점을 완전히 제어할 수 있고 로그아웃도 즉시 적용된다. 사용자를 강제로 추방할 수도 있다. 하지만 세션 저장소의 규모가 문제다. 모든 요청마다 세션 ID로 DB나 레디스를 조회해야 한다. 서비스 일간 활성 사용자가 10만 명이라면, 하나의 세션에 1KB만 할당해도 약 100MB를 차지한다. 거기에 만료 시간을 하루로 잡으면 동시 접속자가 유휴 세션까지 포함해 수십만 개가 쌓일 수 있다.
CDN edge에서 JWT를 검증하는 패턴과 달리, 세션은 반드시 오리진 서버나 공유 세션 저장소에 접근해야 한다. 지연 시간은 보통 1~5ms지만, 마이크로서비스가 10개를 건너가면서 각각 세션을 조회하면 50ms가 순수 오버헤드로 발생한다. 게다가 세션 저장소가 SPOF가 되면 전체 인증이 마비된다. 세션 클러스터링, 장애 조치, 데이터 정합성 모두 운영 부담으로 돌아온다.
아이러니하게도, JWT와 세션 중 어느 쪽이 '좋다'고 말할 수 있는 경우는 생각보다 드물다. 결정은 서비스의 요청 패턴과 위협 모델이 무엇이냐에 따라 갈린다. API 호출 간격이 수 분 단위로 넉넉하다면 세션이 더 단순하고, 초당 수백 건의 요청을 처리해야 한다면 JWT로 인증 부하를 분산하는 쪽이 유리하다.
토큰 탈취를 감지할 수 없는 시스템은 무기력하다
JWT 도입 후 가장 두려웠던 순간은 "지금 이 토큰이 진짜 사용자가 보낸 게 맞을까?"라는 의문이 들 때였다. 세션이라면 마지막 접속 IP가 바뀌었을 때 의심스러운 활동으로 플래그를 찍을 수 있다. 하지만 JWT는 토큰 자체에 모든 정보가 들어 있기 때문에 위치, 디바이스, 행동 패턴 같은 컨텍스트를 페이로드에 넣지 않는 이상 서버가 알 방법이 없다. 페이로드에 IP를 넣으면 NAT 환경에서 오탐이 쏟아지고, 위치 정보를 넣으면 VPN 사용자가 매번 재로그인해야 한다.
이 문제의 실용적인 접근법은 토큰에 모든 것을 담지 않는 것이다. 최소한의 식별자만 JWT에 담고, 민감한 작업(비밀번호 변경, 결제, 개인정보 조회)은 별도의 세션 저장소를 조회하거나 추가 인증을 요구한다. 하이브리드 방식이라고 부르는데, 실제로 많은 서비스가 이렇게 운영된다. JWT로는 라우팅과 권한 스키마만 확인하고, 실제 민감한 결정은 서버가 내린다. 이렇게 하면 JWT의 stateless 이점을 포기하지 않으면서도 블랙리스트가 필요한 최소 범위만 레디스에 저장할 수 있다.
로그아웃은 인증 설계의 리트머스 시험지
"JWT에서 로그아웃을 어떻게 구현할 것인가"는 인증 시스템 설계의 모든 약점을 드러내는 질문이다. access token 만료 시간 전까지는 토큰이 살아 있으므로, 진정한 로그아웃을 원한다면 블랙리스트가 필수다. 블랙리스트를 쓰지 않으려면 access token 만료 시간을 극단적으로 짧게(수 초) 설정하고 refresh token을 매 요청마다 교체하는 전략도 가능하다. 이 방식은 이론적으로 stateless를 유지하지만, refresh 요청이 거의 모든 API 호출보다 앞서 실행되므로 사실상 매 요청마다 DB 조회가 일어나는 것과 다를 바 없다.
더 현실적인 타협안은 블랙리스트를 레디스에 저장하되 TTL을 access token 남은 만료 시간으로 설정하는 것이다. 사용자가 로그아웃 버튼을 누르면 해당 access token의 jti(고유 식별자)를 레디스에 넣고, 모든 요청마다 미들웨어가 이 리스트를 확인한다. TTL이 자동 만료되므로 블랙리스트가 무한정 쌓이지 않는다. 레디스가 죽으면 모든 로그아웃이 무효화되지만, 레디스는 HA 구성으로 커버할 수 있는 리스크다. 완전한 stateless를 포기하는 대신, 블랙리스트 검사 비용은 세션 조회보다 훨씬 가볍다.
# jti 기반 블랙리스트 미들웨어 (의사 코드) async def auth_middleware(request, next): token = extract_token(request) if token and await redis.exists(f"blacklist:{token['jti']}"): return 401, "token revoked" return await next(request)
이 코드가 보여주는 교훈은 하나다. 완벽한 stateless는 현실에서 존재하지 않는다. 세션 저장소를 레디스 블랙리스트로 대체했을 뿐, 어딘가에는 상태가 남아 있다. 인증 시스템 선택의 기준은 'stateless 여부'가 아니라 '상태를 어디에, 얼마나, 어떻게 저장할 것인가'다.
몇 가지 원칙을 정리하면 이렇다. refresh token은 회전시키되 재사용 탐지 로직을 반드시 포함하고, 회전 충돌 시에는 양쪽을 모두 무효화하고 재로그인을 요구하는 쪽이 안전하다. access token의 만료는 서비스의 API 호출 간격보다 3~5배 길게 잡는 것이 UX와 보안 사이의 실용적인 타협점이다. 그리고 모든 선택에는 반대 급부가 있음을 인정해야 한다. 세션은 제어력과 단순함을 주지만 지연 시간과 운영 부담을 대가로 받고, JWT는 확장성과 성능을 주지만 로그아웃과 탈취 대응이라는 숙제를 남긴다.
어느 쪽을 선택하든 "로그아웃이 제대로 동작하는가"를 첫 번째 테스트 케이스로 삼아라. 그 질문에 답하지 못하는 인증 시스템은, 아무리 화려한 기술 스택으로 무장해도 첫 배포에서 무너진다.
댓글
댓글을 읽어오는 중입니다.
같이 읽으면 좋은 글
방금 읽은 주제와 이어지는 글을 골랐습니다.
시크릿은 매번 같은 길로 도망친다
환경변수와 시크릿이 유출되는 가장 흔한 세 가지 경로인 클라이언트 번들 포함, 로그 출력, 실수로 인한 커밋을 분석한다. 각 경로별 사고 패턴과 조기 탐지를 위한 도구 설정, 그리고 유출 확인 시 즉시 실행해야 할 키 롤링과 로그 감사까지 실전 대응 루틴을 제시한다. NEXT PUBLIC API KEY=sk live ...
취약점 목록을 읽는 방식이 바뀌는 순간
공식 CVE 목록이 단순 조회용 페이지에서 Git과 JSON 중심의 배포 모델로 이동하면서, 보안 데이터 소비 방식도 함께 달라지고 있다. `cvelistV5`는 속도와 구조화라는 장점을 주지만, 버전 차이·컨테이너 분리·동기화 전략까지 함께 이해해야 진짜 운영 자산이 된다.
이전 글
next/image sizes 한 줄이 LCP를 0.5초 당긴다
다음 글
삽질 없이 CI를 줄이는 캐시 3종 세트
DevInsight Digest
새 글이 쌓이면, 피드에서 바로 이어 읽으세요.
과장된 알림 대신 발행한 글 전체를 RSS로 제공합니다.