DevInsight

개발의 판단과 맥락을 기록하는 곳

Security
조회 0약 6분 읽기

토큰이 로그인을 기억한다는 착각

JWT와 서버 세션은 취향 문제가 아니라 로그아웃 즉시성, 토큰 탈취 대응, 확장 방식까지 전부 갈라놓는 구조적 선택이다. refresh token 회전과 재사용 감지, 세션 저장소 운영 부담, 로그아웃 처리의 어려움을 실제 구현 사례를 기준으로 비교하며, 무상태의 매력과 상태의 안전함 사이에서 서비스 특성에 맞는 판단 기준을 세우는 방법을 정리한다.

DevInsight 편집팀 발행

AI 보조 초안과 편집 검수를 거쳐 발행했습니다.

#JWT#세션#refresh token#인증#보안#로그아웃#토큰 탈취#무상태 아키텍처#Redis#트레이드오프

로그아웃 버튼을 누른 뒤 개발자 도구로 토큰을 확인해 보면, 로그아웃은 사실 아무것도 지우지 않는다는 걸 알게 된다. 토큰은 클라이언트가 스스로 버려야 하고, 서버는 그 사실을 전혀 모른다. "로그인을 기억한다"는 표현이 널리 쓰이지만 정확히 말하면 이는 착각에 가깝다. 기억하는 것은 토큰이 아니라 어딘가의 저장소다. 그 저장소가 서버의 손에 있느냐 클라이언트의 손에 있느냐에 따라 세션과 JWT가 갈라진다.

JWT로 로그인을 붙이면 서버는 access token의 서명만 확인한다. 상태를 들고 있지 않으니 로그아웃이라는 동작 자체가 성립하지 않는다. 24시간 유효한 토큰이라면, 로그아웃 뒤에도 하루 동안 그 복사본은 멀쩡히 통과한다. 이것이 로그아웃이 서버의 거짓말이 되는 순간이다. 더 골치 아픈 점은 인증이 통과되는 동안 그 토큰이 정상으로 보인다는 것이다. 로그아웃이 되지 않고 있다는 사실조차 눈치채기 어렵다. 로그아웃 후 최대 대기 시간을 줄이려고 access token 만료를 15분으로 낮추는 서비스가 많다. 이 계산은 통한다. 다만 그다음부터는 토큰 갱신 흐름이 필요해진다.

로그아웃은 어느 쪽이 기억을 지우는가

세션 방식은 쿠키에 세션 ID만 넣어 둔다. 로그인 여부는 서버의 Redis에 있고, 로그아웃은 그 레코드 한 줄을 지우는 일에 그친다.

DEL session:abc123

서버가 상태를 갖고 있으니 이 한 줄이 즉시 로그아웃으로 이어진다. 이후 같은 세션 ID가 들어와도 서버는 모르는 세션으로 답한다. 토큰 탈취 대응도 같은 원리에서 시작된다. 의심스러운 기기 하나만 골라 그 세션을 지우면 나머지 기기는 건드리지 않는다. JWT로 이 "기기 하나만 끊기"를 하려면 전 기기의 토큰 수명을 다루는 구조를 따로 만들어야 한다. 탈취 신고 한 건마다 비밀번호 변경 안내로 끝내는 서비스라면 그 정도로도 넘어가기도 한다.

세션 쪽에도 비슷한 함정이 있다. 활동이 있을 때마다 만료를 늘려 주는 sliding expiration을 쓰면, 탈취된 세션으로 도둑이 꾸준히 활동하는 한 만료가 계속 연장된다. 절대 만료 상한을 따로 두지 않으면 그 세션은 반영구적으로 살아남는다. 로그아웃과 무효화를 잘 갖춰 놓아도 이 설정 하나가 구멍이 된다. 세션도 결국 "언제 죽을지"를 설계해야 안전하다.

그 반대 급부로 세션은 매 호출마다 저장소를 읽는다. Redis 왕복 한 번은 보통 1밀리초 안팎이지만, 초당 수천 번의 호출이 몰리는 서비스라면 그 차이가 지연과 인프라 비용으로 드러난다. JWT는 서명만 로컬에서 검증하므로 이 왕복이 아예 없다. 세션 만료를 Redis 만료 키로 처리하느냐, sliding expiration으로 갱신하느냐 같은 결정도 뒤따른다. 여기까지만 보면 무상태의 매력은 분명하다.

무상태의 함정은 탈취 대응을 시작하는 순간 드러난다. 탈취된 토큰을 막으려면 어딘가에 "이 토큰은 무효"라는 목록을 둬야 한다. jti로 블랙리스트를 만들거나 토큰 버전을 관리하는 식이다. 그 순간부터 JWT도 서버 저장소를 다시 요구한다. 순수 무상태 JWT는 실전에서 거의 존재하지 않는다고 봐야 한다. 서명 검증만으로 끝나는 토큰은 탈취된 뒤 아무런 대응 수단이 없기 때문이다.

