본문으로 건너뛰기
Kreath Archive
TechProjectsBooksAbout
TechProjectsBooksAbout

내비게이션

  • Tech
  • Projects
  • Books
  • About
  • Tags

카테고리

  • AI / ML
  • 웹 개발
  • 프로그래밍
  • 개발 도구

연결

  • GitHub
  • Email
  • RSS
© 2026 Kreath Archive. All rights reserved.Built with Next.js + MDX
홈TechProjectsBooksAbout
//
  1. 홈
  2. 테크
  3. 2장: SSE(Server-Sent Events) 심층 분석
2026년 3월 20일·아키텍처·

2장: SSE(Server-Sent Events) 심층 분석

HTTP 기반 단방향 스트리밍 프로토콜인 SSE의 동작 원리, EventSource API, 자동 재연결 메커니즘을 분석하고 Next.js와 FastAPI에서의 LLM 토큰 스트리밍 구현을 다룹니다.

14분720자10개 섹션
streamingai
공유
streaming-ai2 / 10
12345678910
이전1장: 스트리밍 아키텍처의 필요성과 핵심 개념다음3장: WebSocket — 양방향 실시간 통신

학습 목표

  • SSE의 프로토콜 명세와 동작 원리를 이해합니다
  • EventSource API의 사용법과 자동 재연결 메커니즘을 학습합니다
  • text/event-stream 포맷의 구조를 파악합니다
  • Next.js Route Handler에서 SSE를 구현합니다
  • FastAPI에서 StreamingResponse를 활용한 LLM 스트리밍을 구현합니다
  • SSE의 무상태 스케일링 이점과 한계를 분석합니다

SSE란 무엇인가

**SSE(Server-Sent Events, 서버 전송 이벤트)**는 서버에서 클라이언트로 단방향 이벤트를 전송하기 위한 HTTP 기반 프로토콜입니다. HTML5 표준의 일부로, 별도의 라이브러리 없이 브라우저 내장 EventSource API만으로 사용할 수 있습니다.

SSE가 LLM 토큰 스트리밍의 사실상 표준이 된 이유는 명확합니다.

  1. HTTP 위에서 동작 — 기존 로드밸런서, CDN, 프록시와 완벽 호환
  2. 무상태 — 연결이 끊어지면 클라이언트가 자동으로 재연결하며, 서버는 상태를 유지할 필요 없음
  3. 단순함 — 텍스트 기반 프로토콜로 디버깅이 용이
  4. LLM 출력 패턴에 최적 — 서버에서 클라이언트로의 단방향 데이터 흐름과 정확히 일치

text/event-stream 프로토콜

SSE의 핵심은 text/event-stream MIME 타입입니다. 서버는 이 Content-Type으로 응답하면서, 특정 형식에 맞춰 텍스트 데이터를 보냅니다.

SSE 메시지 형식
text
event: message
data: 첫 번째 토큰입니다
id: 1
 
event: message
data: 두 번째 토큰입니다
id: 2
 
event: done
data: [DONE]

각 필드의 의미는 다음과 같습니다.

필드설명필수 여부
data전송할 데이터 (여러 줄 가능)필수
event이벤트 타입 (기본값: message)선택
id이벤트 ID (재연결 시 Last-Event-ID로 사용)선택
retry재연결 대기 시간(ms)선택
Info

메시지 간 구분은 빈 줄(두 개의 연속 개행)로 합니다. 하나의 data 필드 안에서 여러 줄을 보내려면 각 줄마다 data: 접두사를 붙입니다.

HTTP 응답 헤더

SSE 연결을 위한 서버 응답에는 다음 헤더가 필요합니다.

필수 HTTP 응답 헤더
text
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive

Cache-Control: no-cache는 프록시나 브라우저가 스트리밍 데이터를 캐시하지 않도록 보장합니다. Connection: keep-alive는 TCP 연결을 유지하여 지속적인 데이터 전송을 가능하게 합니다.

EventSource API

브라우저에서 SSE를 수신하는 방법은 EventSource 객체를 사용하는 것입니다.

event-source-basic.ts
typescript
// 기본 사용법
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를 포함합니다. 서버는 이를 통해 놓친 이벤트를 재전송할 수 있습니다.

Warning

EventSource API는 GET 요청만 지원하며, 커스텀 헤더를 설정할 수 없습니다. 인증 토큰을 헤더로 보내야 한다면 fetch API와 ReadableStream을 조합하거나, eventsource-parser 같은 라이브러리를 사용해야 합니다.

fetch 기반 SSE 구현

실무에서는 EventSource 대신 fetch API를 사용하는 경우가 많습니다. POST 요청 지원, 커스텀 헤더, 더 세밀한 제어가 가능하기 때문입니다.

fetch-sse-client.ts
typescript
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에서의 SSE 구현

Next.js App Router의 Route Handler를 사용하면 깔끔하게 SSE 엔드포인트를 만들 수 있습니다.

app/api/chat/route.ts
typescript
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",
    },
  });
}

FastAPI에서의 SSE 구현

Python 백엔드에서는 FastAPI의 StreamingResponse를 활용합니다.

