infra
Platform

모듈 맵

[Infra Ops] Tomcat 세션 클러스터링과 Redis 세션 외부화

0 / 52 완료

펼치기
0 / 52 완료0%

인프라 운영 & SRE · 17 / 52

[Infra Ops] Tomcat 세션 클러스터링과 Redis 세션 외부화

DeltaManager 클러스터, Nginx sticky session, Redis spring-session까지 — Tomcat 2대 이상 운영 시 세션 유지 문제를 해결하는 실무 패턴

🚨INCIDENT ALERT
HIGH

쇼핑몰 운영팀 슬랙에 메시지가 올라왔습니다. "오늘 오전부터 장바구니가 자꾸 비워진다는 고객 문의가 10건 넘게 들어왔어요. 로그인도 풀리는 경우가 있고요." 전날 밤 WAS 서버를 1대에서 2대로 늘리는 작업을 했습니다. 트래픽은 잘 분산되고 있는데 이 민원은 뭔가요?

원인은 세션입니다. 로그인 후 장바구니를 담은 세션이 Tomcat1 메모리에 있는데, 다음 요청은 Nginx가 Tomcat2로 보냈습니다. Tomcat2는 그 세션을 모릅니다. 로그인이 풀리고, 장바구니가 비워집니다.

WAS를 이중화할 때 반드시 세션 문제를 함께 해결해야 합니다.

이번 챕터에서 배울 것
  • 1WAS 이중화 시 세션 소실이 발생하는 구조적 원인을 설명할 수 있다
  • 2Tomcat DeltaManager 클러스터를 server.xml에 설정하고 멤버십을 확인할 수 있다
  • 3Nginx ip_hash로 sticky session을 구성하고 jvmRoute와 연동할 수 있다
  • 4spring-session-data-redis로 세션을 Redis에 외부화하고 WAS 재기동 후 세션 유지를 확인할 수 있다
  • 5세션 전략 3가지의 트레이드오프를 근거로 환경에 맞는 방법을 선택할 수 있다
실습 환경 준비
Tomcat 버전 확인
catalina.sh version 2>/dev/null || /opt/tomcat/bin/catalina.sh version
Redis 실행 확인
redis-cli ping
Nginx upstream 설정 확인
nginx -T 2>/dev/null | grep -A 5 upstream
멀티캐스트 인터페이스 확인
ip route show | grep -E 'dev|multicast'

WAS 이중화 시 세션 문제

💡개념

세션 소실이 발생하는 구조적 원인

WAS를 이중화하면 트래픽 분산이라는 목적은 달성하지만, 세션이라는 새로운 문제가 즉시 발생합니다. 각 WAS 인스턴스는 자신의 JVM 메모리에 세션을 독립적으로 관리하기 때문에, 로드밸런서가 요청을 다른 WAS로 보내는 순간 그 사용자의 로그인 상태는 사라집니다. 이 문제를 해결하지 않고 WAS 이중화를 운영하면 사용자에게는 "자꾸 로그아웃된다"는 민원이 반복됩니다.

WAS 이중화 세션 소실 — 로드밸런서가 다른 WAS로 요청을 보내면 세션이 없다확대

HTTP는 상태가 없는(stateless) 프로토콜입니다. 로그인 정보, 장바구니처럼 사용자별로 유지해야 하는 데이터는 서버 쪽에서 세션이라는 형태로 보관합니다. 각 WAS는 세션을 자신의 JVM 힙(메모리)에 저장합니다.

WAS가 1대일 때는 문제가 없습니다. 모든 요청이 같은 JVM으로 들어오므로 세션을 찾을 수 있습니다. WAS를 2대로 늘리면 달라집니다. 로드밸런서는 요청마다 Tomcat1과 Tomcat2 중 하나를 고릅니다. 세션이 Tomcat1 메모리에 있는데 Tomcat2가 요청을 받으면, Tomcat2는 세션을 모르므로 "인증되지 않은 사용자"로 취급합니다.

해결 방법은 세 가지입니다. 각각 구현 복잡도와 장애 허용 범위가 다릅니다.

방법원리장점단점
Sticky Session로드밸런서가 같은 클라이언트를 항상 같은 WAS로 보냄구현 단순, 추가 인프라 불필요WAS 장애 시 세션 소실, NAT 환경 집중
Session ReplicationWAS 간에 세션 데이터를 복제해 동기화WAS 어디에 요청이 가도 세션 유지노드 증가 시 복제 트래픽 급증
Session 외부화WAS 밖의 저장소(Redis)에 세션 보관WAS 재기동에도 세션 유지, 수평 확장 용이Redis 추가 운영 필요
💡개념

세션 하나가 노드 사이에서 살아남는 7단계 — 라우팅부터 페일오버 복원까지

