---
id: web-sse-server-sent-events
title: SSE — 단방향 스트리밍 (LLM, 알림)
category: Coding
status: draft
source_trust_level: B
verification_status: conceptual
created_at: 2026-05-09
updated_at: 2026-05-09
tags: [web, sse, streaming, vibe-coding]
tech_stack: { language: "TypeScript / EventSource", applicable_to: ["Web", "Backend"] }
applied_in: []
aliases: [Server-Sent Events, EventSource, text/event-stream, retry]
---

# SSE — Server-Sent Events

> 단방향 (서버 → 클라이언트) 스트리밍. WebSocket 보다 단순. **LLM 토큰 스트리밍 / 알림 / 진행률**에 적합. 자동 재연결 내장. 단 헤더 / 쿼리스트링 인증만 (커스텀 헤더 X — fetch 기반 SSE 라이브러리로 우회).

## 📖 핵심 개념
- HTTP long-lived connection.
- 응답 `Content-Type: text/event-stream`.
- `data: <json>\n\n` 단위 메시지.
- 자동 reconnect — `Last-Event-ID` 로 resume.

## 💻 코드 패턴

### 서버 (Express)
```ts
app.get('/api/stream', (req, res) => {
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache, no-transform');
  res.setHeader('Connection', 'keep-alive');
  res.setHeader('X-Accel-Buffering', 'no'); // nginx buffer 끄기

  let id = Number(req.header('last-event-id') ?? 0);

  const send = (event: string, data: any) => {
    res.write(`id: ${++id}\nevent: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
  };

  const interval = setInterval(() => send('tick', { time: Date.now() }), 1000);

  req.on('close', () => clearInterval(interval));
});
```

### 클라이언트 — EventSource
```ts
const es = new EventSource('/api/stream', { withCredentials: true });

es.addEventListener('tick', e => {
  const data = JSON.parse(e.data);
  console.log('tick', data.time);
});

es.onerror = () => {
  // 자동 재연결됨. 5xx 면 reconnect, 401 이면 close.
};

// 종료
es.close();
```

### LLM 토큰 스트리밍
```ts
// 서버
app.post('/api/chat', async (req, res) => {
  res.setHeader('Content-Type', 'text/event-stream');
  for await (const chunk of llm.stream(req.body.prompt)) {
    res.write(`data: ${JSON.stringify({ token: chunk })}\n\n`);
  }
  res.write('data: [DONE]\n\n');
  res.end();
});
```

```ts
// 클라이언트 — fetch + 스트림 (헤더 보내야 할 때)
import { fetchEventSource } from '@microsoft/fetch-event-source';

await fetchEventSource('/api/chat', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
  body: JSON.stringify({ prompt }),
  onmessage(ev) {
    if (ev.data === '[DONE]') return;
    const { token } = JSON.parse(ev.data);
    setText(t => t + token);
  },
  onerror(err) { throw err; }, // 재연결 안 함
});
```

## 🤔 의사결정 기준
| 상황 | SSE | WebSocket | Polling |
|---|---|---|---|
| 단방향 (서버 → 클) | ✅ | ✅ | △ |
| 양방향 | ❌ | ✅ | — |
| LLM 응답 스트림 | ✅ | OK | ❌ |
| 진행률 / status | ✅ | OK | ❌ |
| 자동 재연결 필요 | ✅ (내장) | 직접 | — |
| HTTP/2 다중화 | ✅ | ❌ | — |
| 커스텀 헤더 / cookie 외 인증 | fetchEventSource 라이브러리 | ✅ | ✅ |

## ❌ 안티패턴
- **proxy buffer 켬**: 메시지 지연. nginx `proxy_buffering off;`, `X-Accel-Buffering: no`.
- **CORS 응답 헤더 누락**: 다른 origin EventSource 차단.
- **Content-Type 잘못 (`application/json`)**: EventSource 거부.
- **재연결 무한 루프 + 401**: 서버가 401 보내야 close. backoff 있더라도 인증 만료 처리 별도.
- **너무 짧은 keep-alive timeout**: 매분 재연결.
- **메시지 \n\n 없이**: 클라가 안 받음.
- **Last-Event-ID 무시**: resume 못 함.

## 🤖 LLM 활용 힌트
- 신규 LLM streaming = SSE 디폴트.
- 인증 헤더 필요 = fetchEventSource.

## 🔗 관련 문서
- [[Web_WebSocket_Reconnect]]
- [[AI_Streaming_LLM_Response]]