api/stream.py
python
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 버퍼링 비활성화
        },
    )
Tip

X-Accel-Buffering: no 헤더는 Nginx 리버스 프록시를 사용할 때 필수입니다. 이 헤더 없이는 Nginx가 응답을 버퍼링하여 스트리밍이 청크 단위로 지연될 수 있습니다.

무상태 스케일링의 이점

SSE의 가장 큰 아키텍처적 이점은 무상태 스케일링입니다.

WebSocket과 달리 SSE는 다음과 같은 스케일링 이점을 가집니다.

항목SSEWebSocket
로드밸런서표준 HTTP L7스티키 세션 필요
상태 관리무상태 (재연결 시 새 서버 가능)유상태 (연결 유지 필요)
스케일 아웃서버 추가만으로 가능Redis Pub/Sub 등 추가 인프라
배포롤링 업데이트 자유로움연결 드레이닝 필요
인프라 복잡도낮음높음

SSE의 한계

모든 기술에는 한계가 있습니다. SSE를 선택하기 전에 다음을 고려해야 합니다.

1. 단방향 통신

SSE는 서버에서 클라이언트로만 데이터를 보냅니다. 사용자가 생성 중단을 요청하려면 별도의 HTTP 요청을 보내야 합니다.

abort-pattern.ts
typescript
// SSE에서 생성 중단 패턴
const abortController = new AbortController();
 
// SSE 스트리밍 시작
fetch("/api/chat", { signal: abortController.signal });
 
// 중단 버튼 클릭 시
function handleStop() {
  abortController.abort(); // 연결 종료
  // 또는 서버에 별도 요청
  fetch("/api/chat/cancel", { method: "POST" });
}

2. 브라우저 연결 제한

HTTP/1.1에서는 동일 도메인당 6개의 동시 연결 제한이 있습니다. SSE 연결이 하나를 점유하므로, 여러 탭을 열면 문제가 생길 수 있습니다. HTTP/2에서는 멀티플렉싱으로 이 문제가 해소됩니다.

3. 바이너리 데이터 비지원

SSE는 텍스트 기반이므로 바이너리 데이터를 직접 전송할 수 없습니다. Base64 인코딩이 필요하지만, 이는 약 33%의 오버헤드를 수반합니다.


정리

이번 장에서는 SSE의 프로토콜 명세부터 실제 구현까지를 살펴보았습니다.

  • SSE는 HTTP 위에서 동작하는 단방향 스트리밍 프로토콜로, LLM 토큰 스트리밍의 사실상 표준입니다
  • text/event-stream 형식은 data, event, id, retry 필드로 구성됩니다
  • EventSource API는 자동 재연결을 지원하지만, 실무에서는 fetch + ReadableStream 조합이 더 유연합니다
  • Next.js Route Handler와 FastAPI 모두에서 간결하게 구현할 수 있습니다
  • 무상태 특성 덕분에 수평 스케일링이 단순하며, 이것이 WebSocket 대비 가장 큰 이점입니다

다음 장에서는 SSE의 한계를 보완하는 WebSocket을 다룹니다. 양방향 통신이 왜 필요한 경우가 있는지, 그리고 AI 채팅에서 생성 중단이나 실시간 협업을 어떻게 구현하는지 살펴보겠습니다.

이 글이 도움이 되셨나요?

관련 주제 더 보기

#streaming#ai

관련 글

아키텍처

3장: WebSocket — 양방향 실시간 통신

WebSocket의 핸드셰이크, 프레이밍 구조, 양방향 통신의 강점과 상태 관리의 복잡성을 분석합니다. AI 채팅에서의 생성 중단, Socket.IO, 스케일링 전략을 다룹니다.

2026년 3월 22일·17분
아키텍처

1장: 스트리밍 아키텍처의 필요성과 핵심 개념

요청-응답 모델의 한계를 넘어 스트리밍 아키텍처가 왜 AI 시대의 필수 인프라인지 살펴봅니다. TTFT, TPOT 등 핵심 지표와 프로토콜 생태계를 개관합니다.

2026년 3월 18일·15분
아키텍처

4장: gRPC Streaming — 고성능 백엔드 통신

HTTP/2 기반 gRPC의 4가지 스트리밍 모드, Protobuf 직렬화, 마이크로서비스 간 추론 파이프라인 구현을 다룹니다. gRPC-Web의 제약과 Python/Go 구현 예제를 포함합니다.

2026년 3월 24일·16분
이전 글1장: 스트리밍 아키텍처의 필요성과 핵심 개념
다음 글3장: WebSocket — 양방향 실시간 통신

댓글

목차

약 14분 남음
  • 학습 목표
  • SSE란 무엇인가
  • text/event-stream 프로토콜
    • HTTP 응답 헤더
  • EventSource API
    • 자동 재연결 메커니즘
  • fetch 기반 SSE 구현
  • Next.js에서의 SSE 구현
  • FastAPI에서의 SSE 구현
  • 무상태 스케일링의 이점
  • SSE의 한계
    • 1. 단방향 통신
    • 2. 브라우저 연결 제한
    • 3. 바이너리 데이터 비지원
  • 정리