SSE — 서버가 먼저 말하는 법 · 와이어와 끊김 · 이론
SSE 가 WebSocket 보다 자주 맞는 이유
한 줄 요약
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규칙은 다섯 줄이면 끝난다.
- 빈 줄이 이벤트의 끝이다. 빈 줄을 안 보내면 클라이언트는 아직 안 끝났다고 생각하고 기다린다 — "왜 아무것도 안 오지" 의 1번 원인
data:가 여러 개면 개행으로 이어 붙여 하나의 문자열이 된다event:는 이벤트 이름. 생략하면messageid:를 주면 브라우저가 기억했다가 재접속 때Last-Event-ID헤더로 되돌려 준다retry:는 재접속 대기 시간(밀리초):로 시작하면 주석. 아무 의미 없지만 바이트가 흘러서 연결이 살아 있다
브라우저 쪽은 세 줄이다
const es = new EventSource("/stream")es.addEventListener("token", e => append(e.data))es.onerror = () => { /* 브라우저가 알아서 다시 붙는다 */ }끊기면 자동으로 재접속하고, 마지막으로 받은 id 를 Last-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 가 운영이 훨씬 싸다. 연결이 끊겼을 때 무엇이 자동으로 복구되는가를 기준으로 고르면 대개 답이 나온다.