실시간성 확보: SSE 도입부터 Polling으로의 회귀까지

실시간성 확보: SSE 도입부터 Polling으로의 회귀까지

1. 서론

Qra([Qurator]는 Vizend 플랫폼 서비스 중에 하나로 구독한 앱을 자동으로 배포하고 관리합니다)라는 GitOps 기반 배포 오케스트레이션 서비스 개발에 참여하며 직면한 가장 큰 사용자 피드백은 "내 배포가 지금 어디쯤 진행중인지 모르겠다"는 것이었습니다.

Qra의 배포 파이프라인은 요청 검증 → 배포 준비 → GitOps Push → ArgoCD Sync → Pod 안정화 → 완료 통보라는 단계를 거칩니다. 각 단계가 성공적으로 수행되어야 다음으로 넘어가며, 전체 과정은 수십 초에서 길게는 수 분까지 소요됩니다. 하지만 기존 시스템은 최종 결과가 나오기 전까지 중간 과정을 사용자에게 보여주지 못했습니다. 사용자는 엘리베이터 앞에서 현재 층수 표시기를 보지 못한 채 마냥 기다리는 듯한 답답함을 느껴야 했습니다. 이 파이프라인 내부 상태를 실시간으로 투영할 방법이 필요했습니다.

2. 왜 SSE(Server-Sent Events)였는가?

항목 SSE WebSocket Polling
데이터 방향 단방향(서버 → 클라이언트) 양방향 단방향(클라이언트 → 서버)
재연결 EventSource가 자동 처리 직접 구현 필요 해당 없음 (매 요청이 독립적)
인프라 호환성 표준 HTTP Upgrade헤더 처리 필요 표준 HTTP
구현 복잡도 낮음 중간~높음 낮음
실시간성 높음 (이벤트 발생 즉시) 높음 (이벤트 발생 즉시) 폴링 주기에 의존
서버 자원 연결당 emitter 유지 연결당 세션 유지 요청-응답 후 즉시 해제

실시간 데이터 전송을 위해 세 가지 선택지를 검토했습니다.

배포 모니터링의 본질은 서버에서 발생하는 상태 변화를 클라이언트에 전달하는 단방향 전파입니다. 굳이 양방향 통신인 WebSocket을 사용하여 구현 복잡도를 높일 필요가 없었습니다. 또한, SSE는 표준 HTTP를 사용하므로 인프라 설정에서 자유로웠고, 일시적인 네트워크 끊김 시 브라우저가 자동으로 재연결을 시도한다는 점이 매력적이었습니다. 당시 요구사항에서 SSE는 가장 경제적이면서도 강력한 해답이었습니다.

3. SSE 아키텍처 설계

3.1. 비즈니스 로직과 전송 인프라의 분리

"비즈니스 로직은 전송 채널의 존재를 몰라야 한다"는 원칙을 지키려고 하였습니다. 파이프라인을 담당하는 Feature 레이어에서 SseEmitter를 직접 다루게 되면 코드 간의 결합도가 높아져 추후 변경이 어려워지기 때문입니다. 이를 해결하기 위해 Spring의 ApplicationEventPublisher를 가교로 활용했습니다.

  1. Feature 레이어: "단계 완료" 혹은 "상태 변경"이라는 이벤트만 발행합니다.
  2. Facade 레이어: 이벤트를 구독하여 DB에 기록(TimelineFlow.record)함과 동시에 SSE로 전송(sseRegistry.broadcast)합니다.

이러한 이벤트 주도 구조 덕분에 배포 로직은 전송 매체가 무엇인지 신경 쓰지 않아도 되었으며, 이는 후에 SSE를 제거할 때 코드 수정을 최소화하는 결정적인 기반이 되었습니다.

3.2. 관심사의 분리에 따른 채널 전략