사용자가 로그인하고 장바구니에 상품을 담습니다. WAS가 2대인데도 새로고침할 때마다 로그인이 유지되려면, 그 세션이 노드 사이에서 살아남아야 합니다. 이 과정은 LB 라우팅 → 세션 생성 → 데이터 저장 → 복제·외부화 → 재요청 라우팅 → 페일오버 → 세션 복원의 순서로 일어납니다. 이 단계를 나눠 두면 "왜 매 요청마다 로그아웃되지", "왜 노드 하나 죽으니 세션이 사라지지", "왜 배포 직후에만 로그아웃되지"를 특정 단계로 좁힐 수 있습니다.

TEXT
[사용자]  로그인 요청  →  Nginx(LB)
   │
   ① LB 라우팅 결정                         (sticky: ip_hash·jvmRoute로 고정 / 비sticky: round-robin)
   │
   ② tomcat1 도착 → 세션 생성               (new HttpSession, JSESSIONID=ABC.tomcat1 쿠키 발급)
   │
   ③ 세션에 데이터 저장                      (로그인 정보·장바구니 → tomcat1 JVM 힙)
   │
   ④ 복제 or 외부화                          (DeltaManager=전체 / BackupManager=백업1대 / Redis=외부저장)
   │    → 세션 객체를 직렬화(byte)해 전파·저장
   │
   ⑤ 다음 요청 재도착                        (sticky면 tomcat1, 아니면 tomcat2로 갈 수도)
   │
   ⑥ tomcat1 다운 → 페일오버                (LB가 살아있는 tomcat2로 넘김)
   │
   ⑦ tomcat2가 세션 복원                    (복제본 or Redis에서 로드 → 로그인 유지)
   ▼
[사용자]  로그인·장바구니 그대로 유지

각 단계에서 무슨 일이 일어나고, 막히면 어떤 증상인가:

단계하는 일여기서 막히면
① LB 라우팅sticky면 같은 노드로 고정, 아니면 요청마다 분산sticky 없이 round-robin + 복제·외부화도 없음 → 다음 요청이 세션 없는 노드로 → 매 요청 로그아웃
② 세션 생성최초 요청에 HttpSession 생성, JSESSIONID 쿠키 발급jvmRoute 미설정 → 쿠키에 .tomcat1 접미사 없음 → LB가 노드를 식별 못 해 sticky 불가
③ 세션 저장세션 데이터를 그 노드 JVM 힙에 보관(단일 WAS면 무해) 다중화하면 이 힙-로컬 저장이 소실의 근원
④ 복제·외부화세션을 직렬화해 전파(Delta=all-to-all · Backup=1대 · Redis=외부)세션 객체가 Serializable 아님 → NotSerializableException, 복제·저장 실패 · 복제 지연 창에 페일오버 시 최신 변경 유실
⑤ 재요청 라우팅sticky면 원래 노드로 되돌아옴복제 지연(수십~수백ms) 중 다른 노드 도착 → 방금 담은 장바구니가 안 보임(복제 랙)
⑥ 페일오버노드 다운 시 LB가 살아있는 다른 노드로 넘김sticky만 있고 복제·외부화 없음 → 죽은 노드의 세션 전부 소실
⑦ 세션 복원복제본·Redis에서 세션을 로드해 이어감JvmRouteBinderValve 없으면 원 노드 복구 시 세션이 튕겨감 · 배포로 세션 클래스 바뀌면 역직렬화 실패(InvalidClassException)

세 가지 세션 전략이 각각 어느 단계를 책임지는지가 한눈에 보입니다. sticky는 ①⑤(라우팅 고정)만 담당하고, 복제·외부화는 ④⑦(다른 노드에서 복원)을 담당합니다 — 그래서 sticky만으로는 ⑥ 페일오버를 못 버팁니다. 증상으로 단계를 역추적하면: 매 요청마다 로그아웃이면 ①(sticky도 공유도 없음), 노드가 죽을 때만이면 ⑥(복제·외부화 없음), 배포 직후에만이면 ⑦(직렬화 비호환)입니다. 어디서 세션이 사라지는지를 이 단계로 좁힌 뒤 해당 계층(LB·클러스터·직렬화)을 고칩니다.

Tomcat DeltaManager 클러스터 설정

💡개념

DeltaManager — All-to-All 세션 복제

Tomcat 클러스터는 추가 인프라 없이 WAS 노드끼리 세션을 동기화하는 방법입니다. 멀티캐스트로 클러스터 멤버를 발견하고, 세션 변경이 생길 때마다 그 차이(delta)를 전체 노드에 전파하는 방식이라 어느 WAS로 요청이 가도 동일한 세션을 읽을 수 있습니다. 단, 이 방식은 노드 수가 늘어날수록 복제 트래픽이 N-1배로 늘어나는 구조적 한계가 있어, 소규모 클러스터(2~3대)에 적합합니다.

Tomcat DeltaManager All-to-All 복제 — 모든 노드에 세션 변경분을 브로드캐스트확대

