전환 자체는 하루였다
a1837d49(PR #557)에서 JWT 기반 인증을 Redis 기반 Spring Session으로 바꿨다. JwtFilter, JwtProvider 등 클래스 6개 374줄이 삭제됐다. 커밋 하나로 끝난 작업이다.
정작 내용이 있는 건 그다음 3주였다. 세션으로 옮기고 나서 드러난 문제들이 이 글의 주제다.
(a) API 호출을 세션 갱신 신호로 쓰기
세션 기반으로 오면 만료 관리가 필요하다. 4162f9be(PR #586)에서 SessionSyncResponseAdvice를 만들었다.
핵심 아이디어는 별도의 갱신 엔드포인트를 두지 않는 것이다. 사용자가 API를 호출하고 있다는 사실 자체가 “활동 중”이라는 신호다. ResponseBodyAdvice로 모든 응답을 가로채 세션 타이머를 갱신하고, 남은 시간을 X-Session-Remaining 헤더로 내려준다.
프런트엔드가 이 헤더를 읽으려면 CORS 설정에 노출 헤더로 등록해야 한다. 기본적으로 브라우저는 안전 목록에 없는 응답 헤더를 JS에 보여주지 않는다 — 서버가 헤더를 보내도 프런트에서 undefined가 나온다. exposedHeaders에 추가해서 풀었다.
폴링용 keep-alive 엔드포인트를 만드는 방식보다 나은 점은, 유휴 상태를 유휴로 판정한다는 것이다. 탭만 열어두고 아무것도 안 하는 사용자는 폴링이 있으면 무한정 세션이 유지되지만, 이 방식에서는 정상적으로 만료된다.
(b) 동시 요청과 Redis 장애
191dd914에서 두 가지를 고쳤다.
원자적 연산으로 전환 — 세션 동기화 상태를 Redis에 기록할 때 “읽고 → 없으면 쓰기”로 하면 동시 요청에서 레이스가 난다. 브라우저가 여러 API를 병렬로 던지는 게 흔한 상황이라 실제로 걸리는 문제다. setIfAbsent(Redis SETNX)로 바꿔 한 번의 원자적 연산으로 처리했다.
Redis 장애 가드 — 세션 갱신은 부가 기능인데, Redis가 죽으면 응답 어드바이스에서 예외가 나 모든 API 응답이 실패한다. 부가 기능이 주 기능을 죽이는 구조였다. 예외를 잡아 갱신만 건너뛰도록 가드를 넣었다.
응답 후처리에 뭔가를 얹을 때는 그 실패가 응답 전체를 막는지 항상 확인해야 한다.
(c) 배포하니 로그인이 500
73f52ed3이 가장 고전적인 함정이다.
Redis에 저장된 세션은 자바 객체의 직렬화 결과다. 배포로 세션에 담기는 클래스의 구조가 바뀌면, 이전 버전이 만든 세션은 역직렬화에 실패한다. 기존 세션을 정리하는 로직(invalidateExistingSessions())이 역직렬화를 시도하다 SerializationException으로 터졌고, 결과는 로그인 시 500이었다.
로그인이 안 되니 사용자가 새 세션을 만들 수도 없다. 낡은 세션이 새 로그인을 막는 교착이다.
해결은 예외를 잡아 역직렬화 없이 Redis 인덱스 키를 직접 삭제하는 것이었다. 세션 내용을 읽을 수 없어도 키는 지울 수 있다. 객체로 복원하지 않고 키 레벨에서 처리하는 것이 요점이다.
세션에 도메인 객체를 담으면 배포마다 이 위험이 따라온다. 담는 걸 최소화하고, 정리 경로는 역직렬화에 의존하지 않게 만드는 게 안전해 보인다.
(d) WebSocket과 권한 변경
HTTP는 요청마다 세션을 확인하지만 WebSocket은 연결이 한 번 맺어지면 계속 살아있다. 연결 시점에 인증했다고 끝이 아니다.
STOMP SUBSCRIBE 시점에 세션을 재검증하도록 했다. 구독은 특정 주제의 데이터를 받겠다는 요청이라, 그 시점에 여전히 유효한 세션인지·권한이 있는지 확인해야 한다.
권한 변경 처리도 붙였다. 관리자가 사용자 권한을 낮췄는데 그 사용자의 세션이 그대로 살아있으면 변경이 즉시 반영되지 않는다. 권한 변경 이벤트가 발생하면 강제 로그아웃 이벤트를 보낸다.
관련해서 e9acfb6a는 캐시 무효화 버그를 고친 커밋이다. 권한 변경 시 Spring CacheManager의 사용자 캐시만 지우고 Redis에 따로 둔 권한 캐시 키를 지우지 않고 있었다. 캐시가 두 곳에 있으면 무효화도 두 곳에 해야 한다 — 한쪽만 지우면 어긋난 상태가 남는다.
(e) 쿠키 속성 정합성
세션 쿠키에는 SameSite, Secure, 도메인 같은 속성이 걸린다. 이게 환경별로 달라야 하는데(로컬은 Secure 불가, 운영은 필수), yml에 흩어져 있으면 조합이 어긋나기 쉽다.
대표적인 게 SameSite=None인데 Secure=false인 조합이다. 브라우저가 이 쿠키를 거부하므로 로그인이 되는 것처럼 보이다가 다음 요청에서 인증이 풀린다. 서버 로그에는 에러가 없어서 원인 찾기가 오래 걸리는 종류다.
@ConfigurationProperties로 쿠키 설정을 한 클래스에 모으고, 부팅 시 불일치 조합이면 경고 로그를 남기도록 했다. 부팅을 막지는 않는다 — 로컬 개발 편의를 위해 의도적으로 어긋나게 두는 경우가 있기 때문이다.
남는 교훈
| 영역 | 증상 | 처방 |
|---|---|---|
| 세션 갱신 | 별도 폴링 필요 | API 호출을 갱신 신호로 |
| 동시성 | 병렬 요청 레이스 | setIfAbsent 원자 연산 |
| Redis 장애 | 전체 API 실패 | 부가 기능 예외 격리 |
| 배포 | 로그인 500 | 인덱스 키 직접 삭제 |
| WebSocket | 권한 변경 미반영 | SUBSCRIBE 재검증 + 강제 로그아웃 |
| 쿠키 | 조용한 인증 실패 | 설정 통합 + 불일치 경고 |
JWT에서 세션으로 옮기는 건 흔히 “상태를 서버로 되가져오는” 선택으로 설명된다. 맞는 말이지만, 실제로 값을 치른 건 그 상태를 어디에 두고, 언제 갱신하고, 실패하면 어떻게 되는가였다.
JWT는 검증이 자족적이라 이런 질문이 적은 대신 무효화가 어렵다. 세션은 무효화가 쉬운 대신 저장소가 단일 장애점이 되고, 직렬화가 배포와 결합되고, 갱신 정책을 직접 정해야 한다. 어느 쪽이 낫다기보다 비용이 나타나는 위치가 다르다는 게 3주 동안 확인한 것이다.