HTTP 기반 단방향 스트리밍 프로토콜인 SSE의 동작 원리, EventSource API, 자동 재연결 메커니즘을 분석하고 Next.js와 FastAPI에서의 LLM 토큰 스트리밍 구현을 다룹니다.
text/event-stream 포맷의 구조를 파악합니다**SSE(Server-Sent Events, 서버 전송 이벤트)**는 서버에서 클라이언트로 단방향 이벤트를 전송하기 위한 HTTP 기반 프로토콜입니다. HTML5 표준의 일부로, 별도의 라이브러리 없이 브라우저 내장 EventSource API만으로 사용할 수 있습니다.
SSE가 LLM 토큰 스트리밍의 사실상 표준이 된 이유는 명확합니다.
SSE의 핵심은 text/event-stream MIME 타입입니다. 서버는 이 Content-Type으로 응답하면서, 특정 형식에 맞춰 텍스트 데이터를 보냅니다.
event: message
data: 첫 번째 토큰입니다
id: 1
event: message
data: 두 번째 토큰입니다
id: 2
event: done
data: [DONE]각 필드의 의미는 다음과 같습니다.
| 필드 | 설명 | 필수 여부 |
|---|---|---|
data | 전송할 데이터 (여러 줄 가능) | 필수 |
event | 이벤트 타입 (기본값: message) | 선택 |
id | 이벤트 ID (재연결 시 Last-Event-ID로 사용) | 선택 |
retry | 재연결 대기 시간(ms) | 선택 |
메시지 간 구분은 빈 줄(두 개의 연속 개행)로 합니다. 하나의 data 필드 안에서 여러 줄을 보내려면 각 줄마다 data: 접두사를 붙입니다.
SSE 연결을 위한 서버 응답에는 다음 헤더가 필요합니다.
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-aliveCache-Control: no-cache는 프록시나 브라우저가 스트리밍 데이터를 캐시하지 않도록 보장합니다. Connection: keep-alive는 TCP 연결을 유지하여 지속적인 데이터 전송을 가능하게 합니다.
브라우저에서 SSE를 수신하는 방법은 EventSource 객체를 사용하는 것입니다.
// 기본 사용법
const source = new EventSource("/api/stream");
// 기본 message 이벤트 수신
source.onmessage = (event: MessageEvent) => {
const token = event.data;
appendToUI(token);
};
// 커스텀 이벤트 수신
source.addEventListener("reasoning", (event: MessageEvent) => {
showReasoningStep(event.data);
});
// 연결 상태 확인
// 0: CONNECTING, 1: OPEN, 2: CLOSED
console.log(source.readyState);
// 에러 처리
source.onerror = (event: Event) => {
if (source.readyState === EventSource.CLOSED) {
console.log("연결이 종료되었습니다");
}
};
// 명시적 연결 종료
source.close();EventSource의 강력한 특성 중 하나는 자동 재연결입니다. 네트워크 오류로 연결이 끊어지면, 브라우저는 자동으로 재연결을 시도합니다.
서버가 id 필드를 포함해 이벤트를 보냈다면, 재연결 시 브라우저는 Last-Event-ID 헤더에 마지막으로 받은 ID를 포함합니다. 서버는 이를 통해 놓친 이벤트를 재전송할 수 있습니다.
EventSource API는 GET 요청만 지원하며, 커스텀 헤더를 설정할 수 없습니다. 인증 토큰을 헤더로 보내야 한다면 fetch API와 ReadableStream을 조합하거나, eventsource-parser 같은 라이브러리를 사용해야 합니다.
실무에서는 EventSource 대신 fetch API를 사용하는 경우가 많습니다. POST 요청 지원, 커스텀 헤더, 더 세밀한 제어가 가능하기 때문입니다.
async function streamChat(message: string) {
const response = await fetch("/api/chat", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ message }),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const reader = response.body?.getReader();
const decoder = new TextDecoder();
if (!reader) throw new Error("ReadableStream not supported");
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// SSE 형식 파싱: 빈 줄로 메시지 구분
const messages = buffer.split("\n\n");
buffer = messages.pop() ?? "";
for (const msg of messages) {
const dataLine = msg
.split("\n")
.find((line) => line.startsWith("data: "));
if (dataLine) {
const data = dataLine.slice(6);
if (data === "[DONE]") return;
appendToken(data);
}
}
}
}Next.js App Router의 Route Handler를 사용하면 깔끔하게 SSE 엔드포인트를 만들 수 있습니다.
import { NextRequest } from "next/server";
export async function POST(request: NextRequest) {
const { message } = await request.json();
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
try {
// LLM API 호출 (예: OpenAI)
const response = await fetch(
"https://api.openai.com/v1/chat/completions",
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
},
body: JSON.stringify({
model: "gpt-4o",
messages: [{ role: "user", content: message }],
stream: true,
}),
}
);
const reader = response.body?.getReader();
if (!reader) throw new Error("No reader");
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
if (line.startsWith("data: ")) {
const data = line.slice(6);
if (data === "[DONE]") {
controller.enqueue(
encoder.encode("data: [DONE]\n\n")
);
controller.close();
return;
}
const parsed = JSON.parse(data);
const token =
parsed.choices[0]?.delta?.content ?? "";
if (token) {
controller.enqueue(
encoder.encode(`data: ${token}\n\n`)
);
}
}
}
}
} catch (error) {
controller.enqueue(
encoder.encode(
`event: error\ndata: ${JSON.stringify({
message: "스트리밍 중 오류 발생",
})}\n\n`
)
);
controller.close();
}
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}Python 백엔드에서는 FastAPI의 StreamingResponse를 활용합니다.
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
from anthropic import Anthropic
import json
import asyncio
app = FastAPI()
client = Anthropic()
async def generate_stream(message: str):
"""Anthropic Claude 스트리밍 응답 생성기"""
with client.messages.stream(
model="claude-sonnet-4-20250514",
max_tokens=1024,
messages=[{"role": "user", "content": message}],
) as stream:
for text in stream.text_stream:
# SSE 형식으로 인코딩
data = json.dumps({"token": text}, ensure_ascii=False)
yield f"data: {data}\n\n"
await asyncio.sleep(0) # 이벤트 루프에 제어권 반환
yield "data: [DONE]\n\n"
@app.post("/api/chat")
async def chat(request: dict):
message = request.get("message", "")
return StreamingResponse(
generate_stream(message),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no", # Nginx 버퍼링 비활성화
},
)X-Accel-Buffering: no 헤더는 Nginx 리버스 프록시를 사용할 때 필수입니다. 이 헤더 없이는 Nginx가 응답을 버퍼링하여 스트리밍이 청크 단위로 지연될 수 있습니다.
SSE의 가장 큰 아키텍처적 이점은 무상태 스케일링입니다.
WebSocket과 달리 SSE는 다음과 같은 스케일링 이점을 가집니다.
| 항목 | SSE | WebSocket |
|---|---|---|
| 로드밸런서 | 표준 HTTP L7 | 스티키 세션 필요 |
| 상태 관리 | 무상태 (재연결 시 새 서버 가능) | 유상태 (연결 유지 필요) |
| 스케일 아웃 | 서버 추가만으로 가능 | Redis Pub/Sub 등 추가 인프라 |
| 배포 | 롤링 업데이트 자유로움 | 연결 드레이닝 필요 |
| 인프라 복잡도 | 낮음 | 높음 |
모든 기술에는 한계가 있습니다. SSE를 선택하기 전에 다음을 고려해야 합니다.
SSE는 서버에서 클라이언트로만 데이터를 보냅니다. 사용자가 생성 중단을 요청하려면 별도의 HTTP 요청을 보내야 합니다.
// SSE에서 생성 중단 패턴
const abortController = new AbortController();
// SSE 스트리밍 시작
fetch("/api/chat", { signal: abortController.signal });
// 중단 버튼 클릭 시
function handleStop() {
abortController.abort(); // 연결 종료
// 또는 서버에 별도 요청
fetch("/api/chat/cancel", { method: "POST" });
}HTTP/1.1에서는 동일 도메인당 6개의 동시 연결 제한이 있습니다. SSE 연결이 하나를 점유하므로, 여러 탭을 열면 문제가 생길 수 있습니다. HTTP/2에서는 멀티플렉싱으로 이 문제가 해소됩니다.
SSE는 텍스트 기반이므로 바이너리 데이터를 직접 전송할 수 없습니다. Base64 인코딩이 필요하지만, 이는 약 33%의 오버헤드를 수반합니다.
이번 장에서는 SSE의 프로토콜 명세부터 실제 구현까지를 살펴보았습니다.
text/event-stream 형식은 data, event, id, retry 필드로 구성됩니다fetch + ReadableStream 조합이 더 유연합니다다음 장에서는 SSE의 한계를 보완하는 WebSocket을 다룹니다. 양방향 통신이 왜 필요한 경우가 있는지, 그리고 AI 채팅에서 생성 중단이나 실시간 협업을 어떻게 구현하는지 살펴보겠습니다.
이 글이 도움이 되셨나요?
관련 주제 더 보기
WebSocket의 핸드셰이크, 프레이밍 구조, 양방향 통신의 강점과 상태 관리의 복잡성을 분석합니다. AI 채팅에서의 생성 중단, Socket.IO, 스케일링 전략을 다룹니다.
요청-응답 모델의 한계를 넘어 스트리밍 아키텍처가 왜 AI 시대의 필수 인프라인지 살펴봅니다. TTFT, TPOT 등 핵심 지표와 프로토콜 생태계를 개관합니다.
HTTP/2 기반 gRPC의 4가지 스트리밍 모드, Protobuf 직렬화, 마이크로서비스 간 추론 파이프라인 구현을 다룹니다. gRPC-Web의 제약과 Python/Go 구현 예제를 포함합니다.