비가 오면 사장님이 먼저 압니다: SSE로 만든 실시간 기상악화 알림
배민셀프서비스는 가게를 운영하는 파트너가 가게 정보, 메뉴, 할인, 배달지역을 직접 관리하는 서비스입니다.

이 중 배달지역은 한번 정해 두면 자주 바꿀 일이 없습니다. 그런데 기상이 나빠지면 이야기가 달라집니다. 폭우나 폭설이 오면 주문은 평소처럼 들어오는데 배달할 라이더가 부족해져, 취소되는 주문이 급격히 늘어납니다. 취소를 겪은 고객은 다시 주문하지 않을 가능성이 높아지고, 파트너는 가게 운영에 타격을 입습니다.
이 문제를 줄이려고 셀프서비스에 임시배달지역 조정 기능을 만들었습니다. 기상악화 같은 사유가 생기면 배달 반경을 일시적으로 좁혀, 지금 배달 여건으로 감당할 수 있는 가까운 주문 위주로 받는 기능입니다. 받을 수 있는 만큼만 받으니 취소를 줄일 수 있고, 기상이 좋아지면 원래 배달지역으로 되돌립니다. 다만 기능을 만들었다고 끝이 아니었습니다.
임시배달지역 조정 기능이 제 역할을 하려면 파트너가 "지금이 기상악화 상황이다"라는 사실을 제때 알아야 합니다. 기상악화는 비나 눈이 오는지만 보는 게 아니라, 외부 팀이 지역별 날씨와 주문 취소율 같은 걸 종합해 단계별로 판단합니다. 셀프서비스는 외부 팀이 이벤트로 발행하는 기상악화 단계를 받아 파트너에게 전달해야 합니다.
하지만 기존 셀프서비스에는 서버가 파트너에게 먼저 무언가를 보낼 방법이 없었습니다. 지금까지 셀프서비스의 통신은 파트너가 화면을 열거나 버튼을 누를 때 시작됐고, 서버는 그 요청에 응답만 하면 됐기 때문입니다. 파트너가 화면을 열 때까지 기다려서는 기상악화를 제때 알릴 수 없으니, 서버가 먼저 통신을 시작해야 했습니다.
그래서 셀프서비스에 SSE(Server-Sent Events)를 도입했습니다. 기상악화 이벤트는 다음과 같은 흐름으로 파트너 화면까지 전달됩니다.