서버를 2대로 늘린 직후부터 장바구니가 비워진다는 민원이 쏟아집니다. Nginx 로그에는 이상이 없고, 두 Tomcat 모두 정상 동작 중입니다. 문제는 세션이 각자의 메모리에만 있다는 것입니다. 이 문제를 Tomcat 레벨에서 해결하는 방법이 DeltaManager 클러스터입니다. 세션이 변경될 때마다 변경분을 클러스터 전체로 전파해, 어떤 WAS로 요청이 가도 동일한 세션 데이터를 읽게 합니다.

Tomcat 내장 클러스터 기능을 사용하는 방법입니다. 세션이 변경될 때마다 그 차이(delta)를 클러스터 내 모든 노드에 멀티캐스트로 전파합니다. 어떤 WAS로 요청이 가도 최신 세션을 찾을 수 있습니다.

DeltaManager와 BackupManager의 차이는 복제 범위입니다. DeltaManager는 All-to-All — 클러스터 전체에 뿌립니다. 노드가 2~4대일 때 단순하고 효과적입니다. BackupManager는 Primary-Backup — 지정된 백업 노드 1개에만 복제해 트래픽을 아낍니다. 노드가 많을수록 BackupManager가 유리합니다.

server.xml<Cluster> 블록을 추가합니다. 두 Tomcat 서버 모두 동일하게 설정하되, jvmRoute 값만 다르게 줍니다.

XML
<!-- /opt/tomcat/conf/server.xml — Tomcat1 -->
<Server port="8005" shutdown="SHUTDOWN">
  <Service name="Catalina">
    <Engine name="Catalina" defaultHost="localhost" jvmRoute="tomcat1">

      <Cluster className="org.apache.catalina.ha.tcp.SimpleTcpCluster"
               channelSendOptions="8">

        <Manager className="org.apache.catalina.ha.session.DeltaManager"
                 expireSessionsOnShutdown="false"
                 notifyListenersOnReplication="true"/>

        <Channel className="org.apache.catalina.tribes.group.GroupChannel">

          <!-- 멀티캐스트로 클러스터 멤버 발견 -->
          <Membership className="org.apache.catalina.tribes.membership.McastService"
                      address="228.0.0.4"
                      port="45564"
                      frequency="500"
                      dropTime="3000"/>

          <!-- 세션 복제 수신 포트 -->
          <Receiver className="org.apache.catalina.tribes.transport.nio.NioReceiver"
                    address="auto"
                    port="4000"
                    autoBind="100"
                    selectorTimeout="5000"
                    maxThreads="6"/>

          <Sender className="org.apache.catalina.tribes.transport.ReplicationTransmitter">
            <Transport className="org.apache.catalina.tribes.transport.nio.PooledParallelSender"/>
          </Sender>

          <Interceptor className="org.apache.catalina.tribes.group.interceptors.TcpFailureDetector"/>
          <Interceptor className="org.apache.catalina.tribes.group.interceptors.MessageDispatchInterceptor"/>
        </Channel>

        <Valve className="org.apache.catalina.ha.tcp.ReplicationValve" filter=""/>
        <Valve className="org.apache.catalina.ha.session.JvmRouteBinderValve"/>
      </Cluster>

      <Host name="localhost" appBase="webapps">
        <!-- ... -->
      </Host>

    </Engine>
  </Service>
</Server>

Tomcat2는 jvmRoute="tomcat2"로만 바꾸면 됩니다. 나머지는 동일합니다.

클러스터가 동작하려면 애플리케이션의 web.xml<distributable/> 태그가 있어야 합니다. 이 태그가 없으면 Tomcat이 세션을 분산 가능한 형태로 관리하지 않습니다.

XML
<!-- WEB-INF/web.xml -->
<web-app>
  <distributable/>
  <!-- ... -->
</web-app>

멀티캐스트 포트(45564)와 복제 수신 포트(4000)는 방화벽에서 열어야 합니다.

로컬 터미널
# firewalld 환경
firewall-cmd --permanent --add-port=45564/udp
firewall-cmd --permanent --add-port=4000/tcp
firewall-cmd --reload

Nginx Sticky Session 설정

💡개념

ip_hash와 jvmRoute — 로드밸런서 레이어 세션 고정

세션 복제 없이 세션 소실 문제를 빠르게 막아야 할 때 Nginx ip_hash가 현실적인 첫 번째 선택지입니다. 코드나 애플리케이션 설정 변경 없이 Nginx 설정 파일 한 줄로 적용되기 때문에, 긴급 패치나 일정이 촉박한 상황에서도 즉시 투입할 수 있습니다. 다만 NAT 환경에서 트래픽 집중이나 WAS 장애 시 세션 전체 소실 같은 구조적 한계가 있으므로, 근본 해결책보다는 임시 대응으로 사용해야 합니다.

Nginx ip_hash sticky session — 클라이언트 IP 해시로 WAS 고정확대

