“1편 무중단 배포 Nginx HAProxy 둘 다 써본 후기“에서 다룬 무중단 배포 구조를 도입한 직후, 예상치 못한 민원이 폭주했습니다. “방금 로그인했는데 갑자기 로그아웃됩니다”. 원인은 명확했습니다. 외장 톰캣 두 대로 트래픽이 분산되는데, 세션은 각 톰캣 메모리에만 존재했기 때문입니다. 본 글은 그 문제를 해결한 톰캣 세션 클러스터링 도입 과정과, 실전에서 마주친 한계까지 10년차 Spring Boot 개발자 관점으로 정리합니다.
이 글에서는 server.xml 클러스터 설정을 풀 공개하고, DeltaManager와 BackupManager의 차이, 그리고 결국 JWT로 전환할 수밖에 없었던 세션 누락 이슈의 정체까지 다룹니다.
무중단 배포가 만든 새로운 문제
소프트 로드밸런싱을 도입하기 전까지는 톰캣이 한 대였기 때문에 세션 관리에 아무 문제가 없었습니다. HttpSession 객체는 톰캣 JVM 힙 메모리에 그대로 보존되었고, 모든 요청이 같은 인스턴스로 들어왔기 때문입니다.
그러나 무중단 배포를 위해 톰캣을 두 대로 분리하는 순간, 인증 상태가 갈라졌습니다. Nginx의 upstream이 라운드로빈으로 트래픽을 분산하면 사용자의 다음 요청은 다른 인스턴스로 갈 수 있고, 그곳에는 해당 세션이 존재하지 않습니다. 결과는 강제 로그아웃이었습니다.
선택지는 크게 세 가지였습니다.
| 해결 방식 | 장점 | 단점 |
|---|---|---|
| Sticky Session (IP 해시) | 코드 변경 없음 | 인스턴스 다운 시 모든 세션 소실 |
| 세션 클러스터링 (DeltaManager) | 모든 노드에 세션 복제 | 노드 수 증가 시 트래픽 폭증 |
| 외부 세션 저장소 (Redis) | 무한 확장 가능 | 추가 인프라 필요 |
당시 운영 환경은 톰캣 2대 고정이었고, Redis 도입은 보안 심사로 막혀 있었기 때문에 DeltaManager 기반 세션 클러스터링을 1차 선택지로 결정했습니다.
DeltaManager vs BackupManager 구조 차이
톰캣 세션 클러스터링은 두 가지 매니저를 제공합니다. 차이를 명확히 이해해야 운영 중 사고를 막을 수 있습니다.
DeltaManager는 모든 노드가 모든 세션을 복제 보관합니다. 세션이 변경되면 변경분(Delta)을 클러스터 전체로 멀티캐스트합니다. 노드가 N개일 때 복제 트래픽이 N×N에 가까워지므로 소규모(2~4대) 클러스터에 적합합니다.
BackupManager는 각 세션마다 Primary 1개 + Backup 1개 노드에만 저장합니다. 노드 수가 늘어도 복제 트래픽이 일정 수준에 머물러 대규모 클러스터에 적합합니다. 다만 백업 노드가 동시에 죽으면 해당 세션은 소실됩니다.
저희 환경은 2대 구성이라 DeltaManager가 정답이었습니다. 어차피 2대에서는 BackupManager도 사실상 모든 노드에 백업되는 형태이고, 설정 복잡도는 DeltaManager가 더 낮습니다.
server.xml 클러스터 설정 풀 공개
실제 운영에 적용한 conf/server.xml의 클러스터 블록 전체입니다.
<Engine name="Catalina" defaultHost="localhost" jvmRoute="tomcat-a">
<Cluster className="org.apache.catalina.ha.tcp.SimpleTcpCluster"
channelSendOptions="6">
<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="10.0.1.11"
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=".*\.(gif|jpg|png|js|css|ico|html)$"/>
<Valve className="org.apache.catalina.ha.session.JvmRouteBinderValve"/>
<ClusterListener className="org.apache.catalina.ha.session.ClusterSessionListener"/>
</Cluster>
</Engine>
핵심 포인트는 다음 네 가지입니다.
첫째, jvmRoute="tomcat-a"는 각 톰캣마다 다르게 설정해야 합니다(B 노드는 tomcat-b). 이 값이 세션 ID 끝에 붙어 어느 노드에서 만든 세션인지 추적합니다.
둘째, channelSendOptions="6"은 비동기(Async) + 안전한 전송 모드입니다. 동기 전송(channelSendOptions="8")은 신뢰도는 높지만 응답 지연을 유발합니다. 운영에서는 6이 무난합니다.
셋째, McastService의 address="228.0.0.4"는 멀티캐스트 주소입니다. 네트워크 장비가 멀티캐스트를 차단한 환경에서는 동작하지 않으므로 사전 확인이 필수입니다. 차단 환경이라면 StaticMembershipInterceptor로 노드 IP를 명시하는 방식으로 전환해야 합니다.
넷째, ReplicationValve의 filter 속성은 정적 자원 요청에는 세션 복제 트리거를 걸지 않도록 정규식 필터를 지정합니다. 누락하면 이미지 한 장 받을 때마다 세션 동기화가 발생해 트래픽이 폭증합니다.
web.xml과 애플리케이션 측 설정
세션 클러스터링을 활성화하려면 애플리케이션의 web.xml에 distributable 태그를 추가해야 합니다.
<web-app xmlns="https://jakarta.ee/xml/ns/jakartaee"
version="5.0">
<distributable/>
</web-app>
이 태그가 없으면 톰캣은 세션 복제를 시도하지 않습니다. Spring Boot에서 외장 톰캣을 사용한다면 src/main/webapp/WEB-INF/web.xml에 명시합니다.
또한 세션에 저장되는 모든 객체는 Serializable을 구현해야 합니다. 직렬화되지 않는 객체가 세션에 들어가면 복제 시 NotSerializableException이 발생하고, 사용자 입장에서는 또다시 로그아웃처럼 보입니다.
public class LoginUser implements Serializable {
private static final long serialVersionUID = 1L;
private final Long userId;
private final String role;
// ... getter, equals, hashCode
}
멀티캐스트 차단 환경에서의 정적 구성
운영을 하다 보면 고객사 IDC가 멀티캐스트를 차단한 사례를 자주 만납니다. 이 경우 멤버십을 정적으로 지정해야 합니다.
<Channel className="org.apache.catalina.tribes.group.GroupChannel">
<Membership className="org.apache.catalina.tribes.membership.cloud.CloudMembershipService"
membershipProviderClassName=
"org.apache.catalina.tribes.membership.StaticMembershipProvider"/>
<Interceptor className="org.apache.catalina.tribes.group.interceptors.StaticMembershipInterceptor">
<Member className="org.apache.catalina.tribes.membership.StaticMember"
port="4000" host="10.0.1.11" uniqueId="{0,1,2,3,4,5,6,7,8,9,0,1,2,3,4,5}"/>
<Member className="org.apache.catalina.tribes.membership.StaticMember"
port="4000" host="10.0.1.12" uniqueId="{0,1,2,3,4,5,6,7,8,9,0,1,2,3,4,6}"/>
</Interceptor>
</Channel>
uniqueId는 노드별로 반드시 달라야 하며, 같은 클러스터에서 중복되면 양 노드 모두 정상 동작하지 않습니다. 멤버 노드를 추가할 때마다 모든 노드의 server.xml을 갱신해야 하는 점이 단점입니다.
도입 후 마주친 세션 누락 이슈
설정을 마치고 운영에 투입한 직후에는 매우 만족스러웠습니다. 트래픽이 어느 톰캣으로 가도 사용자는 동일한 세션을 사용했고, 무중단 배포 시에도 로그아웃이 발생하지 않았습니다.
그러나 약 한 달 뒤부터 간헐적인 세션 누락 이슈가 보고되기 시작했습니다. 패턴은 다음과 같았습니다.
첫째, 동시 요청이 많을 때(특히 AJAX 다중 요청) 한쪽 노드에서 세션 값이 잠깐 비었다가 다시 복구되는 현상. 둘째, 장시간 트랜잭션이 진행 중일 때 복제가 지연되어 다른 노드에서는 이전 상태의 세션이 보이는 현상. 셋째, GC 풀 정지(Full GC) 가 길게 발생한 노드에서 복제 메시지를 놓치는 현상.
근본 원인은 명확했습니다. DeltaManager의 복제는 결국 비동기 메시지 기반이고, 네트워크와 GC라는 불확실성에 종속됩니다. 동기 모드로 바꾸면 응답 지연이 발목을 잡고, 비동기 모드를 유지하면 누락이 발생하는 트레이드오프의 함정에 빠진 것입니다.
channelSendOptions="8" (동기 전송)으로 바꿔 테스트해 보았지만, 응답 시간이 평균 30ms에서 110ms로 증가했습니다. SLA를 어기는 수준이라 운영 적용은 불가능했습니다.
무엇이 한계였는가 — 다음 단계로의 결단
세션 클러스터링은 2~3대 규모, 트래픽이 균일한 환경에서는 충분히 잘 작동합니다. 하지만 저희가 마주한 한계는 다음 세 가지였습니다.
첫째, 수평 확장의 비용. 노드를 3대 이상으로 늘리면 DeltaManager 복제 트래픽이 기하급수적으로 증가합니다. 둘째, 운영 부담. 멀티캐스트가 막힌 환경마다 정적 멤버십 설정을 다르게 관리해야 했고, 이는 곧 휴먼 에러의 온상이 되었습니다. 셋째, 세션 누락 이슈의 본질적 해결 불가. 동기와 비동기 사이 어느 쪽도 만족스럽지 않았습니다.
결국 팀은 세션 기반 인증 자체를 폐기하는 결정을 내렸습니다. 인증 정보를 클러스터 메모리에 보관하지 않고, 클라이언트가 토큰을 들고 다니는 무상태(stateless) 인증으로 전환하기로 한 것입니다.
이 여정은 3편 “Spring Security JWT 전환기“에서 자세히 다룹니다.
자주 묻는 질문 (FAQ)
Q1. Sticky Session만 써도 되는데 굳이 클러스터링이 필요한가요?
무중단 배포 환경에서는 Sticky Session만으로는 부족합니다. 배포로 인해 인스턴스가 내려가는 순간 그 노드에 묶인 모든 사용자가 로그아웃되기 때문입니다. 무중단 배포의 본질은 사용자에게 단절을 느끼지 못하게 하는 것이므로 세션 공유 또는 무상태 인증이 필요합니다.
Q2. 세션 클러스터링 대신 Redis 세션 스토어가 더 낫지 않나요?
대부분의 경우 Spring Session + Redis 조합이 더 낫습니다. 외부 저장소로 분리되어 톰캣 노드를 자유롭게 증감할 수 있고, 멀티캐스트 같은 네트워크 의존성도 사라집니다. 저희는 보안 심사 때문에 Redis를 못 썼던 특수 케이스였습니다. 신규 프로젝트라면 Spring Session Redis를 우선 검토하시기를 권합니다.
Q3. DeltaManager에서 BackupManager로 바꾸면 누락 이슈가 해결되나요?
근본 해결책은 아닙니다. BackupManager도 비동기 복제를 사용하기 때문에 GC나 네트워크 지연 영향을 받습니다. BackupManager는 트래픽 효율을 위한 선택이지, 복제 신뢰성을 위한 선택이 아닙니다.
Q4. 세션에 큰 객체를 넣으면 어떤 문제가 생기나요?
세션이 변경될 때마다 전체 객체가 직렬화되어 네트워크로 전송됩니다. 큰 List나 Map을 세션에 넣으면 단순 페이지 이동만으로도 MB 단위 트래픽이 발생합니다. 세션에는 식별자와 권한 정도만 두고, 큰 데이터는 DB나 캐시에서 조회하는 패턴이 안전합니다.
Q5. 톰캣 버전 차이가 클러스터링에 영향을 주나요?
같은 클러스터에 속하는 노드는 메이저 버전과 매니저 클래스의 시그니처가 동일해야 합니다. 톰캣 9와 10은 패키지 경로(javax → jakarta)도 다르기 때문에 혼합 운영이 불가능합니다. 업그레이드는 클러스터 전체를 한 번에 진행해야 합니다.
마무리
톰캣 세션 클러스터링은 무중단 배포의 부작용을 가장 빠르게 메우는 임시 해법이었습니다. 도입 초기에는 정말 깔끔하게 동작했지만, 트래픽이 늘고 운영 환경이 다양해질수록 한계가 분명히 드러났습니다. 특히 세션 누락 이슈는 사용자 입장에서는 단순한 “이상한 로그아웃”이지만, 디버깅하는 입장에서는 재현이 어려운 가장 골치 아픈 종류의 버그였습니다.
돌이켜 보면 세션 클러스터링은 반드시 거쳐야 했던 과정이었다고 생각합니다. 이 단계를 직접 운영하면서 세션 기반 인증의 본질적 한계를 체득했고, 그 경험이 다음 단계인 JWT 도입을 망설임 없이 결정할 수 있는 근거가 되었습니다. 단순히 “요즘 트렌드라서” 토큰을 도입했다면 운영 중에 더 많은 시행착오를 겪었을 것입니다.
다음 편에서는 그 결단의 결과인 Spring Security 기반 JWT 인증으로의 전환 과정과 점진적 마이그레이션 전략을 실제 코드와 함께 공유하겠습니다.
핵심 요약
- 무중단 배포 도입 직후 발생한 로그아웃 민원의 원인은 톰캣 간 세션 미공유였음
- 2대 규모에는 DeltaManager가 최적, 대규모 클러스터는 BackupManager가 유리
- server.xml의 jvmRoute, channelSendOptions, ReplicationValve filter가 핵심 설정 포인트
- 멀티캐스트 차단 환경에서는 StaticMembershipInterceptor로 정적 구성 필요
- 세션 누락 이슈는 비동기 복제의 본질적 한계로, 결국 무상태 인증(JWT) 전환의 계기가 됨
“톰캣 세션 클러스터링 도입과 한계 실전기”에 대한 3개의 생각