이 글은 누구를 위한 것인가
- LLM API를 연동하고 응답을 스트리밍으로 보여주고 싶은 프론트엔드·풀스택 개발자
- ChatGPT처럼 글자가 실시간으로 나타나는 UX를 구현하려는 팀
- 스트리밍 중 사용자가 "중단" 버튼을 눌렀을 때 안전하게 처리하는 방법이 필요한 엔지니어
들어가며
LLM 응답이 전체가 완성될 때까지 기다리는 방식은 사용자 경험에 치명적이다. GPT-4o 기준 한 응답이 완성되는 데 10~30초가 걸릴 수 있고, 그동안 화면에 아무것도 안 나타나면 사용자는 "고장났나?"라고 느낀다.
스트리밍을 쓰면 첫 글자가 0.3~0.5초 안에 나타나고, 사용자는 응답이 진행 중임을 즉각 확인할 수 있다. 체감 응답속도가 10배 이상 빨라진다.
이 글은 bluefoxdev.kr의 LLM API 연동 실전 가이드 를 참고하고, 스트리밍 UX 구현 관점에서 확장하여 작성했습니다.
1. 스트리밍 전송 방식 비교
1.1 SSE vs WebSocket
[SSE (Server-Sent Events)]
클라이언트 ──────────────────→ 서버 (HTTP 요청 1회)
클라이언트 ←── chunk1 ←── 서버
클라이언트 ←── chunk2 ←── 서버
클라이언트 ←── [DONE] ←── 서버
단방향, HTTP/1.1 호환, 자동 재연결
[WebSocket]
클라이언트 ←──────────────→ 서버 (양방향 연결)
클라이언트 ←── chunk1 ←── 서버
클라이언트 ──→ "중단" ──→ 서버
양방향, 빠름, 별도 프로토콜 업그레이드 필요
| 항목 | SSE | WebSocket |
|---|---|---|
| 방향 | 단방향 (서버→클라이언트) | 양방향 |
| 프로토콜 | HTTP | ws:// |
| 브라우저 지원 | 광범위 | 광범위 |
| LLM 스트리밍 | 적합 | 과할 수 있음 |
| 중단 처리 | AbortController | 메시지 전송 |
| 프록시 친화성 | 좋음 | 설정 필요 |
LLM 텍스트 스트리밍은 SSE가 더 단순하고 적합하다.
2. 백엔드 스트리밍 구현
2.1 Next.js API Route (App Router)
// app/api/chat/route.ts
import Anthropic from '@anthropic-ai/sdk';
import { NextRequest } from 'next/server';
const client = new Anthropic();
export async function POST(req: NextRequest) {
const { messages } = await req.json();
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
try {
const anthropicStream = await client.messages.stream({
model: 'claude-opus-4-7',
max_tokens: 1024,
messages,
});
for await (const chunk of anthropicStream) {
if (chunk.type === 'content_block_delta' &&
chunk.delta.type === 'text_delta') {
const data = `data: ${JSON.stringify({ text: chunk.delta.text })}\n\n`;
controller.enqueue(encoder.encode(data));
}
}
controller.enqueue(encoder.encode('data: [DONE]\n\n'));
controller.close();
} catch (error) {
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ error: 'Stream failed' })}\n\n`)
);
controller.close();
}
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
});
}
2.2 Express.js + OpenAI
// server/routes/chat.ts
import express from 'express';
import OpenAI from 'openai';
const router = express.Router();
const openai = new OpenAI();
router.post('/stream', async (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders();
const { messages } = req.body;
try {
const stream = await openai.chat.completions.create({
model: 'gpt-4o',
messages,
stream: true,
});
for await (const chunk of stream) {
const text = chunk.choices[0]?.delta?.content ?? '';
if (text) {
res.write(`data: ${JSON.stringify({ text })}\n\n`);
}
}
res.write('data: [DONE]\n\n');
res.end();
} catch (error) {
res.write(`data: ${JSON.stringify({ error: 'Stream error' })}\n\n`);
res.end();
}
// 클라이언트 연결 끊기면 스트림 중단
req.on('close', () => {
stream?.controller.abort();
});
});
3. 프론트엔드 스트리밍 소비
3.1 React Hook: useStreamingResponse
// hooks/useStreamingResponse.ts
import { useState, useRef, useCallback } from 'react';
interface UseStreamingOptions {
onChunk?: (chunk: string) => void;
onComplete?: (fullText: string) => void;
onError?: (error: Error) => void;
}
export function useStreamingResponse(options: UseStreamingOptions = {}) {
const [text, setText] = useState('');
const [isStreaming, setIsStreaming] = useState(false);
const [error, setError] = useState<Error | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const startStream = useCallback(async (
endpoint: string,
body: object
) => {
// 기존 스트림 중단
abortControllerRef.current?.abort();
abortControllerRef.current = new AbortController();
setText('');
setIsStreaming(true);
setError(null);
let fullText = '';
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: abortControllerRef.current.signal,
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
if (!response.body) throw new Error('No response body');
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const lines = decoder.decode(value).split('\n');
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const data = line.slice(6);
if (data === '[DONE]') break;
try {
const parsed = JSON.parse(data);
if (parsed.text) {
fullText += parsed.text;
setText(fullText);
options.onChunk?.(parsed.text);
}
if (parsed.error) throw new Error(parsed.error);
} catch {
// JSON 파싱 실패 무시 (빈 줄 등)
}
}
}
options.onComplete?.(fullText);
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') return; // 사용자 중단
const error = err instanceof Error ? err : new Error('Unknown error');
setError(error);
options.onError?.(error);
} finally {
setIsStreaming(false);
}
}, [options]);
const abort = useCallback(() => {
abortControllerRef.current?.abort();
setIsStreaming(false);
}, []);
return { text, isStreaming, error, startStream, abort };
}
3.2 스트리밍 채팅 컴포넌트
// components/StreamingChat.tsx
import { useState } from 'react';
import { useStreamingResponse } from '@/hooks/useStreamingResponse';
import { StreamingMarkdown } from './StreamingMarkdown';
export function StreamingChat() {
const [input, setInput] = useState('');
const [messages, setMessages] = useState<Array<{role: string, content: string}>>([]);
const { text, isStreaming, error, startStream, abort } = useStreamingResponse({
onComplete: (fullText) => {
setMessages(prev => [...prev, { role: 'assistant', content: fullText }]);
},
});
const handleSubmit = async () => {
if (!input.trim() || isStreaming) return;
const userMessage = { role: 'user', content: input };
const newMessages = [...messages, userMessage];
setMessages(newMessages);
setInput('');
await startStream('/api/chat', { messages: newMessages });
};
return (
<div className="flex flex-col h-screen">
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.map((msg, i) => (
<div key={i} className={`p-3 rounded-lg ${
msg.role === 'user' ? 'bg-blue-50 ml-8' : 'bg-gray-50 mr-8'
}`}>
{msg.content}
</div>
))}
{/* 현재 스트리밍 중인 응답 */}
{isStreaming && (
<div className="bg-gray-50 mr-8 p-3 rounded-lg">
<StreamingMarkdown text={text} />
<span className="inline-block w-1 h-4 bg-gray-400 animate-pulse ml-1" />
</div>
)}
{error && (
<div className="text-red-500 text-sm">오류: {error.message}</div>
)}
</div>
<div className="p-4 border-t flex gap-2">
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
}}
placeholder="메시지를 입력하세요..."
className="flex-1 border rounded p-2 resize-none"
rows={2}
disabled={isStreaming}
/>
{isStreaming ? (
<button
onClick={abort}
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
>
중단
</button>
) : (
<button
onClick={handleSubmit}
disabled={!input.trim()}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50"
>
전송
</button>
)}
</div>
</div>
);
}
4. 마크다운 점진적 렌더링
스트리밍 중 마크다운을 렌더링할 때 코드 블록이 미완성 상태에서 깨지는 문제가 있다.
4.1 버퍼링 전략
// components/StreamingMarkdown.tsx
import { useMemo } from 'react';
import ReactMarkdown from 'react-markdown';
interface StreamingMarkdownProps {
text: string;
}
function sanitizeIncompleteMarkdown(text: string): string {
let result = text;
// 미완성 코드 블록 처리: ``` 개수가 홀수면 닫기
const codeBlockCount = (result.match(/```/g) || []).length;
if (codeBlockCount % 2 !== 0) {
result += '\n```';
}
// 미완성 인라인 코드 처리
const inlineCodeCount = (result.match(/`/g) || []).length;
if (inlineCodeCount % 2 !== 0) {
result += '`';
}
// 미완성 볼드/이탤릭 처리
const boldCount = (result.match(/\*\*/g) || []).length;
if (boldCount % 2 !== 0) {
result += '**';
}
return result;
}
export function StreamingMarkdown({ text }: StreamingMarkdownProps) {
const safeText = useMemo(() => sanitizeIncompleteMarkdown(text), [text]);
return (
<ReactMarkdown
className="prose prose-sm max-w-none"
components={{
code({ className, children }) {
const isBlock = className?.includes('language-');
if (isBlock) {
return (
<pre className="bg-gray-900 text-gray-100 p-4 rounded overflow-x-auto">
<code>{children}</code>
</pre>
);
}
return <code className="bg-gray-100 px-1 rounded">{children}</code>;
},
}}
>
{safeText}
</ReactMarkdown>
);
}
5. 청크 버퍼링으로 렌더링 성능 최적화
LLM이 매우 빠르게 토큰을 내보낼 때 매 청크마다 리렌더링하면 성능이 저하된다.
// hooks/useBufferedStreaming.ts
import { useState, useRef, useCallback, useEffect } from 'react';
export function useBufferedStreaming(bufferMs = 50) {
const [displayText, setDisplayText] = useState('');
const bufferRef = useRef('');
const timerRef = useRef<NodeJS.Timeout | null>(null);
const flush = useCallback(() => {
setDisplayText(bufferRef.current);
timerRef.current = null;
}, []);
const appendChunk = useCallback((chunk: string) => {
bufferRef.current += chunk;
if (!timerRef.current) {
timerRef.current = setTimeout(flush, bufferMs);
}
}, [bufferMs, flush]);
const reset = useCallback(() => {
bufferRef.current = '';
setDisplayText('');
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
}, []);
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, []);
return { displayText, appendChunk, reset };
}
6. 스트리밍 상태 처리 패턴
[스트리밍 상태 전이도]
idle ──→ loading ──→ streaming ──→ complete
↑ ↓ ↓
└── abort ──┘ error ──→ idle
각 상태의 UI:
- idle: 입력창 활성화, 전송 버튼 활성화
- loading: 스피너 표시, 입력 비활성화
- streaming: 텍스트 증가, 커서 깜박임, 중단 버튼 표시
- complete: 전체 텍스트 고정, 입력창 재활성화
- error: 에러 메시지, 재시도 버튼
type StreamState = 'idle' | 'loading' | 'streaming' | 'complete' | 'error';
const UI_BY_STATE: Record<StreamState, { showStop: boolean; inputDisabled: boolean }> = {
idle: { showStop: false, inputDisabled: false },
loading: { showStop: false, inputDisabled: true },
streaming: { showStop: true, inputDisabled: true },
complete: { showStop: false, inputDisabled: false },
error: { showStop: false, inputDisabled: false },
};
마무리
LLM 스트리밍 구현의 핵심은 세 가지다.
- SSE로 단방향 스트리밍: 대부분의 LLM 채팅 시나리오에서 WebSocket보다 단순하고 충분하다
- AbortController로 중단 처리: 서버 측 abort와 클라이언트 측 신호를 연결해야 API 비용이 낭비되지 않는다
- 마크다운 점진적 렌더링: 미완성 코드 블록을 임시로 닫아줘야 렌더링이 깨지지 않는다
체감 응답 속도는 실제 속도보다 더 중요하다. 스트리밍 하나로 사용자 만족도가 크게 달라진다.