← ALL ENTRIES

배달의 민족 주문~! ServerSentEvents로 실시간 알림 전송하기 우아콘2025

ORIGINAL SOURCE ↗

YouTube에서 보기 | 영상 길이: 25분 47초

요약

발표 배경과 팀 소개

발표자는 배달의민족 주문접수 채널 팀의 한우석이다. 이 팀은 수십만 파트너(가맹점)가 주문을 빠르게 처리할 수 있도록 돕는 프로그램을 개발한다. PC, 앱, 안드로이드 포스 등 다양한 환경을 지원한다. 발표의 핵심은 “배달의민족 주문~!” 알림음으로 대표되는 주문 알림 시스템을 어떻게 바꿨고, 왜 바꿨는가를 공유하는 것이다.

목차는 크게 네 가지다: 기존 알림 시스템의 문제 → AWS IoT 도입 → SSE 도입 및 구현 → 전환 과정에서 생긴 문제들과 향후 개선 방향.


기존 알림 시스템과 4가지 문제점

기존 알림 시스템 구조는 다음과 같다: 각 도메인 서버 → MQTT Publisher → MQTT Broker → 클라이언트. 클라이언트는 실행 시 MQTT 브로커에 연결해 자신의 토픽을 구독한다. 주문 접수 같은 도메인 이벤트가 발생하면 서버가 DB에 기록한 뒤 MQTT Publisher를 통해 메시지를 발행하고, 브로커가 이를 클라이언트에게 전달한다. 클라이언트는 메시지를 수신하면 API를 호출해 실제 변경 내용을 확인하고 알림을 노출하는 구조였다.

이 시스템에는 크게 네 가지 문제가 있었다.

1. 제로 페이로드 문제 레거시 클라이언트에서 MQTT 메시지 페이로드가 일정 길이를 초과하면 잘려서 수신되는 버그가 있었다. 이를 해결하고자 페이로드를 최대한 작게, 즉 “1번 주문이 변경되었다”는 식별자 정보만 담는 제로 페이로드 방식으로 전환했다. 클라이언트는 알림 수신 후 API를 호출해 변경된 내용을 가져오고, 메모리에 있는 기존 데이터와 비교해서 알림을 노출했다. 이 구조 때문에 알림 문구와 알림 노출 시점이 클라이언트 코드에 박혀 있었고, 클라이언트 버전 업데이트 없이는 문구조차 바꿀 수 없는 문제가 생겼다.

2. 보안 문제 클라이언트가 MQTT 브로커에 연결할 때 인증은 있었으나, 토픽 구독 단계에서는 별도 인증이 없었다. 즉 B 파트너가 A 파트너의 토픽을 구독할 수 있는 환경이었다. 다만 제로 페이로드 방식이었기 때문에 메시지 자체에 유의미한 정보가 없어 실질적인 데이터 유출 위험은 낮았다.

3. 네트워크(포트) 문제 MQTT는 HTTP와 달리 1883 또는 8883 포트를 사용한다. 일부 특수한 네트워크 환경(예: 방화벽이 강한 가맹점)에서는 해당 포트가 차단되어 있었다. 그 결과 클라이언트는 MQTT 로직 외에도 주기적으로 서버를 확인하는 폴링 로직을 병행 운영해야 했다.

4. 웹 연결 어려움 MQTT 연결에는 X509 인증서가 필요한데, 웹뷰 환경에서는 여러 브라우저 탭이 동시에 열릴 수 있어 인증서 관리의 구현 복잡도가 매우 높았다.


AWS IoT 도입 — 1번, 2번 문제 해결 [06:00]

