본문 바로가기
Web Application Server

Spring Session + Redis로 WAS 세션 외부화하기 - 무중단 배포와 스케일아웃에도 로그인 유지

빅토르최·2026년 8월 30일·조회 1

스케일아웃한 톰캣 앞에 로드밸런서를 붙였는데 배포만 하면 사용자들이 로그아웃되더라, 하는 하소연을 현장에서 자주 듣는다. 스티키 세션을 걸어 두면 당장은 넘어가지만, 인스턴스 한 대를 내리는 순간 그 노드에 붙어 있던 세션이 통째로 날아간다. 무중단 배포를 하려면 세션이 WAS 밖에 있어야 한다는 결론에 매번 도달한다.

결론부터 말하자면, 톰캣의 세션 클러스터링(DeltaManager 멀티캐스트 복제) 대신 Spring Session + Redis로 세션을 외부 저장소에 두는 방식을 권한다. 애플리케이션은 세션을 메모리에 들고 있지 않으므로 아무 인스턴스나 요청을 받아도 되고, 재배포나 롤링 업데이트에도 로그인이 유지된다. 다만 직렬화 방식과 저장 시점(FlushMode, SaveMode), 네임스페이스를 잘못 잡으면 조용히 세션이 유실되거나 다른 앱과 충돌하니 이 세 가지를 정확히 잡아야 한다.

세션을 외부화한다는 것

HTTP 세션은 서버가 사용자별 상태(로그인 정보, 장바구니 등)를 기억하려고 서블릿 컨테이너 메모리에 들고 있는 객체다. 문제는 이 객체가 특정 WAS 인스턴스의 힙에 묶여 있다는 점이다. 그 인스턴스가 죽거나 재배포되면 세션도 함께 사라진다.

Spring Session은 이 HttpSession을 서블릿 필터 단계에서 가로채 자기 구현체로 갈아끼운다. 정확히는 SessionRepositoryFilter가 요청을 감싸서, 애플리케이션 코드가 request.getSession()을 호출하면 톰캣의 세션 대신 Redis를 백엔드로 쓰는 세션을 돌려준다. 애플리케이션 코드는 한 줄도 바꿀 필요가 없다. 표준 HttpSession API를 그대로 쓰면 된다.

톰캣 세션 클러스터링을 권하지 않는 이유

톰캣도 세션 복제를 지원한다. server.xml<Cluster>를 걸고 DeltaManagerBackupManager로 노드끼리 세션을 복제하는 방식이다. 예전에 이 방식으로 운영하던 시스템을 몇 번 봤는데, 공통적으로 겪는 문제가 있다.

  • 멀티캐스트 기반 멤버십이 클라우드/컨테이너 네트워크에서 잘 안 뜬다. 방화벽, VPC, 오토스케일링과 궁합이 나쁘다.
  • DeltaManager는 모든 노드가 모든 세션을 들고 있어 노드가 늘수록 메모리와 복제 트래픽이 함께 늘어난다.
  • 노드가 동적으로 붙고 빠지는 스케일아웃 환경에서 멤버십이 흔들리면 복제가 어긋난다.

세션 저장 책임을 Redis라는 별도 저장소로 넘기면 WAS는 상태를 안 가져도 된다. 인스턴스는 순수하게 무상태(stateless)가 되고, 스케일아웃과 무중단 배포가 자연스럽게 따라온다.

기본 설정: 의존성과 활성화

Spring Boot 3.x 기준으로 의존성 두 개면 시작할 수 있다. Boot의 스타터가 자동 설정을 대부분 잡아 준다.

// build.gradle
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springframework.session:spring-session-data-redis'

spring-session-data-redis가 클래스패스에 있으면 Spring Boot가 세션 저장소를 Redis로 자동 설정한다. 명시하고 싶으면 프로퍼티로 못 박아 둔다.

# application.yml
spring:
  data:
    redis:
      host: 10.0.1.20
      port: 6379
  session:
    store-type: redis
    timeout: 1800s          # 세션 만료 시간(30분)
    redis:
      namespace: "spring:session:shop"
      repository-type: default

자바 설정으로 잡으려면 @EnableRedisHttpSession을 쓴다. 이 애너테이션의 주요 속성은 maxInactiveIntervalInSeconds(만료 시간, 기본 1800), redisNamespace, flushMode다.

