Sticky Session을 제거하며 MCP 재연결 상태를 Redis로 옮긴 방법

롤링 배포 중 MCP 서버 Pod 하나를 종료했을 때, 클라이언트의 재연결은 정상적으로 다른 Pod에 도착했습니다. 문제는 그다음이었습니다. 새 Pod는 이전 연결에서 어디까지 응답을 보냈는지, 실행 중이던 도구 요청이 취소됐는지 알지 못했습니다. 연결에는 성공했지만 대화를 이어갈 수는 없었습니다.

처음에는 같은 클라이언트의 요청을 계속 같은 Pod로 보내는 Sticky Session으로 이 문제를 피했습니다. 평소에는 잘 동작했지만 Pod가 교체되는 순간에는 도움이 되지 않았습니다. 결국 해결해야 할 문제는 라우팅이 아니라, 재연결에 필요한 상태를 누가 소유하느냐였습니다.

Streamable HTTP와 두 식별자

이 글에서 다루는 서버는 MCP의 Streamable HTTP 전송 방식을 사용합니다. 서버는 필요할 때 SSE 스트림으로 응답을 보내고, 클라이언트가 다시 연결하면 마지막으로 받은 이벤트 ID 이후부터 응답을 이어갈 수 있습니다.

여기에는 두 식별자가 등장합니다. Mcp-Session-Id는 논리적인 MCP 세션을 찾는 데 사용하고, Last-Event-ID는 끊어진 스트림에서 클라이언트가 어디까지 받았는지를 나타냅니다. 두 값은 비슷해 보이지만 역할이 다릅니다. 세션 ID만 Redis에 저장한다고 해서 아직 전달하지 못한 이벤트까지 복구되는 것은 아닙니다.

Client ── request ──> Pod A
Client <── SSE event 41, 42 ── Pod A

Pod A 종료

Client ── Last-Event-ID: 42 ──> Pod B
Pod B ── 외부 저장소에서 43 이후 이벤트 조회 ──> Client

이 흐름을 먼저 적어 놓고 보니, 기존 설계가 연결 객체와 복구 데이터를 한 덩어리로 취급하고 있었다는 점이 보였습니다.

인증과 세션 복구의 경계

초기 구현에서는 OAuth 인증 상태도 MCP 세션의 일부처럼 다뤘습니다. 하지만 MCP 서버에 OAuth 2.1 인증을 붙인 과정을 다시 정리하면서 두 흐름을 분리했습니다.

OAuth의 state는 로그인 요청과 콜백을 연결하고 위조된 응답을 막기 위한 단기 데이터입니다. 인증이 끝난 뒤 MCP 연결을 복구하는 용도로 사용하지 않습니다. 액세스 토큰도 연결이 살아 있다는 이유로 생략하지 않고 HTTP 요청마다 검증합니다.

인증을 통과한 뒤에야 Mcp-Session-Id로 세션을 조회합니다. 이때 토큰에서 확인한 사용자와 세션 소유자가 일치하는지도 검사합니다. 이렇게 경계를 나누면 “인증에는 성공했는데 이전 스트림은 복구하지 못한 상황”을 별도의 문제로 설명할 수 있습니다.

Redis에 남긴 최소 상태

Pod의 메모리에 있던 모든 객체를 Redis로 옮기지는 않았습니다. 소켓과 SSE 연결 객체, 백엔드 클라이언트, 직렬화할 수 없는 프레임워크 객체는 새 요청에서 다시 만듭니다. 반대로 Pod가 사라진 뒤 다른 인스턴스가 알 방법이 없는 값은 외부 저장소에 남겼습니다.

  • 세션 소유자와 만료 시각
  • 마지막으로 발행한 이벤트의 위치와 재전송할 이벤트
  • 실행 중인 요청의 식별자와 상태
  • 사용자가 보낸 취소 의도
  • 오래된 Pod의 쓰기를 구분하기 위한 세션 세대 값

아래 코드는 실제 저장 구조를 설명하기 위해 필드만 단순화한 예시입니다.

data class SessionState(
    val ownerId: String,
    val generation: Long,
    val lastEventId: String?,
    val expiresAt: Instant,
)

val key = "mcp:session:$sessionId"
redis.opsForValue().set(key, state, Duration.ofMinutes(30))

