LabHub
배우기 러닝패스 코스

SSE — 서버가 먼저 말하는 법 · 서버는 보냈는데 화면은 조용했다 · 이론

스트림을 죽이는 것은 사이에 있는 것들이다

LabHub 에서 이어서 보기

한 줄 요약

스트리밍이 죽는 자리는 대개 애플리케이션이 아니라 사이에 있는 것들이다 —
프록시 버퍼, 읽기 시간 초과, 그리고 브라우저의 동시 연결 상한.

왜 이게 필요했나

로컬에서는 토큰이 한 글자씩 흐르는데 배포하면 답이 다 끝난 뒤 한꺼번에 나온다.
애플리케이션 코드는 한 줄도 다르지 않다. 로그를 봐도 서버는 제때 보냈다고
적혀 있다.

중간에 프록시가 있기 때문이다. nginx 의 proxy_buffering
[문서상 기본값이 on](https://nginx.org/en/docs/http/ngx_http_proxy_module.html)
이고, 켜져 있으면 응답을 버퍼에 모았다가 내보낸다. 스트리밍은 "조금씩 자주" 가
전부인데, 버퍼링은 "모아서 한 번에" 가 목적이라 정확히 반대다.

두 번째로 자주 만나는 것이 시간 초과다. 같은 문서에서 proxy_read_timeout
기본값은 60s 이고, 설명이 분명하다 — 프록시된 서버가 그 시간 안에 **아무것도
보내지 않으면 연결을 닫는다.** 사용자가 질문을 하지 않는 밤 시간대에 알림
스트림이 1분마다 끊기는 이유가 이것이다.

어떻게 동작하나

버퍼링을 끄는 두 가지 자리

설정 파일에서 proxy_buffering off 로 끌 수도 있지만, 그러면 그 경로의 모든
응답이 버퍼링을 잃는다. 같은 문서가 더 좁은 방법을 적어 둔다 — **응답 헤더로
끄는 것**이다.

X-Accel-Buffering: no

yes 또는 no 를 이 헤더로 내려보내면 그 응답에 대해서만 버퍼링이 켜지거나
꺼진다. 스트리밍 엔드포인트에서만 이 헤더를 붙이면 나머지 경로는 버퍼링의
이점을 그대로 유지한다. 애플리케이션이 자기 사정을 가장 잘 아는 곳에서
결정하게 만드는 설계라, 설정 파일을 고칠 권한이 없을 때도 쓸 수 있다.

주의할 것은 이게 nginx 에게만 하는 말이라는 점이다. 앞단에 CDN 이나 다른
프록시가 또 있으면 그쪽에도 같은 이야기를 따로 해야 한다. 한 층만 풀고
"고쳤다" 고 넘어가면 다른 층에서 그대로 막힌다.

하트비트는 예의가 아니라 필수다

읽기 시간 초과를 피하는 방법은 조용한 구간을 없애는 것이다. 명세가 정한
주석 줄이 그 자리에 딱 맞는다 — 콜론으로 시작하는 줄은 무시되므로, 클라이언트
쪽에는 아무 영향 없이 바이트만 흐른다.

: keep-alive

주기는 프록시가 참아 주는 시간보다 넉넉히 짧아야 한다. 기본값이 60초인 곳에서
55초 주기를 쓰면 지터 한 번에 끊긴다. 실무에서는 그 절반 이하를 쓴다.

하트비트에는 두 번째 쓸모가 있다. 클라이언트 쪽에서 "언제부터 아무것도 안
오는가" 를 잴 수 있게 해 준다. 이벤트가 원래 드문 스트림에서는 조용한 것이
정상인지 죽은 것인지 구별할 방법이 없는데, 하트비트가 있으면 그 판단이 선다.

브라우저의 동시 연결 상한

HTTP/1.1 에서 브라우저는 한 출처(origin)에 여는 연결 수를 제한한다. MDN 은
"한때 2 에서 3 사이였던 기본값이 지금은 흔히 6개" 라고 적는다
([HTTP/1.x 연결 관리](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Connection_management_in_HTTP_1.x)).
SSE 연결은 끝나지 않는 응답이라 그 자리를 계속 차지한다. 탭을 여섯 개 열면
여섯 자리가 다 차고, **일곱 번째 탭은 물론 그 출처로 가는 평범한 API 요청까지
줄을 선다.** 화면이 멈춘 것처럼 보이는데 서버 로그에는 요청이 도착조차 하지
않아서, 원인을 찾기가 특히 어렵다.

푸는 방법은 둘이다. 하나는 HTTP/2 로 올리는 것 — 한 TCP 연결 위에서 스트림을
여러 개 다중화하므로([RFC 9113](https://www.rfc-editor.org/rfc/rfc9113.html))
연결 하나 문제로 바뀐다. 다른 하나는 탭마다 연결하지 않는 것이다. 공유
워커([SharedWorker](https://developer.mozilla.org/en-US/docs/Web/API/SharedWorker))가
연결 하나를 쥐고,
[BroadcastChannel](https://developer.mozilla.org/en-US/docs/Web/API/Broadcast_Channel_API)
로 같은 출처의 다른 탭에 나눠 준다.

현장에서 만나는 모습

"로컬에서는 되는데 스테이징에서만 한꺼번에 나온다" 는 제보는 거의 항상
버퍼링이다. 확인하는 방법은 애플리케이션 로그가 아니라 **첫 바이트까지 걸린
시간**이다. 스트리밍 경로와 한꺼번에 주는 경로의 첫 바이트 시각을 나란히 재면
한 번에 갈린다 — 총 시간이 같은데 첫 바이트만 다르면 중간에서 모으고 있는 것이다.

밤에만 끊기는 알림 스트림은 하트비트가 없거나 주기가 시간 초과에 너무 가까운
경우다. 재현이 어려워 보이지만, 조용한 시간대에만 난다는 사실 자체가 이미
"무엇을 보내지 않아서" 라는 단서다.

동시 연결 상한은 QA 에서 거의 안 잡힌다. 테스트는 탭 하나로 하기 때문이다.
제보는 늘 "탭을 여러 개 켜 두면 느려진다" 로 들어오고, 그 문장을 성능 문제로
읽는 순간 며칠이 사라진다.

다음 퀴즈에서 확인할 것

버퍼링을 응답 헤더로 끄는 자리, 하트비트 주기를 무엇에 맞춰 잡는지, 동시 연결
상한이 어떤 증상으로 나타나는지를 여섯 문항으로 확인한다.