1번(제로 페이로드/버전 종속)과 2번(토픽 보안) 문제는 AWS IoT 도입으로 해결했다. AWS IoT는 AWS의 IoT 관리 서비스로, MQTT 브로커를 기본 제공하면서 보안·디바이스 관리·모니터링 툴도 함께 제공한다.

  • 보안 강화: MQTT 연결 전 X509 인증서를 발급하고, 인증서에 자신의 토픽만 구독할 수 있는 권한을 부여. B 파트너가 A 파트너의 토픽을 구독하는 것이 원천 차단되었다.
  • 페이로드 정형화: 보안이 강화되었으니 이제 메시지 페이로드에 유의미한 정보를 담아도 안전해졌다. 기존 제로 페이로드를 버리고, title, content, event_type, sound_type 등의 필드를 포함한 정형화된 페이로드로 변경했다. 클라이언트는 메시지 수신 즉시 페이로드 내 정보만으로 알림을 노출하면 된다. 더 이상 API를 호출해 변경점을 비교하는 로직이 불필요해졌고, 알림 문구와 노출 시점이 클라이언트 버전에 종속되지 않게 되었다.

단, 3번(포트/방화벽), 4번(웹 연결 복잡도) 문제는 AWS IoT도 MQTT를 사용하기 때문에 여전히 해결되지 않았다.


SSE 도입 결정 — 왜 WebSocket이 아닌가 [07:44]

남은 두 문제(포트 차단, 웹 연결 복잡도)를 해결하기 위해 실시간 통신 방식을 다시 검토했다.

  • 폴링: 주기적으로 API를 호출하는 방식. 데이터 변경이 없어도 요청을 보내야 하므로 비효율적. 탈락.
  • SSE(Server-Sent Events): 서버 → 클라이언트 단방향 푸시. HTTP 기반이라 포트 문제 없음.
  • WebSocket: 서버-클라이언트 양방향 통신.

SSE를 선택한 이유는 기존 아키텍처와의 정합성이다. WebSocket으로 전환하려면 잘 운영 중인 기존 REST API 전체에 WebSocket 인터페이스를 새로 정의해야 했다. 서버와 클라이언트 양쪽 모두 대규모 변경이 필요하고, 이는 알림 기능 구현에 오버엔지니어링이라고 판단했다. 알림은 서버에서 클라이언트로 보내는 단방향 푸시면 충분하기 때문에 SSE를 선택했다.


SSE 메시지 전달 아키텍처 — 3가지 방식 비교 [09:25]

메시지를 어떤 방식으로 클라이언트에게 전달할지 세 가지 방식을 비교했다.

방식 1: 세션이 연결된 서버로 API 직접 호출 클라이언트는 SSE 서버에 연결하고, 서버는 세션 매니저에 “이 클라이언트는 이 서버에 연결되어 있다”는 정보를 기록한다. 특정 클라이언트에게 메시지를 전달할 때, 세션 매니저에서 해당 클라이언트가 연결된 서버를 조회한 뒤 그 서버 API를 직접 호출한다.

  • 장점: 네트워크 홉 최소화, 레이턴시/비용 절감
  • 단점: 세션 주기적 관리 필요, 서버 새로 뜨는 중 메시지 유실 가능, 순서 역전 가능, 메시지 전송 API가 퍼블릭에 노출됨

방식 2: 브로커를 통해 세션이 연결된 서버로 전달 서버가 뜨면 자신의 구독 토픽을 생성하고 세션 매니저에 등록. 메시지 전달 시 세션 매니저에서 올바른 토픽을 조회하고 브로커를 통해 전달.

  • 장점: 브로커로 메시지 순서 보장, 효율적
  • 단점: 토픽 관리 복잡, 서버 추가 중 유실 가능성

방식 3: 모든 서버로 브로드캐스트 후 서버 측 필터링 (채택) 메시지 브로커가 모든 SSE 서버에 메시지를 전달하고, 각 서버가 자신에게 연결된 클라이언트에게 해당하는 메시지인지 필터링해서 전달.

  • 장점: 구현이 매우 단순, 스케일 인/아웃 용이, 메시지 순서 보장
  • 단점: 불필요한 네트워크 I/O 증가

3번 방식을 채택한 이유: 배달의민족 시스템에서 가장 중요한 것은 메시지 신뢰성이다. 유실 없이 순서대로 전달되는 것이 최우선이고, 쉬운 스케일 인/아웃도 중요하다. 네트워크 증가라는 단점은 감수할 수 있었다.


메시지 안정성을 위한 핵심 구현 4가지 [13:00]