access token에 담는 것은 sub, iss, aud, exp 정도로 충분하다. 문제는 검증하는 쪽의 시계가 발급 쪽과 어긋나는 경우다. 서버 시계가 30초 뒤로 가 있으면 아직 만료가 안 된 토큰을 만료로 처리한다. JWT 검증 라이브러리들이 leeway 값을 두는 이유가 여기 있다. 서명 키를 바꾸는 날에는 구 키로 서명된 토큰이 살아 있는 동안 두 키를 동시에 유효하게 두는 기간을 잡는다. 이런 디테일은 세션을 골라도 피해 가지 않는다.

복제된 토큰은 언젠가 반드시 충돌한다

이 절충점에서 가장 흔히 채택되는 방식이 refresh token 회전이다. access token은 15분처럼 짧게 잡고, refresh token은 서버 저장소에 보관한다. 갱신할 때마다 refresh token을 새것으로 교체하고 이전 것은 폐기한다. 여기서 핵심이 되는 게 재사용 감지다. 이미 폐기된 refresh token이 다시 나타나면, 그건 토큰이 복제되어 돌아다닌다는 신호로 해석한다. 이 경우 해당 토큰 가족 전체를 폐기한다.

회전이 재미있는 이유는 피해자와 도둑 중 누가 먼저 갱신을 시도하든 결과가 같기 때문이다. 피해자가 먼저 갱신하면 도둑의 복사본이 다음 시점에 "이미 쓰인 토큰"으로 판정된다. 도둑이 먼저 갱신하면 반대가 된다. 어느 쪽이든 한쪽은 반드시 폐기된 토큰을 들고 갱신을 시도한다. 그 충돌이 곧 발각 지점이다. 갱신마다 저장소 왕복이 추가되는 비용은 이 대가로 감당할 만하다.

구현 관점에서 이 가족 폐기는 의외로 단순하다. refresh token마다 family id와 순번을 저장하고, 검증 때 순번이 저장된 값보다 작으면 이미 쓴 토큰으로 판정한다. 이때 해당 family id로 묶인 저장소 값을 전부 지운다. 새 토큰이 발급될 때마다 순번만 올려 저장하면 되므로 토큰 하나하나를 개별 폐기하는 부담이 없다. 검증 방법도 토큰 두 개를 복사해 두고 갱신을 순서대로 시도하는 것으로 끝난다. 두 번째 갱신이 거부되고 가족 폐기 로그가 남으면 정상이다. 재사용 감지가 꺼져 있다면 두 번째 갱신이 그냥 성공한다. 폐기 이벤트는 로그가 아니라 알림으로 붙여야 한다. 정상 트래픽에서는 절대 나오지 않는 이벤트라, 한 건만 나와도 즉시 조사로 이어져야 한다.

회전에는 동시성과 네트워크 실패라는 두 번째 함정이 있다. 갱신 도중 연결이 끊겨 새 토큰을 받지 못하면 클라이언트는 이전 토큰으로 재시도하게 된다. 순번이 이미 올라가 있으니 이 재시도는 재사용으로 판정된다. 직전 토큰에 짧은 유예 기간을 주는 구현도 있지만, 유예가 길어지면 재사용 감지의 의미가 약해진다. 이건 상황에 따라 다르다. 클라이언트의 재시도가 잦은지, 네트워크가 얼마나 불안정한지가 판단 기준이다.

회전의 효과에는 전제가 하나 붙는다. refresh token이 매 갱신마다 반드시 서버 저장소에 닿아야 한다는 것. 저장소가 없다면 재사용 감지라는 개념 자체가 성립하지 않는다. 다시 말해 refresh token 회전은 세션을 다른 이름으로 부르는 것에 가깝다. 무상태를 고집하다 회전을 들이면 Redis 운영 비용은 세션 방식과 거의 같아지고, 무상태의 이점은 access token 검증 한 건으로만 남는다. 회전을 선택했다면 "우리는 세션을 쓰고 있다"고 팀 안에서 이름 붙여 말할 줄 알아야 한다. 이름이 정확해야 저장소 장애 대응과 만료 정책의 주인이 정해진다.

세션 저장소를 굴리는 비용

그렇다면 세션은 언제 선택지에서 빠지는가. 세션 방식의 실질적 부담은 기능 구현이 아니라 운영에 있다. 휴지 세션 정리, 인증 서버와 서비스 간 Redis 공유, 장애 시 단일 장애점 문제까지. 쿠키를 HttpOnly로 설정하면 XSS에서 세션 ID를 빼내기 어려워지지만 CSRF 대응이 필요해진다. JWT를 localStorage에 두는 쪽은 그 반대라서 XSS 스크립트 한 줄이면 토큰이 통째로 유출된다. 어느 쪽이든 공격 표면이 사라지지 않는다.

