View

728x90
반응형

ChatGPT 응답 스트리밍을 보고 "이것은 우리도 만들어야 한다"는 분위기가 형성된 지 1년이 넘었다. 그래서 SSE(Server-Sent Events)가 갑자기 다시 부상했고, 백엔드를 설계할 때 "Go가 좋다더라", "FastAPI가 빠르다더라", "Rust를 쓰면 끝장난다더라" 식의 잡담만 무성하다. 다만 SSE 스트림 언어 비교 글을 살펴보면 벤치마크 없이 일반론만 나열하거나, 단일 언어 튜토리얼에서 끝나버린다.

 

진짜 중요한 질문은 "누가 빠른가"가 아니다. 운영에 들어가면 답은 달라진다. "구독자 200명을 넘어도 죽지 않는가, 1000명에서는 손실률이 얼마나 누적되는가" 가 실제 SLA를 깨먹는 변수이다.

 

그래서 직접 돌려봤다. 50/200/500/1000명 구독자 구간에서 동일 하드웨어·동일 런타임을 고정하고 동시성 패턴만 바꿔 측정했다. 결론부터 말하면 직렬 패턴(Python/Node)은 50명 구간부터 손실률 56%를 찍었다. 고루틴 병렬(Go)은 1000명까지 드롭 0건이었다. 워커 풀(Rust 패턴)은 가장 평탄한 p99 분포를 보여줬다. 수치를 모두 공개하니 본인 환경에 대입해 보면 된다.

 

출처: https://goframe.org/en/articles/go-sse-implementation-guide

 

 

SSE란 무엇이며 언어 선택이 왜 중요한가

 

SSE는 HTTP 위에서 서버 → 클라이언트 단방향 스트리밍을 보내는 표준이다(WHATWG HTML spec의 EventSource 정의 참고). WebSocket보다 가볍고, 재연결이 자동이며, 프록시·CDN 호환성이 좋다. MDN의 Server-sent events 문서에 브라우저 호환성·이벤트 포맷이 모두 정리돼 있다. LLM 토큰 단위 응답, 실시간 알림, 라이브 대시보드는 거의 다 SSE로 굴러간다.

 

WebSocket과는 무엇이 다른가

 

WebSocket은 양방향이고 풀 듀플렉스이다. 채팅처럼 클라이언트도 자주 보내야 한다면 그쪽을 써야 한다. 다만 SSE는 단방향이라 서버 구현이 훨씬 단순하다. HTTP를 그대로 쓰니 인증·CORS·로드밸런서 같은 기존 인프라를 그대로 재활용할 수 있다. ChatGPT류 응답 스트리밍은 클라이언트가 한 번 요청을 보내고 서버가 토큰을 줄줄이 흘려주는 구조라 SSE와 결이 정확히 맞는다.

 

LLM 시대에 SSE가 다시 부상한 이유는 무엇인가

 

OpenAI API, Anthropic API를 비롯한 거의 모든 LLM 게이트웨이가 응답을 SSE로 흘려준다. 토큰 100개를 한 번에 받는 것이 아니라 1개씩 받아 화면에 흘리는 UX가 표준이 됐다. 이는 백엔드 입장에서는 골치 아픈 변화이다. 한 사용자 요청이 30초~5분간 연결을 유지하면서 토큰 수백 개를 푸시해야 한다. 동시 사용자 100명만 돼도 살아 있는 SSE 커넥션이 100개 떠 있는 셈이다.

 

이때 SSE 서버 전송 이벤트를 처리하는 동시성 모델이 비싼 변수가 된다. 단일 이벤트 루프로 모두 돌리면 한 사용자가 느려질 때 다른 사용자도 함께 느려진다. 그래서 SSE 스트림 언어 비교가 단순 hello world 벤치가 아니라 운영 안정성 기준으로 다시 보게 되는 것이다.

 

비교 후보 4종 — Go, Python(FastAPI), Node.js, Rust

 

언어 자체보다 각 언어가 추천하는 동시성 패턴이 더 큰 변수이다. 패턴별로 정리하면 다음과 같이 갈린다.

 