@Configuration
@EnableRedisHttpSession(
    maxInactiveIntervalInSeconds = 1800,
    redisNamespace = "spring:session:shop"
)
public class SessionConfig {
}

네임스페이스: 하나의 Redis를 여러 앱이 쓸 때

Redis에 저장되는 세션 키는 네임스페이스로 시작한다. 기본값은 spring:session이다. Boot 프로퍼티로는 spring.session.redis.namespace, 애너테이션으로는 redisNamespace로 바꾼다.

여기서 한 번 걸린다. 여러 애플리케이션이 하나의 Redis 인스턴스를 공유하면서 네임스페이스를 기본값 그대로 두면, 서로 다른 앱의 세션이 같은 키 공간에 섞인다. 클래스 구조가 다른 앱끼리 역직렬화하다 깨지거나, 한 앱의 배포가 다른 앱 세션을 건드리는 사고가 난다. 앱마다 spring:session:shop, spring:session:admin처럼 구분해 두는 것을 권한다.

실제로 저장된 키는 이렇게 생겼다. 기본(비인덱스) 저장소인 RedisSessionRepository는 세션 하나를 Redis Hash 하나로 저장한다.

127.0.0.1:6379> keys spring:session:shop:*
1) "spring:session:shop:sessions:2f9a...c1"

127.0.0.1:6379> type spring:session:shop:sessions:2f9a...c1
hash

127.0.0.1:6379> hkeys spring:session:shop:sessions:2f9a...c1
1) "creationTime"
2) "lastAccessedTime"
3) "maxInactiveInterval"
4) "sessionAttr:SPRING_SECURITY_CONTEXT"

127.0.0.1:6379> ttl spring:session:shop:sessions:2f9a...c1
(integer) 1798

TTL이 세션 만료 시간과 함께 걸려 있는 것을 볼 수 있다. 세션에 접근할 때마다 이 TTL이 갱신된다.

default와 indexed 저장소의 차이

저장소 타입은 두 가지다. 프로퍼티 spring.session.redis.repository-type으로 고른다.

default (RedisSessionRepository) - 세션 ID로만 조회한다. 가볍고 Redis Cluster와도 잘 맞는다. 대부분의 로그인 세션 유지 용도라면 이걸로 충분하다.

indexed (RedisIndexedSessionRepository) - 추가 인덱스를 만들어 principal(사용자) 이름으로 세션을 찾거나, 세션 생성/삭제/만료 이벤트(SessionCreatedEvent, SessionExpiredEvent 등)를 받을 수 있다. "같은 계정으로 로그인한 다른 세션을 강제 로그아웃" 같은 기능이 필요하면 이걸 쓴다.

다만 indexed 저장소를 Redis Cluster와 함께 쓸 때 주의할 점이 있다. 만료 이벤트 구독이 클러스터의 임의 한 노드만 바라보기 때문에, 세션 인덱스가 완전히 정리되지 않을 수 있다. 공식 문서가 명시한 제약이다. 이벤트가 꼭 필요한 게 아니라면 default를 권한다.

직렬화 함정: 기본은 JDK 직렬화다

Spring Session이 세션 속성을 Redis에 저장할 때 기본 직렬화는 JDK 직렬화다. 여기서 두 가지가 자주 문제가 된다.

첫째, 세션에 담는 객체는 모두 java.io.Serializable을 구현해야 한다. 안 그러면 저장 시점에 이런 예외가 난다.

org.springframework.core.serializer.support.SerializationFailedException:
  Failed to serialize object using DefaultSerializer
Caused by: java.io.NotSerializableException: com.example.shop.CartItem

둘째, 이게 무중단 배포에서 진짜 골치 아픈 부분인데, JDK 직렬화는 클래스의 serialVersionUID에 민감하다. 세션에 담긴 클래스의 필드를 바꾸고 재배포하면, 이미 Redis에 저장돼 있던 (구 버전으로 직렬화된) 세션을 새 버전 코드가 역직렬화하려다 깨진다.

java.io.InvalidClassException: com.example.shop.UserProfile;
  local class incompatible:
  stream classdesc serialVersionUID = 8712...,
  local class serialVersionUID = 3391...

