- Published on
Spring Boot Redis 세션 꼬임 - TTL·동시로그인 해법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버를 수평 확장하면서 세션을 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}: 만료 시각 관련 키
여기서 중요한 포인트는 다음입니다.
- 세션 TTL은 Redis의 키 만료와 연결됩니다.
- 마지막 접근 시각(활동) 업데이트, 만료 인덱스 갱신 등으로 인해 여러 키가 동시에 움직입니다.
- 애플리케이션이 세션을 “찾는 방식”이 바뀌면(예: 유저 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.timeoutspring.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가지 정책으로 나뉩니다.
- 동시 로그인 허용(여러 디바이스 로그인 유지)
- 동시 로그인 차단(이미 로그인 중이면 신규 로그인 거부)
- 최신 로그인만 유지(기존 세션 강제 만료)
문제는 2번/3번을 구현하려고 “유저 ID로 세션 ID를 찾는 역인덱스”를 직접 만들 때 자주 발생합니다.
안티패턴: 유저 ID -> 세션 ID를 Redis에 단일 키로 저장
예를 들어 아래처럼 구현하면, 동시 요청에서 레이스 컨디션이 쉽게 생깁니다.
user:{userId}:session키에 현재 세션 ID 저장- 로그인 시 기존 세션을 찾아 삭제
문제는 로그인 요청이 동시에 2개 들어오면,
- 요청 A가 기존 세션을 삭제하려고 조회
- 요청 B가 더 늦게/빠르게 값을 덮어씀
- 결과적으로 “살아야 할 세션이 죽거나”, “죽어야 할 세션이 살아남거나”
하는 식으로 꼬입니다.
해결 1: Spring Security의 동시 세션 제어 사용
가능하면 커스텀 구현 대신 Spring Security가 제공하는 동시 세션 제어를 사용하세요. 핵심은 SessionRegistry와 ConcurrentSessionControlAuthenticationStrategy 계열을 통해 “유저별 세션 수 제한”과 “만료 처리”를 일관되게 수행하는 것입니다.
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-site는 none이 필요할 수 있으며, 그 경우 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가지
- 세션 TTL을
server.servlet.session.timeout만 믿고 Redis TTL을 확인하지 않음 - 성능 최적화로 저장 빈도를 줄였는데 TTL 연장도 같이 줄어듦
- 유저 ID 역인덱스를 단일 키로 두고 원자성 없이 덮어씀
- 기존 세션을
DEL로 날려 레이스 컨디션을 키움 - 쿠키 속성 문제를 “Redis 세션 꼬임”으로 오진
마무리: “세션 꼬임”은 대개 일관성 문제다
Redis 세션은 서버 확장에 강력하지만, TTL과 동시 로그인 정책이 애매하면 “가끔씩만” 터지는 가장 잡기 어려운 장애가 됩니다. 해결의 핵심은 다음 두 가지입니다.
- TTL은 단일 소스로 통일하고, Redis에서 실제 TTL을 관측하며 검증하기
- 동시 로그인 제어는 가능하면 Spring Security 기본 메커니즘을 사용하고, 커스텀이 필요하면 원자성(스크립트) 또는 버전 토큰으로 일관성을 확보하기
운영에서 비슷한 “캐시/상태 꼬임” 유형의 디버깅 접근이 필요하다면, 캐시 재검증 이슈를 다룬 글도 함께 참고하면 도움이 됩니다: Next.js App Router 캐시 꼬임·재검증 버그 해결