TL;DR

  • SSE๋Š” HTTP ์œ„์—์„œ ์„œ๋ฒ„โ†’ํด๋ผ์ด์–ธํŠธ ๋‹จ๋ฐฉํ–ฅ ์ด๋ฒคํŠธ ํ‘ธ์‹œ๋ฅผ ๊ตฌํ˜„ํ•˜๋Š” ํ”„๋กœํ† ์ฝœ์ด๋‹ค.
  • text/event-stream ๋ฏธ๋””์–ด ํƒ€์ž…, ํ•„๋“œ ๊ธฐ๋ฐ˜ ๋ฉ”์‹œ์ง€ ํฌ๋งท(data:, event:, id:, retry:), ๋นˆ ์ค„(\n\n)๋กœ ๋ฉ”์‹œ์ง€ ๊ฒฝ๊ณ„๋ฅผ ๊ตฌ๋ถ„ํ•œ๋‹ค.

SSE (Server-Sent Events)

๋ฐฐ๊ฒฝ

์ „ํ†ต์ ์ธ HTTP๋Š” ํด๋ผ์ด์–ธํŠธ๊ฐ€ ์š”์ฒญํ•ด์•ผ ์„œ๋ฒ„๊ฐ€ ์‘๋‹ตํ•˜๋Š” ๊ตฌ์กฐ๋‹ค. SSE๋Š” ์„œ๋ฒ„๊ฐ€ ํด๋ผ์ด์–ธํŠธ์—๊ฒŒ ์ง€์†์ ์œผ๋กœ ๋ฐ์ดํ„ฐ๋ฅผ ํ‘ธ์‹œํ•  ์ˆ˜ ์žˆ๊ฒŒ ํ•ด์ฃผ๋Š” ํ‘œ์ค€์œผ๋กœ, ํด๋ง ์—†์ด ์‹ค์‹œ๊ฐ„ ์—…๋ฐ์ดํŠธ๋ฅผ ๊ตฌํ˜„ํ•œ๋‹ค. WHATWG HTML ์ŠคํŽ™์˜ ์ผ๋ถ€๋กœ ์ •์˜๋˜์–ด ์žˆ๋‹ค.

๋ฉ”์‹œ์ง€ ํฌ๋งท

SSE ์ŠคํŠธ๋ฆผ์€ ํ•„๋“œ ๊ธฐ๋ฐ˜ ํ…์ŠคํŠธ ํฌ๋งท์ด๋‹ค. ๊ฐ ์ค„์€ field: value ํ˜•ํƒœ์ด๋ฉฐ, ๋นˆ ์ค„(\n\n)์ด ๋ฉ”์‹œ์ง€ ๊ฒฝ๊ณ„๋ฅผ ๋‚˜ํƒ€๋‚ธ๋‹ค.

ํ•„๋“œ ์ข…๋ฅ˜

ํ•„๋“œ์—ญํ• 
data:๋ฉ”์‹œ์ง€ ํŽ˜์ด๋กœ๋“œ. ์—ฌ๋Ÿฌ ์ค„์ด๋ฉด \n์œผ๋กœ ์—ฐ๊ฒฐ
event:์ด๋ฒคํŠธ ํƒ€์ž… (์ƒ๋žต ์‹œ "message")
id:์ด๋ฒคํŠธ ID. ์žฌ์—ฐ๊ฒฐ ์‹œ Last-Event-ID ํ—ค๋”๋กœ ์ „์†ก
retry:์žฌ์—ฐ๊ฒฐ ๋Œ€๊ธฐ ์‹œ๊ฐ„ (๋ฐ€๋ฆฌ์ดˆ, ASCII ์ˆซ์ž๋งŒ)

: ๋กœ ์‹œ์ž‘ํ•˜๋Š” ์ค„์€ ์ฝ”๋ฉ˜ํŠธ๋กœ ๋ฌด์‹œ๋œ๋‹ค (keep-alive ์šฉ๋„๋กœ ํ™œ์šฉ).

์˜ˆ์‹œ

event: update
data: {"score": 42}
id: 7

event: update
data: {"score": 43}
id: 8

ํ•ต์‹ฌ ํŠน์„ฑ

  • ํ”„๋กœํ† ์ฝœ: HTTP ์ „์šฉ (๋‹จ๋ฐฉํ–ฅ, ์„œ๋ฒ„โ†’ํด๋ผ์ด์–ธํŠธ)
  • ๋ฏธ๋””์–ด ํƒ€์ž…: text/event-stream
  • ์ธ์ฝ”๋”ฉ: UTF-8 ํ•„์ˆ˜
  • ์ค„ ๋: \n, \r\n, \r ๋ชจ๋‘ ํ—ˆ์šฉ
  • ๋ฉ”์‹œ์ง€ ๊ตฌ๋ถ„: ๋นˆ ์ค„ (\n\n)
  • ์žฌ์—ฐ๊ฒฐ: ์—ฐ๊ฒฐ ๋Š๊ธฐ๋ฉด ์ž๋™ ์žฌ์—ฐ๊ฒฐ. id: ํ•„๋“œ๊ฐ€ ์žˆ์œผ๋ฉด Last-Event-ID ํ—ค๋”๋กœ ์ด์–ด๋ฐ›๊ธฐ ๊ฐ€๋Šฅ

