서버를 2대로 늘렸더니 로그인이 자꾸 풀립니다. 사용자는 분명 로그인했는데, 다음 요청이 다른 서버로 가면 세션을 찾을 수 없다고 합니다 — 어떻게 해결해야 할까요?

단일 서버에서는 HttpSession이 서버 메모리에 저장되니까 아무 문제가 없습니다. 하지만 서버를 여러 대로 늘리는 순간, 세션이 특정 서버에 묶여 있다는 사실이 큰 문제가 됩니다. 이 글에서는 Redis를 세션 저장소로 사용하여 분산 환경에서 세션을 공유하는 방법을 정리합니다.

개념 정의

분산 세션 은 여러 서버가 하나의 세션 저장소를 공유하여, 어떤 서버로 요청이 가든 동일한 세션 데이터에 접근할 수 있게 하는 구조입니다. Spring Session은 이 분산 세션을 기존 HttpSession API를 그대로 유지하면서 투명하게 구현해주는 프로젝트입니다.

분산 세션 해결 방법 비교

서버를 여러 대 운영할 때 세션 문제를 해결하는 방법은 크게 세 가지입니다.

Sticky Session

로드 밸런서가 같은 사용자의 요청을 항상 같은 서버로 보내는 방식입니다.

  • 구현이 단순하고 추가 인프라가 필요 없습니다
  • 서버가 죽으면 해당 서버의 세션이 전부 유실됩니다
  • 특정 서버에 트래픽이 몰릴 수 있어 부하 분산 효과가 떨어집니다

세션 복제(Session Replication)

서버 간에 세션 데이터를 동기화하는 방식입니다. Tomcat의 DeltaManager 같은 기능으로 구현할 수 있습니다.

  • 서버가 N대면 모든 세션이 N곳에 복제되어 메모리를 N배 소비합니다
  • 서버가 늘어날수록 복제 트래픽도 기하급수적으로 증가합니다
  • 소규모 클러스터(2~3대)에서는 가능하지만, 그 이상은 비현실적입니다

외부 저장소(Redis)

세션을 서버 메모리가 아닌 외부 저장소에 저장하는 방식입니다.

  • 서버가 몇 대든 상관없이 동일한 세션에 접근할 수 있습니다
  • 서버 장애 시에도 세션이 유실되지 않습니다
  • Redis 자체의 고가용성(Sentinel, Cluster)을 활용할 수 있습니다

실무에서는 거의 대부분 외부 저장소 방식을 선택합니다. 그 중에서도 Redis가 가장 널리 쓰이는데, 빠른 읽기/쓰기 성능과 자동 만료(TTL) 기능이 세션 관리에 딱 맞기 때문입니다.

아래는 외부 저장소 방식의 전체 아키텍처입니다.

PLAINTEXT
┌──────────┐
│  Client  │
└────┬─────┘
     │ (Cookie: SESSION=abc123)

┌──────────────┐
│ Load Balancer│  ← 어떤 서버로 보내든 상관없음
└──┬────────┬──┘
   │        │
   ▼        ▼
┌──────┐ ┌──────┐
│ App1 │ │ App2 │  ← 서버 로컬에 세션을 저장하지 않음
└──┬───┘ └──┬───┘
   │        │
   ▼        ▼
┌──────────────┐
│    Redis     │  ← 세션 저장소 (공유)
│  (Session)   │
└──────────────┘

Spring Session + Redis 동작 원리

Spring Session의 핵심은 기존 코드를 하나도 바꾸지 않고 세션 저장소만 Redis로 전환한다는 점입니다.

SessionRepositoryFilter의 역할

Spring Session은 SessionRepositoryFilter라는 서블릿 필터를 가장 앞단에 등록합니다. 이 필터가 하는 일은 단순합니다.

  1. HttpServletRequestSessionRepositoryRequestWrapper로 감쌉니다
  2. request.getSession()이 호출되면, Tomcat 내장 세션 대신 Redis에서 세션을 조회합니다
  3. 세션에 값을 저장하면, 요청이 끝날 때 Redis에 반영합니다