1. Last-Event-ID 활용 (연결 순단 시 유실 방지) SSE는 HTTP 스펙으로 Last-Event-ID 헤더를 지원한다. 서버가 각 메시지에 ID를 붙여 전송하면 클라이언트는 마지막으로 수신한 ID를 저장해 둔다. 연결이 끊겼다가 재연결될 때 이 ID를 헤더에 담아 보내면, 서버는 해당 ID 이후의 미전달 메시지를 DB에서 조회해 재전송한다.

예시 흐름: 서버가 1번, 2번 메시지를 전송 → 연결 끊김 → 그 사이 3번, 4번 메시지 발생했지만 전달 불가 → 클라이언트 재연결 시 Last-Event-ID: 2 헤더 전송 → 서버가 3번, 4번을 재전송 → 유실 없이 복구.

구현은 Spring WebFlux + 코루틴(Coroutine Flow) 환경. 연결 요청이 오면 코루틴 플로우를 반환하고, Last-Event-ID 기준으로 DB에서 미전달 메시지를 먼저 전송한 뒤 이후 메시지를 실시간으로 스트리밍한다. 하트비트도 주기적으로 전송하며, 연결 종료 시 세션을 정리한다.

2. 커밋 메시지(Commit Message) 인터페이스 — 정확히 1회 전달 보장 주문서 출력, 신규 주문 알림 등 “절대 유실되면 안 되고 디바이스당 정확히 1번만 전달되어야 하는” 메시지가 있었다. 이를 커밋 메시지라고 정의하고 별도 필드 두 개를 추가했다: 커밋 여부 플래그 + 커밋 URL.

동작 방식: 클라이언트가 커밋 메시지를 수신하면 즉시 커밋 URL을 호출 → 서버는 DB에 전달 완료로 마킹. 서버는 주기적으로 커밋되지 않은 이벤트 목록을 조회하고, 재발행 가능한 이벤트만 필터링해서 다시 전송한다. 이미 접수된 주문이 재전달되는 것도 방지된다.

3. 세션 보안 강화 — 동일 세션 중복 접속 차단 HMAC 인증으로 API 보안을 기본 적용하는 것 외에, 메시지가 절대 유출되면 안 되기 때문에 같은 세션 ID로 동시에 두 클라이언트가 접속할 수 없도록 처리했다.

동작 방식: 악의적 클라이언트 A가 연결하면 서버는 S1 세션 ID를 부여하고 세션 매니저에 활성 세션으로 등록. 이후 정상 사용자가 재연결하면 S2 세션으로 덮어쓴다. 메시지 전송 직전에 세션 유효성을 검증 → 유효한 S2만 메시지를 수신하고, S1(악의적 세션)은 close 메시지를 수신하여 강제 종료된다.

4. 클라이언트 폴링을 서버 측으로 이전 기존에 클라이언트에는 여러 폴링 로직이 존재했다: 주문을 처리할 수 있는 환경인지 확인하는 폴링, 화면 상태를 변경하기 위한 폴링 등. 이는 일부 도메인 서버가 이벤트를 직접 발행할 수 없는 구조적 한계 때문이었다.

해결: SSE 연결이 수립되면 서버가 대신 각 도메인 서버를 주기적으로 호출해 상태를 확인하고, 변경이 있을 때만 SSE를 통해 클라이언트에게 전달한다. 기존에 클라이언트 → 서버 → 도메인으로 이어지던 네트워크 뎁스를 1단계 줄이고, 불필요한 클라이언트 폴링을 제거했다.


전환 과정에서 겪은 3가지 문제 [18:51]

1. 백프레셔(Backpressure) 문제 — 코루틴 채널 버퍼 설정 베타 환경에서 메시지가 수신되지 않는다는 제보를 받았다. 로그를 보니 “메시지 처리가 너무 오래 걸린다”는 로그가 남아 있었고, 카프카의 max.poll.interval.ms가 초과되어 파티션 할당이 해제되었다. 카프카 설정을 튜닝해도 동일한 문제가 반복되었고, 메시지 처리 성능 테스트에서는 이상이 없었다.