여기서 lastEventId만 보관해서는 응답을 재전송할 수 없습니다. 이벤트 본문은 별도의 Redis Stream이나 이벤트 저장소에 일정 시간 유지하고, 클라이언트가 보낸 Last-Event-ID보다 뒤에 있는 이벤트를 조회해야 합니다. 세션 메타데이터와 이벤트 보관 기간도 같은 값으로 묶지 않았습니다. 연결은 살아 있지만 이미 소비한 이벤트는 더 일찍 정리할 수 있기 때문입니다.

새 Pod가 재연결을 처리하는 순서

재연결을 받은 Pod는 이전 Pod의 메모리 객체를 복원하려고 하지 않습니다. 요청에 담긴 정보와 외부 저장소의 데이터로 처리에 필요한 문맥을 새로 만듭니다.

먼저 액세스 토큰을 검증해 사용자를 확인합니다. 이어서 Mcp-Session-Id로 세션을 읽고 소유자가 일치하는지 검사합니다. Last-Event-ID가 있다면 그 이후의 이벤트를 조회해 새 SSE 연결로 전달합니다. 실행 중인 요청은 요청 ID로 상태를 확인하고, 이미 취소된 작업이라면 다시 시작하지 않습니다.

취소 처리에는 경쟁 조건이 있었습니다. Pod A에서 시작한 작업이 늦게 끝나면, 재연결을 처리한 Pod B가 갱신한 상태를 과거 결과로 덮을 수 있습니다. 그래서 상태를 쓸 때 세션의 generation을 함께 비교했습니다. 현재 세대와 맞지 않는 작업의 결과는 저장하지 않도록 해, 종료된 Pod의 늦은 응답이 새 세션에 섞이는 일을 막았습니다.

Sticky Session을 끄기 전 만든 실패 시나리오

설정을 먼저 제거하면 정상 요청이 분산된다는 사실만 확인할 수 있습니다. 필요한 것은 Pod가 사라진 뒤에도 흐름이 이어지는지 보는 테스트였습니다.

응답 스트림을 연 상태에서 Pod를 종료하고, 재연결 요청이 다른 Pod로 들어가게 했습니다. 새 Pod가 이미 받은 이벤트를 중복 전송하지 않는지, 아직 받지 못한 이벤트를 빠뜨리지 않는지 확인했습니다. 실행 중인 요청에 취소 알림을 보낸 뒤 기존 작업의 결과가 늦게 도착하는 상황도 함께 만들었습니다.

Redis 장애도 별도로 다뤘습니다. 토큰 자체를 검증할 수 없는 문제와, 인증은 끝났지만 기존 세션을 읽지 못하는 문제는 응답 정책이 달라야 합니다. 세션 저장소를 사용할 수 없을 때 무조건 새 세션을 만드는 방식은 중복 실행으로 이어질 수 있어, 복구할 수 없는 기존 요청은 명시적으로 실패시키는 편을 택했습니다.

이 검증을 통과한 뒤에야 Service의 세션 고정을 끄고 Pod를 순환 재시작했습니다.

apiVersion: v1
kind: Service
spec:
  sessionAffinity: None
  selector:
    app: mcp-server

상태가 없는 것은 Pod뿐입니다

이 구조에 상태가 없는 것은 아닙니다. 세션과 이벤트, 실행 상태는 여전히 존재하고 Redis도 운영해야 합니다. 달라진 점은 특정 Pod만 알고 있는 복구 불가능한 상태가 없어졌다는 것입니다.

그 결과 요청을 어느 Pod가 받았는지와 관계없이 같은 절차로 인증하고 세션을 찾고 스트림을 재개할 수 있었습니다. 롤링 배포를 위해 트래픽을 특정 인스턴스에 붙잡아 둘 필요도 없어졌습니다.

이후 비슷한 기능을 추가할 때는 “이 값이 없으면 다른 Pod가 처리를 이어갈 수 있는가”를 먼저 확인합니다. 다시 만들 수 있다면 요청 범위의 메모리에 두고, 이어받는 데 꼭 필요하다면 명시적인 프로토콜 상태로 관리합니다. Sticky Session을 제거한 핵심은 Redis 자체가 아니라 이 경계를 분명히 한 데 있었습니다.

댓글

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다