셀프서비스 서버는 쿠버네티스 위에서 동일한 애플리케이션을 여러 개의 파드로 띄워 운영합니다. 파드는 애플리케이션이 실행되는 단위 하나를 가리킵니다. 파트너의 연결은 그중 한 파드에만 붙고, 어느 파드에 붙었는지는 서버가 따로 알지 못합니다.
기상악화 이벤트 한 건은 Kafka를 거쳐 모든 파드에 뿌려지고, 그중 파트너가 연결되어 있는 파드에서만 프론트로 전달됩니다. 프론트가 받는 건 뭔가 바뀌었다는 신호뿐이라, 최신 기상악화 상태를 다시 조회합니다.
연결 하나를 여는 건 어렵지 않았지만, 여러 파드에 흩어진 연결을 관리하는 일은 쉽지 않았습니다.
서버에서 클라이언트로 실시간 푸시를 보내는 방식을 검토 중인데, SSE가 자신의 서비스에 맞을지 판단이 서지 않는 분께 이 글이 도움이 되면 좋겠습니다.
1. 서버가 파트너 화면에 먼저 알리는 네 가지 방법
폴링으로 실시간처럼 보이게 하면 요청이 얼마나 늘어나는지 따져 봅니다. 그다음 SSE가 무엇인지, 롱 폴링과 WebSocket은 왜 안 썼는지, 왜 SSE를 골랐는지 차례로 봅니다.
폴링
개발 시점에 서버가 프론트에 제공하는 통신 수단은 REST API뿐이었습니다.
그래서 남는 방법은 프론트가 계속 물어보는 것이었습니다. 이걸 폴링이라고 합니다. 몇 초마다 "지금 기상악화인가요?"를 묻고, 답이 바뀌면 화면을 고쳐서 실시간처럼 보이게 하는 방식입니다.
여기서 걸리는 게 있습니다. 기상악화 알림을 받아야 하는 파트너는 전체 중 일부인데, 폴링은 묻는 쪽이 프론트라 서버가 그 일부만 골라낼 수가 없습니다. 누가 대상인지는 이벤트를 받아야 알 수 있으니까요. 결국 셀프서비스를 열어 둔 파트너 모두가 몇 초마다 "우리 지역 날씨가 어때?"를 묻게 됩니다.
주기를 정하면 요청 수는 바로 따라 나옵니다. 30초 주기면 접속한 파트너 수의 30분의 1이 매초 서버로 들어옵니다. 접속이 몰리는 저녁 피크에는 그만큼 더 커집니다.
그리고 그 요청의 거의 전부가 "기상 상태 좋음"이라는 답을 받습니다. 이벤트 한 건을 제때 전달하려고, 아무 일도 없는 시간 내내 모두가 요청을 보냅니다.
더 자주 물어보면 요청이 그만큼 늘고, 덜 자주 물어보면 그만큼 늦게 압니다. 30초마다 물어보면 최악의 경우 30초 뒤에 압니다. 지연을 줄이려면 부하를 키워야 하고, 부하를 줄이면 더 늦게 압니다. 어느 쪽으로 가도 실시간은 아닙니다.
SSE
SSE는 응답을 끝내지 않는 GET 요청입니다.
일반적인 REST API와 똑같은 GET 요청 하나로 시작합니다. 서버가 Content-Type: text/event-stream 으로 응답을 열고, 바디를 닫지 않은 채로 텍스트를 계속 씁니다. 클라이언트는 그 바디를 읽으면서 기다립니다. 보낼 게 생기면 서버가 한 줄 더 씁니다.
실제로 오가는 내용은 이렇게 생겼습니다.
retry: 7213
event: state-changed
data: {"shopOwnerNumber":12345,"uuid":"a3f1..."}
event: heartbeat
data:
SSE 표준이 정해 둔 형식에 따라 빈 줄로 이벤트를 구분하고, event 로 종류를 나누고, data 에 내용을 싣습니다. 형식은 이게 전부입니다. 프로토콜을 바꾸지 않고 HTTP 응답 본문에 그대로 실려 옵니다.
앞에서 계산한 폴링과 나란히 놓고 비교해 보겠습니다. 왼쪽은 프론트가 계속 물어보는 폴링, 오른쪽은 한 번 연결해 두고 서버가 보내는 SSE입니다.