Go 고루틴 패턴 — 구독자별 비차단 팬아웃

 

Go는 이벤트가 발생할 때마다 구독자별로 고루틴을 띄워 동시 전송한다. 채널이 가득 차면 그 구독자만 드롭 처리하고 다른 구독자는 영향을 받지 않는다. 고루틴이 워낙 가벼우니(스택 2KB부터 시작) 수천 개를 띄워도 메모리 부담이 적다. Go SSE 구현을 검색하면 99% 이 패턴이 나온다.

 

for _, sub := range subs {
    go func(s *Sub) {
        select {
        case s.ch <- evt:
        default:
            // backpressure: 이 구독자만 드롭
        }
    }(sub)
}

 

Python(FastAPI) asyncio 직렬 패턴 — sse-starlette EventSourceResponse

 

FastAPI는 sse-starlette 패키지의 EventSourceResponse를 쓰는 것이 표준이다(Starlette 본체에는 들어 있지 않고 별도 설치가 필요하다). 단일 이벤트 루프가 구독자 목록을 순차로 돌면서 await sub.send()를 호출한다. FastAPI SSE 코드 예제를 보면 모두 비슷하게 생겼다. 직관적이고 짜기 쉽다. 다만 한 구독자가 느리면 뒤에 줄 선 구독자들이 모두 함께 느려진다.

 

async def event_stream():
    async for evt in queue:
        for sub in subscribers:
            try:
                await sub.send(evt)
            except QueueFull:
                # 뒤 구독자도 같이 밀림
                pass

 

Node.js 이벤트 루프 — 단일 스레드 순회의 한계

 

Node.js도 본질적으로 같다. 단일 스레드 이벤트 루프 위에서 비동기 콜백을 돌리는 구조라 패턴이 Python asyncio와 거의 동치이다. Node.js SSE 안정성을 검증할 때 worker_threads로 멀티 코어를 활용하는 트릭을 쓰지만, 기본형은 직렬 팬아웃이다. CPU 코어 1개만 쓰니 구독자가 늘어나면 처리량 천장이 뚜렷해진다.

 

Rust tokio 워커 풀 — 바운드 작업 채널

 

Rust는 tokio 위에서 고정 크기 워커 풀(예: 16개)을 만들고 공유 작업 채널을 비우는 패턴이 일반적이다. 고루틴 무한 생성도 하지 않고, 직렬 처리도 하지 않는다. 그 중간이다. Rust tokio SSE 구현을 보면 mpsc::channel로 잡 큐를 깔고 워커가 recv().await로 빨아들인다. 워커 수가 CPU 코어 수에 매핑되니 컨텍스트 스위치 비용이 최소화된다. 참고로 tokio mpsc::Receiver는 단일 소비자 설계라 clone()이 되지 않으므로, 워커가 함께 빼먹게 하려면 Arc>로 감싸거나 MPMC를 지원하는 async-channel 같은 외부 크레이트를 써야 한다.

 

use std::sync::Arc;
use tokio::sync::Mutex;

let (tx, rx) = mpsc::channel::<Event>(64);
let rx = Arc::new(Mutex::new(rx));
for _ in 0..16 {
    let rx = Arc::clone(&rx);
    tokio::spawn(async move {
        while let Some(evt) = rx.lock().await.recv().await {
            deliver(evt).await;
        }
    });
}

 

직접 돌려본 벤치마크 — sse-fanout-bench 실험 결과

 

각설하고 숫자를 보자. 단일 컨테이너 제약 때문에 4개 런타임을 동시에 띄울 수는 없었다. 그래서 동일 하드웨어·동일 런타임(Go 1.22)을 고정하고 위 세 가지 동시성 패턴만 Go로 시뮬레이션해 측정했다. 런타임 자체의 차이가 아니라 동시성 패턴 변수만 분리해 보는 것이 목적이다.

 

실험 환경

루프백 HTTP 비용을 배제하기 위해 인프로세스로 측정했다. 실제 운영에서는 네트워크 오버헤드가 더 붙겠지만, 패턴 간 상대 비교에는 이쪽이 더 깨끗하다.

 

