Published on

Spring Boot Redis 세션 꼬임 - TTL·동시로그인 해법

Authors

서버를 수평 확장하면서 세션을 Redis로 옮기면, 어느 순간부터 “로그인했는데 갑자기 로그아웃됨”, “A 계정으로 로그인했는데 B 계정으로 보임”, “동시 로그인 시 세션이 엉킴” 같은 증상이 간헐적으로 발생합니다. 대부분은 애플리케이션 로직 버그라기보다 세션 TTL(만료) 정책과 동시 로그인 처리 방식이 Redis 세션 저장 구조와 충돌해서 생깁니다.

이 글에서는 Spring Boot에서 Spring Session + Redis 조합을 기준으로, 세션 꼬임을 만드는 대표 원인(특히 TTL)과 동시 로그인 제어(중복 로그인 허용/차단/최신 세션만 유지)를 운영 관점에서 재현 가능하게 정리하고, 설정과 코드 예제로 해결책을 제시합니다.

전제: Spring Session Redis의 저장 구조 이해

Spring Session이 Redis에 세션을 저장할 때는 대략 다음과 같은 키들이 생성됩니다(버전에 따라 접두사나 세부 키는 다를 수 있음).

  • spring:session:sessions:{sessionId}: 세션 본문(속성, 메타데이터)
  • spring:session:expirations:{timestamp}: 만료 스케줄링용 인덱스
  • spring:session:sessions:expires:{sessionId}: 만료 시각 관련 키

여기서 중요한 포인트는 다음입니다.

  1. 세션 TTL은 Redis의 키 만료와 연결됩니다.
  2. 마지막 접근 시각(활동) 업데이트, 만료 인덱스 갱신 등으로 인해 여러 키가 동시에 움직입니다.
  3. 애플리케이션이 세션을 “찾는 방식”이 바뀌면(예: 유저 ID로 역인덱스 구축) 원자성 문제가 생길 수 있습니다.

증상 1: TTL 불일치로 인한 “랜덤 로그아웃”

가장 흔한 유형은 “분명 세션 타임아웃이 30분인데 5분 만에 로그아웃된다” 같은 현상입니다.

원인 A: 애플리케이션 설정 TTL과 Redis 실제 TTL이 다름

Spring Boot 설정(예: server.servlet.session.timeout)은 서블릿 컨테이너의 세션 정책이고, Spring Session이 Redis에 저장할 때는 Spring Session의 TTL 설정이 관여합니다. 환경에 따라 둘이 어긋나면 TTL이 예상보다 짧거나 길어질 수 있습니다.

다음 항목을 한 번에 정리해 두는 것이 안전합니다.

  • server.servlet.session.timeout
  • spring.session.timeout(버전/스타터에 따라)
  • Redis 키 TTL(실제)

점검: Redis에서 세션 TTL 확인

운영에서 재현할 때는 세션 ID를 알아야 합니다. 브라우저 쿠키의 SESSION 값을 확인한 뒤, Redis에서 TTL을 확인합니다.

# sessionId가 abc123이라고 가정
redis-cli TTL spring:session:sessions:abc123
redis-cli TTL spring:session:sessions:expires:abc123

TTL이 서로 다르거나, 기대치보다 훨씬 짧다면 설정/갱신 흐름을 의심해야 합니다.

원인 B: FlushMode/SaveMode 조합으로 “접속해도 TTL이 안 늘어남”

Spring Session은 요청마다 세션을 Redis에 어떻게 반영할지 정책이 있습니다.

  • FlushMode: 언제 Redis에 쓰는지
  • SaveMode: 어떤 변경을 저장하는지

트래픽이 많아 성능 최적화를 위해 저장 빈도를 줄였는데, 그 결과로 마지막 접근 시각이 충분히 자주 갱신되지 않아 TTL이 연장되지 않는 경우가 있습니다.

해결: 의도에 맞는 모드로 명시

아래 예시는 “요청 종료 시점에 저장” + “변경된 속성만 저장” 같은 일반적인 절충안입니다. (정답은 서비스 특성에 따라 다릅니다.)

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.session.FlushMode;
import org.springframework.session.SaveMode;
import org.springframework.session.data.redis.config.annotation.web.http.EnableRedisHttpSession;

@Configuration
@EnableRedisHttpSession(
    redisNamespace = "myapp:spring:session",
    maxInactiveIntervalInSeconds = 1800,
    flushMode = FlushMode.ON_SAVE,
    saveMode = SaveMode.ON_SET_ATTRIBUTE
)
public class SessionConfig {
}
  • maxInactiveIntervalInSeconds로 TTL을 단일 소스로 고정
  • 네임스페이스를 분리해 운영에서 키 충돌을 줄임

원인 C: 로드밸런서/프록시가 세션 쿠키를 흔듦