롱 폴링과 WebSocket
서버가 먼저 보내는 방법으로 후보가 둘 더 있었습니다. 롱 폴링과 WebSocket입니다.
롱 폴링은 서버가 요청을 들고 있다가 이벤트가 생기면 응답하는 방식입니다. 그런데 이벤트 하나를 보내면 응답이 끝나서 클라이언트가 다시 요청해야 하고, 그 틈에 생긴 이벤트는 서버가 따로 보관해야 합니다. 연결을 유지해야 하는 부담은 똑같은데 할 일만 늘어납니다. SSE는 롱 폴링과 같은 자리에서 출발하되 응답을 닫지 않는다는 점만 다릅니다.
WebSocket은 양방향 채널입니다. 그런데 저희가 쓸 건 한 방향뿐이었습니다. 파트너가 서버로 실시간으로 보낼 데이터가 없고, 보낼 건 "상태가 바뀌었다"는 정보 하나입니다.
문제는 쓰지 않는 다른 방향의 통신도 공짜가 아니라는 점입니다.
텍스트 한 줄을 보내려고 인증, 로그와 지표, 프록시, 재연결 같은 걸 전부 새로 챙겨야 하는 셈이었습니다. SSE로 하면 인증과 로그, 지표는 쓰던 걸 그대로 씁니다. 저희한테 필요한 건 서버에서 클라이언트로 가는 한 방향뿐인데 WebSocket은 양방향이라, 쓰지도 않는 기능 때문에 관리 포인트가 늘어납니다.
SSE를 고른 이유
폴링에서 걸렸던 두 가지가 SSE에서는 걸리지 않습니다. 보내는 쪽이 서버라 대상을 고를 수 있고, 지연과 부하를 맞바꿀 일도 없습니다. 연결은 한 번만 맺고, 이벤트는 생긴 그 순간에 해당 파트너에게만 나갑니다. 30초를 기다릴 일도 없고, 날씨가 좋은 동안 모두가 묻는 불필요한 API 호출도 사라집니다.
롱 폴링과 WebSocket도 서버가 먼저 보낼 수 있습니다. 그래도 SSE를 고른 건 방향이 하나면 됐고, 지금 쓰는 걸 가장 덜 바꿔도 됐기 때문입니다.
SSE도 결국 HTTP GET 하나입니다. 응답을 끝내지 않을 뿐입니다. 주소도, 헤더도, 쿠키도 기존 API와 같습니다. 그래서 로그인 확인, 액세스 로그, 주소별 지표, 로드밸런서가 SSE 요청을 평범한 GET으로 보고 하던 대로 처리합니다. 추가 공수가 들지 않습니다.
연결을 바꾸는 절차도 없습니다. GET 요청 하나로 시작하니 중간에 선 로드밸런서나 프록시가 따로 알아야 할 게 없습니다. WebSocket은 HTTP로 시작해 WebSocket으로 바꾸는 절차를 거치고, 그 길목마다 절차를 허용해 줘야 합니다.
고민한 것들
우선 응답 시간 지표가 엉뚱한 값이 됩니다. 응답 시간은 요청이 들어온 때부터 응답이 끝난 때까지를 잽니다. 그런데 SSE는 연결이 끊겨야 응답이 끝납니다. 수십 분 붙어 있던 연결이 그대로 응답 시간으로 찍히니, 그래프만 보면 API가 느려진 것처럼 보입니다.
더 크게 걸린 건 따로 있었습니다. 알림이 안 갔을 때 어디서 끊겼는지 알 수 없습니다. "파트너가 알림을 못 받았다"는 문의가 오면 그거 하나 확인하는 데만 시간을 많이 씁니다. 그래서 이벤트와 가게별로 어느 단계까지 갔는지 추적할 수 있게 만들었습니다.
앞으로 풀어 갈 네 가지
파트너 브라우저와 서버 사이의 SSE 연결은 열었지만, 그 연결을 관리하기가 어려웠습니다.
아래 네 가지를 중점으로 고민했습니다.
| 고려한 것 | 어떤 문제였는가 |
|---|---|
| 연결이 붙은 파드 | 파드가 여럿인데 어느 파드로 보낼 것인가 |
| 연결 개수 | 연결이 수만 개로 늘면 무엇이 같이 커질 것인가 |
| 연결 생명주기 | 평소 아무것도 오가지 않는 연결을 어떻게 살려둘 것인가. 끊긴 연결은 어떻게 다시 이을 것인가 |
| 전달 보장 | 못 받은 이벤트는 어떻게 처리할 것인가 |
2. 연결이 어느 파드에 붙었는지 모르는 문제

첫 번째 고려사항은 연결이 어느 파드에 붙어 있느냐입니다. 파드가 여럿일 때 이벤트를 어디로 보낼지 봅니다.
서버는 여러 파드로 뜨는데 SSE 연결은 특정 파드에 붙습니다. 파트너 A가 2번 파드에 붙어 있으면 2번 파드만 그분에게 이벤트를 보낼 수 있습니다. 다른 파드는 그 파트너와 연결돼 있지 않습니다.
서버는 파트너 A가 몇 번 파드에 붙었는지 모릅니다. 로드밸런서가 붙여준 거라 따로 기록하지 않으면 알 수 없습니다.
게다가 한 파트너가 연결을 하나만 여는 게 아닙니다. 가게 PC와 사장님앱을 같이 켜 두면 연결이 둘이고, 그 둘은 서로 다른 파드에 붙을 수 있습니다. 파드 하나를 찾아 보내는 것으로는 부족합니다. 그 파트너의 연결이 붙어 있는 파드가 전부 이벤트를 받아야 합니다.
알려면 어딘가에 파트너와 파드의 짝을 기록하고, 파드가 뜨고 죽을 때마다 갱신하고, 끊긴 연결을 정리해야 합니다. 그 상태를 관리하지 않으려고 "전부에게 보낸다"를 택했습니다. 자기한테 붙은 파트너만 골라 보내고 나머지는 버립니다. 이걸 fan-out이라고 부릅니다.