50명 구독자 — 모두 비슷하다

 

50명 구간에서는 어떤 패턴을 골라도 큰 차이가 없다. 직렬 패턴조차 처리율이 높아 보일 수 있다.

 

모델 전달 드롭 처리율(eps) p99(μs)
fanout-parallel 20,000 0 1,715,751 52.5
fanout-serial 8,693 **11,307** 12,937,937 305.4
worker-pool 20,000 0 4,260,394 57.8

 

다만 잘 봐야 한다. fanout-serial은 처리율 1300만 eps를 찍었지만 드롭이 11,307건이다. 즉, 시도한 2만 건 중 56.5%가 손실됐다. 처리율 숫자만 보고 "직렬이 빠르네?"라고 판단해서는 안 된다. 보낼 수 없는 것을 빠르게 버린 것이지 빠르게 전달한 것이 아니다.

 

200~500명 — 직렬 패턴이 무너지는 구간

 

구독자를 늘리면 차이가 확연히 벌어진다.

 

모델 구독자 드롭 손실률 p99(μs)
fanout-parallel 200 0 0% 342.6
fanout-serial 200 47,727 **59.7%** 1,001.6
worker-pool 200 0 0% 272.5
fanout-parallel 500 0 0% 559.5
fanout-serial 500 116,337 **58.2%** 2,679.8
worker-pool 500 0 0% 218.2

 

직렬 패턴은 200명 구간에서 60% 가까이 드롭한다. p99 지연도 1ms를 넘는다. 이는 SSE 백프레셔가 발생해 채널 버퍼(64)가 가득 차자마자 이벤트가 버려진 결과이다. asyncio 단일 루프가 구독자 200명을 순회하는 동안 새 이벤트가 계속 쌓이는데 소비 속도가 따라가지 못해 누락이 발생한다.

 

반면 fanout-parallel(Go 패턴)은 구독자 500명에서도 드롭 0건이다. 처리율은 약 193만 eps로 직렬 대비 낮아 보이지만, 이는 손실률 0% 기준 처리율이다. 진짜 비교 대상이 된다.

 

1000명 — 워커 풀의 진가

 

모델 구독자 전달 드롭 p99(μs) max(μs)
fanout-parallel 1000 400,000 0 954.7 1,548.2
fanout-serial 1000 181,445 **218,555** 4,591.6 4,728.0
worker-pool 1000 400,000 0 **346.4** 853.9

 

1000명 구간에서 진짜 결판이 난다. worker-pool은 p99 346μs로 fanout-parallel(954μs)의 3분의 1 수준이다. 최댓값(max)도 853μs로 평탄하다. 고루틴을 무한 생성하는 fanout-parallel은 컨텍스트 스위치 부하가 누적되면서 꼬리 지연이 늘어났다.

 

직렬 패턴(fanout-serial)은 그대로 무너졌다. 218,555건 드롭, 손실률 54.6%, p99 4.5ms. SLA 0.1%를 보장하라는 운영팀이 있다면 즉시 알람 폭격을 받을 수치이다.

 

=== SSE-style fanout benchmark ===
model              subs  delivered  dropped     thr_eps    p99_us  gc_pause_ms
fanout-parallel    1000     400000        0     1778822    954.70         0.85
fanout-serial      1000     181445   218555    16352135   4591.60         0.05
worker-pool        1000     400000        0     3782315    346.40         0.15

 

GC 일시정지는 진짜 문제인가 — Go GC pause 수치

 

"Go는 GC 때문에 SSE에서는 못 쓴다"는 옛날 글이 아직 돌아다닌다. 다만 측정 결과는 다르다. fanout-parallel 1000명 구간에서 GC 일시정지 누적은 0.85ms이다. 224ms 측정 구간 중 0.4% 비중에 불과하다. p99 지연 954μs에 GC pause가 직접 영향을 준 흔적은 없다.

 

