--- 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: \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]]