서비스의 가용성과 트래픽 분산을 위해 WAS(Tomcat 기반)를 이중화 또는 다중화하여 구성하는 경우가 많습니다.
이때 L4/L7 로드밸런서의 분산 규칙에 따라 유저의 요청이 서로 다른 WAS 프로세스로 번갈아 유입될 수 있으며, WAS 간에 인증/인가 세션 데이터가 공유되지 않으면 크리티컬한 장애로 이어집니다.
단일 서버 환경과 달리, 이중화된 WAS 간에 세션 데이터가 공유되지 않으면 다음이 발생합니다.
로드밸런서(L4/L7) 설정으로 최초 접속한 유저의 요청이 항상 동일한 WAS로만 전달되도록 고정합니다.
⚠️ 핵심 인식: 스티키 세션은 세션을 공유하는 것이 아니라, 동일한 연결(WAS)을 유지해 주는 역할입니다. 해당 WAS가 죽으면 그 서버에 묶여 있던 유저의 세션은 그대로 유실됩니다.
mod_jk)Tomcat의 jvmRoute 값과 Apache workers.properties의 워커 이름을 일치시키는 것이 핵심입니다. Apache가 JSESSIONID 뒤에 붙은 서버 ID(.node1)를 확인해 해당 서버로 요청을 고정합니다.
<!-- Tomcat server.xml : 인스턴스마다 고유 jvmRoute -->
<Engine name="Catalina" defaultHost="localhost" jvmRoute="node1">
<!-- 다른 인스턴스 -->
<Engine name="Catalina" defaultHost="localhost" jvmRoute="node2"># Apache workers.properties
worker.list=balancer,jkstatus
worker.node1.port=8009
worker.node1.host=192.168.0.10
worker.node1.type=ajp13
worker.node1.lbfactor=1
worker.node2.port=8009
worker.node2.host=192.168.0.11
worker.node2.type=ajp13
worker.node2.lbfactor=1
worker.balancer.type=lb
worker.balancer.balance_workers=node1,node2
worker.balancer.sticky_session=1
# 세션 쿠키 없는 첫 요청 실패 시 다른 서버로 넘길지 여부
worker.balancer.sticky_session_force=0방법 A: ip_hash (오픈소스 기본 제공) — 클라이언트 IP를 해싱하여 특정 WAS로 매핑. 모듈 설치 불필요.
http {
upstream backend_servers {
ip_hash;
server 192.168.0.10:8080 max_fails=3 fail_timeout=10s;
server 192.168.0.11:8080 max_fails=3 fail_timeout=10s;
}
server {
listen 80;
server_name example.com;
location / {
proxy_pass http://backend_servers;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
}주의: 동일 사설 IP·프록시(NAT) 뒤의 대규모 사내 유저는
ip_hash적용 시 특정 WAS 한 곳으로 쏠려 병목이 생길 수 있습니다.
방법 B: sticky cookie (Nginx Plus 또는 서드파티 모듈) — Nginx가 쿠키를 생성·주입하고 추적. mod_jk와 유사하며 분산이 가장 정교합니다.
http {
upstream backend_servers {
sticky cookie route_cookie expires=1h domain=.example.com path=/;
server 192.168.0.10:8080;
server 192.168.0.11:8080;
}
server {
listen 80;
server_name example.com;
location / { proxy_pass http://backend_servers; }
}
}WAS 자체 기능으로 서버 간 세션을 실시간 복제·동기화합니다. <Cluster> + DeltaManager(전체 델타 복제) 또는 BackupManager(1:1 백업)를 사용하고, 멀티캐스트 또는 정적 멤버십으로 멤버를 구성합니다.
<!-- server.xml -->
<Cluster className="org.apache.catalina.ha.tcp.SimpleTcpCluster">
<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>
</Channel>
<Valve className="org.apache.catalina.ha.tcp.ReplicationValve" filter=""/>
<Valve className="org.apache.catalina.ha.session.JvmRouteBinderValve"/>
<ClusterListener className="org.apache.catalina.ha.session.ClusterSessionListener"/>
</Cluster>세션에 저장되는 모든 VO/DTO는 Serializable 구현 + serialVersionUID 명시 고정이 필수입니다. (클래스 변경 시 역직렬화 실패 방지)
public class UserSessionInfo implements Serializable {
private static final long serialVersionUID = 1L;
private String userId;
private String role;
}실무 판단: "톰캣 클러스터링이면 간단히 된다"고 보기 쉽지만, 멀티캐스트 허용·노드 간 4000번대 포트 개방·정적 멤버십 유지·직렬화 호환 관리가 모두 필요합니다. 클라우드/보안망에서는 오히려 손이 더 갑니다.
WAS 메모리에 세션을 두지 않고, 외부 저장소로 분리하거나 상태를 아예 유지하지 않는(Stateless) 구조로 근본 해결합니다. 최근 엔터프라이즈에서 가장 선호되는 방향입니다.
WAS 메모리에 있던 세션을 별도 저장소로 이관합니다. Spring Boot에서는 Spring Session으로 코드 변경을 최소화하며 적용됩니다.
Spring Session 의존성 추가 후 세션 스토어 지정<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># application.yml
spring:
session:
store-type: redis
timeout: 30m
data:
redis:
host: redis-host
port: 6379@EnableRedisHttpSession
@Configuration
public class SessionConfig { } // 직렬화/역직렬화는 Spring Session이 처리메모리 기반 Redis는 고성능 Key-Value 조회가 가능해, 디스크 I/O 부담 없이 대용량 세션을 안정적으로 처리합니다. 다만 Redis 자체가 SPOF가 되지 않도록 Sentinel/Cluster 이중화가 함께 필요합니다.
서버에 세션을 저장하지 않고, 인증 정보를 서명된 토큰으로 클라이언트가 직접 보유합니다.
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String token = resolveToken(request);
if (token != null && jwtTokenProvider.validateToken(token)) {
Authentication auth = jwtTokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(auth);
}
filterChain.doFilter(request, response);
}
private String resolveToken(HttpServletRequest request) {
String bearer = request.getHeader("Authorization");
if (StringUtils.hasText(bearer) && bearer.startsWith("Bearer ")) return bearer.substring(7);
return null;
}
}⚠️ JWT도 "무비용"이 아닙니다. 로그아웃·강제 만료(블랙리스트), Refresh Token 회전·저장을 위해 결국 서버 측 메모리/저장소(Redis 등)가 필요합니다. 상태를 클라이언트로 옮겼을 뿐, 완전히 없앤 것은 아닙니다.
이상적 방향은 애플리케이션 단에서 제어 가능하고 확장성이 좋은 JWT 또는 외부 세션 스토어(Redis) 도입입니다. 인프라와 애플리케이션이 분리되어 향후 아키텍처 변경에 유연합니다.
그러나 Tomcat 클러스터링을 위한 멀티캐스트/포트 개방/정적 멤버십, Redis를 위한 공유 네트워크·이중화 구성이 어려운 환경도 많습니다. 이때는 프록시 + 내부 토큰이나 스티키 세션으로 이중화 경험을 유사하게라도 확보할 수 있습니다.
단, 스티키 세션은 실제 세션 공유가 아니라 "동일 연결 유지" 수준임을 항상 전제해야 합니다. 서버 장애 = 세션 유실입니다.
JWT는 클라이언트에게 책임을 전가하는 방식이지만, 이를 공유·무효화하기 위한 메모리(저장 공간)가 여전히 필요합니다.
어느 하나도 간단하지 않습니다. 시스템 규모·동시 접속자 수·개발 공수·유지보수 비용, 즉 소모 비용과 기회비용을 함께 따져 선택하는 것이 핵심입니다. 엔드유저 규모가 작고 빠른 안정화가 필요하면 스티키 세션도 충분히 좋은 선택지입니다.
댓글
GitHub 계정으로 의견이나 질문을 남길 수 있습니다.