Server-Sent Events 로 실시간 알림 전달하기

2 weeks ago 8

식당에 있다 보면 “배달의민족 주문~!” 알림을 한 번쯤 들어보셨을 거예요. 이 알림은 주문이 들어올 때 사장님이 빠르게 확인하고 처리할 수 있도록 도와주는 주문 접수 프로그램에서 발생합니다. 이 프로그램을 만드는 팀이 바로 주문접수채널팀으로, Windows·macOS PC, 모바일 앱(Android, iOS), Android POS 등 다양한 환경에서 사용할 수 있도록 개발하고 있습니다.

그동안 알림 시스템은 큰 문제 없이 잘 동작해 왔습니다. 하지만 서비스가 커지고 사용자가 늘어나면서, 더 빠르고 안정적인 알림 전송이 중요해졌습니다.

이번 글에서는 기존 알림 시스템이 어떻게 동작했는지, 잘 동작하고 있던 시스템을 왜 변경했고, 어떻게 더 빠르고 신뢰도 있는 알림을 전달하도록 하였는지 공유합니다.

기존의 알림 전송 방식

기존의 알림은 MQTT(Message Queuing for Telemetry Transport)를 통하여 전달하였습니다. MQTT는 경량 메시지 프로토콜의 한 종류로, 이 글의 핵심 주제는 아니므로 상세한 설명은 생략하겠습니다.

전체 아키텍처 개요

기존 시스템의 메시지 전달 구조는 다음과 같습니다

  • 도메인 서비스: 주문, 배달 등 각 도메인에서 이벤트 발행
  • MQTT Publisher: 메시지 브로커로 전달
  • MQTT Broker: 클라이언트 연결 관리 및 메시지 전달
  • 클라이언트: PC, 모바일, POS 등 다양한 접수 프로그램

기존 알림 전송 방식의 문제점

기존 알림 전송에는 Zero Payload 형식의 메시지 전달, 보안, 네트워크 방화벽 차단, Webview 연결 불가 등의 문제들이 있었습니다.

Zero Payload 형식

MQTT로 전달받은 메시지의 Topic에 따라 클라이언트는 여러 가지 행동을 하였습니다. 그리고 레거시 클라이언트가 사용하는 라이브러리에서는 메시지 페이로드가 일정 바이트 이상 수신 못하는 이슈가 있었습니다.
이러한 이유로 전달하는 메시지는 Zero payload 형식으로 전달하고, 메시지를 수신하면 API를 호출하여 클라이언트에 하드코딩된 알림을 노출하고 있었습니다. 그래서 알림 문구가 클라이언트 버전에 종속되고 API 응답까지의 레이턴시가 있었습니다.

최소한의 보안

초기 구현 시점에는 MQTT 보안 적용의 복잡성으로 인해, 최소한의 연결 보안 외에는 별도의 정책이 적용되지 않은 상태였습니다. 하지만 Zero payload 형식으로 전달하고 있었기 때문에 다른 파트너의 Topic을 구독하더라도 보안상의 문제가 없었습니다.

네트워크 방화벽 차단

MQTT는 HTTP 프로토콜과 다르기 때문에 1883/8883 포트를 사용합니다. 하지만 일부 네트워크 환경에서는 해당 포트가 방화벽으로 차단되어 있어 메시지를 수신하지 못하는 문제가 있었습니다. 이를 해결하기 위해 MQTT를 사용하면서도, 메시지 수신을 보장하기 위한 Polling 로직을 병행해야 했습니다.

Webview에서 연결 어려움

모바일 앱과 Webview도 지원하고 있었으나, Webview 환경에서는 MQTT over WebSocket 연결에 제약이 있었습니다. 브라우저 환경에서의 인증서 처리 및 보안 문제, 그리고 여러 브라우저 탭이 동시에 열릴 수 있는 환경 특성상 구현 복잡도가 높았습니다.

저희는 설명드린 문제들을 해결하고자 먼저 많은 API를 사용하기 쉽게 제공하면서, 글로벌 서비스로 안정성이 검증된 AWS IoT를 도입하였습니다.

AWS IoT 도입

AWS IoT(Internet of Things)는 AWS에서 사물 인터넷을 관리하기 쉽게 도와주는 서비스입니다.
쉽게 사용할 수 있도록 MQTT Broker를 제공하고, 추가로 보안, 각각의 디바이스 관리 및 모니터링을 제공합니다.