SQS와 Kafka
원천 이벤트는 외부 팀이 AWS SNS로 발행하고 셀프서비스는 SQS로 받습니다.
SQS는 큐 하나에 소비자가 여럿 붙으면 메시지를 나눠 갖습니다. 한 파드가 가져가면 다른 파드는 그 메시지를 못 봅니다. 저희가 원하는 건 나눠 갖는 게 아니라 다 같이 받는 것이라 맞지 않았습니다. 모두가 받게 하려면 파드마다 큐를 파야 하고, 파드가 늘고 줄 때마다 손으로 관리해야 합니다.
그래서 SQS에서 받은 이벤트를 Kafka로 다시 발행하고, 파드마다 groupId를 다르게 줘서 모든 파드가 같은 메시지를 받게 했습니다. groupId를 공유하면 파드끼리 메시지를 나눠 갖습니다. 그러면 파트너가 붙어 있는 파드가 정작 그 메시지를 못 받는 일이 생깁니다.
Kafka는 같은 파티션 안에서만 순서를 보장해서, 파티션 키를 파트너 번호로 지정했습니다. 그리고 지난 기상악화를 뒤늦게 띄우면 틀린 정보라, 파드가 새로 뜨면 쌓여 있던 메시지는 건너뛰고 새로 오는 것부터 받게 했습니다.
3. 연결 수만 개가 만드는 부담

두 번째 고려사항은 연결 개수입니다. 연결이 수만 개가 되면 연결마다 하나씩 생기는 Sink와 프록시가 쓰는 메모리, 그리고 남는 로그가 같이 늘어납니다.
SSE 연결은 응답이 끝나지 않습니다. 파트너가 탭을 닫을 때까지 열려 있습니다. 그래서 동시 연결 수는 접속 중인 파트너 수와 거의 같고, 저녁 피크에 가장 높습니다.
기상악화가 없으면 보낼 것도 없어서 연결 하나가 하루 동안 하는 일은 거의 없습니다. 그런데도 메모리와 포트는 연결이 살아 있는 내내 잡혀 있습니다. 부담이 주고받는 양이 아니라 연결 개수를 따라갑니다.
파트너 연결 하나당 Sink 하나
연결을 받았으면 이벤트가 왔을 때 그 파트너에게 보낼 수 있어야 합니다. 그래서 파트너 번호를 키로 Reactor의 Sinks 를 하나씩 둡니다. 이벤트를 넣으면 그 파트너의 연결로 이어지는 통로입니다. 그걸 Flux 로 바꿔 연결에 물려 줍니다.

