SSE egress 비용 분석: 트래픽 4배 원인 찾기
요청 수는 약 25% 늘었는데 egress는 4배가 된 사례로 SSE 이벤트 크기·빈도, heartbeat와 재연결 비용을 계산하는 법을 설명한다.
이 글의 목차요청 수를 봤는데 숫자가 맞지 않았다14
회사에서 기존 서비스의 비용 내역을 보다가 눈에 걸리는 그래프 하나를 발견했다.
서버에서 사용자 쪽으로 나간 데이터양인 egress가 이전보다 약 네 배 올라 있었다.
클라우드에서는 이 바깥 방향의 전송량이 비용 항목이 될 수 있어, 같은 사용자 수에서도 응답 바이트가 커지면 청구액이 달라진다.
처음에는 사용자가 늘었거나 API 호출이 몰린 줄 알았다.
그런데 요청 수 그래프만으로는 이 증가를 설명할 수 없었다.
이 글에서는 당시 숫자를 추측으로 채우지 않았다.
남아 있는 두 수치를 다시 계산해 어디까지 확정할 수 있는지 먼저 정리하고, 같은 문제를 다시 만났을 때 그대로 적용할 수 있는 해결 순서까지 만들었다.
요청 수를 봤는데 숫자가 맞지 않았다
비용이 오르기 시작한 기간과 요청 수를 같은 구간에 놓고 비교했다.
요청 한 번이 돌려주는 데이터 크기가 이전과 같다면, 전송량이 네 배가 될 때 요청 수도 네 배 가까이 늘어야 한다.
실제로는 요청 그래프가 그만큼 오르지 않았다.
그래서 질문을 바꿨다.
몇 번 요청했는지가 아니라, 한 번의 요청 또는 연결 안에서 얼마나 많은 데이터를 보냈는가?
같은 기간의 전체 응답 바이트와 요청당 평균 응답 바이트를 확인하자 비용 그래프와 비슷한 모양이 나왔다.
- 전체 응답 바이트는 이전보다 약 4배였다.
- 요청당 평균 응답 바이트는 약 3.2배였다.
- 두 변화가 시작된 구간은 기존 서비스의 SSE 전환 시점과 겹쳤다.
아래 역산은 두 지표가 같은 기간·경로·단위에서 헤더와 본문을 같은 범위로 집계했다는 조건에서만 성립한다.
클라우드 청구서의 전체 egress와 특정 애플리케이션 로그의 본문 바이트처럼 범위가 다르면 4 ÷ 3.2로 요청 수를 구할 수 없다.
여기서 요청 수 증가 폭도 계산할 수 있다.
요청 수는 약 25% 늘었고, 요청당 바이트는 약 220% 늘었다.
두 값을 곱하면 전체 응답 바이트 약 4배가 된다.
비개발자 입장에서는 택배로 생각하면 쉽다.
배송 횟수는 100번에서 125번으로 조금 늘었는데, 상자 하나의 무게가 3.2배가 된 셈이다.
전체 운송량이 크게 늘어난 중심은 배송 횟수보다 상자 하나의 무게였다.
SSE에서는 요청 한 건이 비용의 단위가 아니었다
응답 바이트가 커지기 시작한 시점과 배포 기록을 나란히 놓고 보니, 기존 서비스의 일부 통신을 SSE로 전환한 배포가 같은 구간에 있었다.
SSE(Server-Sent Events)는 브라우저와 서버의 연결을 열어 둔 채 서버가 새 데이터를 계속 보내는 단방향 실시간 통신 방식이다.
브라우저에서는 주로 EventSource를 사용한다.
일반 API가 필요할 때마다 전화를 걸어 질문하고 끊는 방식이라면, SSE는 통화를 연결해 둔 채 새 소식이 생길 때마다 알려 주는 방식에 가깝다.
SSE는 실시간 통신의 유일한 선택이 아니다.
클라이언트가 일정 간격으로 새 데이터가 있는지 묻는 폴링은 구현이 단순하지만, 변화가 없어도 요청이 발생한다.
WebSocket은 한 연결에서 브라우저와 서버가 양방향으로 데이터를 주고받을 수 있지만 연결 상태와 메시지 계약을 더 많이 관리해야 한다.
| 요구사항 | 먼저 검토할 방식 | 비용·운영에서 볼 점 |
|---|---|---|
| 몇 분에 한 번 바뀌어도 괜찮다 | 일반 API 폴링 | 변화가 없어도 요청이 생기므로 간격을 조정한다 |
| 알림·진행률처럼 서버에서 브라우저로만 자주 보낸다 | SSE | 연결당 이벤트·heartbeat·재연결 바이트를 센다 |
| 채팅·게임처럼 양쪽이 계속 메시지를 보낸다 | WebSocket | 동시 연결, 양방향 메시지와 재연결 상태를 관리한다 |
| 탭이 닫혀 있어도 알려야 한다 | 푸시 알림 등 별도 채널 | SSE와 WebSocket은 열린 페이지 연결에 의존한다 |
이 사례에서 중요한 것은 SSE를 선택한 사실 자체가 아니었다.
단방향 실시간 갱신에 맞는 선택을 했더라도, 일반 API와 같은 “요청 수” 단위로 비용을 보면 연결 안의 전송량을 놓친다는 점이었다.
둘의 전송량을 같은 식으로 보면 안 되는 이유가 여기에 있다.
SSE 연결에는 화면에 보여 줄 이벤트 본문만 흐르지 않는다.
- 서버가 보내는 실제 이벤트
- 연결을 유지하기 위한 주석 형태의 heartbeat
- 연결이 끊긴 뒤 다시 연결하면서 생기는 데이터
- 재연결 뒤 빠진 이벤트를 보충하는 재전송
MDN의 SSE 문서에 따르면 EventSource는 연결이 닫혔을 때 기본적으로 재연결을 시도한다.
또 콜론(:)으로 시작하는 주석 줄은 중간 장비가 유휴 연결을 끊지 않도록 하는 heartbeat로 사용할 수 있다.
heartbeat는 SSE가 저절로 붙이는 필수 데이터가 아니라 서버가 필요에 따라 보내는 데이터다.
따라서 SSE에서 요청 수 하나만 보면 연결 안에서 몇 개의 이벤트가 오갔는지, 연결이 얼마나 오래 열려 있었는지, 몇 번 다시 연결했는지가 모두 가려진다.
내가 이 사례에서 바꾼 기준은 간단했다.
예를 들어 연결 100개가 각각 10분 유지됐다면 전체 연결 유지 시간은 1,000분이다.
그동안 120MB를 보냈다면 연결 하나가 1분 동안 보낸 양은 평균 0.12MB다.
이 값이면 사용자 수나 연결 시간이 달라져도 같은 기준으로 전후를 비교할 수 있다.
SSE egress를 찾는 6단계
같은 문제를 다시 만났을 때는 아래 순서로 확인하면 된다.
앞 단계에서 범위를 잘못 잡으면 뒤의 숫자가 모두 섞이기 때문에 순서가 중요하다.
1. 과금 지표가 무엇을 세는지 확인한다
클라우드 비용 화면의 egress와 애플리케이션의 응답 바이트가 항상 같은 범위인 것은 아니다.
CDN, 로드밸런서, 프록시 중 어느 구간인지, 응답 헤더와 본문을 모두 포함하는지, 압축 전과 후 중 어느 값을 세는지 먼저 확인한다.
CDN, 로드밸런서, 프록시는 사용자와 애플리케이션 서버 사이에서 데이터를 전달하거나 분산하는 중간 지점이다.
어느 지점의 숫자를 보느냐에 따라 같은 요청도 집계되는 바이트가 달라질 수 있다.
여기서 말하는 wire bytes는 헤더와 압축까지 반영돼 실제 네트워크에 실린 데이터양이다.
애플리케이션에서 JSON 문자열 길이만 센 값과 다를 수 있다.
2. 일반 API와 SSE를 분리한다
전체 평균을 한 번에 보지 않는다.
경로나 응답의 Content-Type을 기준으로 일반 JSON과 text/event-stream을 나눈다.
Content-Type은 응답에 어떤 종류의 데이터가 담겼는지 알려 주는 표지다.
일반 API는 요청당 바이트를 보고, SSE는 연결당 분당 바이트를 본다.
이렇게 해야 짧은 응답 수천 건과 한 시간 열린 연결 한 건을 같은 ‘요청 한 번’으로 계산하는 실수를 피할 수 있다.
3. 이벤트의 크기와 빈도를 곱한다
SSE의 실제 내용인 payload를 먼저 본다.
payload는 이벤트 안에 담아 보내는 본문 데이터다.
이벤트 하나가 크지 않아도 너무 자주 보내면 전송량이 커진다.
반대로 빈도가 낮아도 매번 전체 객체를 보내면 같은 문제가 생긴다.
4. heartbeat를 별도 항목으로 센다
heartbeat는 연결이 살아 있다는 것을 알려 주는 작은 신호다.
크기는 작아도 모든 연결에 짧은 간격으로 보내면 합계가 커질 수 있다.
정답처럼 통하는 하나의 주기는 없다.
사용 중인 프록시나 로드밸런서가 데이터 없는 연결을 언제 끊는지 확인하고, 그 제한보다 여유 있게 짧은 주기부터 실험한다.
5. 재연결과 중복 연결을 확인한다
재연결 횟수만 보지 말고 연결이 평균 몇 초 만에 끊기는지도 함께 본다.
짧은 연결이 반복된다면 서버·프록시의 timeout, 브라우저의 네트워크 전환, 화면 이동 뒤 남은 중복 연결을 나눠 확인한다.
timeout은 상대의 데이터를 얼마 동안 기다린 뒤 연결을 종료할지 정한 제한 시간이다.
SSE의 retry 값은 재연결 대기 시간을 조정하고, 이벤트 id와 Last-Event-ID는 재연결 뒤 이어 받을 위치를 관리하는 데 쓸 수 있다.
이 정책이 없으면 같은 이벤트를 다시 보내거나 빠진 범위를 크게 재전송하기 쉽다.
자세한 동작은 WHATWG의 Server-sent events 표준에 정리돼 있다.
6. 프록시 뒤에서 실제 동작을 검증한다
개발 환경에서는 바로 도착한 이벤트가 운영 환경의 프록시에서는 버퍼에 모였다가 한꺼번에 도착할 수 있다.
그 상태에서는 실시간성도 나빠지고 재연결 원인도 찾기 어려워진다.
Nginx를 사용한다면 proxy_buffering은 기본적으로 켜져 있다.
Nginx 공식 문서에 따라 SSE 경로의 스트리밍 동작과 X-Accel-Buffering 응답 헤더를 확인한다.
proxy_read_timeout은 전체 연결 시간이 아니라 upstream에서 두 번의 읽기 사이에 데이터가 없을 수 있는 시간을 제한하므로 heartbeat 주기와 함께 봐야 한다.
여기서 upstream은 Nginx 뒤에서 실제 응답을 만드는 애플리케이션 서버를 뜻한다.
Nginx의 $body_bytes_sent는 응답 헤더를 제외한 본문 바이트이고, $bytes_sent는 클라이언트로 보낸 전체 바이트다.
둘을 함께 남기면 애플리케이션 payload와 실제 전송량의 차이를 좁힐 수 있다.
# nginx.conf의 http 블록 안에 선언한다.
log_format traffic
'$request_method $uri status=$status '
'type=$sent_http_content_type '
'body=$body_bytes_sent total=$bytes_sent '
'duration=$request_time';SSE 요청의 access log는 연결이 끝난 뒤 기록된다는 점도 기억해야 한다.
현재 열려 있는 연결은 애플리케이션 지표로 따로 세는 편이 빠르다.
원인별 해결책은 이렇게 고른다
SSE 자체를 없애는 것이 첫 해결책은 아니다.
측정 결과에 따라 가장 큰 항목부터 줄이는 편이 안전하다.
| 발견한 현상 | 먼저 적용할 해결책 | 함께 볼 지표 |
|---|---|---|
| 이벤트 본문이 크다 | 사용하지 않는 필드를 빼고, 전체 객체 대신 바뀐 값만 보낸다 | 이벤트당 전송 바이트 |
| 작은 이벤트가 너무 자주 온다 | 사용자가 느끼지 못하는 짧은 범위에서 중복을 없애고 여러 변경을 묶어 보낸다 | 초당 이벤트 수, 화면 반영 지연 |
| heartbeat 비중이 크다 | 실제 인프라의 유휴 연결 제한에 맞춰 간격을 늘린다 | heartbeat 바이트, 연결 종료율 |
| 재연결이 많다 | timeout과 버퍼링을 바로잡고 retry와 이벤트 ID 정책을 적용한다 | 평균 연결 시간, 재연결률, 중복·누락 |
| 화면 이동 뒤 연결이 남는다 | 컴포넌트나 페이지가 사라질 때 EventSource.close()를 호출한다 | 사용자당 동시 연결 수 |
| 반복되는 텍스트가 많다 | JSON과 SSE를 분리해 압축 전후를 각각 실험한다 | wire bytes, CPU, 첫 이벤트 도착 시간 |
여러 작은 변경을 묶어 보내는 것을 **배칭(batching)**이라고 한다.
예를 들어 100ms 안에 세 번 바뀐 값을 세 번 보내지 않고 마지막 상태 한 번만 보내는 방식이다.
전송 횟수는 줄지만 화면 반영이 늦어질 수 있으므로 허용 가능한 지연 안에서만 사용한다.
압축도 마지막 표처럼 별도 실험으로 다룬다.
보내는 바이트는 줄지만 서버 CPU를 더 쓰고, 스트리밍 구간에서 버퍼링이 생기면 첫 이벤트 도착이 늦어질 수 있기 때문이다.
압축률 하나가 아니라 실제 전송 바이트, CPU, 오류율, 첫 이벤트 도착 시간의 p95를 함께 비교한다.
p95는 측정값 100개를 빠른 순서로 세웠을 때 95번째에 해당하는 값이다.
평균에 가려진 느린 구간을 확인할 때 쓴다.
계산상 전송량을 얼마나 줄일 수 있을까
현재 값은 전체 응답 바이트 4배, 요청당 응답 바이트 3.2배, 요청 수 1.25배다.
요청 수가 지금과 같다고 두면, 요청당 바이트를 줄인 만큼 전체 전송량도 같은 비율로 줄어든다.
| 요청당 추가 바이트를 줄인 정도 | 기준 기간 대비 예상 전송량 | 현재 4배 대비 감소 |
|---|---|---|
| 추가분의 25% 제거 | 약 3.31배 | 약 17.2% |
| 추가분의 50% 제거 | 약 2.63배 | 약 34.4% |
| 추가분의 75% 제거 | 약 1.94배 | 약 51.6% |
| 요청당 바이트를 이전 수준으로 복원 | 약 1.25배 | 약 68.75% |
여기서 추가분은 현재 요청당 바이트 3.2배 중 이전 수준인 1배를 뺀 2.2배를 뜻한다.
예를 들어 추가분의 절반을 없애면 요청당 바이트는 3.2배 → 2.1배가 되고, 요청 수 1.25배를 곱한 전체 전송량은 약 2.63배가 된다.
68.75%는 압축 예상치나 실제 회사 비용 절감 실적이 아니다.
현재 요청 수를 유지한 채 요청당 바이트를 이전 수준으로 완전히 되돌렸을 때의 전송량 모델 상한이다.
클라우드 단가와 과금 구간이 같을 때 비용도 비슷한 방향으로 움직이지만, 청구 금액은 공급자의 실제 과금 지표로 다시 확인해야 한다.
전체 전송량을 과거의 1배까지 낮추려면 요청 수가 이미 1.25배이므로 요청당 바이트를 과거의 0.8배까지 줄여야 한다.
현재 3.2배에서 보면 75%를 줄여야 하는 값이다.
그래서 현실적인 첫 목표는 과거 총량을 한 번에 맞추는 것보다, 연결당 분당 바이트가 가장 큰 이벤트부터 단계적으로 줄이는 것이다.
다시 발생하지 않도록 남길 지표
이 문제의 재발 방지는 대시보드에 요청 수 그래프를 하나 더 추가하는 것이 아니다.
서로 다른 통신 방식을 서로 다른 단위로 보는 것이다.
| 지표 | 무엇을 알려 주는가 |
|---|---|
| 일반 API 요청당 응답 바이트 | 호출 한 번의 응답이 커졌는지 |
| SSE 활성 연결 수 | 지금 열려 있는 연결이 평소보다 많은지 |
| SSE 전체 연결 유지 시간 | 연결 수와 지속 시간을 합친 실제 사용 규모 |
| SSE 연결당 분당 바이트 | 연결 하나의 단위 전송량이 커졌는지 |
| 이벤트 수와 payload 바이트 | 이벤트 크기와 빈도 중 무엇이 늘었는지 |
| heartbeat 바이트 | 연결 유지 신호가 차지하는 비중 |
| 재연결 횟수와 평균 연결 시간 | 짧은 연결이 반복되는지 |
대시보드에서는 전체 egress, 일반 API 요청당 바이트, SSE 연결당 분당 바이트, 재연결률을 같은 시간축에 놓는다.
배포 표시도 함께 넣으면 숫자가 바뀐 시점과 코드 변경을 바로 연결할 수 있다.
결론: SSE를 없애기보다 단위 전송량을 관리한다
이번 문제의 답은 SSE를 무조건 없애는 것이 아니었다.
요청 수만 세던 방식에서 벗어나, 연결 하나가 열린 동안 얼마나 많은 데이터를 보냈는지 관리하는 것이었다.
계산 결과 요청 수 증가는 약 25%였지만 요청당 응답 바이트는 3.2배였다.
그래서 개선 우선순위도 명확해졌다.
payload 크기, 전송 빈도, heartbeat, 재연결 순서로 비중을 확인하고 가장 큰 항목부터 줄이면 된다.
요청당 바이트를 이전 수준으로 되돌릴 수 있다면, 현재 요청 수가 유지돼도 전체 전송량은 4배에서 약 1.25배까지 내려간다.
이제 같은 그래프를 다시 만나도 요청 수만 넘겨다보며 추측할 필요가 없다.
일반 API는 요청당 바이트를, SSE는 연결당 분당 바이트를 먼저 본다.