웹 앱에서 LLM 응답 스트리밍하기: SSE부터 실시간 UI까지 완벽 정리
ChatGPT나 Claude 써보신 적 있죠? 글자가 하나씩 타이핑되듯 나타나는 그 효과요. 근데 막상 내 앱에서 이걸 구현하려고 하면, API 호출하고 응답 렌더링하면 끝일 줄 알았는데 그게 아니더라고요.
LLM 응답 스트리밍은 2024-2025년 AI 앱 개발에서 가장 흔하게 부딪히는 문제 중 하나예요. 10초 넘게 로딩 스피너만 보여주는 앱이랑, 바로바로 텍스트가 나타나는 앱이랑 사용자 경험 차이가 엄청나거든요.
이 글에서는 LLM 응답 스트리밍을 처음부터 끝까지 다뤄볼게요. 기본 프로토콜부터 프로덕션 배포까지, OpenAI든 Anthropic이든 Ollama든 어떤 LLM을 쓰든 적용할 수 있는 패턴들이에요.
스트리밍이 왜 중요할까요?
기술 얘기 전에, 왜 이렇게까지 신경 써야 하는지 먼저 살펴봐요.
숫자로 보는 현실
사용자가 LLM에 프롬프트를 보내면, 첫 번째 토큰이 나오기까지(TTFT)와 전체 응답 시간이 이렇게 달라요:
| 모델 | 평균 TTFT | 전체 응답 시간 (500 토큰) |
|---|---|---|
| GPT-4 Turbo | 0.5-1.5초 | 8-15초 |
| Claude 3 Opus | 0.8-2.0초 | 10-20초 |
| GPT-3.5 Turbo | 0.2-0.5초 | 3-6초 |
| Llama 3 70B (로컬) | 0.1-0.3초 | 15-45초 |
스트리밍 없이는 사용자가 전체 시간 동안 로딩 스피너만 봐야 해요. 근데 스트리밍을 쓰면 TTFT 안에 글자가 나타나기 시작하니까, 체감 속도가 10-20배 빨라지는 거죠.
사용자 심리와 신뢰
UX 연구 결과를 보면요:
- 스트리밍 UI는 같은 시간이 걸려도 40% 더 빠르게 느껴져요
- 글자가 하나씩 나타나면 "기다리는 느낌"과 불안감이 줄어들어요
- 타이핑 효과가 "AI가 생각하고 있구나" 느낌을 줘서 오히려 신뢰도가 올라가요
스트리밍 파이프라인 구조
LLM 응답이 화면에 나타나기까지 어떤 과정을 거치는지 볼게요:
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ LLM API │───▶│ 백엔드 │───▶│ 전송 │───▶│ 프론트엔드│
│ (OpenAI) │ │ 서버 │ │ 프로토콜 │ │ (React) │
└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘
토큰 청크 SSE/WS DOM 업데이트
각 단계마다 신경 써야 할 게 있어요:
- LLM API: 토큰이 생성될 때마다 바로바로 보내줘요
- 백엔드 서버: API 응답을 클라이언트가 받을 수 있는 형태로 변환해요
- 전송 프로토콜: SSE, WebSocket, HTTP 스트리밍 중 선택해요
- 프론트엔드: 버벅임 없이 DOM을 효율적으로 업데이트해요
Part 1: Server-Sent Events (SSE) — 업계 표준
SSE는 LLM 스트리밍에서 가장 많이 쓰이는 방식이에요. OpenAI, Anthropic 등 대부분의 LLM API가 이걸 기본으로 써요.
SSE가 뭔가요?
SSE는 서버에서 클라이언트로 데이터를 계속 보낼 수 있게 해주는 웹 표준이에요. WebSocket과 비교하면:
- 단방향: 서버 → 클라이언트만 가능해요
- HTTP 기반: 프록시, CDN, 로드 밸런서 다 통과해요
- 자동 재연결: 연결 끊기면 알아서 다시 연결해요
- 텍스트 기반: 메시지가 텍스트 이벤트로 전달돼요
SSE 메시지 형식
SSE 스트림은 이런 텍스트 형식을 따라요:
event: message
data: {"content": "안녕"}
event: message
data: {"content": "하세요"}
event: done
data: [DONE]
핵심 규칙:
- 각 필드는 줄바꿈으로 구분:
field: value - 메시지 끝에는 빈 줄 두 개(
\n\n) data:에 실제 데이터(보통 JSON)event:,id:,retry:는 선택사항
Node.js 백엔드 코드
OpenAI API를 스트리밍하는 Express 서버를 만들어볼게요:
// server.js - Express + OpenAI 스트리밍 import express from 'express'; import OpenAI from 'openai'; import cors from 'cors'; const app = express(); app.use(cors()); app.use(express.json()); const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY, }); app.post('/api/chat/stream', async (req, res) => { const { messages, model = 'gpt-4-turbo-preview' } = req.body; // SSE 헤더 설정 res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); res.setHeader('X-Accel-Buffering', 'no'); // nginx 버퍼링 끄기 // 헤더 바로 보내기 res.flushHeaders(); try { const stream = await openai.chat.completions.create({ model, messages, stream: true, stream_options: { include_usage: true }, }); let totalTokens = 0; for await (const chunk of stream) { const content = chunk.choices[0]?.delta?.content; const finishReason = chunk.choices[0]?.finish_reason; if (chunk.usage) { totalTokens = chunk.usage.total_tokens; } if (content) { res.write(`data: ${JSON.stringify({ type: 'content', content })}\n\n`); } if (finishReason) { res.write(`data: ${JSON.stringify({ type: 'done', finishReason, usage: { totalTokens } })}\n\n`); } } } catch (error) { console.error('스트림 에러:', error); res.write(`data: ${JSON.stringify({ type: 'error', message: error.message })}\n\n`); } finally { res.end(); } }); app.listen(3001, () => { console.log('서버 실행 중: http://localhost:3001'); });
실수하기 쉬운 포인트들
스트리밍 구현할 때 자주 걸리는 함정들이에요:
1. 프록시/로드 밸런서 버퍼링
Nginx나 Cloudflare 같은 리버스 프록시는 기본적으로 응답을 버퍼링해요. 그러면 스트리밍이 안 돼요. 이 헤더들을 꼭 추가하세요:
res.setHeader('X-Accel-Buffering', 'no'); // Nginx용 res.setHeader('Cache-Control', 'no-cache, no-transform'); // CDN용
Cloudflare는 대시보드에서 "Auto Minify" 끄고 "Chunked Transfer Encoding" 켜야 할 수도 있어요.
2. 연결 타임아웃
오래 걸리는 응답은 타임아웃에 걸릴 수 있어요. heartbeat로 연결을 유지하세요:
// 15초마다 heartbeat 보내서 연결 유지 const heartbeat = setInterval(() => { res.write(': heartbeat\n\n'); // SSE 주석, 클라이언트가 무시함 }, 15000); req.on('close', () => { clearInterval(heartbeat); });
3. backpressure 처리
클라이언트가 데이터를 빨리 못 받아가면 버퍼가 쌓여요:
for await (const chunk of stream) { const content = chunk.choices[0]?.delta?.content; if (content) { const data = `data: ${JSON.stringify({ type: 'content', content })}\n\n`; const canContinue = res.write(data); if (!canContinue) { // 버퍼 빌 때까지 대기 await new Promise(resolve => res.once('drain', resolve)); } } }
Part 2: React 프론트엔드 구현
이제 SSE를 받아서 화면에 보여주는 React 코드를 만들어볼게요.
스트리밍 훅
// hooks/useStreamingChat.ts import { useState, useCallback, useRef } from 'react'; interface Message { role: 'user' | 'assistant'; content: string; } interface StreamState { isStreaming: boolean; error: string | null; usage: { totalTokens: number } | null; } export function useStreamingChat() { const [messages, setMessages] = useState<Message[]>([]); const [streamState, setStreamState] = useState<StreamState>({ isStreaming: false, error: null, usage: null, }); const abortControllerRef = useRef<AbortController | null>(null); const sendMessage = useCallback(async (userMessage: string) => { // 기존 스트림 취소 abortControllerRef.current?.abort(); abortControllerRef.current = new AbortController(); const newMessages: Message[] = [ ...messages, { role: 'user', content: userMessage }, ]; setMessages([...newMessages, { role: 'assistant', content: '' }]); setStreamState({ isStreaming: true, error: null, usage: null }); try { const response = await fetch('/api/chat/stream', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ messages: newMessages }), signal: abortControllerRef.current.signal, }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const reader = response.body?.getReader(); if (!reader) throw new Error('응답 본문 없음'); const decoder = new TextDecoder(); let buffer = ''; let assistantContent = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n\n'); buffer = lines.pop() || ''; for (const line of lines) { if (!line.startsWith('data: ')) continue; const jsonStr = line.slice(6); if (jsonStr === '[DONE]') continue; try { const data = JSON.parse(jsonStr); if (data.type === 'content') { assistantContent += data.content; setMessages(prev => { const updated = [...prev]; updated[updated.length - 1] = { role: 'assistant', content: assistantContent, }; return updated; }); } else if (data.type === 'done') { setStreamState(prev => ({ ...prev, usage: data.usage, })); } else if (data.type === 'error') { throw new Error(data.message); } } catch (parseError) { console.warn('SSE 파싱 실패:', line); } } } } catch (error) { if ((error as Error).name === 'AbortError') { return; } setStreamState(prev => ({ ...prev, error: (error as Error).message, })); setMessages(prev => prev.slice(0, -1)); } finally { setStreamState(prev => ({ ...prev, isStreaming: false })); } }, [messages]); const cancelStream = useCallback(() => { abortControllerRef.current?.abort(); setStreamState(prev => ({ ...prev, isStreaming: false })); }, []); const clearMessages = useCallback(() => { setMessages([]); setStreamState({ isStreaming: false, error: null, usage: null }); }, []); return { messages, streamState, sendMessage, cancelStream, clearMessages, }; }
렌더링 최적화
스트리밍 중에는 초당 10-50번씩 업데이트가 와요. 그냥 렌더링하면 버벅여요:
// components/MessageContent.tsx import { memo, useMemo } from 'react'; import { marked } from 'marked'; import DOMPurify from 'dompurify'; interface MessageContentProps { content: string; isStreaming: boolean; } export const MessageContent = memo(function MessageContent({ content, isStreaming, }: MessageContentProps) { const renderedContent = useMemo(() => { if (isStreaming) { // 스트리밍 중엔 마크다운 파싱 안 함 (느림) return content.split('\n').map((line, i) => ( <span key={i}> {line} {i < content.split('\n').length - 1 && <br />} </span> )); } // 스트리밍 끝나면 마크다운 렌더링 const html = marked(content, { breaks: true, gfm: true }); const sanitized = DOMPurify.sanitize(html); return <div dangerouslySetInnerHTML={{ __html: sanitized }} />; }, [content, isStreaming]); return ( <div className="message-content"> {renderedContent} {isStreaming && <span className="cursor-blink">▊</span>} </div> ); });
전체 채팅 컴포넌트
// components/StreamingChat.tsx import { useState, useRef, useEffect, FormEvent } from 'react'; import { useStreamingChat } from '../hooks/useStreamingChat'; import { MessageContent } from './MessageContent'; export function StreamingChat() { const [input, setInput] = useState(''); const messagesEndRef = useRef<HTMLDivElement>(null); const inputRef = useRef<HTMLTextAreaElement>(null); const { messages, streamState, sendMessage, cancelStream, clearMessages, } = useStreamingChat(); useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages]); useEffect(() => { if (inputRef.current) { inputRef.current.style.height = 'auto'; inputRef.current.style.height = `${inputRef.current.scrollHeight}px`; } }, [input]); const handleSubmit = async (e: FormEvent) => { e.preventDefault(); if (!input.trim() || streamState.isStreaming) return; const message = input.trim(); setInput(''); await sendMessage(message); }; const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSubmit(e); } }; return ( <div className="chat-container"> <div className="messages-area"> {messages.length === 0 ? ( <div className="empty-state"> <h2>대화 시작하기</h2> <p>메시지를 보내서 AI와 대화해보세요</p> </div> ) : ( messages.map((message, index) => ( <div key={index} className={`message message-${message.role}`} > <div className="message-avatar"> {message.role === 'user' ? '👤' : '🤖'} </div> <MessageContent content={message.content} isStreaming={ streamState.isStreaming && index === messages.length - 1 && message.role === 'assistant' } /> </div> )) )} <div ref={messagesEndRef} /> </div> {streamState.error && ( <div className="error-banner"> <span>⚠️ {streamState.error}</span> <button onClick={clearMessages}>닫기</button> </div> )} {streamState.usage && ( <div className="usage-info"> 사용 토큰: {streamState.usage.totalTokens} </div> )} <form onSubmit={handleSubmit} className="input-area"> <textarea ref={inputRef} value={input} onChange={(e) => setInput(e.target.value)} onKeyDown={handleKeyDown} placeholder="메시지 입력... (Shift+Enter로 줄바꿈)" disabled={streamState.isStreaming} rows={1} /> {streamState.isStreaming ? ( <button type="button" onClick={cancelStream} className="cancel-btn"> ⏹ 중지 </button> ) : ( <button type="submit" disabled={!input.trim()}> 전송 → </button> )} </form> </div> ); }
Part 3: SSE 말고 다른 방법은?
SSE가 대부분의 경우에 맞지만, 다른 게 필요할 때도 있어요.
WebSocket이 필요한 경우
이런 게 필요하면 WebSocket을 쓰세요:
- 실시간 취소: 생성 중간에 끊기
- 여러 스트림 동시: 한 연결로 여러 대화
- 양방향 통신: 서버에서 먼저 메시지 보내기
class StreamingWebSocket { private ws: WebSocket; private messageHandlers = new Map<string, (data: any) => void>(); constructor(url: string) { this.ws = new WebSocket(url); this.ws.onmessage = (event) => { const data = JSON.parse(event.data); const handler = this.messageHandlers.get(data.streamId); if (handler) handler(data); }; } async streamChat( messages: Message[], onChunk: (content: string) => void ): Promise<{ streamId: string; cancel: () => void }> { const streamId = crypto.randomUUID(); this.messageHandlers.set(streamId, (data) => { if (data.type === 'content') { onChunk(data.content); } }); this.ws.send(JSON.stringify({ type: 'start_stream', streamId, messages, })); return { streamId, cancel: () => { this.ws.send(JSON.stringify({ type: 'cancel', streamId })); this.messageHandlers.delete(streamId); }, }; } }
Vercel AI SDK 쓰기
프로덕션에서 빠르게 구현하려면 Vercel AI SDK가 편해요:
import { useChat } from 'ai/react'; export function Chat() { const { messages, input, handleInputChange, handleSubmit, isLoading } = useChat({ api: '/api/chat', }); return ( <div> {messages.map(m => ( <div key={m.id}>{m.role}: {m.content}</div> ))} <form onSubmit={handleSubmit}> <input value={input} onChange={handleInputChange} /> <button type="submit" disabled={isLoading}>전송</button> </form> </div> ); }
Part 4: 프로덕션에서 신경 쓸 것들
에러 복구와 재시도
일시적인 오류는 자동으로 재시도하게 해요:
async function streamWithRetry( messages: Message[], maxRetries = 3 ): AsyncGenerator<string> { let attempt = 0; while (attempt < maxRetries) { try { yield* streamFromAPI(messages); return; } catch (error) { attempt++; if (attempt >= maxRetries) throw error; // 지수 백오프: 1초, 2초, 4초 const delay = Math.pow(2, attempt - 1) * 1000; await new Promise(resolve => setTimeout(resolve, delay)); } } }
Rate Limiting
여러 사용자가 동시에 스트리밍할 때:
class RateLimiter { private tokens: number; private lastRefill: number; constructor( private maxTokens: number, private refillRate: number ) { this.tokens = maxTokens; this.lastRefill = Date.now(); } async acquire(): Promise<boolean> { this.refill(); if (this.tokens >= 1) { this.tokens -= 1; return true; } return false; } private refill() { const now = Date.now(); const elapsed = (now - this.lastRefill) / 1000; this.tokens = Math.min( this.maxTokens, this.tokens + elapsed * this.refillRate ); this.lastRefill = now; } }
모니터링 지표
이 지표들은 꼭 추적하세요:
- TTFT (Time To First Token): 첫 글자가 나올 때까지
- TPS (Tokens Per Second): 생성 속도
- 스트림 완료율: 에러 없이 끝나는 비율
- 연결 지속 시간: 스트림이 열려 있는 시간
import { Counter, Histogram } from 'prom-client'; const streamDuration = new Histogram({ name: 'llm_stream_duration_seconds', help: 'LLM 스트리밍 요청 소요 시간', buckets: [0.5, 1, 2, 5, 10, 30, 60], }); const tokensGenerated = new Counter({ name: 'llm_tokens_generated_total', help: '생성된 총 토큰 수', }); const streamErrors = new Counter({ name: 'llm_stream_errors_total', help: '스트리밍 에러 수', labelNames: ['error_type'], });
비용 최적화
스트리밍 자체가 비용을 줄이진 않지만, 이렇게 최적화할 수 있어요:
function selectModel(prompt: string): string { const estimatedComplexity = analyzePromptComplexity(prompt); if (estimatedComplexity < 0.3) { return 'gpt-3.5-turbo'; // 저렴 } else if (estimatedComplexity < 0.7) { return 'gpt-4-turbo-preview'; // 중간 } else { return 'gpt-4'; // 복잡한 작업용 } }
Part 5: 엣지 케이스 처리
긴 응답 처리
응답이 너무 길면 브라우저 메모리가 부족할 수 있어요:
import { FixedSizeList as List } from 'react-window'; function VirtualizedMessage({ content }: { content: string }) { const lines = content.split('\n'); return ( <List height={400} itemCount={lines.length} itemSize={24} width="100%" > {({ index, style }) => ( <div style={style}>{lines[index]}</div> )} </List> ); }
코드 블록 깜빡임 방지
스트리밍 중에 코드 블록 하이라이팅이 깜빡거리는 거 방지:
function isCompleteCodeBlock(content: string): boolean { const openBlocks = (content.match(/```/g) || []).length; return openBlocks % 2 === 0; } function MessageContent({ content, isStreaming }: Props) { const shouldHighlight = !isStreaming || isCompleteCodeBlock(content); const processed = shouldHighlight ? highlightCode(content) : escapeHtml(content); return <div dangerouslySetInnerHTML={{ __html: processed }} />; }
느린 네트워크 대응
모바일이나 느린 네트워크에서는 렌더링 빈도를 줄여요:
function getChunkingStrategy(): 'immediate' | 'batched' { if ('connection' in navigator) { const connection = (navigator as any).connection; if (connection.effectiveType === '2g' || connection.effectiveType === 'slow-2g') { return 'batched'; } } return 'immediate'; }
마무리
LLM 응답 스트리밍, 이제 ChatGPT나 Claude 쓰는 사용자들은 당연하게 기대하는 기능이에요.
핵심 정리:
- SSE가 기본: 단순하고 HTTP 기반이라 어디서든 잘 돌아가요
- backpressure랑 타임아웃 조심: 프로덕션에서 자주 문제 돼요
- 렌더링 최적화 필수: 마크다운 파싱이랑 DOM 업데이트 비용 커요
- 모니터링 꼭 하세요: TTFT, TPS, 에러율 다 추적해야 해요
- 엣지 케이스 대비: 긴 응답, 코드 블록, 모바일 사용자 다 고려하세요
이 가이드 코드들 다 프로덕션에서 검증된 패턴이에요. 가져다 쓰시고, 멋진 AI 앱 만들어보세요! 🚀
관련 도구 둘러보기
Pockit의 무료 개발자 도구를 사용해 보세요