multicast 를 쓴 이유는 한 파트너가 여러 탭이나 여러 기기에서 동시에 접속하기 때문입니다. PC에서 보고 있는데 사장님앱에서도 열어 두는 경우가 있습니다. 구독자가 0이 되면 Sink를 정리합니다.
여기서 메모리가 샐 수 있습니다. 이벤트가 올 때마다 Sink를 만들면 접속하지도 않은 파트너의 Sink까지 쌓이고, 구독자가 없으니 치워지지도 않습니다. 그래서 연결된 구독자가 없으면 Sink를 만들지 않고 바로 실패를 돌려줍니다.
받는 쪽이 느릴 때 어떻게 할지도 정해야 합니다. 프론트가 느리거나 네트워크가 막히면 보낼 이벤트가 서버에 쌓입니다. 보통은 쌓아 두고 기다리지만, 저희는 버리는 쪽을 골랐습니다. 밀린 구독자를 기다리면 그만큼 메모리가 늘고, 같은 파드에 붙어 있는 다른 파트너의 알림까지 늦어지기 때문입니다. 한 건을 버려도 화면은 그다음 이벤트를 받아 최신 상태를 유지합니다.
프록시 메모리와 로그량
연결이 늘면 Nginx 사이드카와 로그 쪽에도 부하가 전달됩니다.
먼저 프록시 메모리입니다. Nginx는 업스트림 응답을 버퍼에 모았다가 보냅니다. 일반 응답에는 좋은 동작입니다. 그런데 끝나지 않는 응답에서는 연결마다 버퍼가 계속 잡힙니다.
| 설정 | 하는 일 |
|---|---|
proxy_buffering |
off로 둡니다. 응답을 모았다 보내지 않고 오는 대로 흘려보냅니다 |
proxy_buffer_size |
연결 하나가 잡는 버퍼 크기입니다. 연결 수를 곱하면 그대로 메모리가 됩니다 |
proxy_read_timeout |
이 시간 동안 아무것도 안 오가면 끊습니다. heartbeat 주기보다 길게 잡습니다 |
limit_conn |
동시 연결 상한입니다. 넘으면 새 연결만 503으로 막습니다 |
버퍼를 꺼도 연결 하나는 메모리를 잡습니다. 얼마가 드는지는 프록시 설정과 커널 버퍼에 따라 달라져서 환경마다 직접 재야 합니다. 저희 환경에서는 연결 하나당 약 43KB였습니다. 이 숫자 하나가 두 가지를 정합니다.
- 사이드카에 준 메모리 ÷ 연결 하나가 쓰는 메모리 = 파드 하나가 버틸 수 있는 연결 수
- 받고 싶은 연결 수 × 연결 하나가 쓰는 메모리 = 사이드카에 줘야 하는 메모리
메모리가 모자라면 한꺼번에 죽습니다. 커널의 OOM killer가 나서면 워커 하나만 죽지 않고, 같은 컨테이너의 Nginx 프로세스가 전부 강제로 종료됩니다. 그 워커들이 물고 있던 연결도 전부 그 자리에서 끊깁니다. 그리고 브라우저는 저희가 내려준 retry 값만큼 기다렸다가 전부 다시 붙습니다. 메모리가 다시 차고, 또 OOM됩니다.

끊긴 연결이 전부 돌아오면 메모리도 그대로 다시 찹니다. 한 번 이렇게 되면 저절로 멈추지 않고 같은 자리를 돕니다.
동시 연결 상한을 건 것도 이 악순환 때문입니다. 상한이 없으면 프록시 파드의 커널 포트(약 28K)가 먼저 고갈돼 502가 납니다. 한도를 그 아래 두면 기존 연결은 유지한 채 새 연결에만 503이 나갑니다. 어차피 막힐 거라면 예측 가능한 쪽이 낫다고 보고 이쪽을 선택했습니다.
로그량도 같이 커집니다. 연결 생명주기마다 로그를 남기면 로그량이 동시 연결 수가 아니라 재연결 횟수에 비례합니다. SSE는 재연결이 정상 동작이라 평소에는 멀쩡하다가 재연결이 몰리는 순간 같이 터집니다. 그래서 연결 하나하나를 남기던 로그는 레벨을 낮췄습니다.
이벤트 트레이싱 로그도 그렇습니다. 이벤트 하나가 어느 단계까지 갔는지 남기려면 SNS, SQS, Kafka, Sink, 브라우저마다 한 줄씩 적어야 합니다. 그 로그를 동기로 남기면 로그를 남기는 일이 DB 서버에 부하를 줍니다. 부하가 큰 순간은 추적이 가장 필요한 순간인데 그때 DB 서버가 같이 느려집니다.
SSE는 저희가 짠 코드만의 문제가 아닙니다. 연결 수가 늘면 앞단 프록시와 로그, 뒷단 DB, 그리고 이어서 호출하는 외부 API까지 부하를 줍니다.
4. 연결 관리에서 까다로웠던 세 가지

세 번째 고려사항은 연결 생명주기입니다. 세 가지를 차례로 봅니다.
- 보낼 이벤트가 없어 한참 동안 아무것도 오가지 않으면 인프라가 죽은 줄 알고 연결을 끊습니다.
- 배포로 파드가 교체되면 끊긴 연결이 한꺼번에 다시 붙습니다.
- 끊기지도 않은 채 죽어 있는 연결이 남습니다.