DeltaManager를 설정하기엔 일정이 촉박하고, 당장 이번 주 배포에서 세션 소실 문제만 막아야 하는 상황이 있습니다. Nginx 설정 몇 줄로 같은 클라이언트를 항상 같은 WAS로 보내는 것이 ip_hash sticky session입니다. 코드 변경 없이 Nginx 레이어에서 처리하기 때문에 빠르게 적용할 수 있습니다.

세션 복제 없이 로드밸런서 레이어에서 세션 문제를 해결하는 방법입니다. Nginx가 동일한 클라이언트 IP를 항상 같은 Tomcat으로 보내면, 세션이 항상 같은 JVM에 있으므로 소실되지 않습니다.

ip_hash는 클라이언트 IP의 처음 세 옥텟(xxx.xxx.xxx)으로 해시를 계산합니다. upstream 서버 목록이 변경되지 않는 한 같은 클라이언트는 항상 같은 서버로 갑니다.

Nginx
# /etc/nginx/conf.d/tomcat-cluster.conf
upstream tomcat_cluster {
    ip_hash;
    server 10.0.0.1:8080;
    server 10.0.0.2:8080;
}

server {
    listen 80;
    server_name app.example.com;

    location / {
        proxy_pass http://tomcat_cluster;

        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        proxy_http_version 1.1;
        proxy_connect_timeout 10s;
        proxy_read_timeout    60s;
    }
}

jvmRoute를 설정하면 JSESSIONID 쿠키가 ABC123XYZ.tomcat1처럼 노드 식별자를 포함합니다. Nginx는 쿠키의 .tomcat1 접미사를 파싱해 해당 노드로 정확히 라우팅할 수 있습니다. ip_hash보다 더 정확한 sticky session이 필요하다면 nginx-sticky-module(쿠키 기반)을 사용합니다.

ip_hash의 한계:

상황문제
사무실 NAT (수백 명이 공인 IP 1개 사용)모든 트래픽이 단일 Tomcat으로 집중
모바일 사용자 (WiFi ↔ LTE 전환)IP가 바뀌면서 다른 노드로 이동, 세션 소실
upstream 서버 추가/제거해시 재계산으로 일부 클라이언트가 다른 서버로 이동
Tomcat 장애해당 노드를 사용하던 세션 전부 소실

이런 한계 때문에 ip_hash는 보조 수단으로 쓰고, 근본 해결책으로 세션 외부화를 선택하는 경우가 많습니다.

Redis 세션 외부화 (spring-session)

💡개념

왜 세션 복제보다 외부화가 더 나은가

세션을 WAS 메모리에 두는 한, WAS 재시작이나 스케일 아웃은 항상 세션 소실 위험을 동반합니다. Redis 외부화는 세션의 소유권을 WAS에서 분리해 이 문제를 구조적으로 해결합니다. 어느 WAS로 요청이 가든 같은 Redis를 읽기 때문에 세션 복제 트래픽도 없고, WAS를 마음대로 재시작하거나 늘려도 사용자 세션이 유지됩니다. Auto Scaling 환경이라면 세션 외부화 없이는 WAS를 탄력적으로 운영하기가 사실상 불가능합니다.

Redis 세션 외부화 — WAS는 세션 ID만 갖고 실제 데이터는 Redis에 저장확대

세션 복제(DeltaManager)의 근본 문제는 노드가 늘어날수록 복제 트래픽도 늘어난다는 점입니다. 노드가 N개이면 세션 변경 1건당 N-1번 복제가 발생합니다. 또한 WAS 재시작 시 메모리가 초기화되므로 배포 시 세션 소실이 일어납니다.

Redis 외부화는 이 문제를 구조적으로 해결합니다. WAS는 세션 ID(쿠키)만 클라이언트에 보내고, 실제 세션 데이터는 Redis에 저장합니다. 어떤 WAS로 요청이 가도 동일한 Redis를 읽으므로 세션이 항상 일치합니다. WAS를 재시작해도 Redis 데이터는 그대로이므로 세션이 유지됩니다.

Spring Boot 프로젝트 기준 설정입니다.

XML
<!-- pom.xml -->
<dependency>
    <groupId>org.springframework.session</groupId>
    <artifactId>spring-session-data-redis</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
PROPERTIES
# application.properties
spring.session.store-type=redis
spring.redis.host=redis-server
spring.redis.port=6379

# 세션 만료 시간 (초) — 기본값 1800초 (30분)
spring.session.timeout=1800

# Redis 커넥션 풀 설정
spring.redis.lettuce.pool.max-active=8
spring.redis.lettuce.pool.max-idle=8
spring.redis.lettuce.pool.min-idle=2

@EnableRedisHttpSession 어노테이션을 구성 클래스에 추가하면 spring-session이 Tomcat 기본 세션 관리자를 대체합니다.

Java
// SessionConfig.java
@Configuration
@EnableRedisHttpSession(maxInactiveIntervalInSeconds = 1800)
public class SessionConfig {
    // Redis 연결은 application.properties 설정으로 자동 구성됨
}