원인은 코드에 있었다. 메시지를 전달하기 위해 **코루틴 채널(Channel)**을 사용했는데, 별도 설정 없이 기본값으로 생성했다. 기본값은 capacity = RENDEZVOUS(버퍼 크기 0)이고, onBufferOverflow = SUSPEND(버퍼 가득 차면 중단)였다. 버퍼가 0이므로 프로듀서가 메시지를 보내려면 컨슈머가 즉시 받을 준비가 되어 있어야만 했다(랑데뷰는 프랑스어로 “만남”). 컨슈머가 조금이라도 처리가 늦어지면 프로듀서 코루틴이 suspend되고, 결과적으로 카프카 poll 간격이 초과된 것이다.

onBufferOverflow 옵션은 세 가지다: SUSPEND(기본, 버퍼 가득 차면 중단), DROP_LATEST(최신 메시지 버림), DROP_OLDEST(가장 오래된 메시지 버림). 해결은 적절한 capacity와 overflow 전략을 지정해 채널을 재생성하는 것으로 마무리되었다.

2. 배포 시 주기적 CPU 스파이크 배포를 하면 서버가 종료되면서 모든 클라이언트 SSE 연결이 동시에 끊어진다. 클라이언트들이 한꺼번에 재연결을 시도하고, 연결 유지 시간도 모두 동일하게 설정되어 있었기 때문에 일정 주기마다 모든 클라이언트가 동시에 연결했다 끊었다를 반복하게 되었다. 결과적으로 CPU가 주기적으로 급등했다가 떨어지는 스파이크 패턴이 나타났다.

해결: 각 연결의 유지 시간과 재연결 시도 시점에 **랜덤 지터(Jitter)**를 부여해서, 클라이언트들이 제각기 다른 타이밍에 연결/재연결하도록 분산시켰다. 이후 배포 시 CPU 스파이크가 사라지고, SSE 세션 수가 점점 안정적으로 복구되는 것을 확인했다.

3. 불필요한 네트워크 I/O로 인한 CPU 상승 PC, 웹뷰, 안드로이드 포스 등 프로그램마다 처리할 수 있는 메시지 타입이 달랐다. 그러나 서버는 클라이언트 종류와 무관하게 모든 클라이언트에게 모든 메시지를 전달하고 있었다. 처리할 수 없는 메시지도 네트워크를 통해 전달되면서 불필요한 I/O가 발생했고 CPU가 올라갔다.

해결: SSE 연결 수립 시점에 클라이언트가 처리 가능한 이벤트 타입 목록을 서버에 등록하고, 서버는 해당 이벤트 타입에 해당하는 메시지만 전달하도록 필터링했다.


향후 개선 계획 [23:40]

  1. 메시지 브로커 변경: 현재 Kafka를 사용하고 있으나, SSE의 특성에 더 맞는 경량 프로토콜로 전환을 검토 중이다. Redis Stream 또는 NATS 등으로 POC 예정.

  2. 모바일 앱 SSE 도입: 현재 모바일 앱의 알림은 전부 푸시(Push)로 수신한다. 앱이 포어그라운드 상태일 때는 SSE, 백그라운드 상태일 때는 기존 푸시를 사용하는 방식으로 전환할 예정이다. 푸시보다 훨씬 빠르고 비용도 절감할 수 있다.


성과 정리

SSE 도입으로 얻은 효과:

  • 서버 비용 절감 및 인프라 효율화
  • 일 평균 4천만 건 메시지를 안정적으로 처리
  • 클라이언트 폴링 제거로 네트워크 트래픽 감소
  • 클라이언트 버전 업데이트 없이 알림 문구와 노출 시점을 서버에서 제어 가능
  • HTTP 기반이라 방화벽/포트 문제 해결, 웹뷰 지원 가능
  • 코틀린 코드로 작성되어 팀 내 수정/반영 용이
  • 세션 보안 강화로 메시지 유출 방지

키워드

SSE, Server-Sent-Events, MQTT, AWS-IoT, Kafka, 코루틴-채널, Last-Event-ID, 백프레셔, Jitter, 배달의민족