무중단 배포로 세션을 살리려고 외부화했는데, 정작 배포 때마다 역직렬화가 깨지면 목적을 잃는다. 대응은 두 가지다.

  1. 세션에 담는 클래스에 serialVersionUID를 명시적으로 박아 두고, 필드 변경 시 하위 호환을 신경 쓴다.
  2. JDK 직렬화 대신 JSON 직렬화로 바꾼다. 클래스 구조 변화에 상대적으로 관대하다.

JSON으로 바꾸려면 springSessionDefaultRedisSerializer라는 이름의 RedisSerializer<Object> 빈을 등록하면 된다. Spring Session이 이 이름의 빈을 찾아 기본 직렬화기로 쓴다.

@Bean
public RedisSerializer<Object> springSessionDefaultRedisSerializer() {
    return new GenericJackson2JsonRedisSerializer();
}

GenericJackson2JsonRedisSerializer는 JSON 안에 @class라는 타입 힌트 필드를 함께 저장한다. 그래서 역직렬화 때 어떤 클래스로 복원할지 안다. 다만 Spring Security의 SecurityContext처럼 기본 생성자가 없거나 프로퍼티가 까다로운 객체를 담을 때는 별도 ObjectMapper 설정이 필요할 수 있다. Spring Security를 세션에 태운다면 spring-security-core가 제공하는 SecurityJackson2ModulesObjectMapper에 등록해 주는 것을 권한다.

FlushMode: Redis에 언제 쓰는가

FlushMode는 세션 변경분을 Redis에 언제 기록할지 정한다. 애너테이션의 flushMode 속성으로 바꾼다.

ON_SAVE (기본값) - 요청 처리가 끝나고 SessionRepositorysave()가 호출되는 시점에 한 번에 쓴다. 요청당 Redis 쓰기가 최소화돼 성능에 유리하다. 대부분 이걸 그대로 쓰면 된다.

IMMEDIATE - 변경이 생기는 즉시 Redis에 쓴다. 요청 처리 도중에 다른 경로에서 같은 세션을 읽어야 하는 특수한 경우가 아니라면 굳이 쓸 이유가 없다. 쓰기 횟수가 늘어 부하가 커진다.

SaveMode: 어떤 변경을 저장 대상으로 볼 것인가

FlushMode와 헷갈리기 쉬운 게 SaveMode다. FlushMode가 "언제 쓰나"라면, SaveMode는 "세션의 어떤 변화를 저장할 대상으로 추적하나"를 정한다. 이게 조용한 세션 유실의 흔한 원인이다.

ON_SET_ATTRIBUTE (기본값) - setAttribute()를 호출한 속성만 변경으로 보고 저장한다.

ON_GET_ATTRIBUTE - getAttribute()로 꺼낸 속성도 변경 후보로 추적한다.

ALWAYS - 세션의 모든 속성을 항상 다시 저장한다.

기본값 ON_SET_ATTRIBUTE에서 흔히 밟는 함정이 이거다. 세션에서 객체를 꺼내(getAttribute) 그 객체 내부 필드만 바꾸고 setAttribute를 다시 호출하지 않으면, Spring Session은 "이 속성이 바뀌었다"고 인지하지 못한다. 그래서 변경분이 Redis에 반영되지 않는다.

// 이렇게 하면 Redis에 반영 안 될 수 있다
Cart cart = (Cart) session.getAttribute("cart");
cart.add(item);   // 내부 상태만 변경, setAttribute 없음

// 이렇게 다시 넣어 줘야 변경으로 인식된다
cart.add(item);
session.setAttribute("cart", cart);

가변 객체를 세션에 담아 내부를 수정하는 코드가 있다면, 수정 후 setAttribute로 다시 넣거나 SaveMode를 ALWAYS로 바꾸는 방법이 있다. 다만 ALWAYS는 매 요청마다 모든 속성을 다시 직렬화해 쓰므로 세션이 크면 비용이 커진다. 웬만하면 코드에서 setAttribute를 명시하는 쪽을 권한다.

동작 확인

설정을 마쳤으면 로그인 후 redis-cli로 세션이 실제로 쌓이는지 확인한다.