Redis에 저장된 세션은 spring:session:sessions:SESSION_ID 키로 Hash 자료구조로 보관됩니다. spring:session:expirations:TIMESTAMP로 만료 인덱스도 관리됩니다.

실습 1: 세션 클러스터 구성 확인

1DeltaManager 클러스터 멤버십 로그 확인

Tomcat 시작 후 catalina.out에서 클러스터 관련 로그를 확인합니다. 두 노드가 서로를 발견했다면 Member added 메시지가 나타납니다.

OUTPUT
INFO: Cluster is about to start
INFO: Receiver Server at /10.0.0.1:4000
INFO: Setting cluster member  /10.0.0.2:4000
INFO: Member added:10.0.0.2:4000 to cluster
INFO: Replication member joined: /10.0.0.2:4000

멤버 추가 로그가 보이면 멀티캐스트 통신이 성공한 것입니다. 다음 명령으로 멀티캐스트 포트 수신 여부도 확인합니다.

로컬 터미널
# 멀티캐스트 포트(45564) 수신 확인
ss -ulnp | grep 45564

# 클러스터 복제 포트(4000) 수신 확인
ss -tlnp | grep 4000
grep -i 'cluster\|member\|replication' /opt/tomcat/logs/catalina.out | tail -30
🔍실행 후 확인할 것
  • catalina.out에서 "Member added" 메시지를 먼저 찾는다 — 없으면 멀티캐스트 차단 또는 network 설정 오류. "Member added" 후 노드 IP가 나와야 하며 클러스터 노드 수만큼 메시지가 있어야 함
  • ss -ulnp | grep 45564로 멀티캐스트 UDP 포트를 확인 — java 프로세스가 없으면 Cluster 설정이 server.xml에 누락된 것. ss -tlnp | grep 4000도 확인해 복제 TCP 포트도 열렸는지 함께 검증
  • Member added 메시지가 있고 포트도 열렸는데 세션이 공유 안 되면 — web.xml에 <distributable/> 태그 누락이 원인. grep -r "distributable" /opt/tomcat/webapps/ 결과가 없으면 애플리케이션 재배포 필요
2Nginx ip_hash sticky session 설정 적용 및 검증

upstream 블록에 ip_hash가 설정됐는지 확인합니다. curl로 같은 IP에서 반복 요청 시 동일한 WAS로만 응답이 와야 합니다.

로컬 또는 서버
# 쿠키를 유지하며 반복 요청 — JSESSIONID 뒤 jvmRoute 확인
curl -v -c /tmp/sess.txt -b /tmp/sess.txt http://app.example.com/login 2>&1 | grep -i 'set-cookie\|jsessionid'

# 예상 출력: Set-Cookie: JSESSIONID=ABC123.tomcat1
# 이후 요청에서도 .tomcat1 이 유지되면 sticky session 동작 중
curl -s -c /tmp/sess.txt -b /tmp/sess.txt http://app.example.com/dashboard -I | grep -i 'server\|via'

Nginx 설정 변경 후에는 반드시 문법 검사 후 reload합니다.

로컬 터미널
nginx -t && systemctl reload nginx
nginx -T 2>/dev/null | grep -A 3 'upstream'
🔍실행 후 확인할 것
  • curl 응답에서 Set-Cookie 헤더의 JSESSIONID 값을 먼저 확인 — ".tomcat1" 또는 ".tomcat2" 접미사가 있으면 jvmRoute 설정 정상. 접미사가 없으면 server.xml의 jvmRoute 속성 누락
  • 같은 쿠키 파일로 10번 반복 요청 시 WAS 로그에서 접근 서버가 모두 동일하면 sticky 정상 — 2개 이상의 노드가 번갈아 기록되면 ip_hash 없이 round-robin으로 동작 중인 것
  • nginx -t에서 "syntax is ok"와 "test is successful" 두 줄이 모두 나오면 설정 파일 정상 — ip_hash가 있는데도 sticky가 안 되면 클라이언트 IP가 로드밸런서 뒤에서 고정되지 않는 경우로 $http_x_forwarded_for 기반 hash로 전환 검토

실습 2: Redis 세션 확인

3Redis에 저장된 세션 목록 조회

애플리케이션에 로그인 후 Redis에 세션이 저장됐는지 확인합니다.

DB 클라이언트
# 저장된 세션 수 확인
redis-cli keys "spring:session:sessions:*" | wc -l

# 특정 세션 상세 확인 (SESSION_ID 자리에 실제 ID 입력)
redis-cli hgetall "spring:session:sessions:SESSION_ID"

# 세션 만료까지 남은 시간(초)
redis-cli ttl "spring:session:sessions:SESSION_ID"

WAS 재기동 전후 세션 유지 여부를 검증합니다.

로컬 또는 서버
# 1) 로그인하여 세션 ID 기록
curl -c /tmp/redis-sess.txt http://app.example.com/login -d "user=test&pass=1234"

# 2) Tomcat 재시작
systemctl restart tomcat