이벤트가 장기간 없는 연결
기상악화는 늘 있는 일이 아니라서, SSE 연결은 평소 아무것도 오가지 않습니다. 연결을 열어 두고 몇 시간, 길게는 며칠씩 조용히 있습니다.
데이터가 오가지 않는 연결은 브라우저와 서버 사이에 끼어 있는 로드밸런서나 프록시 입장에서 죽은 연결과 구별되지 않습니다. 그래서 끊습니다. ALB와 Nginx 둘 다 일정 시간 아무것도 오가지 않으면 정리합니다.
그래서 30초마다 빈 신호를 하나 보냅니다. 이 신호를 heartbeat라고 합니다.
이렇게 신호를 주고받으니 ALB도 Nginx도 연결을 끊지 않습니다.
한꺼번에 몰리는 재연결
연결이 끊기면 다시 붙어야 합니다.
문제는 배포 시점입니다. 배포하면 파드가 교체되면서 모든 SSE 연결이 동시에 끊깁니다. 재연결 간격이 모두 같으면 붙어 있던 파트너 전부가 같은 순간에 몰립니다. 막 뜬 파드가 그 전부를 한꺼번에 받습니다.
앞에서 본 retry 를 여기서 씁니다. 연결이 끊겼을 때 브라우저가 얼마나 기다렸다 다시 붙을지를 서버가 첫 응답에 적어 보내는 값입니다. 연결을 맺자마자 파트너마다 다른 랜덤 값을 내려보내 재연결 시점을 분산시켰습니다.
연결마다 3초에서 10초 사이의 값을 하나씩 뽑아 주니, 끊긴 파트너들이 한순간에 몰리지 않고 7초 폭에 나뉘어 돌아옵니다. 이렇게 돌아오는 시점을 일부러 분산시키는 걸 지터(jitter)라고 합니다.
그런데 지터만으로는 부족했습니다. 지터는 몰린 걸 퍼뜨릴 뿐 멈추지는 못합니다. 서버에 이슈가 있어 연결이 계속 끊기는 상황이면, 프론트는 끊길 때마다 다시 연결을 시도하고 그 시도가 멈추지 않습니다. 부하가 이미 높은 서버에 재연결 요청까지 더해집니다.
그래서 프론트에 백오프와 상한을 같이 두었습니다. 백오프는 다시 붙을 때마다 대기 시간을 지수로 늘려 재연결 요청률을 떨어뜨리고, 상한은 정해진 횟수를 넘기면 아예 그만두게 합니다. 서버가 회복할 틈을 주려는 장치입니다.
지터는 분산이고 백오프는 제동입니다. 둘 다 있어야 재연결이 한꺼번에 몰리는 걸 막습니다.
종료 신호 없이 사라진 연결
반대 경우도 있습니다. 탭이 강제로 닫히거나 네트워크가 갑자기 끊기면, 브라우저가 연결을 정리할 틈 없이 사라집니다. 서버는 연결을 닫는다는 신호를 아무것도 받지 못합니다.
이런 연결은 서버 입장에서 정상 연결과 구별할 방법이 없습니다. 소켓은 열려 있고 에러도 안 납니다.
모니터링해 보니 4개월간 연결 종료 사유는 100% CANCEL이고 ON_ERROR는 한 건도 없습니다. 평균 연결 수명은 22분인데 최대는 15일입니다. 가게 PC를 안 끄고 둔 경우에 해당합니다.
그래서 모든 연결에 최대 유지 시간을 걸어 주기적으로 정리합니다. 정상 연결도 끊기지만 브라우저가 다시 붙으니 파트너는 모릅니다. 살아 있는 걸 확인하는 대신 주기적으로 전부 정리하고 다시 받는 쪽을 택했습니다.
5. 도착을 보장하지 않기로 한 선택