์„œ๋ฒ„ ๊ตฌํ˜„ (FastAPI)

sse-starlette์˜ EventSourceResponse๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด SSE ํฌ๋งทํŒ…์„ ์ง์ ‘ ํ•˜์ง€ ์•Š์•„๋„ ๋œ๋‹ค:

from fastapi import FastAPI
from sse_starlette.sse import EventSourceResponse
import asyncio, json
 
app = FastAPI()
 
async def event_generator():
    for i in range(10):
        yield {"event": "update", "id": str(i), "data": json.dumps({"score": i})}
        await asyncio.sleep(1)
 
@app.get("/stream")
async def stream():
    return EventSourceResponse(event_generator())

EventSourceResponse๊ฐ€ text/event-stream ํ—ค๋”, data:/event:/id: ํ•„๋“œ ํฌ๋งทํŒ…, \n\n ๊ตฌ๋ถ„์„ ๋ชจ๋‘ ์ฒ˜๋ฆฌํ•œ๋‹ค.

์ง์ ‘ ๊ตฌํ˜„

sse-starlette ์—†์ด๋„ StreamingResponse(generator, media_type="text/event-stream")์œผ๋กœ ์ง์ ‘ f"data: {json}\n\n" ๋ฌธ์ž์—ด์„ yieldํ•˜๋Š” ๋ฐฉ์‹๋„ ๊ฐ€๋Šฅํ•˜๋‹ค.

ํด๋ผ์ด์–ธํŠธ (EventSource API)

๋ธŒ๋ผ์šฐ์ €์—์„œ SSE๋ฅผ ์†Œ๋น„ํ•˜๋Š” ํ‘œ์ค€ JavaScript API:

const source = new EventSource('/stream');
source.addEventListener('update', (e) => console.log(JSON.parse(e.data)));
source.addEventListener('error', (e) => console.error(e));

์ด๋ฒคํŠธ: open (์—ฐ๊ฒฐ ์ˆ˜๋ฆฝ), message (๊ธฐ๋ณธ ์ด๋ฒคํŠธ), error (์—ฐ๊ฒฐ ์‹คํŒจ). ์ปค์Šคํ…€ event: ํ•„๋“œ๋ฅผ ์“ฐ๋ฉด ํ•ด๋‹น ์ด๋ฒคํŠธ๋ช…์œผ๋กœ ๋ฆฌ์Šค๋‹ํ•ด์•ผ ํ•œ๋‹ค.

ํ™œ์šฉ ๋งฅ๋ฝ

  • LLM ์ŠคํŠธ๋ฆฌ๋ฐ ์‘๋‹ต: OpenAI, Anthropic ๋“ฑ LLM API๊ฐ€ ํ† ํฐ ๋‹จ์œ„ ์ŠคํŠธ๋ฆฌ๋ฐ์— SSE ์‚ฌ์šฉ
  • ์‹ค์‹œ๊ฐ„ ๋Œ€์‹œ๋ณด๋“œ: ์ฃผ๊ฐ€, ์Šค์ฝ”์–ด๋ณด๋“œ, ์•Œ๋ฆผ ๋“ฑ ์„œ๋ฒ„ ํ‘ธ์‹œ
  • CI/CD ๋กœ๊ทธ ์ŠคํŠธ๋ฆฌ๋ฐ: ๋นŒ๋“œ ๋กœ๊ทธ๋ฅผ ์‹ค์‹œ๊ฐ„์œผ๋กœ ๋ธŒ๋ผ์šฐ์ €์— ์ „๋‹ฌ

Connections

  • NDJSON (Newline Delimited JSON) โ€” ๋‘˜ ๋‹ค ์ค„ ๋‹จ์œ„ ์ŠคํŠธ๋ฆฌ๋ฐ์ด์ง€๋งŒ, NDJSON์€ ํ”„๋กœํ† ์ฝœ ๋ฌด๊ด€ ์ˆœ์ˆ˜ JSON ์ง๋ ฌํ™” ํฌ๋งท์ด๊ณ  SSE๋Š” HTTP ์ด๋ฒคํŠธ ์‹œ๋งจํ‹ฑ(id, retry, event type) ๋‚ด์žฅ
  • WebSocket โ€” SSE๋Š” ๋‹จ๋ฐฉํ–ฅ(์„œ๋ฒ„โ†’ํด๋ผ์ด์–ธํŠธ), WebSocket์€ ์–‘๋ฐฉํ–ฅ

Source Trail