OpenAI, Anthropic, Google의 스트리밍 API 차이를 비교하고, 구조화된 출력의 파셜 파싱, React 스트리밍 UI 렌더링, Vercel AI SDK 활용법을 다룹니다.
2026년 현재, 주요 LLM 제공자들은 모두 스트리밍 API를 지원합니다. 그러나 각각의 이벤트 형식과 구조는 상당히 다릅니다. 이 차이를 이해하는 것은 멀티모델 시스템을 구축할 때 필수적입니다.
OpenAI의 Chat Completions API는 SSE 기반으로 delta 객체에 토큰을 담아 전송합니다.
data: {"id":"chatcmpl-abc","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}
data: {"id":"chatcmpl-abc","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"안녕"},"finish_reason":null}]}
data: {"id":"chatcmpl-abc","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"하세요"},"finish_reason":null}]}
data: {"id":"chatcmpl-abc","object":"chat.completion.chunk","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}
data: [DONE]핵심 특성은 다음과 같습니다.
choices[0].delta.content에 토큰이 점진적으로 전달됩니다finish_reason이 null이면 생성 진행 중, "stop"이면 완료입니다data: [DONE] 이벤트가 전송됩니다Anthropic의 Messages API는 보다 구조화된 이벤트 타입을 사용합니다.
event: message_start
data: {"type":"message_start","message":{"id":"msg_abc","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","usage":{"input_tokens":25,"output_tokens":1}}}
event: content_block_start
data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}
event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"안녕하세요"}}
event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":", 무엇을"}}
event: content_block_stop
data: {"type":"content_block_stop","index":0}
event: message_delta
data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":15}}
event: message_stop
data: {"type":"message_stop"}Anthropic의 형식은 이벤트 타입이 세분화되어 있어, 메시지의 시작/진행/종료를 명확히 구분할 수 있습니다. 또한 usage 정보가 실시간으로 전달되어 토큰 소비를 추적할 수 있습니다.
Google의 Gemini API는 generateContent의 스트리밍 버전에서 각 청크를 완전한 JSON 객체로 전달합니다.
data: {"candidates":[{"content":{"parts":[{"text":"안녕"}],"role":"model"},"finishReason":null,"index":0}]}
data: {"candidates":[{"content":{"parts":[{"text":"하세요"}],"role":"model"},"finishReason":null,"index":0}]}
data: {"candidates":[{"content":{"parts":[{"text":"!"}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":5}}| 특성 | OpenAI | Anthropic | |
|---|---|---|---|
| 토큰 위치 | delta.content | delta.text | parts[0].text |
| 종료 신호 | [DONE] 이벤트 | message_stop 이벤트 | finishReason: "STOP" |
| 이벤트 타입 | 단일 (data만) | 구조화 (7+ 이벤트) | 단일 (data만) |
| 사용량 정보 | 마지막 청크 | 실시간 업데이트 | 마지막 청크 |
| 도구 호출 | 스트리밍 지원 | 스트리밍 지원 | 스트리밍 지원 |
멀티모델 시스템에서는 각 API의 차이를 추상화하는 통합 파서가 필요합니다.
type Provider = "openai" | "anthropic" | "google";
interface StreamToken {
content: string;
isFinished: boolean;
usage?: { inputTokens: number; outputTokens: number };
}
function parseStreamEvent(
provider: Provider,
eventData: string
): StreamToken | null {
if (eventData === "[DONE]") {
return { content: "", isFinished: true };
}
const data = JSON.parse(eventData);
switch (provider) {
case "openai": {
const delta = data.choices?.[0]?.delta;
const finished = data.choices?.[0]?.finish_reason === "stop";
return {
content: delta?.content ?? "",
isFinished: finished,
usage: data.usage
? {
inputTokens: data.usage.prompt_tokens,
outputTokens: data.usage.completion_tokens,
}
: undefined,
};
}
case "anthropic": {
if (data.type === "content_block_delta") {
return {
content: data.delta?.text ?? "",
isFinished: false,
};
}
if (data.type === "message_stop") {
return { content: "", isFinished: true };
}
if (data.type === "message_delta") {
return {
content: "",
isFinished: data.delta?.stop_reason != null,
usage: data.usage
? {
inputTokens: 0,
outputTokens: data.usage.output_tokens,
}
: undefined,
};
}
return null;
}
case "google": {
const candidate = data.candidates?.[0];
const text = candidate?.content?.parts?.[0]?.text ?? "";
const finished = candidate?.finishReason === "STOP";
return { content: text, isFinished: finished };
}
}
}LLM에 JSON 형식의 응답을 요청하는 경우가 늘고 있습니다. 문제는 스트리밍 중에는 JSON이 불완전하다는 것입니다.
토큰 1: {"na
토큰 2: me": "
토큰 3: 홍길동",
토큰 4: "age":
토큰 5: 30, "
토큰 6: skills
토큰 7: ": ["Py
토큰 8: thon"]}각 단계에서 JSON은 불완전합니다. 이를 해결하는 파셜 JSON 파싱(Partial JSON Parsing) 전략이 필요합니다.
/**
* 불완전한 JSON 문자열을 파싱 가능한 형태로 복구합니다.
* 열린 괄호/중괄호를 추적하여 닫아줍니다.
*/
function parsePartialJSON(incomplete: string): unknown | null {
// 빈 문자열이면 null 반환
if (!incomplete.trim()) return null;
// 완전한 JSON이면 그대로 파싱
try {
return JSON.parse(incomplete);
} catch {
// 계속 진행
}
// 열린 구조를 닫아서 파싱 시도
let fixed = incomplete;
const stack: string[] = [];
let inString = false;
let escaped = false;
for (const char of incomplete) {
if (escaped) {
escaped = false;
continue;
}
if (char === "\\") {
escaped = true;
continue;
}
if (char === '"') {
inString = !inString;
continue;
}
if (inString) continue;
if (char === "{") stack.push("}");
else if (char === "[") stack.push("]");
else if (char === "}" || char === "]") stack.pop();
}
// 문자열이 열려있으면 닫기
if (inString) fixed += '"';
// 미완성 값 처리 (trailing comma 등)
fixed = fixed.replace(/,\s*$/, "");
// 열린 구조 닫기
while (stack.length > 0) {
fixed += stack.pop();
}
try {
return JSON.parse(fixed);
} catch {
return null;
}
}프로덕션에서는 partial-json 같은 검증된 라이브러리를 사용하는 것이 안전합니다. 직접 구현은 에지 케이스(이스케이프된 따옴표, 중첩 구조 등)를 놓치기 쉽습니다.
스트리밍 데이터를 React 컴포넌트로 렌더링하는 데는 몇 가지 고려사항이 있습니다.
import { useState, useEffect, useRef, useCallback } from "react";
interface StreamingMessageProps {
endpoint: string;
message: string;
onComplete?: (fullText: string) => void;
}
function StreamingMessage({
endpoint,
message,
onComplete,
}: StreamingMessageProps) {
const [tokens, setTokens] = useState<string[]>([]);
const [isStreaming, setIsStreaming] = useState(false);
const abortRef = useRef<AbortController | null>(null);
const startStreaming = useCallback(async () => {
setIsStreaming(true);
setTokens([]);
const controller = new AbortController();
abortRef.current = controller;
try {
const response = await fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message }),
signal: controller.signal,
});
const reader = response.body?.getReader();
const decoder = new TextDecoder();
if (!reader) return;
let buffer = "";
const accumulated: string[] = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const events = buffer.split("\n\n");
buffer = events.pop() ?? "";
for (const event of events) {
const dataLine = event
.split("\n")
.find((l) => l.startsWith("data: "));
if (!dataLine) continue;
const data = dataLine.slice(6);
if (data === "[DONE]") break;
accumulated.push(data);
setTokens([...accumulated]);
}
}
onComplete?.(accumulated.join(""));
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") {
// 사용자가 취소함
} else {
console.error("스트리밍 오류:", error);
}
} finally {
setIsStreaming(false);
}
}, [endpoint, message, onComplete]);
useEffect(() => {
startStreaming();
return () => abortRef.current?.abort();
}, [startStreaming]);
const handleStop = () => {
abortRef.current?.abort();
};
return (
<div className="streaming-message">
<div className="content">
{tokens.join("")}
{isStreaming && <span className="cursor-blink">|</span>}
</div>
{isStreaming && (
<button onClick={handleStop} className="stop-button">
생성 중단
</button>
)}
</div>
);
}토큰이 빠르게 도착하면 매 토큰마다 setState를 호출하는 것이 성능 문제를 일으킬 수 있습니다. requestAnimationFrame을 활용한 배치 처리가 효과적입니다.
class TokenBatcher {
private buffer: string[] = [];
private rafId: number | null = null;
private onFlush: (tokens: string[]) => void;
constructor(onFlush: (tokens: string[]) => void) {
this.onFlush = onFlush;
}
add(token: string) {
this.buffer.push(token);
if (this.rafId === null) {
this.rafId = requestAnimationFrame(() => {
this.onFlush([...this.buffer]);
this.buffer = [];
this.rafId = null;
});
}
}
dispose() {
if (this.rafId !== null) {
cancelAnimationFrame(this.rafId);
}
}
}Vercel AI SDK는 LLM 스트리밍 UI를 구축하기 위한 프레임워크 수준의 추상화를 제공합니다. 여러 LLM 제공자의 차이를 내부적으로 처리하며, React 훅으로 간결한 인터페이스를 제공합니다.
import { useChat } from "ai/react";
function ChatInterface() {
const {
messages,
input,
handleInputChange,
handleSubmit,
isLoading,
stop,
error,
reload,
} = useChat({
api: "/api/chat",
onFinish: (message) => {
console.log("완료:", message.content);
},
onError: (error) => {
console.error("오류:", error);
},
});
return (
<div className="chat-container">
<div className="messages">
{messages.map((msg) => (
<div key={msg.id} className={`message ${msg.role}`}>
{msg.content}
</div>
))}
</div>
<form onSubmit={handleSubmit}>
<input
value={input}
onChange={handleInputChange}
placeholder="메시지를 입력하세요"
disabled={isLoading}
/>
{isLoading ? (
<button type="button" onClick={stop}>
중단
</button>
) : (
<button type="submit">전송</button>
)}
</form>
{error && (
<div className="error">
오류가 발생했습니다.
<button onClick={() => reload()}>재시도</button>
</div>
)}
</div>
);
}서버 측은 다음과 같이 구현합니다.
import { streamText } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
export async function POST(request: Request) {
const { messages } = await request.json();
const result = streamText({
model: anthropic("claude-sonnet-4-20250514"),
messages,
});
return result.toDataStreamResponse();
}Vercel AI SDK는 내부적으로 SSE를 사용하며, 토큰 스트리밍 외에도 도구 호출 결과, 사용량 정보 등 메타데이터도 스트리밍합니다. useChat, useCompletion, useObject 등 용도별 훅을 제공합니다.
스트리밍 중 발생할 수 있는 오류와 대응 전략을 정리합니다.
| 오류 유형 | 원인 | 대응 전략 |
|---|---|---|
| 네트워크 끊김 | Wi-Fi 전환, 일시적 장애 | 자동 재연결 + 마지막 토큰부터 재요청 |
| 429 Rate Limit | API 호출 한도 초과 | 지수 백오프 + 사용자에게 대기 안내 |
| 500 서버 오류 | 추론 서버 장애 | 다른 모델/서버로 폴백 |
| 타임아웃 | 모델 응답 지연 | 타임아웃 설정 + 부분 응답 보존 |
| 불완전 JSON | 생성 중단으로 JSON 미완성 | 파셜 파싱으로 가용 데이터 추출 |
async function resilientStream(
endpoint: string,
payload: Record<string, unknown>,
options: {
maxRetries?: number;
timeoutMs?: number;
onToken: (token: string) => void;
onError: (error: Error) => void;
}
) {
const { maxRetries = 3, timeoutMs = 30000, onToken, onError } = options;
let retries = 0;
let accumulatedText = "";
while (retries <= maxRetries) {
try {
const controller = new AbortController();
const timeout = setTimeout(
() => controller.abort(),
timeoutMs
);
const response = await fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...payload,
// 재시도 시 이미 받은 텍스트 이후부터 요청
resumeAfter: accumulatedText || undefined,
}),
signal: controller.signal,
});
clearTimeout(timeout);
if (response.status === 429) {
const retryAfter = parseInt(
response.headers.get("Retry-After") ?? "5"
);
await delay(retryAfter * 1000);
retries++;
continue;
}
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
// 정상 스트리밍 처리
await processStream(response, (token) => {
accumulatedText += token;
onToken(token);
});
return; // 성공적으로 완료
} catch (error) {
retries++;
if (retries > maxRetries) {
onError(
error instanceof Error
? error
: new Error("Unknown error")
);
return;
}
// 지수 백오프
await delay(Math.pow(2, retries) * 1000);
}
}
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}이번 장에서는 LLM 스트리밍 응답을 실제로 처리하고 렌더링하는 방법을 살펴보았습니다.
requestAnimationFrame 배치 처리로 성능을 최적화합니다useChat 등의 훅으로 스트리밍 UI 구축을 크게 단순화합니다다음 장에서는 프론트엔드를 넘어, 백엔드 추론 파이프라인을 어떻게 설계하는지 다룹니다. vLLM의 스트리밍 입력, Continuous Batching, 시맨틱 캐싱, 멀티모달 실시간 처리 등 추론 서버 아키텍처의 핵심을 살펴보겠습니다.
이 글이 도움이 되셨나요?
관련 주제 더 보기
vLLM의 스트리밍 입력, Continuous Batching, 시맨틱 캐싱, 추론 라우터, 멀티모달 실시간 처리 등 백엔드 추론 파이프라인의 핵심 아키텍처를 다룹니다.
HTTP/2 기반 gRPC의 4가지 스트리밍 모드, Protobuf 직렬화, 마이크로서비스 간 추론 파이프라인 구현을 다룹니다. gRPC-Web의 제약과 Python/Go 구현 예제를 포함합니다.
이벤트 소싱과 CQRS 패턴의 원리를 살펴보고, AI 시스템에서의 적용 사례를 다룹니다. 대화 이력 관리, 에이전트 상태 추적, 시간 여행 디버깅, Kafka와 EventStoreDB 활용을 포함합니다.