JAVA
// 기존 코드 — 전혀 수정할 필요 없음
@GetMapping("/dashboard")
public String dashboard(HttpSession session) {
    // Spring Session이 내부적으로 Redis에서 조회/저장
    User user = (User) session.getAttribute("loginUser");
    return "dashboard";
}

공부하다 보니 이 부분이 정말 깔끔한 설계라고 느꼈습니다. 필터 하나로 HttpSession의 구현체만 교체하는 것이라, 컨트롤러나 서비스 레이어의 코드를 전혀 건드릴 필요가 없습니다.

요청 처리 흐름

PLAINTEXT
요청 수신


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를 쓰는 기존 코드도 그대로 동작하게 됩니다.

설정 방법

의존성 추가

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

application.yml 설정

YAML
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를 사용한다면 대부분 불필요합니다.

JAVA
// Spring Boot 자동 설정을 사용하면 이 어노테이션은 선택사항
// 커스텀 설정이 필요할 때만 사용
@EnableRedisHttpSession(maxInactiveIntervalInSeconds = 3600)
@Configuration
public class SessionConfig {
}

최소 동작 확인

설정이 끝나면 아무 컨트롤러에서 세션을 사용해봅니다.

JAVA
@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에서 세션이 어떻게 저장되는지 확인해보면 이해가 빨라집니다.

BASH
# 세션 관련 키 전체 조회 (운영 환경에서는 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
BASH
# 세션 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 직렬화로 변경하면 이런 문제를 해결할 수 있습니다.

JAVA
@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에서 세션 내용을 바로 확인할 수 있습니다.

BASH
redis-cli HGET "spring:session:sessions:abc123..." "sessionAttr:username"
# 결과: "\"libra26\""  ← 사람이 읽을 수 있음

Redis 직렬화 전략에 대한 자세한 내용은 Redis 직렬화 전략 글을 참고하면 좋습니다.

실무 고려사항

세션에 큰 객체를 저장하지 마세요

세션에 값을 저장할 때마다 직렬화 → Redis 네트워크 전송이 발생합니다. 사용자 정보나 권한 같은 작은 데이터만 넣고, 큰 데이터는 캐시나 DB에서 조회하는 것이 좋습니다.

JAVA
// 나쁜 예 — 대량 데이터를 세션에 저장
session.setAttribute("orderHistory", hugeOrderList);  // 매 요청마다 직렬화/역직렬화

// 좋은 예 — 식별자만 저장하고 필요할 때 조회
session.setAttribute("userId", userId);
List<Order> orders = orderService.findByUserId(userId);

Redis 장애에 대비하세요

Redis가 죽으면 모든 세션이 사라집니다. 운영 환경에서는 반드시 고가용성 구성을 해야 합니다.

  • Sentinel — 자동 페일오버로 Redis 마스터 장애에 대응
  • Cluster — 데이터를 분산 저장하여 수평 확장
YAML
# Sentinel 구성 예시
spring:
  data:
    redis:
      sentinel:
        master: mymaster
        nodes:
          - sentinel1:26379
          - sentinel2:26379
          - sentinel3:26379

세션 네임스페이스로 멀티 앱 분리

하나의 Redis에 여러 애플리케이션의 세션을 저장한다면, 네임스페이스를 분리해야 세션 키가 충돌하지 않습니다.

YAML
# App A
spring.session.redis.namespace: app-a:session

# App B
spring.session.redis.namespace: app-b:session

Spring Security와의 통합

Spring Session은 Spring Security의 동시 세션 제어와도 잘 연동됩니다. 예를 들어 한 계정으로 동시에 로그인할 수 있는 수를 제한할 수 있습니다.

JAVA
@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 조합은 기존 코드를 거의 수정하지 않고 이 문제를 해결해주기 때문에, 서버를 스케일 아웃할 계획이 있다면 초기부터 적용해두는 것을 추천합니다.

댓글 로딩 중...