이중화를 켰더니 로그인이 풀렸다 — 세션 클러스터링을 접고 Redis 중앙 세션으로
문제: 이중화했더니 로그인이 풀린다
고가용성이 요구되는 공공 시스템에서 웹 서버와 WAS(웹 애플리케이션 서버)가 1:1로 고정된 구성을 운영하고 있었다. 웹 1은 WAS 1로만, 웹 2는 WAS 2로만 요청을 보냈다. 서버는 두 벌이었지만 한쪽 WAS가 멈추면 다른 WAS로 요청을 넘겨 처리하지 못했다. 배포를 위해 WAS를 내릴 때도 해당 경로를 사용하던 이용자가 영향을 받았다.
이를 개선하려고 각 웹 서버가 양쪽 WAS로 요청을 보낼 수 있게 바꿨다. 그러자 다른 WAS로 라우팅된 사용자의 로그인이 풀린다는 문제가 보고됐다. 세션이 각 WAS의 로컬 메모리에 있었기 때문이다. WAS 1에서 로그인한 사용자의 다음 요청이 WAS 2로 가면, 그 서버에는 대응하는 세션이 없었다.
기존에는 요청 경로가 고정돼 있어 로컬 세션의 한계가 드러나지 않았다. 교차 라우팅으로 요청 경로는 바뀌었지만 로그인 상태는 각 서버에 남아 있었다. 다음 도표는 그 불일치를 단순화한 것이다.
접근: 세션 복제 대 중앙 세션
검토한 방식은 WAS끼리 세션을 복제하는 방식과 공통 저장소에서 세션을 읽는 방식이었다. 나는 별도 저장소를 추가하지 않고 시작할 수 있는 톰캣 세션 클러스터링을 먼저 시도했다. 그러나 당시 구성에서는 다른 WAS로 이동할 때 로그인이 풀리는 문제가 해결되지 않았고, 비동기 복제가 다음 요청을 따라가지 못하는 문제로 판단했다.
비동기 복제에서는 변경 내용이 다른 노드에 반영되기 전에 응답이 반환될 수 있다. 다만 톰캣의 모든 세션 복제가 비동기인 것은 아니다. 공식 문서는 동기·비동기 방식을 설정으로 구분하며, 전체 노드에 복제하는 방식과 백업 노드 한 곳에 복제하는 방식도 제공한다. 따라서 이 경험은 당시 구성에서 문제를 해결하지 못했다는 사례이며, 세션 복제 방식 전체가 부적합하다는 근거는 아니다.
나는 각 WAS가 세션 사본을 유지하는 대신 Redis에서 공통 세션을 읽는 쪽을 택했다. 인메모리 접근과 키의 유효 기간을 정하는 TTL(Time To Live)이 선택 이유였다. 관계형 DB에도 세션을 저장할 수 있으며, Spring Session도 JDBC 저장소를 지원한다. 세션이 짧게 유지되고 자주 읽힌다는 이유만으로 관계형 DB가 부적합하다고 단정할 수는 없다.
중앙 세션은 상태를 공유하는 위치를 분명하게 해 주지만 저장소 접근 비용과 장애 의존성을 추가한다. 네트워크 호출 횟수는 세션 구현에 따라 달라지며, Redis의 복제본을 읽는 구성이라면 별도의 복제 지연도 고려해야 한다. Redis 자체도 기본적으로 비동기 복제를 사용한다. 이 글에서 선택한 것은 WAS별 세션 사본을 공통 저장소로 옮기는 방식이지, 모든 동기화 문제를 없애는 방식은 아니다.
구현: 세션 분리와 중복 로그인 로직의 분산화
기존 세션 접근을 Redis 기반으로 바꾸면서 호출부와 저장할 데이터의 호환성을 맞췄다. 이때 중요한 것은 세션 객체를 외부에 보관하는 것뿐 아니라, 그 상태를 사용하는 코드도 같은 기준을 보도록 바꾸는 일이었다. 특히 단일 WAS의 메모리에 의존하던 중복 로그인 방지 로직을 함께 옮겨야 했다.
기존 정책은 같은 계정이 새로 로그인하면 이전 세션을 무효화하는 방식이었다. 한 WAS의 로컬 메모리만 확인해서는 다른 WAS에 로그인한 세션을 알 수 없다. 그래서 계정별 활성 세션의 기준점을 Redis로 옮기고 새 로그인 때 기존 세션을 무효화하도록 했다.
이 동작을 단순한 GET → DELETE → SET 예제로 옮기면 동시성 문제가 가려진다. 두 로그인이 같은 이전 세션을 읽은 뒤 각각 새 세션을 저장하면, 이전 세션만 삭제되고 새 세션 두 개는 모두 남을 수 있다. Redis 명령 하나의 원자성이 여러 명령으로 이루어진 로그인 처리 전체의 원자성을 뜻하지는 않는다.
아래는 실제 운영 코드를 복원한 것이 아니라, 같은 계정의 로그인이 겹칠 때 하나의 처리 단위로 다뤄야 할 작업을 정리한 것이다.
계정별 활성 세션 교체
이전 활성 세션 확인
이전 세션 무효화
새 세션 저장 및 만료 정책 적용
계정의 활성 세션을 새 세션으로 교체
구현할 때는 이런 읽기·수정 과정을 경쟁 요청과 조율해야 한다. 예를 들어 Redis의 WATCH를 이용한 조건부 트랜잭션은 읽은 키가 바뀌었을 때 처리를 중단하고 재시도할 수 있게 한다. 당시 사용한 원자화 수단과 TTL 갱신의 세부 구현은 남은 기록으로 확인하지 못했으므로, 여기서 특정 방식을 적용했다고 주장하지 않는다. 계정 매핑의 정리, 세션 만료와 로그아웃 처리는 함께 맞춰야 할 조건이다.
다른 곳에서 재로그인했을 때 이미 화면을 보고 있는 사용자에게 변화를 알리는 문제도 있었다. 당시에는 서버가 상태 변경을 보내는 연결을 사용하지 않았으므로, 클라이언트가 주기적으로 세션 유효성을 확인하는 폴링 방식을 택했다.
클라이언트가 세션 유효성 확인 요청
→ 서버가 세션과 계정의 현재 로그인 상태 확인
→ 무효한 세션이면 로그아웃 안내
→ 클라이언트가 로그인 화면으로 이동
폴링은 즉시 통지가 아니다. 보통 다음 확인 요청에서 무효화를 알게 되며, 네트워크 지연이나 백그라운드 탭의 실행 지연 때문에 설정한 주기보다 늦어질 수도 있다. 또한 화면의 로그아웃 처리와 서버의 접근 차단은 별개다. 폴링은 화면을 갱신하는 수단이고, 인증이 필요한 요청에서도 세션의 유효성을 확인해야 한다.
당시에는 주기적 요청과 통지 지연을 감수하고 구현·운영의 단순성을 택했다. 서버에서 상태를 보내는 방식도 대안이 될 수 있지만, 이 작업에서는 폴링으로 기존 사용자의 화면에 세션 무효화를 반영했다.
Redis는 공통 세션 저장소를 뜻하는 논리적 상자다. 당시 Redis의 복제·장애 조치 구성을 설명하는 도표는 아니다.
결과
어느 WAS로 요청이 전달되든 같은 세션을 사용할 수 있게 됐고, 교차 라우팅 때 로그인이 풀리던 문제가 해소됐다. 중복 로그인 판단도 특정 WAS의 메모리에 묶이지 않게 됐다. 요청 경로를 바꾸는 작업과 세션에 의존하는 로직의 변경이 함께 이루어진 결과였다.
그 결과 WAS를 한 대씩 배포하면서 사용자 세션을 유지할 수 있었다. 한쪽을 배포하는 동안 다른 WAS가 요청을 처리하고 Redis에 남은 세션을 사용했다. 이것이 이 경험에서 말하는 무중단 배포다. 기존 세션을 Redis로 옮기는 전환 자체를 무중단으로 수행했다는 의미와는 구분한다.
세션 공유만으로 모든 장애나 배포 중 요청 손실이 방지되는 것은 아니다. 실제 배포에는 트래픽 제외·복귀, 처리 중 요청의 종료, 새 버전과 기존 세션 데이터의 호환성도 필요하다. Redis를 추가한 뒤에는 그 저장소의 가용성도 함께 봐야 한다. 이 글의 성과는 WAS가 바뀔 때 세션을 유지하고 순차 배포할 수 있게 된 범위다.
학습
요청 경로를 바꾸면 상태의 위치도 점검한다. 서버가 두 벌이어도 다른 서버가 기존 로그인 상태를 읽지 못하면 세션은 이어지지 않는다. 이 사례에서는 교차 라우팅과 중앙 세션을 함께 적용했다.
공유 저장소에 옮긴 뒤에도 처리 단위를 확인한다. 중복 로그인 방지는 세션 저장 외에 계정별 활성 세션의 교체와 무효화가 맞물린다. 상태를 한곳에 둔 것과 여러 명령을 동시 요청에 안전하게 처리하는 것은 서로 다른 과제다.
실패한 대안은 당시 조건 안에서 해석한다. 톰캣 클러스터링으로 문제를 해결하지 못한 경험은 Redis를 선택한 근거였다. 다만 동기화 방식과 설정에 따라 결과가 달라질 수 있으므로, 그 경험을 모든 세션 복제의 한계로 확대하지 않는다.