Go 1.22는 동시 GC가 잘 다듬어져 sub-millisecond pause가 일반적이다. Go 공식 GC 가이드에도 GC가 대부분 동시(concurrent)로 돌고 STW 구간은 mark/sweep 전환 시점에 짧게만 발생하며 힙 크기에 비례해 늘어나지 않는다고 명시돼 있다(가이드는 특정 마이크로초 수치를 못 박지는 않는다). 적어도 SSE 팬아웃 워크로드에서는 GC가 병목이 되지 않는다. GC 일시정지가 무서워 Rust로 갈아탈 명분으로는 부족하다. SSE 스트림 언어 비교를 할 때 GC FUD에 휘둘리지 말고 본인 워크로드에서 직접 측정해 봐야 한다.

 

손실률(dropped)이 처리량보다 중요한 이유

 

이 글의 핵심 메시지이다. SSE 팬아웃 벤치마크 글은 거의 다 처리율(throughput) 비교에서 끝난다. 다만 SSE 운영자에게는 처리율보다 손실률이 훨씬 중요한 지표이다.

 

SSE는 재전송이 안 된다 — 잃으면 끝이다

 

WebSocket은 메시지가 실패하면 재전송 로직을 넣을 수 있다. Kafka는 오프셋을 커밋해 재처리할 수 있다. 다만 SSE는 그런 것이 없다. 클라이언트가 받지 못하면 그 이벤트는 영영 사라진다. Last-Event-ID 헤더로 재연결 시 일부 보충이 가능하지만, 서버가 이벤트 히스토리를 들고 있어야 작동한다. 대부분의 구현은 이를 들고 있지 않다.

 

LLM 토큰 스트리밍에서 토큰 1개를 잃으면 어떻게 되는가? 사용자에게 깨진 단어가 보인다. 알림 시스템에서 알림 1개를 잃으면? 그저 그 알림은 없는 것이 된다. 손실률이 1%만 돼도 운영 후폭풍은 무섭다.

 

백프레셔 처리 방식별 차이

 

본 실험에서 직렬 패턴의 손실률 56~60%는 채널 버퍼 64가 가득 찼을 때 즉시 드롭하는 정책에 따른 것이다. 운영에서 더 흔한 선택지는 다음과 같다.

 

  1. 블로킹 (block until consumer catches up): 누락은 없지만 느린 구독자 한 명이 전체를 멈춘다
  2. 드롭 (즉시 버림): 빠르지만 손실이 발생한다 — 이번 실험의 fanout-serial
  3. 버퍼 확장 (큐 무한 확장): 메모리 폭발 위험이 있다

 

Go 고루틴 패턴은 구독자별 채널을 따로 들고 있어 한 명만 드롭된다. 직렬 패턴은 공유 큐라 드롭이 누적된다. 이것이 손실률 차이의 본질이다.

 

채널 버퍼 64는 충분한가

 

본 실험은 채널 버퍼를 64로 고정했다. 운영에서는 워크로드별 튜닝이 필요하다. 토큰 1개당 5ms를 소비하는 LLM 응답이라면 버퍼 64는 약 320ms 분량이다. 클라이언트가 0.5초만 느려져도 버퍼가 가득 찬다. 일반적으로 256~1024 사이가 무난하다.

 

다만 버퍼를 늘려도 직렬 패턴의 본질적 한계는 바뀌지 않는다. 단일 루프가 구독자 N명을 순회하는 동안 새 이벤트가 들어오면 그만큼 백프레셔가 누적된다. 패턴 자체를 바꿔야 한다.

 

그렇다면 어떤 언어를 골라야 하는가 — SSE 스트림 언어 비교 최종 권장

 

표로 정리하면 다음과 같다.

 

상황 추천 이유
빠른 프로토타입, 구독자 ~200명 Python(FastAPI) 짜기 빠름, 손실 감수 가능 단계
일반 운영, 구독자 ~수천명 Go 안정성·생산성 균형, GC 문제 없음
극한 처리량, 꼬리 지연 민감 Rust tokio p99 평탄, 학습곡선 감수
프론트 인프라와 통합 Node.js TypeScript 공유, 패턴은 Python과 동치

 

빠른 프로토타입 — Python(FastAPI), 단 구독자 200 이하

 

