Go에서 Server-Sent Events (SSE)를 사용하여 브라우저로 LLM 응답 스트리밍하기
요약
Go Fiber와 SSE(Server-Sent Events)를 사용하여 LLM의 토큰 응답을 브라우저로 실시간 스트리밍하는 방법을 설명합니다. WebSockets 대신 SSE를 사용하는 이유와 Nginx 설정 시 주의할 버퍼링 문제를 다룹니다.
핵심 포인트
- SSE는 LLM 스트리밍에 적합한 단방향 HTTP 프로토콜임
- 전체 응답을 버퍼링하면 스트리밍의 UX 이점이 사라짐
- Nginx 사용 시 proxy_buffering off 설정이 필수적임
- 브라우저의 EventSource API로 자동 재연결 구현 가능
LLM 기반 웹 애플리케이션에서 가장 큰 UX(사용자 경험) 실수는 아무것도 보내지 않은 채 전체 응답이 완료될 때까지 기다리는 것입니다. 일반적인 생성 속도에서 400개 토큰(token) 분량의 답변을 기다린다면, 사용자는 4~8초 동안 스피너(spinner)만 바라보게 됩니다. 스트리밍(streaming)을 사용하면 사용자는 1초 이내에 첫 단어를 볼 수 있으며, 모델이 생성하는 과정을 따라 읽을 수 있습니다. 이 튜토리얼에서는 Go Fiber를 사용하여 Server-Sent Events (SSE) 방식으로 LLM API에서 브라우저로 토큰 단위 스트리밍을 구현하는 정확한 방법을 보여줍니다.
왜 WebSockets가 아니라 SSE인가?
WebSockets는 양방향(bidirectional)입니다. LLM 스트리밍에는 그 기능이 필요하지 않습니다. 사용자는 요청을 한 번 보내고, 서버는 토큰을 다시 밀어주기(push)만 하면 됩니다. SSE는 다음과 같은 특징을 가집니다:
- 단방향(server → client)이며, 이 문제에 정확히 부합합니다.
text/event-stream콘텐츠 타입(content type)을 사용하는 일반적인 HTTP/1.1 연결입니다.- 브라우저의
EventSourceAPI에 의해 자동으로 재연결(reconnectable)됩니다. - (WebSocket 업그레이드와 달리) 특별한 설정 없이 Nginx를 통해 프록시(proxied)될 수 있습니다.
데이터 형식(wire format)은 매우 단순합니다:
data: {"token": "Hello"}\n\n
data: {"token": " world"}\n\n
data: [DONE]\n\n
각 이벤트는 data: <payload>\n\n 형식입니다. 이중 줄바꿈(\n\n)이 이벤트 종료를 나타냅니다.
흔한 실수: 전체 응답을 버퍼링(buffering)하는 것
하지 말아야 할 행동은 다음과 같습니다:
// 나쁜 예: 전체 LLM 응답을 수집한 후 전송함
func badHandler(c *fiber.Ctx) error {
fullResponse := callLLMAndWaitForCompletion(c.Query("q"))
...
응답을 받은 후 "즉시" 전송하더라도, 사용자는 전체 생성 시간 동안 기다려야 했습니다. 버퍼링은 빠른 모델이 제공하는 체감 속도 이점을 없애버립니다.
설정
go get github.com/gofiber/fiber/v2
go get github.com/openai/openai-go # 또는 다른 OpenAI 호환 SDK
SSE 핸들러 (handler)
// handlers/stream.go
package handlers
...
메인 서버 (Main server)
// main.go
package main
...
JavaScript 클라이언트
이것은 완전한 프론트엔드 구현입니다. 별도의 라이브러리는 필요하지 않습니다. 브라우저의 네이티브 EventSource API가 재연결을 자동으로 처리합니다.
<!DOCTYPE html>
<html lang="en">
<head>
...
Nginx 설정
Nginx 서버 블록에 다음 내용을 추가하세요. proxy_buffering off 설정이 없으면 Nginx가 전체 SSE 스트림을 버퍼링(buffering)하게 되어, 응답이 끝날 때까지 사용자는 아무것도 볼 수 없습니다.
location /api/stream {
proxy_pass http://127.0.0.1:4001;
proxy_http_version 1.1;
...
Go 핸들러(handler)에서 X-Accel-Buffering: no 헤더를 사용하는 것도 Nginx가 이를 준수한다면 동일한 효과를 내지만, Nginx 설정에서 proxy_buffering off를 설정하는 것이 가장 확실하고 안전한 방법(belt-and-suspenders approach)입니다.
스트림 중간의 에러 처리
이 부분이 SSE가 까다로워지는 지점입니다. text/event-stream으로 응답 본문(response body)을 쓰기 시작했다면, HTTP 500 상태 코드를 보낼 수 없습니다. 상태 라인(status line)은 이미 전송되었기 때문입니다. 에러 처리는 데이터 이벤트(data event)를 통해 인밴드(in-band) 방식으로 이루어져야 합니다.
// Go 핸들러에서 — 스트림이 시작된 후 LLM 호출에 실패할 경우:
errPayload, _ := json.Marshal(map[string]string{
"error": "rate_limit_exceeded",
...
클라이언트 측에서는 모든 이벤트에 error 필드가 있는지 확인하고, 단순히 onerror가 아닌 onmessage에서 이를 처리해야 합니다. onerror 핸들러는 연결 오류(네트워크 끊김, 서버 재시작)에 대해 발생하며, 스트림에 포함된 애플리케이션 수준의 에러에는 발생하지 않습니다.
성능 참고 사항
각각 SSE 연결을 유지하는 1,000명의 동시 사용자가 있다면, 1,000개의 고루틴(goroutine)을 열어두게 됩니다. Go의 고루틴은 비용이 저렴하기 때문에(기본적으로 4KB 스택), 일반적인 서버에서도 수만 개의 연결까지는 문제없이 처리할 수 있습니다. 병목 현상은 SSE 인프라가 아니라 LLM API의 속도 제한(rate limits)에서 발생할 것입니다.
LLM API가 응답 없이 대기할 경우 고루틴이 누수(leak)되지 않도록 context.WithTimeout 취소 기능을 사용하세요. 핸들러의 defer cancel()은 클라이언트가 [DONE] 이전에 연결을 끊더라도 정리를 보장합니다.
이 패턴 — Fiber에서의 SSE, 브라우저에서의 EventSource, 버퍼링 없는 Nginx 설정 — 은 프로덕션 환경에 바로 적용 가능하며, 표준 Go 웹 API가 이미 사용하는 것 외에 추가적인 의존성(dependencies)이 전혀 필요하지 않습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기