메시지 보안 적용

AWS IoT를 통해 제일 먼저 연결과 구독에 보안을 적용하였습니다. 메시지를 클라이언트가 정의된 형식에 따라 노출하기만 하면 되도록 변경할 예정이었기에 보안이 필수였습니다.

각 클라이언트는 MQTT 브로커와 연결하기 전 X.509 인증서를 통하여 private key를 발급받아 연결합니다.
서버는 내부적으로 인증서에 권한을 부여합니다.

fun create(accountId: String, merchantNo: String): IotPolicy { return IotPolicy( version = "2012-10-17", statement = listOf( Statement( effect = "Allow", action = "iot:Subscribe", resource = listOf( "arn:aws:iot:ap-northeast-2:$accountId:topicfilter/rch/$merchantNo/*", ) ), Statement( effect = "Allow", action = "iot:Receive", resource = listOf( "arn:aws:iot:ap-northeast-2:$accountId:topic/rch/$merchantNo/*", ) ) ) ) }

이를 통해 각 클라이언트는 인가된 Topic의 메시지만 구독하도록 하여 보안을 강화할 수 있었습니다.

메시지 정형화

이제 메시지의 형식을 변경하여도 문제가 없습니다. 기존의 Zero Payload 방식에서 정형화된 메시지 형식으로 제공하고, 클라이언트는 노출만 하도록 하였습니다. 여기서 얻을 수 있는 가장 큰 장점은 클라이언트의 버전을 업데이트하지 않더라도, 새로운 알림을 노출할 수 있을 뿐만 아니라 메시지의 표현이 바뀌어도 모든 버전에서 다 같이 바뀔 수 있다는 점입니다.

{ "id": "123", "notification": { "groupId": "order-notification", "title": "신규 주문", "content": "배달ABCD - 김치찌개 외 2건 / 15,000원", "eventTypes": [ "팝업 실행" ], "soundTypes": [ "신규 주문 알림 재생" ] }, "data": { "..." }, "timestamp": "2025-02-05T20:30:29.443+09:00" }

이렇게 AWS IoT를 적용하였지만, 아직 남은 문제들이 있습니다.
AWS IoT도 MQTT를 사용하기 때문에, 네트워크 방화벽으로 MQTT 수신이 차단된 환경에서는 Polling이 필요했습니다. 그리고 아직 Webview에서는 메시지를 수신할 수 없었습니다.

위에 말씀드린 나머지 문제를 해결하기 위해 Server-Sent Events를 도입하였습니다.

SSE(Server-Sent Events)는 HTTP 연결을 통해 서버에서 클라이언트로 향하는 단방향 통신 채널을 만듭니다.
웹소켓(WebSockets)의 양방향 연결과는 달리, SSE는 서버가 클라이언트에게 업데이트 정보를 보내주기 위해 HTTP 연결을 계속 열어 두는 방식입니다.

실시간 통신 방식 비교 (Polling vs SSE vs WebSockets)

