LabHub
学习 学习路径 课程

SSE — 服务端先说话的方式

第一个字节什么时候到

在 LabHub 中继续学习

한국어 원문으로 표시합니다.

목표

SSE 로 실제 겪는 문제 — 코드는 맞는데 화면에 한꺼번에 나온다 — 를 직접 만들고, 시간으로 확인합니다.

규칙

서버 띄우기

cd /root/work/sse
uvicorn app:app --host 127.0.0.1 --port 8000 > /tmp/uv.log 2>&1 &
curl -N -s localhost:8000/stream

-N 을 빼면 curl 이 자기 쪽에서 버퍼링해서, 서버가 잘 흘려보내도 한꺼번에 나온 것처럼 보입니다. 진단할 때 가장 먼저 의심할 것이 측정 도구 자신입니다.

첫 바이트 재기

curl -N -s -o /dev/null -w '%{time_starttransfer}
' localhost:8000/stream

단계

  1. /stream — 0.3초 간격 3개
  2. 와이어 포맷 → 02-wire.txt
  3. id: · retry:
  4. Last-Event-ID 이어받기 → 04-resume.txt
  5. /idle — 주석 하트비트
  6. /buffered 재현 → 06-timing.txt
  7. /long + finallyclosed.log
  8. 정리 → 08-notes.md

참고

빈 줄(`

`)로 이벤트를 끝내지 않으면 클라이언트는 아직 안 끝났다고 생각하고 계속 기다립니다. "아무것도 안 온다" 의 1번 원인입니다.

끝나지 않는 응답을 만든다

/root/work/sse/app.py 에 FastAPI 앱을 만들고 GET /streamtext/event-stream 으로 이벤트 3개를 0.3초 간격으로 보내게 하세요.

mkdir -p /root/work/sse. 앱 이름은 반드시 app 입니다. StreamingResponse(gen(), media_type="text/event-stream") 이면 충분합니다(sse-starlette 도 설치돼 있습니다). 제너레이터 안에서 await asyncio.sleep(0.3) 를 하세요. 확인은 curl -N localhost:8000/stream-N 이 curl 의 버퍼링을 끕니다.

와이어 포맷을 맞춘다

각 이벤트에 event: tokendata: 를 붙이고, 빈 줄로 이벤트를 끝내세요. 받은 원문 그대로를 02-wire.txt 로 남깁니다.

한 이벤트는 event: token\ndata: 안녕\n\n 입니다. 마지막 빈 줄을 빼먹는 것이 '아무것도 안 온다'의 1번 원인입니다. 저장: curl -N -s localhost:8000/stream > 02-wire.txt.

id 와 retry 를 준다

이벤트마다 id: 를 1부터 붙이고, 스트림 맨 앞에 retry: 를 한 번 보내세요.

id 는 브라우저가 기억했다가 재접속 때 Last-Event-ID 헤더로 돌려주는 값입니다. retry: 3000 은 재접속 대기 시간(밀리초)입니다.

놓친 것부터 이어 준다

요청에 Last-Event-ID 헤더가 있으면 그 다음 id 부터 보내게 하세요. 총 5개를 보내도록 늘리고, Last-Event-ID: 3 으로 받은 결과를 04-resume.txt 로 남깁니다.

request.headers.get("last-event-id") (소문자로 조회하세요). 없으면 1부터, 있으면 그 값+1부터입니다. 확인: curl -N -s -H 'Last-Event-ID: 3' localhost:8000/stream > 04-resume.txt — id 4, 5 만 나와야 합니다. 이게 SSE 가 WebSocket 보다 운영이 싼 이유입니다.

유휴 연결을 살려 둔다

GET /idle 을 만들어 이벤트 없이 주석 하트비트만 0.2초 간격으로 5번 보내게 하세요.

: ping\n\n — 콜론으로 시작하면 주석이라 클라이언트는 이벤트로 보지 않습니다. 바이트만 흘러서 로드밸런서의 유휴 타임아웃(대개 60초)을 넘깁니다.

버퍼링을 재현한다

GET /buffered 를 만드세요. /stream똑같은 양(0.3초씩 5번)을 기다리되, 전부 다 만든 뒤 한 번에 반환합니다. 첫 바이트 도착 시각을 두 경로에서 재서 06-timing.txt 로 남기세요.

제너레이터 대신 리스트를 만들어 Response(...) 로 돌려주면 됩니다. 측정: curl -N -s -o /dev/null -w '%{time_starttransfer}\n' localhost:8000/stream/buffered 를 비교하세요. 스트리밍은 0.3초 안쪽, 버퍼는 1.5초쯤 나옵니다 — 총 시간은 같은데 첫 바이트가 다릅니다. 이게 '코드는 맞는데 화면에 한꺼번에 나오는' 증상의 정체입니다.

끊긴 걸 알아챈다

GET /long 을 만들어 오래 흐르게 하고, 제너레이터에 finally 를 넣어 끊길 때 /root/work/sse/closed.log 에 한 줄 남기게 하세요.

클라이언트가 끊어도 제너레이터는 알아채기 전까지 계속 돕니다. try: ... finally: 로 정리 코드를 넣지 않으면 탭을 닫을 때마다 서버에 좀비 태스크가 쌓입니다. 확인: curl -N -s --max-time 1 localhost:8000/long > /dev/null; cat closed.log.

언제 SSE 이고 언제 WebSocket 인가

08-notes.md 에 세 줄 이상. 6단계의 두 숫자가 무엇을 뜻하는지, Last-Event-ID 가 대신해 주는 일이 무엇인지, 그리고 WebSocket 을 골라야 하는 경우 하나.

본문에 버퍼링, Last-Event-ID, WebSocket 이 들어가야 합니다. 판단 기준 하나만 기억하면 됩니다 — 연결이 끊겼을 때 무엇이 자동으로 복구되는가.