LabHub

SSE — 서버가 먼저 말하는 법 · 와이어와 끊김 · 이론

SSE 가 WebSocket 보다 자주 맞는 이유

LabHub 에서 이어서 보기

한 줄 요약

SSE 는 끝나지 않는 HTTP 응답이다. 새 프로토콜이 아니어서 프록시·인증·재접속·로깅이 전부 그대로 동작한다.

무엇이 다른가

| | SSE | WebSocket |
|---|---|---|
| 프로토콜 | 그냥 HTTP | Upgrade 후 별도 |
| 방향 | 서버 → 클라이언트 | 양방향 |
| 재접속 | 브라우저가 자동으로 | 직접 구현 |
| 놓친 것 이어받기 | Last-Event-ID 내장 | 직접 구현 |
| 인증 | 쿠키·헤더 그대로 | 핸드셰이크에 얹어야 함 |
| 프록시/LB | 설정 그대로 | 별도 설정 필요 |
| 데이터 | 텍스트(UTF-8) | 텍스트 + 바이너리 |

클라이언트가 서버에 계속 말할 필요가 없다면 SSE 가 거의 항상 맞다. 알림, 진행률, 대시보드 갱신, 그리고 LLM 토큰 스트리밍이 전부 그렇다. 사용자의 입력은 평범한 POST 로 보내면 된다.

와이어 포맷

Content-Type: text/event-stream 이고, 본문은 이렇게 생겼다.

event: tokenid: 42data: 안녕data: 여러 줄이면data: data: 를 반복한다: 이건 주석이다. 하트비트로 쓴다retry: 3000

규칙은 다섯 줄이면 끝난다.

브라우저 쪽은 세 줄이다

const es = new EventSource("/stream")es.addEventListener("token", e => append(e.data))es.onerror = () => { /* 브라우저가 알아서 다시 붙는다 */ }

끊기면 자동으로 재접속하고, 마지막으로 받은 idLast-Event-ID 헤더에 담아 보낸다. 서버가 그 id 다음부터 보내 주면 사용자는 끊긴 줄도 모른다. WebSocket 으로 같은 것을 하려면 재접속·중복 제거·순서 보장을 직접 만들어야 한다.

그래서 왜 안 되는가 — 버퍼링

SSE 로 실제 겪는 문제의 대부분은 하나다.

> 코드는 맞는데 화면에 한꺼번에 나온다.

중간의 누군가가 응답을 모으고 있는 것이다. 범인은 셋이다.

1. 리버스 프록시. nginx 는 기본으로 응답을 버퍼링한다.

proxy_buffering off;              # location 에

또는 앱에서 헤더로 끈다 — X-Accel-Buffering: no.

2. 압축. gzip 은 블록 단위로 모아야 압축이 된다. Content-Encoding: gzip 이 붙으면 스트리밍이 사실상 죽는다. SSE 응답은 압축에서 제외한다.

3. 프레임워크. 응답을 만들어 놓고 한 번에 반환하는 코드를 스트리밍이라 착각하는 경우다. 제너레이터를 yield 하지 않고 리스트를 만들어 돌려주면 그냥 큰 응답 하나다.

진단은 시간으로 한다. curl -N 로 붙어 첫 바이트가 언제 오는지 본다. 전체가 다 만들어진 뒤에 온다면 어딘가 모으고 있는 것이다. -N 은 curl 자신의 버퍼링을 끄는 옵션이다.

하트비트가 없으면 끊긴다

로드밸런서·프록시에는 유휴 타임아웃이 있다(대개 60초). 아무 바이트도 안 흐르면 끊는다. 그래서 주기적으로 주석 한 줄을 흘린다.

: ping

클라이언트는 이걸 이벤트로 보지 않는다. 바이트만 흐른다. 15~30초 간격이면 충분하다.

연결 수 제한

HTTP/1.1 에서 브라우저는 오리진당 동시 연결 6개로 제한한다. SSE 하나가 그중 하나를 영구히 잡는다. 탭을 여섯 개 열면 일곱 번째 탭의 요청이 전부 멈춘다. HTTP/2 를 쓰면 사라지는 문제다(멀티플렉싱). 프로덕션에서 SSE 를 쓴다면 HTTP/2 는 선택이 아니다.

서버 쪽에서 잊는 것

클라이언트가 끊어도 제너레이터는 계속 돈다 — 알아채고 멈추지 않으면. 사용자가 탭을 닫을 때마다 서버에 좀비 태스크가 하나씩 쌓인다.

async def gen():    try:        while True:            yield {...}    finally:        await cleanup()      # 끊길 때 반드시 여기로 온다

finally 를 붙이는 것이 습관이 되어야 한다.

현장에서 진단하는 순서

"코드는 맞는데 화면에 한꺼번에 나온다" 는 신고가 들어오면 서버 코드를 고치기 전에 어디까지 흐르는지부터 가른다.

1. curl -N http://앱주소/stream — 앱에 직접 붙는다. -N 은 curl 자신의 출력 버퍼링을 끄는 옵션이다. 이걸 빼면 도구 때문에 서버를 의심하게 된다
2. 같은 요청을 프록시를 거쳐서 한 번 더. 여기서만 뭉치면 범인은 버퍼링이나 압축이다
3. 브라우저 개발자 도구 Network 탭의 EventStream — 이벤트가 하나씩 도착하는지

한 단계씩 좁히면 서버·프록시·클라이언트 중 어디를 고쳐야 하는지 몇 분 안에 갈린다. 진단할 때 가장 먼저 의심할 것은 측정 도구 자신이다.

언제 WebSocket 인가

그 외에는 SSE 가 운영이 훨씬 싸다. 연결이 끊겼을 때 무엇이 자동으로 복구되는가를 기준으로 고르면 대개 답이 나온다.