특징 Polling (폴링)Server-Sent Events (SSE)WebSockets (웹소켓)
연결 방식 HTTP/S 요청-응답 HTTP/S 기반의 단일 연결 유지 HTTP에서 업그레이드된 별도 프로토콜 (ws://, wss://)
데이터 흐름 클라이언트 → 서버
(단방향 요청)
서버 → 클라이언트
(단방향 푸시)
클라이언트 ↔ 서버
(양방향 소통)
효율성 🔴 좋지 않음 🟢 좋음 🟢 좋음
구현 난이도 쉬움 보통 다소 복잡함

이 중 저희는 Websockets과 SSE 방식을 고민하였습니다.

WebSocket 대신 SSE를 선택한 이유

사실 저희는 이미 양방향 통신을 하고 있습니다. 클라이언트에서 서버로는 REST API를 사용하고, 서버에서 클라이언트로는 실시간 메시지를 전달하는 구조입니다. 그렇다면 왜 WebSocket을 사용하지 않았을까요?

1. 기존 아키텍처와의 정합성

  • 이미 안정적으로 운영 중인 REST API 인프라가 있는 상황에서 WebSocket으로 전환하려면 모든 API를 WebSocket 기반으로 재구현해야 합니다
  • 두 가지 프로토콜을 동시에 운영하는 것보다 REST API + SSE 조합이 관리 비용 측면에서 효율적입니다

2. 단방향 통신으로 충분

  • 저희 서비스는 서버에서 클라이언트로의 알림 전달이 핵심입니다
  • 클라이언트의 응답이 필요한 경우 기존 REST API를 활용하면 충분합니다
  • WebSocket의 양방향 통신은 오버엔지니어링이 될 수 있습니다

이러한 이유로 SSE가 최고의 선택이었습니다.

메시지 전달 아키텍처

메시지 전달 방식 검토

클라이언트에게 메시지를 전달할 수 있는 방식에는 크게 세 가지 방법을 고민하였습니다.

  1. 세션이 연결된 서버의 API 호출
  2. 세션이 연결된 서버로 메시지 전달
  3. 모든 서버로 메시지 전달

저희 팀에서는 다양한 상황에서 메시지를 전달하고 있습니다. 그리고 메시지의 순서가 중요하고, 메시지의 유실이 일어나면 안 되는 상황입니다.

메시지 전달 방식 선택

1. 세션이 연결된 서버의 API 호출

메시지 발행자가 클라이언트가 연결된 서버를 찾아 직접 API를 호출하는 방식입니다.

장점:

  • 직접 전달: 연결된 서버에만 메시지를 보내므로 불필요한 네트워크 홉 최소화
  • 레이턴시 최적화: 브로커를 거치지 않아 상대적으로 빠른 전달 가능

단점:

  1. 세션 추적 복잡도
    • 클라이언트가 어느 서버에 연결되어 있는지 별도 관리 필요
    • Redis 등 세션 저장소 구축 및 실시간 동기화 필요
  2. 메시지 유실 위험
    • API 호출 시점에 서버 재시작 중이면 메시지 유실
    • 네트워크 타임아웃 발생 시 재시도 로직 복잡
  3. 보안 문제
    • SSE 서버의 메시지 전송 API를 외부에 노출 시 보안 위험

2. 세션이 연결된 서버로 메시지 전달

브로커를 사용하되 특정 서버 인스턴스에만 메시지를 라우팅하는 방식입니다.

장점:

  • 순서 보장 및 유실 방지: 브로커의 장점을 활용하여 메시지 순서 보장 및 영속화
  • 타겟팅 전달: 특정 서버에만 메시지를 전달하여 불필요한 네트워크 홉 감소

단점:

  1. 복잡한 토픽 관리
    • SSE 서버 인스턴스마다 별도 토픽 필요
    • 서버 10대면 토픽도 10개 관리해야 하는 운영 부담
  2. 세션 추적 여전히 필요
    • 클라이언트가 어느 서버에 연결되어 있는지 알아야 올바른 토픽에 발행 가능
    • 결국 1번 방식의 세션 추적 문제 존재
  3. 동적 확장 어려움
    • 서버 추가/제거 시 토픽 생성/삭제 필요
    • Auto Scaling 환경에서 관리 복잡도 증가
  4. 부하 불균형
    • 현재 서비스에서 균등한 클라이언트 라우팅 어려움
    • 서버 간 메시지 처리량 불균형 발생 가능

3. 모든 서버로 메시지 전달

메시지 발행자와 소비자 사이에 브로커를 두는 방식입니다.

장점:

  • 느슨한 결합: 메시지 발행자는 클라이언트의 연결 상태나 서버 위치를 알 필요 없음
  • 메시지 순서 보장: 브로커의 파티션/토픽 기반으로 순서 보장 가능
  • 확장성: 서버 인스턴스 추가 시 브로커 구독만 하면 됨
  • 장애 격리: 특정 서버 장애가 전체 메시지 흐름에 영향을 주지 않음

단점:

  • 레이턴시 증가: 세션의 위치를 모르기 때문에 모든 SSE 서버가 메시지를 받아 처리해야 하며, 불필요한 네트워크 홉 발생
  • 브로커 인프라 운영 필요: 별도의 메시지 브로커 시스템 구축 및 관리 필요

선택 근거

위 세 가지 방식을 비교한 결과, 저희는 3번 메시지 브로커 방식을 선택하였습니다.

의사결정 이유:

  1. 메시지 순서 및 유실 방지: Kafka와 같은 브로커는 파티션 내 메시지 순서를 보장하고, 컨슈머가 처리할 때까지 메시지를 보관합니다.
  2. 확장성: SSE 서버 인스턴스를 추가하더라도 브로커를 구독하기만 하면 되므로, Auto Scaling 환경에서도 유연하게 대응할 수 있습니다.
  3. 느슨한 결합: 메시지 발행자는 클라이언트 연결 상태를 전혀 신경 쓸 필요가 없습니다. 각 도메인 서비스는 브로커에 메시지만 발행하면 됩니다.
  4. 장애 격리: 특정 SSE 서버가 재시작되거나 장애가 발생해도, 브로커에 메시지가 보관되어 있어 복구 후 처리 가능합니다.
  5. 개발 용이성: 여러 인프라를 사용하지 않고도 코드 레벨에서 서비스를 관리할 수 있음

네트워크 홉이 증가하고 수십ms단위의 레이턴시가 증가하지만, 정확성과 안정성이 더 중요하므로 허용 가능한 수준이었습니다.

메시지 순서 보장 및 유실 방지

클라이언트와 연결이 되어 있더라도 메시지를 전달하는 Broker에 따라 메시지 순서가 역전되거나 유실이 생길 수 있습니다.

이를 해결하기 위해 다음과 같은 작업을 했습니다.

  • 파트너별로 파티션을 분리하여 메시지 순서를 보장
  • SSE 연결이 끊어진 경우를 대비하여 메시지 이력을 일정 기간 보관
  • Last-Event-ID를 통해 재연결 시 놓친 메시지를 차례대로 재전송
  • 메시지 전송 실패 시 재시도 메커니즘 구현

클라이언트와의 연결

저희 팀은 Spring Webflux + Coroutine 환경으로 구축되어 있습니다.

클라이언트는 Last-Event-ID 값과 함께 text/event-stream으로 연결하고, 서버는 해당 요청에 Flow를 응답합니다.

Last-Event-ID는 HTML spec으로 주기적인 재연결 시 전달받지 못한 메시지들을 받기 위해 사용하는 헤더 값입니다.
(출처: https://html.spec.whatwg.org/multipage/server-sent-events.html#eventsource-push)

@RestController class ServerSentEventController( private val serverSentEventConnectUseCase: ServerSentEventConnectUseCase, ) { @GetMapping(produces = [MediaType.TEXT_EVENT_STREAM_VALUE]) suspend fun connect( requestUserInfo: RequestUserInfo, @RequestHeader("Last-Event-ID") lastEventId: String?, ): Flow<ServerSentEvent<String>> = serverSentEventConnectUseCase.connect( requestUserInfo.merchantNo, requestUserInfo.deviceId, requestUserInfo.channelType, lastEventId, ) }

connect 메서드는 Coroutine Flow를 반환하여, 연결이 유지되는 동안 서버에서 flow의 emit을 호출할 때마다 실시간으로 메시지를 수신할 수 있습니다.

event:heartbeat id:test-id event:test data:test

Last-Event-ID를 활용한 메시지 유실 방지

클라이언트는 최초 연결일 경우 Last-Event-ID에 값을 넣어주지 않지만, 한 번 연결된 이후로부터는 메시지 유실을 방지하기 위해 마지막으로 수신했던 ID를 header에 포함합니다.

세션 관리 및 생명주기

서버는 클라이언트와 연결되었을 때 Session을 생성하고 메모리에 저장합니다. 그리고 Last-Event-ID 기준으로 전달받지 못한 이벤트가 있다면 전달합니다. 동시에 heartbeat 메시지를 전달합니다.
연결이 종료되면 세션을 정리하는 작업을 수행합니다.

override suspend fun connect( merchantNo: String, deviceId: String, channelType: ChannelType, lastEventId: String?, ): Flow<ServerSentEvent<String>> { val session = serverSentEventSessionUseCase.createSession(merchantNo, deviceId, channelType) return channelFlow { // 과거 이벤트 전송 (Last-Event-ID 기준) launch { serverSentEventPublishUseCase.getPastEvents( lastEventId, merchantNo, deviceId, ).forEach { session.emit(it) } } // Heartbeat 전송 launch { startHeartBeat(session) } // 연결 종료 시 세션 취소 awaitClose { session.cancel() } }.onCompletion { // 세션 정리 withContext(NonCancellable) { serverSentEventSessionUseCase.closeSession(session) } } }

메시지 보안

저희가 구성한 메시지 전달 방식은 각각의 파트너에게 메시지를 전달하고 있어서 다른 클라이언트의 메시지를 수신할 방법은 없습니다.

하지만 나쁜 의도로 각각의 클라이언트의 정보로 연결하게 된다면 메시지가 유출될 수 있는 상황이 생길 수 있습니다. (물론 연결에 인증과 HMAC을 통해 철저한 보안을 하고 있습니다.)

중복 연결 방지

그래서 저희는 각각의 기기에 대해 중복으로 메시지를 수신할 수 없도록 하였습니다.

  • 연결 시 Session ID 기반으로 활성 세션을 관리
  • 새로운 연결 시 기존 연결을 자동으로 종료
  • 메시지 전송 시 활성 세션인지 확인하여 활성 세션에만 메시지 전달
  • 잘못된 연결의 세션은 강제 종료

메시지 유실 방어

주문 접수 프로그램에서는 반드시 딱 1번 전달해야 하는 이벤트들이 있습니다. 여러 이벤트가 있지만 그중 하나의 예로 주문서 프린트가 있습니다. 주문서 프린트는 주문 알림뿐만 아니라 파트너분이 조리를 시작하고 신규 주문이 인입되었다는 가장 중요한 신호로 활용됩니다.

만약 같은 주문서가 여러 번 출력되면 파트너분들께서 중복 주문으로 오인하실 수 있고, 반대로 주문서가 출력되지 않으면 신규 주문을 인지하지 못하는 치명적인 상황이 발생할 수 있습니다. 이처럼 중요한 이벤트가 유실되거나 중복으로 전송된다면 사용자 경험을 크게 해치게 됩니다.

CommitEvent를 통한 수신 확인

저희는 이런 문제를 해결하고자 중요한 이벤트들에 대해 수신 확인을 하는 절차를 만들었습니다.
중요한 이벤트들을 CommitEvent라는 interface로 정의하고, 해당 이벤트에는 commitUrl이 포함되어 내려갑니다. 클라이언트는 이벤트를 수신하면 해당 URL을 호출하게 되어있습니다.

{ "id": "123", "notification": { "groupId": "order-notification", "title": "신규 주문", "content": "배달ABCD - 김치찌개 외 2건 / 15,000원", "eventTypes": [ "팝업실행" ], "soundTypes": [ "신규 주문 알림 재생" ] }, "data": { "..." }, "timestamp": "2025-02-05T20:30:29.443+09:00", // 중요한 이벤트라면 아래 두 필드 추가 "commitRequired": true, "commitUrl": "https://~~~~~" }

서버는 해당 URL이 호출되면 디바이스가 이벤트를 수신한 것으로 인지하고 DB에 마킹합니다.

비즈니스 로직을 고려한 재전송

override suspend fun getValidUncommittedEvents( fromCreatedAt: LocalDateTime, toIdNotInclude: String?, merchantNo: String, deviceId: String, size: Int, ): List<ServerSentEventMessage> { // 1. 커밋되지 않은 이벤트 목록을 조회합니다. val uncommittedEvents = clientEventMessagePersistentOutputPort.findUncommittedEvents( fromCreatedAt, toIdNotInclude, merchantNo, deviceId, size ) // 2. 재발행이 가능한 이벤트만 필터링하여 반환합니다. return uncommittedEvents.filter { isAvailableToRepublish(it) } }

수신 확인이 되지 않은 이벤트 중, 현재 주문 상태와 비교하여 불필요한 이벤트를 제외하고 전송합니다.

예를 들어, 클라이언트가 신규 주문에 대한 이벤트 수신을 하지 못했지만 이미 주문이 접수된 상태라면 주문서를 다시 출력하면 안 되는 정책이 있기 때문입니다. 이처럼 단순히 미수신 이벤트를 재전송하는 것이 아니라,
비즈니스 로직을 고려하여 유효한 이벤트만 재전송합니다.

클라이언트 Polling 제거

이제 SSE가 안전하고 정확하게 전달될 수 있는 환경이 구축되었습니다. 따라서 클라이언트에 존재하는 많은 Polling 로직들을 제거할 수 있습니다.
클라이언트에서는 알림을 노출하기 위한 것 외에 크게 2가지의 Polling 로직이 존재했습니다.

  1. 클라이언트의 Online 상태를 확인하기 위한 Polling
  2. 클라이언트의 화면을 갱신하기 위한 Polling

1. 클라이언트의 Online 상태를 확인하기 위한 Polling

클라이언트는 접수할 수 있는 환경인지 확인하기 위해 주기적으로 서버의 API를 호출하여 Ping을 보내고 있었습니다. 이제 이 로직을 제거할 수 있습니다.

private suspend fun startHeartBeat( session: ServerSentEventSession, ) { repeat(HEARTBEAT_COUNT) { session.heartbeat() authenticationUseCase.liveOn( session.merchantNo, session.deviceId, session.channelType, ) delay(interval) } session.close() }

위와 같은 로직으로 서버 내부적으로 heartbeat을 전송하는 시점에 online 상태 정보를 갱신하도록 하였습니다. 이를 통해 클라이언트는 별도의 Ping API 호출 없이도 온라인 상태가 유지됩니다.

2. 클라이언트의 화면을 갱신하기 위한 Polling

모든 화면 변경 정보가 실시간 이벤트로 제공되지는 않았습니다. 예를 들어, 특정 데이터는 다른 도메인 팀에서 관리하며, 해당 데이터의 변경을 저희 서버가 즉시 알 수 없는 경우가 있었습니다. 이 때문에 클라이언트는
데이터 변경 여부를 확인하기 위해 주기적으로 API를 호출(Polling)해야 했습니다.

이제는 세션 연결 시 클라이언트가 Polling하던 API를 서버가 대신 호출하도록 변경했습니다. 그리고 해당 결과가 갱신이 필요한 상태라면 서버는 클라이언트에게 SSE를 전송합니다.

override suspend fun getRequiredEvents( merchantNo: String, deviceId: String, version: Version?, ): List<ServerSentEventMessage> = buildList { // 새로운 알림이 있는지 확인하고, 있다면 이벤트를 추가합니다. if (notificationManagementUseCase.hasNewNotificationMessage(merchantNo)) { add(HasNewNotificationEventMessage()) } // 앱 버전 업데이트가 필요한지 확인하고, 필요하다면 이벤트를 추가합니다. if (minimumVersionManagementUseCase.checkRuntimeUpdateRequired(version)) { add(RuntimeVersionUpdateClientEventMessage()) } }

이를 통해 클라이언트 → 서버 → 외부 서버를 주기적으로 호출하고 있던 것을 필요할 때만 서버 → 외부 서버만 호출될 수 있도록 변경하여 네트워크 비용을 크게 줄였습니다.

전환하며 겪은 문제들

Backpressure 제어

저희는 kafka를 메시지 브로커로 채택하여 메시지를 전송하고 있었습니다. 이때 아래와 같은 에러가 발생했습니다.

Consumer poll timeout has expired. This means the time between subsequent calls to poll() was longer than the configured max.poll.interval.ms, which typically implies that the poll loop is spending too much time processing messages.
You can address this either by increasing max.poll.interval.ms or by reducing the maximum size of batches returned in poll() with max.poll. records.

max poll interval의 값을 초과하여 해당 파티션이 컨슈머 그룹에서 할당 해제되었습니다. Kafka의 메시지 수신을 처리하는 로직이 오래 걸려서인데요, 충분한 성능테스트를 하였고 정확히 메시지를 각 세션에 찾아 전달하는 로직 외에는 없었기 때문에 오래 걸릴 이유가 없었습니다.

이유는 Backpressure 처리가 없었기 때문인데요.

세션을 생성할 때 각각의 세션에 Channel을 생성하여 여러 코루틴에서 세션으로 전달해야 할 Event들을 전송하여 처리할 수 있도록 했습니다.

coroutine channel

여기서 보이는 값들이 default 변수들입니다. 여기 값들은 크게 생각하지 못하고, 기본값들로 Channel을 생성해 넣어준 것이 문제였습니다.

capacity에 RENDEZVOUS 라는 값이 기본값으로 되어있습니다. 해당 값은 0입니다. buffer가 0이라 만약 버퍼에서 Consumer가 처리가 늦어진다면 해당 코루틴은 계속 기다릴 것입니다. 이것이 Kafka의 중단을 일으켰습니다.

Rendez-vous 는 프랑스어로 만남이라는 뜻입니다. buffer의 capacity가 0이라 생산자와 소비자가 버퍼 없이 직접 만나 데이터를 교환한다는 의미로 지은 이름 같습니다. 참으로 감동적입니다 (그렇지만 ZERO라고 했으면 더 좋았을 것 같네요)

저희는 적절한 buffer 사이즈로 수정하고, 적절한 BufferOverflow 설정도 추가하였습니다.
그리고 저 에러를 다신 볼 수 없었습니다.

Thundering herd 문제

현재 여러 세션이 많이 연결된 환경에서 만약 서버를 재배포하거나, 알 수 없는 이유로 서버가 종료되어서 재시작된다면 모든 세션은 다시 한꺼번에 서버에 접속하기 위해 시도할 것입니다.
그렇게 되면 아래와 같이 트래픽이 계속 한꺼번에 몰려 CPU가 계속 spike 되는 현상이 생겼습니다.

이러한 현상을 제어하기 위해 각 세션 지속 시간을 random의 jitter 시간을 설정해 골고루 분포되도록 하였습니다.
그리고 서버가 종료되기 전 각 세션에 retry 시간을 random하게 부여하여 배포하더라도 골고루 연결을 시도할 수 있도록 하였습니다.

실제로 이 방식을 적용한 후, 배포 시 세션이 점진적으로 복원되는 것을 확인할 수 있었습니다

불필요한 네트워크 I/O 제거

앞서 말씀드린 것처럼 다양한 클라이언트 환경을 지원하고 있습니다. 그래서 저희가 발행하는 Event들 중 클라이언트별 기기 환경에서 처리할 수 있는 메시지가 다 다릅니다.
각 클라이언트에 처리할 수 없는 메시지를 전달하여도, 클라이언트는 무시합니다. 그렇게 된다면 불필요한 연산과 네트워크 I/O가 발생하여 비용이 더 발생할 수 있습니다.
그걸 방지하기 위해 각 연결 시 클라이언트는 자신이 받을 수 있는 Event들을 명시하여 연결을 시도합니다. 그럼, 서버에서는 해당 클라이언트가 처리할 수 있는 이벤트면 전송하고, 그렇지 않다면 전송하지 않습니다.
이렇게 불필요한 메시지를 전달하지 않음으로 네트워크 I/O를 줄였습니다.

향후 과제

현재 시스템이 안정적으로 운영되고 있지만, 더 나은 서비스를 위해 고민하고 있는 부분들이 있습니다

1. 메시지 브로커 최적화

  • 현재 Kafka를 사용 중이지만, SSE 특성에 더 적합한 브로커 검토
  • Redis Streams, NATS 등 대안 기술 PoC 진행 예정

2. 모바일 앱 SSE 도입

  • 현재 PC 클라이언트에만 적용된 SSE를 모바일 앱으로 확대
  • Foreground 상태에서는 SSE로 실시간 메시지 수신
  • Background 상태에서는 기존 Push Notification 활용
  • FCM, APNs 호출 비용 감소

이번 SSE 도입을 통해 다음과 같은 성과를 달성했습니다:

인프라 효율화

  • 서버 대수 감소
  • 메시지 처리량: 일평균 약 4천만 건의 이벤트를 안정적으로 처리
  • 네트워크 트래픽 감소: 클라이언트 Polling 제거로 불필요한 API 호출 제거
  • 표준 HTTP 포트 사용: 방화벽 이슈 해결 및 Polling 로직 제거

운영 개선

  • 클라이언트 제어: 서버에서 비즈니스 로직을 관리하여 클라이언트 배포 없이 기능 제어 가능
  • 알림 관리 유연성: 메시지 표현 변경 시 모든 버전에 즉시 반영
  • Webview 지원: 모든 플랫폼에서 일관된 실시간 알림 경험 제공
  • 운영 복잡도 감소: MQTT와 Polling을 동시에 운영하던 것을 SSE로 단일화
  • 보안 강화: HMAC 인증 및 중복 연결 방지를 통한 메시지 보안 향상
  • 개발 생산성 향상: 새로운 이벤트 타입 추가 시 서버만 수정하면 됨

앞으로도 파트너분들이 더 안정적이고 빠른 주문 접수를 경험하실 수 있도록 지속적으로 개선해 나가겠습니다.

Read Entire Article