Spring

JWT를 걷어내고 Spring Session으로 — 그 뒤에 온 것들

전환 자체는 하루였다

  • 내용: JWT 기반 인증 → Redis 기반 Spring Session
  • 삭제: JwtFilter, JwtProvider 등 클래스 6개, 374줄
  • 커밋 하나로 종료

  • 정작 내용이 있는 구간: 그다음 3주
  • 이 글의 주제: 세션 전환 후 드러난 문제들

(a) API 호출을 세션 갱신 신호로 쓰기

  • 세션 기반 전환 → 만료 관리 필요
  • 후속 작업에서 SessionSyncResponseAdvice 신설

  • 핵심 아이디어: 별도의 갱신 엔드포인트를 두지 않는 것

  • 전제: 사용자가 API를 호출한다는 사실 자체가 “활동 중” 신호
  • 구현: ResponseBodyAdvice로 모든 응답을 가로채 세션 타이머 갱신
  • 남은 시간은 X-Session-Remaining 헤더로 전달

  • 프런트엔드가 이 헤더를 읽으려면 CORS 노출 헤더 등록 필요
  • 기본 동작: 브라우저는 안전 목록에 없는 응답 헤더를 JS에 미노출
  • 증상: 서버가 헤더를 보내도 프런트에서 undefined
  • 조치: exposedHeaders에 추가

폴링용 keep-alive 엔드포인트를 만드는 방식보다 나은 점은, 유휴 상태를 유휴로 판정한다는 것이다.

  • 탭만 열어두고 아무것도 안 하는 사용자 — 폴링 방식에서는 세션 무한 유지
  • 이 방식에서는 정상 만료

(b) 동시 요청과 Redis 장애

  • 한 커밋에서 수정한 두 가지

원자적 연산으로 전환

  • 문제: 세션 동기화 상태를 Redis에 기록할 때 “읽고 → 없으면 쓰기” → 동시 요청에서 레이스
  • 브라우저의 병렬 API 호출은 흔한 상황이라 실제로 걸림
  • 조치: setIfAbsent(Redis SETNX) → 한 번의 원자적 연산

Redis 장애 가드

  • 세션 갱신은 부가 기능
  • Redis 다운 → 응답 어드바이스에서 예외 → 모든 API 응답 실패
  • 부가 기능이 주 기능을 죽이는 구조
  • 조치: 예외를 잡아 갱신만 건너뛰는 가드 추가

  • 응답 후처리에 무언가를 얹을 때의 필수 확인: 그 실패가 응답 전체를 막는지 여부

(c) 배포하니 로그인이 500

  • 가장 고전적인 함정

  • Redis 저장 세션 = 자바 객체의 직렬화 결과
  • 배포로 세션에 담기는 클래스 구조 변경 → 이전 버전이 만든 세션은 역직렬화 실패
  • 기존 세션 정리 로직(invalidateExistingSessions())이 역직렬화를 시도하다 SerializationException 발생
  • 결과: 로그인 시 500
  • 로그인 불가 → 새 세션 생성도 불가
  • 구조: 낡은 세션이 새 로그인을 막는 교착

  • 해결: 예외를 잡아 역직렬화 없이 Redis 인덱스 키를 직접 삭제
  • 근거: 세션 내용을 읽을 수 없어도 키는 삭제 가능
  • 요점: 객체로 복원하지 않고 키 레벨에서 처리

  • 세션에 도메인 객체를 담을 경우: 배포마다 이 위험 동반
  • 안전한 방향: 담는 것 최소화 + 정리 경로를 역직렬화 비의존으로 구성

(d) WebSocket과 권한 변경

HTTP는 요청마다 세션을 확인하지만 WebSocket은 연결이 한 번 맺어지면 계속 살아있다.

  • 즉 연결 시점의 인증만으로는 불충분

  • 조치: STOMP SUBSCRIBE 시점에 세션 재검증
  • 근거: 구독은 특정 주제의 데이터를 받겠다는 요청 → 그 시점의 세션 유효성·권한 확인 필요

권한 변경 처리:

  • 문제: 관리자가 권한을 낮춰도 해당 사용자 세션이 살아있으면 즉시 미반영
  • 조치: 권한 변경 이벤트 발생 시 강제 로그아웃 이벤트 전송

  • 관련 커밋 — 캐시 무효화 버그 수정
  • 기존 동작: 권한 변경 시 Spring CacheManager의 사용자 캐시만 삭제
  • 누락: Redis에 따로 둔 권한 캐시 키

  • 원칙: 캐시가 두 곳이면 무효화도 두 곳 — 한쪽만 지우면 어긋난 상태 잔류

(e) 쿠키 속성 정합성

  • 세션 쿠키 속성: SameSite, Secure, 도메인
  • 환경별로 달라야 함 — 로컬은 Secure 불가, 운영은 필수
  • yml에 흩어져 있으면 조합이 어긋나기 쉬움

  • 대표 사례: SameSite=None + Secure=false
  • 브라우저가 이 쿠키를 거부
  • 증상: 로그인이 되는 것처럼 보이다가 다음 요청에서 인증이 풀린다
  • 서버 로그에는 에러가 없어서 원인 찾기가 오래 걸리는 종류

  • 조치: @ConfigurationProperties로 쿠키 설정을 한 클래스에 통합
  • 부팅 시 불일치 조합이면 경고 로그
  • 부팅은 막지 않음 — 로컬 개발 편의를 위해 의도적으로 어긋나게 두는 경우가 있기 때문

남는 교훈

영역 증상 처방
세션 갱신 별도 폴링 필요 API 호출을 갱신 신호로
동시성 병렬 요청 레이스 setIfAbsent 원자 연산
Redis 장애 전체 API 실패 부가 기능 예외 격리
배포 로그인 500 인덱스 키 직접 삭제
WebSocket 권한 변경 미반영 SUBSCRIBE 재검증 + 강제 로그아웃
쿠키 조용한 인증 실패 설정 통합 + 불일치 경고

JWT에서 세션으로 옮기는 건 흔히 “상태를 서버로 되가져오는” 선택으로 설명된다.

  • 맞는 설명이지만 실제 비용이 든 지점: 그 상태를 어디에 두고, 언제 갱신하고, 실패하면 어떻게 되는가

  • JWT — 검증이 자족적이라 이런 질문이 적음. 대신 무효화가 어려움
  • 세션 — 무효화가 쉬움. 대신 저장소가 단일 장애점, 직렬화가 배포와 결합, 갱신 정책을 직접 결정

  • 3주 동안 확인한 것: 어느 쪽이 낫다기보다 비용이 나타나는 위치가 다르다