사용자마다 필요한 정보의 범위가 달랐습니다. 특정 배포 건의 상세 진행 상황을 보고 싶은 사용자도 있었고, 전체 앱의 배포 현황판을 관리하는 운영자도 있었습니다. 이를 위해 SseRegistry라는 관리 객체를 두고, 내부적으로 ConcurrentHashMap을 이용해 채널을 분리했습니다. 키 값을 deploymentId 혹은 pavilionId로 세분화하여, 필요한 이벤트만 타겟팅된 클라이언트에게 전송함으로써 불필요한 네트워크 트래픽을 방지했습니다.

3.3. 자원 고갈을 막는 방어 체계

SSE는 연결이 유지되는 동안 서버 자원을 점유하는 Stateful한 특성을 가집니다. 이를 방치하면 좀비 연결이 쌓여 서버 메모리와 스레드 풀이 고갈될 위험이 있었습니다. 이를 방지하기 위해 세 가지 방어 기제를 도입했습니다.

  1. 연결 상한제: key(deployment/pavilion)당 10개, 서버 전체 100개로 연결을 제한했습니다. 상한 초과 시 429 Too Many Requests를 반환하고, 클라이언트는 자동으로 Polling 모드로 전환되도록 설계하여 가용성을 확보했습니다.
  2. 즉각적인 자원 회수: onCompletion, onTimeout, onError 콜백을 등록하여 어떤 이유로든 연결이 종료되면 레지스트리에서 즉시 삭제되도록 했습니다.
  3. 능동적 하트비트 탐지: 25초 주기로 빈 데이터를 전송하여, 클라이언트가 비정상 종료되었으나 TCP FIN을 보내지 못한 '유령 연결'을 찾아내고 제거했습니다.

4. 수평 확장(Scale-out)의 벽

단일 인스턴스 환경에서 SSE는 설계 의도대로 완벽하게 동작했습니다. 하지만 운영 환경인 Kubernetes의 수평 확장 구조에서 한계를 드러냈습니다. SSE 연결은 특정 서버 인스턴스의 메모리에 종속됩니다. 하지만 배포 파이프라인은 Kafka 컨슈머 그룹이나 로드밸런싱에 의해 어느 인스턴스에서 실행될지 예측할 수 없습니다. 만약 클라이언트가 연결된 인스턴스와 이벤트가 발생한 인스턴스가 다르면, 이벤트는 사라지고 사용자는 아무런 업데이트를 받지 못하게 됩니다.

이 문제를 해결하기 위해 이미 사내에서 사용 중인 인프라들을 대안으로 검토했습니다.

A. Redis Pub/Sub

가장 먼저 검토한 것은 Redis였습니다. 모든 인스턴스가 Redis 채널을 구독하고, 이벤트 발생 시 Redis를 통해 모든 인스턴스에 메시지를 뿌려주는 방식입니다. 이 패턴은 많은 실시간 서비스에서 표준처럼 사용됩니다. 하지만 사내 공용 Redis가 주로 캐시 용도로만 운영되고 있어, Pub/Sub의 메시지 유실 특성(구독자가 없으면 즉시 소멸)에 대한 운영 방침을 또다시 정해야 했고, 또 다양한 고객사의 운영 환경에 유연하게 배포되어야 하는 Qra 특성상 Redis를 필수 의존성으로 가져가는 것이 옳은 것인지에 대한 고민이 있었습니다.

B. Kafka