# 3) 같은 세션으로 요청 — 로그인 유지 확인
curl -b /tmp/redis-sess.txt http://app.example.com/dashboard -I | head -5
redis-cli keys 'spring:session:sessions:*' | wc -l
🔍실행 후 확인할 것
  • redis-cli keys "spring:session:sessions:*" | wc -l 결과를 먼저 확인 — 0이면 세션이 Redis에 저장 안 된 것. 1 이상이면 저장 중이며 hgetall로 특정 키 내용 확인 가능
  • ttl 값으로 만료 설정 판단 — ttl이 -1이면 만료 시간 없음(세션이 영구 누적되어 메모리 고갈 위험), -2이면 키가 이미 만료되거나 없음, 양수면 남은 TTL(초). 기본 세션 타임아웃(30분)에 맞게 1800초 내외가 정상
  • Tomcat 재시작 후 동일 쿠키로 HTTP 200이 오고 세션 데이터(로그인 상태 등)가 유지되면 세션 외부화 성공 — 재시작 후 401이나 로그인 페이지로 리다이렉트되면 Redis 연결은 됐지만 세션 직렬화 오류

트러블슈팅

Tomcat 클러스터 시작 시 멀티캐스트 소켓을 바인딩하지 못하는 오류입니다. address="auto" 설정이 잘못된 인터페이스를 잡았거나, 멀티캐스트를 지원하지 않는 인터페이스에 바인딩하려고 시도하는 경우 발생합니다.

로컬 터미널
# 현재 네트워크 인터페이스와 IP 확인
ip addr show

# 멀티캐스트를 지원하는 인터페이스 확인
ip link show | grep MULTICAST

# Tomcat 클러스터 오류 로그 확인
grep -i 'multicast\|McastService\|Can.t assign' /opt/tomcat/logs/catalina.out | tail -10

address="auto"를 실제 서버 IP로 교체합니다.

XML
<!-- 수정 전 -->
<Receiver address="auto" port="4000" ... />

<!-- 수정 후 — 실제 서버 IP 명시 -->
<Receiver address="10.0.0.1" port="4000" ... />

컨테이너나 가상 인터페이스(docker0, lo)가 여러 개 있을 때 address="auto"가 잘못된 인터페이스를 선택하는 것이 주요 원인입니다.

server.xml<Cluster> 설정을 완료했는데 세션이 복제되지 않는 경우입니다. 가장 흔한 원인은 애플리케이션 web.xml<distributable/> 태그가 없는 것입니다. Tomcat은 이 태그가 있는 애플리케이션만 분산 세션 관리 대상으로 취급합니다.

로컬 터미널
# web.xml에 distributable 태그 확인
find /opt/tomcat/webapps -name "web.xml" -exec grep -l "distributable" {} \;

# 태그가 없다면 수동으로 확인
cat /opt/tomcat/webapps/ROOT/WEB-INF/web.xml | grep -i distributable

# 세션 복제 실패 로그 확인
grep -i 'session\|replicate\|distribute' /opt/tomcat/logs/catalina.out | grep -i 'error\|fail\|warn' | tail -20

web.xml<web-app> 태그 안 어느 위치에든 <distributable/> 한 줄을 추가하고 Tomcat을 재시작합니다. Spring Boot 내장 Tomcat을 사용하면 SpringBootServletInitializer가 자동으로 처리하지만, 외장 Tomcat에 WAR 배포 시에는 수동으로 넣어야 합니다.

Redis 장애 시 spring-session이 세션을 읽지 못해 사용자가 로그아웃되는 증상입니다. 기본 spring-session 설정은 Redis 연결 실패 시 예외를 던지고 세션을 복구하지 않습니다.

DB 클라이언트
# Redis 연결 상태 확인
redis-cli ping

# Redis 연결 거부 로그 확인
grep -i 'redis\|lettuce\|connection refused\|timeout' /var/log/your-app/app.log | tail -20

# Redis 슬레이브 복제 상태 확인 (Sentinel 또는 Cluster 구성 시)
redis-cli info replication | grep -E 'role|connected_slaves|master_host'

단기 대응으로는 Redis Sentinel이나 Redis Cluster로 고가용성을 구성합니다.

PROPERTIES
# application.properties — Redis Sentinel 연결
spring.redis.sentinel.master=mymaster
spring.redis.sentinel.nodes=sentinel1:26379,sentinel2:26379,sentinel3:26379

# 연결 타임아웃과 재시도 설정
spring.redis.timeout=2000
spring.redis.lettuce.pool.max-active=8

운영 환경에서 Redis를 단일 노드로 구성하면 Redis 자체가 SPOF(Single Point of Failure)가 됩니다. WAS 세션을 Redis에 외부화했다면 Redis 고가용성 설계도 함께 완료해야 합니다.

심화 — 노드가 죽는 순간, 그리고 배포하는 순간이 진짜 시험이다

💡개념