세션 자체는 Redis에 있어도, 클라이언트가 보내는 쿠키가 흔들리면(도메인, 경로, SameSite, Secure) 서버는 다른 세션으로 인식합니다. 이 경우 “세션이 꼬였다”로 보이지만 실제로는 “세션이 바뀌었다”에 가깝습니다.

  • HTTPS 환경에서 Secure 누락
  • 크로스 사이트 리다이렉트/SSO에서 SameSite 문제
  • 서브도메인 간 쿠키 도메인 범위 문제

세션 이슈를 디버깅할 때는 애플리케이션 로그뿐 아니라 응답의 Set-Cookie 변화를 반드시 같이 봐야 합니다.

증상 2: 동시 로그인에서 세션이 꼬이거나 사용자 매핑이 뒤섞임

동시 로그인 시나리오는 크게 3가지 정책으로 나뉩니다.

  1. 동시 로그인 허용(여러 디바이스 로그인 유지)
  2. 동시 로그인 차단(이미 로그인 중이면 신규 로그인 거부)
  3. 최신 로그인만 유지(기존 세션 강제 만료)

문제는 2번/3번을 구현하려고 “유저 ID로 세션 ID를 찾는 역인덱스”를 직접 만들 때 자주 발생합니다.

안티패턴: 유저 ID -> 세션 ID를 Redis에 단일 키로 저장

예를 들어 아래처럼 구현하면, 동시 요청에서 레이스 컨디션이 쉽게 생깁니다.

  • user:{userId}:session 키에 현재 세션 ID 저장
  • 로그인 시 기존 세션을 찾아 삭제

문제는 로그인 요청이 동시에 2개 들어오면,

  • 요청 A가 기존 세션을 삭제하려고 조회
  • 요청 B가 더 늦게/빠르게 값을 덮어씀
  • 결과적으로 “살아야 할 세션이 죽거나”, “죽어야 할 세션이 살아남거나”

하는 식으로 꼬입니다.

해결 1: Spring Security의 동시 세션 제어 사용

가능하면 커스텀 구현 대신 Spring Security가 제공하는 동시 세션 제어를 사용하세요. 핵심은 SessionRegistryConcurrentSessionControlAuthenticationStrategy 계열을 통해 “유저별 세션 수 제한”과 “만료 처리”를 일관되게 수행하는 것입니다.

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.core.session.SessionRegistry;
import org.springframework.security.core.session.SessionRegistryImpl;

@Configuration
public class SecurityConfig {

  @Bean
  public SessionRegistry sessionRegistry() {
    return new SessionRegistryImpl();
  }

  @Bean
  public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
      .authorizeHttpRequests(auth -> auth
        .requestMatchers("/login", "/health").permitAll()
        .anyRequest().authenticated()
      )
      .formLogin(Customizer.withDefaults())
      .sessionManagement(session -> session
        .maximumSessions(1)
        .maxSessionsPreventsLogin(false)
        .sessionRegistry(sessionRegistry())
      );

    return http.build();
  }
}
  • maximumSessions(1): 계정당 세션 1개
  • maxSessionsPreventsLogin(false): 신규 로그인 허용, 기존 세션 만료(최신 로그인만 유지)
  • true로 바꾸면 “동시 로그인 차단”

주의: 이 기능이 “정확히” 동작하려면 세션 이벤트 발행(세션 생성/파괴)이 정상이어야 하고, 다중 인스턴스 환경에선 세션 레지스트리의 일관성도 고려해야 합니다.

해결 2: “최신 로그인만 유지”를 원자적으로 구현(커스텀)

정책상 커스텀이 필요하다면, 최소한 다음을 지키는 것이 좋습니다.

  • 유저별 현재 세션을 가리키는 키는 원자적 갱신
  • 이전 세션 무효화는 “삭제”보다 “무효 플래그”나 “버전 토큰”을 선호
  • 세션 본문과 역인덱스의 갱신을 하나의 트랜잭션처럼 다루기

Redis에서는 Lua 스크립트로 원자성을 확보할 수 있습니다. 예를 들어 user:{id}:session을 새 세션으로 바꾸고, 이전 세션 ID를 반환받아 후처리하는 패턴입니다.

-- KEYS[1] = user session pointer key
-- ARGV[1] = newSessionId
local old = redis.call('GET', KEYS[1])
redis.call('SET', KEYS[1], ARGV[1])
return old

Spring에서 실행 예시는 다음과 같습니다.

import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Component;

import java.util.List;

@Component
public class UserSessionPointer {

  private final StringRedisTemplate redis;
  private final DefaultRedisScript<String> swapScript;

  public UserSessionPointer(StringRedisTemplate redis) {
    this.redis = redis;
    this.swapScript = new DefaultRedisScript<>();
    this.swapScript.setResultType(String.class);
    this.swapScript.setScriptText(
      "local old = redis.call('GET', KEYS[1]) " +
      "redis.call('SET', KEYS[1], ARGV[1]) " +
      "return old"
    );
  }

  public String swap(String userId, String newSessionId) {
    String key = "user:" + userId + ":session";
    return redis.execute(swapScript, List.of(key), newSessionId);
  }
}