POC 단계에서는 FastAPI가 압도적으로 빠르게 짜진다. EventSourceResponse 한 줄이면 끝난다. 다만 사용자가 200명을 넘는 순간 손실이 시작된다. 베타를 풀고 트래픽이 들어오면 갈아엎어야 한다. "MVP는 FastAPI, 본격 운영은 Go" 패턴이 흔하다.

 

일반 운영 — Go, 안정성·생산성 균형

 

대부분의 케이스에서 답이다. 고루틴 모델이 SSE 팬아웃에 거의 자연스럽게 매핑된다. 1000명까지 드롭 0건 데이터를 확인했다. p99는 1ms 안쪽이고 GC pause도 무시할 수 있는 수준이다. 채용 시장도 넓고 라이브러리도 풍부하다.

 

극한 처리량 — Rust tokio, 학습곡선을 감수해야 한다

 

처리율 4배, p99 평탄함이 진짜 필요한 환경(트레이딩, 게임, 실시간 광고 입찰)에서는 Rust가 답이다. 다만 lifetime, async trait, mpsc 패턴을 익히는 데 3~6개월을 잡아야 한다. 팀 전부가 Rust를 다룰 수 있어야 운영이 가능하다.

 

Node.js는 언제 쓰는가 — 프론트 인프라와 묶어 운영할 때만

 

기술적으로는 Python asyncio와 동치라 안정성 측면의 메리트는 없다. 다만 프론트가 Next.js이고 백엔드도 TypeScript로 통일하면 타입 공유·코드 공유 메리트가 크다. SSE 단독 성능보다 풀스택 일체화가 중요한 팀에서는 선택지로 들어간다.

 

실전 적용 체크리스트

 

운영에 들어가기 전에 다음을 점검하면 된다.

 

  • 채널/큐 버퍼 크기: 토큰 1개당 처리 시간 × 256~1024로 잡고 시작, 운영 데이터를 보면서 튜닝
  • 드롭 발생 시 알림: dropped 카운터 메트릭을 노출, 0이 아니면 즉시 알람
  • p99 지연 모니터링: 처리율 대시보드 대신 p99·p999 히스토그램이 필수
  • GC 일시정지 측정: Go는 runtime/metrics, Rust는 jemalloc stats, Node는 --trace-gc
  • 재연결 시 보충 정책: Last-Event-ID를 받아 처리할지 결정, 그러지 않을 경우 명시적으로 문서화
  • 백프레셔 정책 명문화: 블로킹/드롭/확장 중 무엇을 쓸지 코드 주석에 박아두기

 

핵심 3줄 요약

 

수치를 다시 정리하면 다음과 같다.

 

  1. 직렬 패턴(Python asyncio, Node.js 이벤트 루프)은 구독자 200명을 넘으면 손실률 60%를 찍는다. 프로토타입까지는 OK이지만, 운영 진입 전 갈아타야 한다.
  2. 고루틴 병렬(Go)이 안전한 기본값이다. 1000명까지 드롭 0건, GC pause 1ms 미만, 작성 또한 직관적이다.
  3. 워커 풀(Rust tokio)이 극한 환경에서 최강이다. p99 346μs로 가장 평탄하다. 다만 학습곡선·인력 비용이 크다.

 

SSE 스트림 언어 비교의 결론을 한 줄로 줄이면, "언어 자체보다 동시성 패턴이 더 큰 변수이다". 같은 Python이라도 직렬 루프를 쓰지 않고 워커 프로세스 풀을 깔면 다른 결과가 나온다. 같은 Go라도 고루틴 무한 생성을 하지 않고 워커 풀을 깔면 Rust와 비슷해진다.

 

본인 환경에서 직접 측정하는 것을 강력히 권한다. 실험 코드는 인프로세스 Go 시뮬레이션이지만 패턴 자체는 어떤 언어로든 옮길 수 있다. 채널 버퍼·워커 수·구독자 수 변수를 바꿔가며 본인 워크로드의 특성을 찾는 것이 진짜 답이다. 운영에 들어가서 새벽 3시에 알람을 받는 것보다 백배 낫다.

728x90
반응형
Share Link
reply
«   2026/05   »
1 2
3 4 5 6 7 8 9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
31