심화: 페일오버 재귀속(JvmRouteBinderValve)과 직렬화 호환성

세션 전략을 '평소에 세션이 유지되는가'로만 보면 절반만 본 것입니다. 진짜 차이는 노드가 죽을 때와 새 버전을 배포할 때 드러납니다.

  • sticky만: tomcat1이 죽으면 ip_hash가 클라이언트를 tomcat2로 넘기는데, 세션은 tomcat1 메모리에만 있었으니 그대로 소실됩니다. sticky는 '평소 고정'일 뿐 페일오버 안전장치가 아닙니다.
  • sticky + 복제(DeltaManager): tomcat2에도 세션이 복제돼 있어 유지됩니다. 그런데 JSESSIONID 접미사는 여전히 .tomcat1이라, tomcat1이 복구되면 sticky가 세션을 원래 노드로 되돌리려 합니다. 이때 JvmRouteBinderValve가 페일오버를 감지해 세션의 jvmRoute 접미사를 현재 처리 노드인 .tomcat2재기록합니다. 그래서 세션이 살아난 원래 노드로 튕겨가지 않고 새 노드에 안정적으로 눌러앉습니다(세션 재귀속).
  • 외부화(Redis): 세션이 WAS 밖에 있으니 노드가 죽어도 유지되고, 이론상 sticky조차 필요 없습니다. 대신 Redis가 SPOF가 되지 않게 HA가 전제입니다.

핵심 한계: 복제와 외부화는 둘 다 직렬화된 바이트를 다룹니다. 그래서 세션에 담기는 클래스가 배포로 바뀌면(필드 추가·타입 변경) 저장소에 남은 이전 버전 바이트를 역직렬화하다 실패합니다. 특히 세션 클래스에 serialVersionUID를 고정해 두지 않으면 재컴파일마다 UID가 달라져, 필드를 하나도 안 바꿔도 배포만으로 역직렬화가 깨질 수 있습니다. 단일 WAS 메모리 세션에서는 절대 안 보이다가 다중화·외부화하는 순간 배포 때 터지는 함정입니다.

상황: Redis 외부 세션(또는 클러스터 복제) 환경입니다. 배포가 끝나자마자 기존 세션 사용자들이 갑자기 로그아웃되거나 에러를 받는데, 새로 로그인하면 멀쩡합니다. 시간이 지나면(기존 세션이 다 만료되면) 저절로 사라지는 것도 특징입니다.

원인: 세션에 통째로 담아 두던 도메인 객체(예: 사용자 프로필 클래스)를 이번 배포에서 수정했습니다. 저장소(Redis)에는 이전 버전 클래스로 직렬화된 바이트가 남아 있고, 새 코드가 그걸 역직렬화하려다 클래스 구조 불일치로 InvalidClassException 등을 던집니다. serialVersionUID를 명시하지 않았다면 필드 변경이 없어도 재컴파일로 UID가 바뀌어 같은 증상이 납니다.

진단: 앱 로그에서 InvalidClassException·NotSerializableException·SerializationException을 배포 시각과 대조합니다. Redis에서 문제 세션 키의 값이 남아 있는지, 배포 전후 세션에 담기는 클래스의 필드 diff를 확인합니다. '새 세션은 정상, 옛 세션만 실패'라는 패턴이면 직렬화 호환 문제로 거의 확정입니다.

해결: 급하게는 배포 창에서 호환이 깨진 세션 키를 무효화(비우기)해 사용자가 새 세션을 재발급받게 합니다(재로그인). 근본 대책은 세션 클래스에 serialVersionUID를 고정하고, 세션 페이로드를 최소화(사용자 ID·권한만 담고 도메인 객체 통째 저장은 피함)하며, 스키마 진화가 필요하면 JSON처럼 버전 내성 있는 직렬화로 전환하는 것입니다. 세션 호환이 깨지는 릴리스는 처음부터 세션 무효화를 배포 계획에 포함합니다. 세션에 담는 객체의 직렬화 규칙은 WAR 배포부터 server.xml 튜닝, 장애 대응까지에서 다룬 클래스로딩·의존성 관리와 함께 봐야 합니다.

실무 맥락

💼
실무 맥락
현업 패턴

세션 전략 선택 기준 — 트래픽 규모와 인프라 비용에 따른 판단

실제 쇼핑몰이나 금융 서비스에서 WAS 이중화 시 세션 전략을 결정할 때는 세 가지 기준을 봅니다.

1. 트래픽 규모와 노드 수

노드 2~3대, 세션 크기 작음  →  DeltaManager (All-to-All)로 시작
노드 4대 이상, 세션 크기 중간  →  BackupManager 또는 Redis 외부화 검토
노드 수시로 변동 (Auto Scaling)  →  Redis 외부화 필수

Auto Scaling 환경에서 DeltaManager를 사용하면 노드가 추가될 때마다 전체 세션을 새 노드로 복제해야 합니다. 트래픽 증가로 노드를 늘리는 시점에 역설적으로 세션 복제 트래픽이 더 몰리는 문제가 생깁니다. Redis 외부화는 이 문제에서 자유롭습니다.