기기 목록 화면을 만들면 세션 저장소에 역인덱스가 필요해진다. user id를 키로 세션 id를 모아 두는 구조다. 로그아웃 시 이 목록에서도 세션을 지워야 한다. Redis가 만료 키를 자동으로 정리하더라도 역인덱스는 직접 정리해야 한다. 이걸 빼먹으면 기기 목록에 죽은 세션이 계속 쌓인다. 세션 수가 몇십만 개를 넘어가는 서비스라면 이 정리는 일상적인 운영 업무가 된다.

서비스가 몇 개 되지 않으면 이 부담은 작다. 마이크로서비스가 열 개를 넘어가면 상황이 달라진다. 모든 서비스가 같은 Redis를 바라봐야 하므로 인증과 서비스가 강하게 결합된다. JWT는 공개키만 공유하면 되므로 인증과 서비스를 느슨하게 분리한다. 다만 그 대가로 짧은 access token을 끊임없이 재발급하는 흐름이 인증 서버 한곳에 모인다. 이벤트성 트래픽이 치솟는 순간에는 이 흐름이 인증 서버의 병목이 되기도 한다. 어느 쪽이든 비용이 사라지지 않는다.

선택 기준을 좁히면 이렇다. 로그아웃 즉시 반영이 필요한가. 비밀번호 변경 시 전 기기 로그아웃이 필요한가. 기기 목록 화면에서 특정 기기만 끊는 기능이 필요한가. 셋 중 하나라도 "그래야 한다"면 서버 상태는 필수다. 세션이면 각각 레코드 삭제, 전체 삭제, 조건부 삭제로 끝난다. JWT라면 토큰 버전을 올려 전 기기를 무효화하는 정도로 근사할 뿐, 기기 단위 제어는 토큰마다 상태를 들이지 않는 한 구현이 어렵다. 이 경우의 판단 기준은 토큰의 무상태성이 아니라 "무효화를 얼마나 빠르게 반영해야 하는가"가 된다.

반대로 이 요구가 전혀 없고, 접근 검증 비용을 최소화해야 하는 서비스라면 짧은 access token과 회전이 현실적인 선택이다. 다만 이 선택은 접근 토큰이 복제되면 유효기간까지 누가 써도 막을 수 없다는 사실을 수용한 것이다. 이건 팀 취향의 문제가 아니라 서비스가 감당할 리스크를 정하는 문제라, 상황에 따라 다르다. 정답이 먼저 정해져 있지는 않다.

최종적으로 할 일은 하나다. 로그아웃 버튼을 눌렀을 때 서버가 거짓말하지 않고 "로그아웃됐다"고 답하는 구조인지 확인하는 것. 그 답이 당장 필요하면 세션, 하루쯤은 참을 수 있다면 JWT와 회전 조합에서 시작해도 된다. 만료 시간을 몇 분 단위로 줄이는 것보다, 무효화가 필요한 순간이 언제인지부터 정하는 게 순서다.

댓글

댓글을 읽어오는 중입니다.

같이 읽으면 좋은 글

방금 읽은 주제와 이어지는 글을 골랐습니다.

Security 전체 보기
Security

나의 첫 JWT 도입은 참사였다

JWT의 stateless함에 매료돼 도입했다가 로그아웃이 안 되는 사실을 배포 직전에 깨달은 적이 있는가. access token 만료 시간을 15분으로 설정하면 UX가 나빠지고, 길게 잡으면 보안이 걱정된다. refresh token을 회전시키면 탈취 위험은 줄지만 구현 복잡도가 급상승한다. 세션 기반 인증으로 회귀할 수도 없는 노릇. 이 글은 실전에서 부딪힌 이 모든 트레이드오프를 사례별로 풀어낸다.

#JWT#세션인증#RefreshToken#AccessToken
Security

시크릿은 매번 같은 길로 도망친다

환경변수와 시크릿이 유출되는 가장 흔한 세 가지 경로인 클라이언트 번들 포함, 로그 출력, 실수로 인한 커밋을 분석한다. 각 경로별 사고 패턴과 조기 탐지를 위한 도구 설정, 그리고 유출 확인 시 즉시 실행해야 할 키 롤링과 로그 감사까지 실전 대응 루틴을 제시한다. NEXT PUBLIC API KEY=sk live ...

#환경변수#시크릿관리#보안사고#API키유출

이전 글

1번 조회가 100번이 되는 순간

DevInsight Digest

새 글이 쌓이면, 피드에서 바로 이어 읽으세요.

과장된 알림 대신 발행한 글 전체를 RSS로 제공합니다.

RSS 구독