왜 스트리밍이 UX 를 바꾸는가
한 줄 요약
300 토큰 응답을 다 만들고 한 번에 보내면 사용자는 6초를 빈 화면으로 기다린다. 스트리밍하면 200ms 뒤부터 글자가 나온다. 총 시간은 같다.
왜 이게 필요했나
체감 성능과 실제 성능이 이렇게 크게 갈리는 영역이 드뭅니다. 총 생성 시간이 6초로 같아도, 6초 뒤 한꺼번에 나오는 것과 200ms 뒤부터 흘러나오는 것은 완전히 다른 제품입니다. 후자는 사용자가 읽기 시작할 수 있고, 방향이 틀렸으면 중간에 멈출 수 있습니다.
그래서 LLM 서비스에서 스트리밍은 선택이 아니라 기본입니다.
어떻게 동작하나
전송 방식은 셋 중 하나입니다. 웹소켓은 양방향이지만 무겁고, 롱폴링은 낡았고, SSE(Server-Sent Events)가 이 용도에 가장 맞습니다. 단방향이고, HTTP 위에서 동작하고, 브라우저에 기본 지원이 있습니다.
SSE 의 형식은 단순합니다. Content-Type: text/event-stream 으로 응답하고, 각 이벤트를 data: <내용> 한 줄과 빈 줄로 구분해 보냅니다.
data: {"token":"안녕"}
data: {"token":"하세요"}
data: [DONE]
빈 줄이 이벤트 구분자라는 점이 중요합니다. 이걸 빠뜨리면 클라이언트가 이벤트를 인식하지 못하고 버퍼에 쌓아 둡니다.
구현에서 반드시 챙길 것이 넷입니다.
첫째, 버퍼링 해제. 서버 프레임워크나 앞단 프록시가 응답을 버퍼링하면 스트리밍이 무의미해집니다. NGINX 앞에 두면 X-Accel-Buffering: no 헤더가 필요합니다.
둘째, 종료 신호. 스트림이 언제 끝났는지 알려 줘야 합니다. [DONE] 같은 관례적 마커를 씁니다.
셋째, 취소 처리. 사용자가 브라우저 탭을 닫으면 서버는 생성을 멈춰야 합니다. 이걸 안 하면 아무도 안 보는 토큰을 GPU 가 계속 만듭니다. 비용이 그대로 나갑니다.
넷째, 오류 전달. 스트림 도중 오류가 나면 HTTP 상태 코드를 바꿀 수 없습니다. 이미 200 으로 헤더를 보냈기 때문입니다. 그래서 오류도 이벤트로 보내야 합니다.
중간에 있는 것들이 스트림을 막는다
SSE 를 제대로 구현했는데도 브라우저에는 한꺼번에 도착하는 일이 흔합니다. 원인은 애플리케이션이 아니라 사이에 있는 것들 입니다.
| 막는 것 | 증상 | 고치는 법 |
|---|---|---|
| nginx 프록시 버퍼 | 4KB 씩 뭉쳐서 도착 | proxy_buffering off 또는 응답에 X-Accel-Buffering: no |
| 압축 미들웨어 | 아무것도 안 나오다가 끝에 한 번에 | 이 경로만 gzip 제외 |
| 프레임워크 버퍼 | 첫 조각이 늦다 | 프레임워크의 스트리밍 응답 타입을 쓴다 |
| CDN | 캐시하려고 전체를 받는다 | 이 경로를 캐시 대상에서 뺀다 |
가장 흔한 것이 첫 줄입니다. nginx 는 기본으로 업스트림 응답을 버퍼링하므로,
아무 설정 없이 앞에 세우면 스트리밍이 통째로 무력화됩니다. 응답 헤더 한 줄
(X-Accel-Buffering: no)로 그 요청만 끌 수 있어 이 방법이 안전합니다.
끊긴 연결을 알아채야 GPU 를 아낀다
사용자가 탭을 닫아도 서버는 계속 토큰을 만듭니다. 아무도 안 보는 답을 GPU 가 6초 동안 생성하는 것입니다. 동시 요청이 많은 서비스에서 이 낭비가 큽니다.
그래서 클라이언트 연결 해제를 감지해 생성을 중단 합니다. FastAPI 라면
await request.is_disconnected() 를 토큰마다 확인하고, vLLM 은 요청을 취소하면
그 시퀀스를 배치에서 뺍니다.
async def stream(request, prompt):
async for chunk in llm.generate(prompt):
if await request.is_disconnected():
await llm.abort(chunk.request_id) # GPU 를 놓아준다
return
yield f"data: {json.dumps(chunk.model_dump())}
"
yield "data: [DONE]
"
오류를 스트림 안에서 전하기
스트리밍의 곤란한 점입니다. 200 OK 와 헤더를 이미 보낸 뒤에 실패하면 상태
코드를 바꿀 수 없습니다. 그래서 오류도 이벤트로 보냅니다.
data: {"token":"안녕하"}
event: error
data: {"code":"context_length","message":"입력이 너무 깁니다"}
클라이언트는 [DONE] 없이 스트림이 끝나는 경우도 처리해야 합니다. 그것은 대개
네트워크가 끊긴 것이고, 이미 받은 부분까지는 살려 두고 "응답이 중간에 끊겼습니다"
를 보여 주는 편이 아무것도 안 보여 주는 것보다 낫습니다.
무엇을 재는가
스트리밍 서비스의 지표는 두 개입니다.
- TTFT(Time To First Token) — 사용자가 체감하는 응답 속도. 200~500ms 를 목표로.
- TPOT(Time Per Output Token) — 흘러나오는 속도. 사람의 읽기 속도(초당 5~10 토큰)보다 빠르면 충분합니다.
총 생성 시간은 이 둘의 결과일 뿐이라 목표로 삼지 않습니다. 배치 크기를 키우면 전체 처리량은 오르지만 TTFT 가 나빠집니다 — 처리량과 체감 속도는 맞바꾸는 관계 이고, 어느 쪽을 살릴지는 제품이 정합니다.
현장에서 만나는 모습
스트리밍을 켜면 측정 방식도 바뀝니다. 응답 완료 시각만 재던 것을 첫 청크 시각과 청크 간 간격으로 나눠 재야 합니다. 그리고 이 값들이 배칭 상황에 따라 크게 흔들리므로 p99 를 봐야 합니다.
취소 처리를 빠뜨린 사고는 조용하고 비쌉니다. 사용자가 긴 응답 중간에 다른 질문으로 넘어가는 패턴이 흔한데, 취소가 없으면 그 요청들이 배치 슬롯을 계속 차지합니다. 동시 처리량이 이유 없이 떨어지고 GPU 청구서만 늘어납니다.
다음 실습에서 할 것
결정적 토큰 생성기를 만들고 SSE 로 흘려보내는 서버를 직접 구현합니다. TTFT 와 청크 간 간격을 측정하고, 취소 시 생성이 실제로 멈추는지 카운터로 증명합니다.