2. 세션 크기

세션에 사용자 ID와 권한 정보만 담으면 수 KB 이하입니다. 장바구니 전체, 파일 업로드 임시 데이터처럼 수십 KB 이상이면 세션 복제 비용이 급격히 높아집니다. Redis는 In-Memory이므로 대용량 세션도 빠르게 처리하지만, 메모리 비용도 함께 늘어납니다.

3. 배포 전략과 다운타임

서버 터미널
# 무중단 배포(Rolling Deploy) 시나리오
# 1) Nginx upstream에서 tomcat1 제외
# upstream 블록에 'server 10.0.0.1:8080 down;' 추가 후 reload

# 2) tomcat1만 재시작 (세션은 Redis에 있으므로 유지됨)
systemctl restart tomcat1

# 3) tomcat1 복귀
# 'down' 제거 후 reload
nginx -t && systemctl reload nginx

Redis 외부화 없이 DeltaManager + ip_hash 방식에서 Rolling Deploy를 하면, tomcat1 재시작 시 해당 노드의 세션이 일시적으로 소실됩니다. Redis 외부화 후에는 WAS 재기동과 세션 유지가 완전히 분리됩니다.

명령어·단축키 빠른 참조

이 모듈에서 다룬 세션 클러스터·sticky·Redis 외부화 진단 명령을 모았습니다.

명령어/단축키용도자주 쓰는 예
grep -i member catalina.out클러스터 멤버십 로그 확인Member added 나오면 멀티캐스트 성공
ss -ulnp | grep 45564멀티캐스트 UDP 포트 확인복제 TCP는 ss -tlnp | grep 4000
redis-cli pingRedis 생존 확인PONG 이어야 세션 외부화 동작
redis-cli keys 'spring:session:sessions:*'저장된 세션 조회redis-cli keys '...' | wc -l 로 세션 수
redis-cli ttl <key>세션 만료까지 남은 초-1(무기한 누적 위험)·-2(없음) 구분
redis-cli hgetall <key>세션 내용 확인Hash 필드로 저장됨
redis-cli info replicationRedis 역할·복제 상태role, connected_slaves
nginx -T | grep -A5 upstreamupstream·ip_hash 반영 확인sticky 설정 적용 여부
nginx -t && systemctl reload nginx문법검사 후 무중단 반영두 줄 OK 확인 후 reload
curl -c/-b <cookiefile>쿠키 유지 반복 요청JSESSIONID .tomcat1 접미사 sticky 확인
ip link show | grep MULTICAST멀티캐스트 지원 인터페이스address="auto" 오선택 진단
find webapps -name web.xml<distributable/> 확인-exec grep -l distributable {} \; 없으면 복제 안 됨
firewall-cmd --add-port=45564/udp클러스터 포트 개방--permanent--reload

관련 모듈로 더 깊이:

다음 모듈에서는 Tomcat JVM 튜닝과 GC 로그 분석을 다룹니다.

지식 확인

퀴즈 — 8문제

Q1

Tomcat 클러스터를 4대에서 8대로 확장하면서 세션 복제 트래픽이 급증해 네트워크 부하가 발생했습니다. 현재 DeltaManager를 쓰고 있습니다. 이 문제의 원인과 올바른 해결 방향은?

Q2

Nginx ip_hash로 sticky session을 구성하는 이유와 한계로 옳은 것은?

Q3

Redis 세션 외부화의 장점 — WAS 재기동 시 세션이 유지되는 이유는?

Q4

세션 클러스터 구성 후 jvmRoute 설정이 필요한 이유는?

Q5

WAS를 2대로 늘렸더니 '로그인했는데 새로고침하면 로그아웃'되는 현상이 생겼다. 가장 흔한 원인은?

Q6

세션을 복제하거나 Redis 같은 외부 저장소로 공유할 때 세션에 담는 객체가 반드시 만족해야 하는 조건은?

Q7

[심화] ip_hash sticky + DeltaManager 복제 환경에서 tomcat1이 죽어 클라이언트가 tomcat2로 넘어갔다. 세션은 복제돼 유지되는데, JSESSIONID의 .tomcat1 접미사를 .tomcat2로 바꿔 이후에도 tomcat2에 고정되게 하는 것은?

Q8

[심화] 새 버전 배포 직후 이미 로그인해 있던 사용자들이 무더기로 로그아웃됐다(로그인 로직은 안 건드림). Redis 외부 세션을 쓴다. 가장 그럴듯한 원인은?

0 / 8 답변

🧪 실습으로 확인하기

Nginx 설치 및 기동

초급

Linux 서버에 Nginx를 설치하고 systemd 서비스로 등록하여 80포트에서 응답하는 상태까지 만든다.

40📋 3단계💻 직접 환경
실습 시작하기 →

이것도 배워보세요