마지막 고려사항은 전달 보장입니다.
파트너가 알림을 못 받는 경우는 그대로 남습니다. 연결이 끊긴 사이의 이벤트, 접속 안 한 파트너, 밀려서 버린 이벤트는 전부 못 받습니다.
선택지는 둘이었습니다. 놓친 걸 복구하거나, 놓쳐도 되게 만들거나. 후자를 골랐습니다. 기상악화 알림은 지난 내역이 아니라 최신 상태만 맞으면 되고, 복구하는 쪽을 고르면 서버가 파트너마다 어디까지 보냈는지를 계속 기억하고 있어야 하기 때문입니다.
이벤트에 상태를 싣지 않은 선택
SSE로 보내는 데이터에 상태를 담지 않았습니다. 대신 식별자만 보내고 프론트가 REST API로 최신 상태를 다시 조회합니다. zero-payload 방식입니다.
처음엔 이상해 보였습니다. 어차피 보낼 거면 상태까지 같이 보내면 되지 않나 싶었습니다.
그런데 기상악화 이벤트는 연달아 올 가능성도 있습니다. 제한 수준이 올라갔다가 다시 내려가는 식으로 2~3회 연속 발생하기도 합니다. 비가 그쳐서 "제한 해제" 이벤트가 나갔는데 그 한 건이 유실되면, 파트너 화면은 비가 간 뒤에도 계속 "배달 제한 중"입니다. 순서가 뒤집혀도 결과는 같습니다.
신호만 보내고 상태는 매번 조회하면, 몇 개를 놓치든 마지막에 한 번만 도착하면 화면은 항상 최신이 됩니다. 유실과 순서 역전을 한꺼번에 푸는 방법이었습니다.
순서를 지킬 필요도 없어집니다.
늘어난 조회와 외부 API 부담
이벤트에 상태를 싣지 않으니 보내는 쪽은 가벼워졌습니다. 대신 알림을 받은 화면이 곧바로 서버에 상태를 물어봅니다. 이벤트 한 건이 조회 한 번을 부릅니다.
그 조회는 셀프서비스 API에서 끝나지 않고 상태를 가지고 있는 외부 팀 API까지 갑니다. 조회가 늘면 그 부담이 외부로 그대로 전달됩니다.
폴링은 부하가 하루 종일 고르게 깔리는데, zero-payload는 평소 거의 0이다가 이벤트 순간 모두가 한꺼번에 몰립니다.
그래서 짧은 캐시를 뒀습니다. 파트너 단위와 가게 단위로 30초를 걸었습니다. 세 가지가 한 번으로 합쳐집니다.
- 같은 파트너가 여러 탭을 열어도 조회는 한 번
- 연달아 온 이벤트로 두세 번 물어봐도 조회는 한 번
- 기상악화가 지역 단위로 터져 같은 30초 안에 몰려도 조회는 한 번
캐시가 짧아도 되는 이유는, 오래 들고 있으려는 게 아니라 동시에 몰린 걸 합치는 것이기 때문입니다. 이벤트가 몰리는 그 짧은 동안만 덮으면 됩니다.
하루에 나가는 조회를 다 합치면 폴링보다 훨씬 적습니다. 바뀐 건 양이 아니라 한꺼번에 몰린다는 점이고, 거기에 맞는 방어가 필요했을 뿐입니다.
조회가 몰리는 순간은 이벤트가 왔을 때만이 아닙니다. 기상악화 알림이 붙은 화면은 SSE 하나에 조회 API 둘이 묶여 있어서, 연결이 끊기면 프론트가 그 묶음을 통째로 다시 시도합니다. 연결 하나가 끊긴 자리에 조회 두 건이 따라붙고, 배포로 전부 한꺼번에 끊기면 그만큼 곱해집니다.
SSE를 붙인 화면은 연결만 영향을 받는 게 아닙니다. 그 화면이 함께 호출하는 조회 API도 연결 상태를 따라 같이 움직입니다.
정책으로 정한 유실 허용
유실은 어떻게 방어해도 완전히 막지 못합니다. 아예 셀프서비스를 열어 두지 않은 파트너는 이벤트를 받을 연결도, 다시 물어볼 기회도 없습니다.
이건 정책으로 결정했습니다. 유실은 허용하되, 파트너가 셀프서비스 화면에 들어오면 REST 조회로 최신 상태로 맞춥니다.
SSE는 빠르게 알려주면 좋은 채널이지 반드시 전달되는 채널이 아닙니다.
배포 때 생기는 유실도 따로 손댈 필요가 없습니다. 파드가 교체되는 동안 발행된 이벤트는 보낼 연결이 없어 그냥 사라집니다. 그런데 같은 배포가 SSE 연결도 끊어서 브라우저가 재연결하고 프론트가 최신 상태로 맞춥니다. 유실을 만든 배포가 복구까지 같이 해 줍니다.
유실을 허용한 대신 다른 숙제가 생겼습니다. 알림을 받은 파트너들이 같은 순간에 한꺼번에 상태를 물어본다는 점입니다. 조회는 캐시로 합쳤고, 어디서 끊겼는지 볼 수단은 따로 만들었습니다.
6. 모니터링
비동기 파이프라인은 단계가 다섯입니다. SNS, SQS, Kafka, Sink, 브라우저. "파트너가 알림을 못 받았다"는 문의가 오면 어느 단계에서 끊겼는지 알아야 하는데, 단계가 따로 놀면 알 방법이 없습니다.