그 다음 단계로 “이전 세션 무효화”를 처리해야 하는데, 여기서 단순히 Redis의 세션 키를 DEL해버리면 타이밍 이슈가 생길 수 있습니다. 더 안전한 방식은 세션에 sessionVersion 같은 값을 넣고, 요청마다 그 버전을 검증하는 것입니다.

  • 로그인 시 유저의 현재 버전 증가
  • 세션에 버전 저장
  • 요청 처리 시 유저의 최신 버전과 세션 버전 비교

이 방식은 세션 삭제 타이밍과 무관하게 “논리적 만료”를 만들 수 있어 동시성에 강합니다.

TTL과 동시 로그인 이슈를 동시에 잡는 운영 체크리스트

1) Redis 키스페이스를 환경별로 분리

개발/스테이징/운영, 혹은 여러 서비스가 같은 Redis를 공유한다면 네임스페이스 충돌은 생각보다 흔합니다.

  • redisNamespace를 서비스 단위로 고정
  • 가능하면 Redis DB 분리보다 키 접두사 정책을 우선

2) 세션 TTL 단일 소스 원칙

TTL을 여러 곳에서 설정하면 “누가 최종 권한인지”가 모호해집니다.

  • Spring Session의 maxInactiveIntervalInSeconds를 기준으로 통일
  • 서블릿 컨테이너 TTL은 보조로만 사용

3) 세션 쿠키 설정을 명시

특히 HTTPS, 크로스 도메인, SSO가 있으면 쿠키 속성 미스매치가 세션 꼬임으로 관측됩니다.

server:
  servlet:
    session:
      cookie:
        name: SESSION
        http-only: true
        secure: true
        same-site: lax

환경에 따라 same-sitenone이 필요할 수 있으며, 그 경우 secure: true가 필수입니다.

4) 장애 시나리오: 재시작, 스케일링, 네트워크 지연

  • 애플리케이션 재시작 직후 세션 이벤트 누락
  • Redis failover 시 TTL/만료 이벤트 지연
  • 네트워크 지연으로 요청 순서가 뒤집힘

이런 케이스는 애플리케이션 로그만 보면 감이 안 오므로, Redis의 TTL과 키 생성 패턴을 같이 봐야 합니다.

재현용 테스트: TTL과 동시 로그인 경합 만들기

운영에서만 보이는 문제를 로컬에서 잡으려면 “동시에 로그인 요청을 날리는” 테스트가 유효합니다.

아래는 매우 단순한 형태의 동시 로그인 경합 테스트 예시입니다.

import org.junit.jupiter.api.Test;

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

class ConcurrentLoginTest {

  @Test
  void concurrentLoginRace() throws Exception {
    int threads = 20;
    ExecutorService pool = Executors.newFixedThreadPool(threads);
    CountDownLatch start = new CountDownLatch(1);
    CountDownLatch done = new CountDownLatch(threads);

    for (int i = 0; i < threads; i++) {
      pool.submit(() -> {
        try {
          start.await();
          // TODO: 같은 계정으로 로그인 API 호출
          // TODO: 응답 쿠키의 SESSION 값을 기록하고, Redis TTL도 함께 기록
        } catch (Exception e) {
          // ignore
        } finally {
          done.countDown();
        }
      });
    }

    start.countDown();
    done.await();
    pool.shutdown();
  }
}

테스트 시에는 “마지막에 살아남아야 하는 세션이 무엇인지”를 정의하고, 그 세션이 실제로 인증이 되는지(보호 API 호출)까지 검증해야 합니다.

자주 하는 실수 5가지

  1. 세션 TTL을 server.servlet.session.timeout만 믿고 Redis TTL을 확인하지 않음
  2. 성능 최적화로 저장 빈도를 줄였는데 TTL 연장도 같이 줄어듦
  3. 유저 ID 역인덱스를 단일 키로 두고 원자성 없이 덮어씀
  4. 기존 세션을 DEL로 날려 레이스 컨디션을 키움
  5. 쿠키 속성 문제를 “Redis 세션 꼬임”으로 오진

마무리: “세션 꼬임”은 대개 일관성 문제다

Redis 세션은 서버 확장에 강력하지만, TTL과 동시 로그인 정책이 애매하면 “가끔씩만” 터지는 가장 잡기 어려운 장애가 됩니다. 해결의 핵심은 다음 두 가지입니다.

  • TTL은 단일 소스로 통일하고, Redis에서 실제 TTL을 관측하며 검증하기
  • 동시 로그인 제어는 가능하면 Spring Security 기본 메커니즘을 사용하고, 커스텀이 필요하면 원자성(스크립트) 또는 버전 토큰으로 일관성을 확보하기

운영에서 비슷한 “캐시/상태 꼬임” 유형의 디버깅 접근이 필요하다면, 캐시 재검증 이슈를 다룬 글도 함께 참고하면 도움이 됩니다: Next.js App Router 캐시 꼬임·재검증 버그 해결