Qra는 이미 Gallery 서비스와의 통신을 위해 Kafka를 활발히 사용 중이었으므로, 이를 SSE fan-out에도 활용하는 방안 또한 고려해 보았습니다. 하지만 검토 결과, Kafka는 이 목적에 너무 무겁고 결이 다른 도구였습니다.

  • Consumer Group 모델의 충돌: Kafka는 기본적으로 "한 메시지를 한 컨슈머가 처리"하는 경쟁적 소비 모델을 따릅니다. 모든 인스턴스가 동일한 이벤트를 수신하려면, 인스턴스마다 별도의 Consumer Group을 생성해야 합니다.
  • 동적 환경에서의 생명주기 관리: Kubernetes 환경에서 Pod(인스턴스)는 수시로 생성되고 소멸합니다. 인스턴스마다 고유한 Group ID(예: qra-group-pod-uuid)를 부여하고, Pod가 죽을 때마다 사용되지 않는 Group ID와 오프셋 정보를 Kafka 브로커에서 정리해야 하는 관리 부채가 발생합니다. 이는 Kafka의 설계 철학인 '대규모 로그 처리 및 영속성'과는 거리가 먼 방식이었습니다.
  • 내구성(Durability) vs 휘발성(Volatility): Kafka는 메시지를 디스크에 저장하고 오프셋을 관리하며 데이터의 안전한 전달을 보장하는 시스템입니다. 반면 SSE 이벤트는 "지금 연결된 사용자에게 전달되지 않으면 의미가 없는" 휘발성 데이터입니다. 찰나의 UI 업데이트를 위해 디스크 I/O와 복잡한 오프셋 관리를 수행하는 것은 과잉 엔지니어링(Overkill)에 가까웠습니다.

5. 증분 Polling으로의 회귀

다시 원점으로 돌아와 "배포 모니터링에 정말로 초 단위의 실시간 푸시가 필수적인가?" 라는 질문을 해 보았습니다.

배포는 주식 거래나 채팅처럼 밀리초(ms) 단위의 반응성이 생명인 도메인이 아닙니다. 5초에서 10초 정도의 지연은 사용자 경험에 치명적이지 않다는 결론에 도달했습니다. 결국 SSE를 걷어내고 커서 기반 증분 조회(Cursor-based Incremental Polling) 방식을 선택했습니다.

단순한 폴링은 매번 전체 데이터를 요청하므로 비효율적입니다. 이를 최적화하기 위해 다음과 같은 패턴을 적용했습니다.

  1. 클라이언트는 첫 요청 후 서버로부터 응답 시점의 시각(queryTime)을 받습니다.
  2. 이후 요청부터는 이 after = queryTime 파라미터를 함께 보냅니다.
  3. 서버는 해당 시각 이후에 발생한 변경분(Delta) 데이터만 골라 응답합니다.

이 방식은 서버의 부담을 줄이면서도, 실질적으로 사용자가 느끼는 데이터의 최신성은 SSE와 크게 다르지 않게 유지해주었습니다.

6. 결론 및 교훈: 걷어내는 것도 엔지니어링이다

SSE를 도입하고 다시 제거하기까지의 과정은 단순한 기술적 시행착오가 아니었습니다. 이 과정을 통해 얻은 교훈은 향후 설계의 이정표가 되었습니다.

첫째, 전송 채널의 추상화는 생명줄과 같다. 아키텍처 초기에 비즈니스 로직과 SSE 전송 로직을 엄격히 분리해둔 덕분에, 약 2,000줄의 SSE 관련 코드를 걷어내면서도 핵심 도메인 로직은 단 한 줄도 수정하지 않을 수 있었습니다.

둘째, 실시간성의 '진짜' 가치를 측정해야 한다. 기술적 화려함(SSE, WebSocket)에 매몰되기보다 도메인의 특성과 비용 대비 효과를 냉정하게 따져야 합니다. 때로는 단순한 솔루션이 가장 견고한 선택이 됩니다.

셋째, Statful 컴포넌트의 부채를 경계해야 한다. 분산 환경(Stateless 인스턴스)이 기본인 현대 인프라에서 서버에 상태를 저장하는 기술을 도입할 때는, 그 상태를 동기화하기 위한 유상태 인프라(Redis 등)의 비용까지 반드시 고려해야 합니다.

성공적으로 동작하던 기능을 제거하는 것은 아쉬운 일일 수 있습니다. 하지만 시스템의 복잡도를 낮추고 수평 확장의 걸림돌을 제거하여 더 단순하고 단단한 시스템을 만드는 것이야 말로 진정한 의미의 엔지니어링임을 깨달은 소중한 경험이었습니다.

HKK

Site footer