Redis 세션 관리 — Spring Session으로 분산 세션 구현하기
서버를 2대로 늘렸더니 로그인이 자꾸 풀립니다. 사용자는 분명 로그인했는데, 다음 요청이 다른 서버로 가면 세션을 찾을 수 없다고 합니다 — 어떻게 해결해야 할까요?
단일 서버에서는 HttpSession이 서버 메모리에 저장되니까 아무 문제가 없습니다. 하지만 서버를 여러 대로 늘리는 순간, 세션이 특정 서버에 묶여 있다는 사실이 큰 문제가 됩니다. 이 글에서는 Redis를 세션 저장소로 사용하여 분산 환경에서 세션을 공유하는 방법을 정리합니다.
개념 정의
분산 세션 은 여러 서버가 하나의 세션 저장소를 공유하여, 어떤 서버로 요청이 가든 동일한 세션 데이터에 접근할 수 있게 하는 구조입니다. Spring Session은 이 분산 세션을 기존 HttpSession API를 그대로 유지하면서 투명하게 구현해주는 프로젝트입니다.
분산 세션 해결 방법 비교
서버를 여러 대 운영할 때 세션 문제를 해결하는 방법은 크게 세 가지입니다.
Sticky Session
로드 밸런서가 같은 사용자의 요청을 항상 같은 서버로 보내는 방식입니다.
- 구현이 단순하고 추가 인프라가 필요 없습니다
- 서버가 죽으면 해당 서버의 세션이 전부 유실됩니다
- 특정 서버에 트래픽이 몰릴 수 있어 부하 분산 효과가 떨어집니다
세션 복제(Session Replication)
서버 간에 세션 데이터를 동기화하는 방식입니다. Tomcat의 DeltaManager 같은 기능으로 구현할 수 있습니다.
- 서버가 N대면 모든 세션이 N곳에 복제되어 메모리를 N배 소비합니다
- 서버가 늘어날수록 복제 트래픽도 기하급수적으로 증가합니다
- 소규모 클러스터(2~3대)에서는 가능하지만, 그 이상은 비현실적입니다
외부 저장소(Redis)
세션을 서버 메모리가 아닌 외부 저장소에 저장하는 방식입니다.
- 서버가 몇 대든 상관없이 동일한 세션에 접근할 수 있습니다
- 서버 장애 시에도 세션이 유실되지 않습니다
- Redis 자체의 고가용성(Sentinel, Cluster)을 활용할 수 있습니다
실무에서는 거의 대부분 외부 저장소 방식을 선택합니다. 그 중에서도 Redis가 가장 널리 쓰이는데, 빠른 읽기/쓰기 성능과 자동 만료(TTL) 기능이 세션 관리에 딱 맞기 때문입니다.
아래는 외부 저장소 방식의 전체 아키텍처입니다.
┌──────────┐
│ Client │
└────┬─────┘
│ (Cookie: SESSION=abc123)
▼
┌──────────────┐
│ Load Balancer│ ← 어떤 서버로 보내든 상관없음
└──┬────────┬──┘
│ │
▼ ▼
┌──────┐ ┌──────┐
│ App1 │ │ App2 │ ← 서버 로컬에 세션을 저장하지 않음
└──┬───┘ └──┬───┘
│ │
▼ ▼
┌──────────────┐
│ Redis │ ← 세션 저장소 (공유)
│ (Session) │
└──────────────┘
Spring Session + Redis 동작 원리
Spring Session의 핵심은 기존 코드를 하나도 바꾸지 않고 세션 저장소만 Redis로 전환한다는 점입니다.
SessionRepositoryFilter의 역할
Spring Session은 SessionRepositoryFilter라는 서블릿 필터를 가장 앞단에 등록합니다. 이 필터가 하는 일은 단순합니다.
HttpServletRequest를SessionRepositoryRequestWrapper로 감쌉니다request.getSession()이 호출되면, Tomcat 내장 세션 대신 Redis에서 세션을 조회합니다- 세션에 값을 저장하면, 요청이 끝날 때 Redis에 반영합니다
// 기존 코드 — 전혀 수정할 필요 없음
@GetMapping("/dashboard")
public String dashboard(HttpSession session) {
// Spring Session이 내부적으로 Redis에서 조회/저장
User user = (User) session.getAttribute("loginUser");
return "dashboard";
}
공부하다 보니 이 부분이 정말 깔끔한 설계라고 느꼈습니다. 필터 하나로 HttpSession의 구현체만 교체하는 것이라, 컨트롤러나 서비스 레이어의 코드를 전혀 건드릴 필요가 없습니다.
요청 처리 흐름
요청 수신
│
▼
SessionRepositoryFilter
│ HttpServletRequest를 래핑
▼
컨트롤러 (session.getAttribute / setAttribute)
│ Redis에서 읽기/쓰기
▼
요청 완료
│
▼
SessionRepositoryFilter.commitSession()
│ 변경된 세션 속성을 Redis에 저장
▼
응답 전송
Redis에 저장되는 데이터 구조
세션 하나는 Redis Hash 하나에 매핑됩니다. 키 이름은 spring:session:sessions:{sessionId} 형태입니다.
| Hash 필드 | 설명 |
|---|---|
creationTime | 세션 생성 시각 (밀리초) |
lastAccessedTime | 마지막 접근 시각 |
maxInactiveInterval | 세션 만료 시간 (초, 기본 1800) |
sessionAttr:{name} | 세션에 저장한 속성값 (직렬화) |
만료 처리 방식
세션 만료는 생각보다 복잡합니다. Redis의 TTL만으로는 부족한데, TTL로 키가 삭제되면 ** 삭제 이벤트가 발생하지 않을 수 있기 때문 **입니다. Spring Session은 이를 해결하기 위해 세 가지를 조합합니다.
- ** 세션 Hash의 TTL** — Redis가 자동으로 메모리를 회수하도록 설정
- ** 만료 추적 키** —
spring:session:expirations:{분 단위 타임스탬프}Set에 세션 ID를 등록 - Keyspace Notification — Redis의 키 만료 이벤트를 구독하여
SessionDestroyedEvent를 발행
이 구조 덕분에 Spring Security의 세션 만료 리스너 같은 기능이 분산 환경에서도 정상 동작합니다. HttpSessionListener를 쓰는 기존 코드도 그대로 동작하게 됩니다.
설정 방법
의존성 추가
// build.gradle
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springframework.session:spring-session-data-redis'
}
application.yml 설정
spring:
data:
redis:
host: localhost
port: 6379
session:
store-type: redis # 세션 저장소를 Redis로 지정
timeout: 30m # 세션 타임아웃 (기본 30분)
redis:
namespace: spring:session # Redis 키 prefix (기본값)
Spring Boot의 자동 설정(auto-configuration)이 위 설정만으로 모든 것을 처리합니다. @EnableRedisHttpSession을 명시적으로 붙일 수도 있지만, Spring Boot를 사용한다면 대부분 불필요합니다.
// Spring Boot 자동 설정을 사용하면 이 어노테이션은 선택사항
// 커스텀 설정이 필요할 때만 사용
@EnableRedisHttpSession(maxInactiveIntervalInSeconds = 3600)
@Configuration
public class SessionConfig {
}
최소 동작 확인
설정이 끝나면 아무 컨트롤러에서 세션을 사용해봅니다.
@RestController
public class SessionTestController {
@GetMapping("/session/set")
public String setSession(HttpSession session) {
session.setAttribute("username", "libra26");
return "세션 저장 완료. ID: " + session.getId();
}
@GetMapping("/session/get")
public String getSession(HttpSession session) {
String username = (String) session.getAttribute("username");
return "세션에서 조회: " + username;
}
}
Redis에 저장되는 세션 구조
실제로 Redis CLI에서 세션이 어떻게 저장되는지 확인해보면 이해가 빨라집니다.
# 세션 관련 키 전체 조회 (운영 환경에서는 SCAN 사용)
redis-cli KEYS "spring:session:*"
# 결과 예시
1) "spring:session:sessions:abc123-def456-..."
2) "spring:session:sessions:expires:abc123-def456-..."
3) "spring:session:expirations:1711612800000"
각 키의 역할은 다음과 같습니다.
sessions:{id}— 세션 데이터가 담긴 Hash (핵심 데이터)sessions:expires:{id}— TTL이 설정된 빈 String (만료 감지 용도)expirations:{timestamp}— 해당 시각에 만료되는 세션 ID를 담은 Set
# 세션 Hash의 내용 확인
redis-cli HGETALL "spring:session:sessions:abc123-def456-..."
# 결과 예시
1) "creationTime"
2) "1711612345678"
3) "lastAccessedTime"
4) "1711612567890"
5) "maxInactiveInterval"
6) "1800"
7) "sessionAttr:username"
8) "\xac\xed\x00\x05t\x00\x07libra26" # JDK 직렬화된 값
마지막 줄에서 볼 수 있듯이, 기본 직렬화 방식은 JDK 직렬화라서 사람이 읽을 수 없는 바이너리 형태입니다.
세션 직렬화 커스터마이징
기본 JDK 직렬화에는 몇 가지 문제가 있습니다.
- Redis CLI에서 값을 확인할 수 없습니다
- 클래스 구조가 변경되면 역직렬화가 실패합니다
- 직렬화/역직렬화 성능이 상대적으로 느립니다
JSON 직렬화로 변경하면 이런 문제를 해결할 수 있습니다.
@Configuration
public class SessionConfig {
@Bean
public RedisSerializer<Object> springSessionDefaultRedisSerializer() {
// Spring Session이 이 빈 이름으로 직렬화기를 찾음
ObjectMapper mapper = new ObjectMapper();
mapper.activateDefaultTyping(
mapper.getPolymorphicTypeValidator(),
ObjectMapper.DefaultTyping.NON_FINAL,
JsonTypeInfo.As.PROPERTY
);
return new GenericJackson2JsonRedisSerializer(mapper);
}
}
JSON 직렬화를 적용하면 Redis CLI에서 세션 내용을 바로 확인할 수 있습니다.
redis-cli HGET "spring:session:sessions:abc123..." "sessionAttr:username"
# 결과: "\"libra26\"" ← 사람이 읽을 수 있음
Redis 직렬화 전략에 대한 자세한 내용은 Redis 직렬화 전략 글을 참고하면 좋습니다.
실무 고려사항
세션에 큰 객체를 저장하지 마세요
세션에 값을 저장할 때마다 직렬화 → Redis 네트워크 전송이 발생합니다. 사용자 정보나 권한 같은 작은 데이터만 넣고, 큰 데이터는 캐시나 DB에서 조회하는 것이 좋습니다.
// 나쁜 예 — 대량 데이터를 세션에 저장
session.setAttribute("orderHistory", hugeOrderList); // 매 요청마다 직렬화/역직렬화
// 좋은 예 — 식별자만 저장하고 필요할 때 조회
session.setAttribute("userId", userId);
List<Order> orders = orderService.findByUserId(userId);
Redis 장애에 대비하세요
Redis가 죽으면 모든 세션이 사라집니다. 운영 환경에서는 반드시 고가용성 구성을 해야 합니다.
- Sentinel — 자동 페일오버로 Redis 마스터 장애에 대응
- Cluster — 데이터를 분산 저장하여 수평 확장
# Sentinel 구성 예시
spring:
data:
redis:
sentinel:
master: mymaster
nodes:
- sentinel1:26379
- sentinel2:26379
- sentinel3:26379
세션 네임스페이스로 멀티 앱 분리
하나의 Redis에 여러 애플리케이션의 세션을 저장한다면, 네임스페이스를 분리해야 세션 키가 충돌하지 않습니다.
# App A
spring.session.redis.namespace: app-a:session
# App B
spring.session.redis.namespace: app-b:session
Spring Security와의 통합
Spring Session은 Spring Security의 동시 세션 제어와도 잘 연동됩니다. 예를 들어 한 계정으로 동시에 로그인할 수 있는 수를 제한할 수 있습니다.
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.sessionManagement(session -> session
.maximumSessions(1) // 동시 세션 1개 제한
.maxSessionsPreventsLogin(false) // 새 로그인이 기존 세션을 만료시킴
);
return http.build();
}
// Spring Session이 Redis 기반 SessionRegistry를 제공
@Bean
public FindByIndexNameSessionRepository<?> sessionRepository(
RedisIndexedSessionRepository redisSessionRepository) {
return redisSessionRepository;
}
}
분산 환경에서 동시 세션 제어가 동작하려면 RedisIndexedSessionRepository를 사용해야 합니다. 이름에 "Indexed"가 들어간 이유는, principal 이름으로 세션을 검색할 수 있도록 추가 인덱스를 유지하기 때문입니다.
정리
| 항목 | 내용 |
|---|---|
| 문제 | 분산 환경에서 서버 로컬 세션은 공유되지 않음 |
| 해결 | Redis를 세션 저장소로 사용 (Spring Session) |
| 핵심 원리 | SessionRepositoryFilter가 HttpServletRequest를 래핑 |
| 저장 구조 | spring:session:sessions:{id} Hash에 세션 속성 저장 |
| 만료 방식 | Redis TTL + 만료 추적 키 + Keyspace Notification |
| 직렬화 | 기본 JDK 직렬화 → JSON으로 변경 권장 |
| 실무 팁 | 작은 데이터만 저장, Redis 고가용성 필수, 네임스페이스 분리 |
세션 관리는 분산 시스템으로 넘어가면서 가장 먼저 부딪히는 문제 중 하나입니다. Spring Session + Redis 조합은 기존 코드를 거의 수정하지 않고 이 문제를 해결해주기 때문에, 서버를 스케일 아웃할 계획이 있다면 초기부터 적용해두는 것을 추천합니다.