그래서 이벤트 하나에 UUID를 붙여 같은 키로 따라갈 수 있게 했습니다. 프론트는 이벤트를 받으면 ACK API를 호출합니다.
ACK를 받는 이유는 "알림을 못 받은 것"과 "받고도 조치를 안 한 것"을 구분하기 위해서입니다. 파트너가 임시배달지역을 설정하지 않았을 때 어느 쪽인지 모르면 무엇을 고쳐야 할지 판단할 수 없습니다.

7. 마치며
결정이 많아 보이지만, 실은 둘만 정하고 나머지는 따라왔습니다.
zero-payload가 세 가지 문제를 한꺼번에 해결했습니다. 연달아 온 이벤트의 중간 상태가 사라지는 문제, 순서가 뒤집히는 문제, 그리고 놓친 이벤트를 서버가 보관했다가 다시 보내 줘야 하는 문제입니다.
상태를 안 실으니 다시 보낼 내용 자체가 없고, 서버가 지난 이벤트를 쌓아 둘 이유도 사라졌습니다. 순서가 뒤집혀도 되니 한꺼번에 몰린 이벤트를 여러 건씩 동시에 처리해도 됩니다.
받는 쪽이 느릴 때 어떻게 할지도 간단해졌습니다. 버려도 되니까 밀리면 그냥 버리는 설정(directBestEffort)을 고르면 됐습니다. 한 건도 잃으면 안 되는 시스템이었다면 못 보낸 이벤트를 메모리에 쌓아 두거나 느린 프론트를 기다려야 했고, 그러면 메모리가 불어나고 다른 파트너에게 가는 알림까지 늦어집니다.
밀리면 그냥 버리기로 했지만, 최근 30일 동안 실제로 버린 이벤트는 한 건도 없었습니다.
다만 따라오는 부담도 있었습니다. zero-payload라 최신 상태를 매번 조회해야 하고, 그 부담은 외부 시스템으로 넘어갑니다. 이 부담은 짧은 캐시로 합쳐서 막았습니다.
유실을 허용하기로 한 결정 하나가 복구 장치, 순서 보장, 백프레셔, 배포 대응까지 연달아 단순하게 만들었습니다. 반대로 유실을 막기로 했다면 네 곳이 전부 무거워졌을 겁니다.
도입 전에 따져볼 두 가지
여기까지 읽고 "우리도 SSE로 바꿔볼까" 싶으셨다면, 먼저 아래 두 가지를 확인해보세요.
클라이언트도 서버로 보내야 한다면 SSE는 단방향이라 안 맞습니다. WebSocket을 고려하시는 편이 낫습니다.
유실을 허용할 수 없는 경우에는 이 글에서 제시한 설계가 맞지 않습니다. 5장에 적은 것처럼 저희는 "놓쳐도 된다"가 정책으로 먼저 결정되고, 그 위에서 나머지를 설계했습니다. 결제나 정산처럼 한 건도 잃으면 안 되는 일은 도착 확인과 재전송을 보장하는 다른 방법으로 풀어야 합니다.
둘 다 걸리지 않는다면, SSE는 생각보다 가벼운 선택입니다.
각자 시스템에 맞게 적용하는 데 이 글이 조금이라도 도움이 됐으면 좋겠습니다.