$ redis-cli -h 10.0.1.20
10.0.1.20:6379> keys spring:session:shop:sessions:*
1) "spring:session:shop:sessions:2f9a...c1"

10.0.1.20:6379> hget spring:session:shop:sessions:2f9a...c1 sessionAttr:SPRING_SECURITY_CONTEXT
# JSON 직렬화를 켰다면 @class 힌트가 붙은 JSON이 보인다

그다음 인스턴스를 한 대 재배포하거나 내려 보고, 로드밸런서가 다른 인스턴스로 요청을 보내도 로그인이 유지되는지 확인한다. 유지되면 세션 외부화가 제대로 걸린 것이다. 브라우저 쿠키의 세션 식별자 이름은 기본이 SESSION(Base64로 인코딩된 세션 ID)이라 톰캣의 JSESSIONID와 다르다는 점도 확인해 두면 좋다.

정리

무중단 배포와 스케일아웃에서 로그인을 유지하려면 세션을 WAS 밖으로 빼야 한다. 톰캣 세션 클러스터링보다 Spring Session + Redis가 클라우드/컨테이너 환경에서 다루기 쉽다. 저장소는 특별한 이벤트 요구가 없으면 default 타입으로, 네임스페이스는 앱마다 분리하고, 직렬화는 배포 호환성을 위해 JSON을 검토하라. 그리고 세션이 조용히 안 저장되는 문제의 대부분은 SaveMode 기본값과 가변 객체 수정 패턴에서 온다는 점을 기억해 두면 디버깅 시간을 아낄 수 있다.

자주 묻는 질문

스티키 세션(세션 어피니티)만 쓰면 Spring Session은 필요 없나?

스티키 세션은 같은 사용자를 같은 인스턴스로 보내 주지만, 그 인스턴스가 재배포되거나 내려가면 그 노드에 있던 세션은 사라진다. 무중단 배포나 오토스케일링에서 인스턴스를 자유롭게 교체하려면 세션이 WAS 밖에 있어야 한다. 세션을 외부화하면 스티키 세션 자체가 필요 없어진다.

세션에 담은 데이터가 Redis에 저장이 안 되는데 원인이 뭔가?

가장 흔한 원인은 SaveMode 기본값(ON_SET_ATTRIBUTE)이다. 세션에서 객체를 꺼내(getAttribute) 내부 필드만 바꾸고 setAttribute를 다시 호출하지 않으면 변경으로 인식되지 않아 저장되지 않는다. 수정 후 setAttribute로 다시 넣거나 SaveMode를 ALWAYS로 바꾸면 해결된다.

배포할 때마다 InvalidClassException이 나는 이유는?

기본 JDK 직렬화가 클래스의 serialVersionUID에 민감하기 때문이다. 세션에 담긴 클래스의 필드를 바꾸고 재배포하면 구 버전으로 저장된 세션을 새 코드가 역직렬화하다 깨진다. serialVersionUID를 명시하거나, springSessionDefaultRedisSerializer 빈으로 GenericJackson2JsonRedisSerializer를 등록해 JSON 직렬화로 전환하면 완화된다.

RedisSessionRepository(default)와 RedisIndexedSessionRepository(indexed) 중 무엇을 쓰나?

세션 ID로만 조회하면 되는 일반 로그인 유지 용도라면 가벼운 default를 권한다. 사용자(principal) 이름으로 세션을 찾거나 세션 생성/만료 이벤트를 받아야 하면 indexed를 쓴다. 단 indexed를 Redis Cluster와 함께 쓰면 만료 이벤트 구독이 임의 한 노드만 바라봐 인덱스가 완전히 정리되지 않을 수 있다.

FlushMode와 SaveMode는 어떻게 다른가?

FlushMode는 변경분을 Redis에 언제 쓸지(ON_SAVE는 요청 끝에 한 번, IMMEDIATE는 즉시) 정한다. SaveMode는 세션의 어떤 변화를 저장 대상으로 추적할지(ON_SET_ATTRIBUTE, ON_GET_ATTRIBUTE, ALWAYS) 정한다. 성능을 위해 대부분 FlushMode는 기본 ON_SAVE를 그대로 두면 된다.

관련 글

댓글 0

로그인 후 댓글을 남길 수 있습니다.

아직 댓글이 없습니다.