LLM 서빙 · 토큰 스트리밍과 SSE · 이론
왜 스트리밍이 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 으로 헤더를 보냈기 때문입니다. 그래서 오류도 이벤트로 보내야 합니다.
현장에서 만나는 모습
스트리밍을 켜면 측정 방식도 바뀝니다. 응답 완료 시각만 재던 것을 첫 청크 시각과 청크 간 간격으로 나눠 재야 합니다. 그리고 이 값들이 배칭 상황에 따라 크게 흔들리므로 p99 를 봐야 합니다.
취소 처리를 빠뜨린 사고는 조용하고 비쌉니다. 사용자가 긴 응답 중간에 다른 질문으로 넘어가는 패턴이 흔한데, 취소가 없으면 그 요청들이 배치 슬롯을 계속 차지합니다. 동시 처리량이 이유 없이 떨어지고 GPU 청구서만 늘어납니다.
다음 실습에서 할 것
결정적 토큰 생성기를 만들고 SSE 로 흘려보내는 서버를 직접 구현합니다. TTFT 와 청크 간 간격을 측정하고, 취소 시 생성이 실제로 멈추는지 카운터로 증명합니다.