본문으로 건너뛰기
Kreath Archive
TechProjectsBooksAbout
TechProjectsBooksAbout

내비게이션

  • Tech
  • Projects
  • Books
  • About
  • Tags

카테고리

  • AI / ML
  • 웹 개발
  • 프로그래밍
  • 개발 도구

연결

  • GitHub
  • Email
  • RSS
© 2026 Kreath Archive. All rights reserved.Built with Next.js + MDX
홈TechProjectsBooksAbout
//
  1. 홈
  2. 테크
  3. 5장: 스트리밍 LLM 응답 처리
2026년 3월 26일·아키텍처·

5장: 스트리밍 LLM 응답 처리

OpenAI, Anthropic, Google의 스트리밍 API 차이를 비교하고, 구조화된 출력의 파셜 파싱, React 스트리밍 UI 렌더링, Vercel AI SDK 활용법을 다룹니다.

15분1,072자8개 섹션
streamingai
공유
streaming-ai5 / 10
12345678910
이전4장: gRPC Streaming — 고성능 백엔드 통신다음6장: 실시간 추론 파이프라인 설계

학습 목표

  • OpenAI, Anthropic, Google의 스트리밍 API 형식 차이를 이해합니다
  • 구조화된 출력(JSON) 스트리밍의 파셜 파싱 전략을 학습합니다
  • React에서 스트리밍 UI를 렌더링하는 패턴을 파악합니다
  • Vercel AI SDK의 핵심 훅과 활용법을 다룹니다
  • 스트리밍 중 에러 처리와 복구 전략을 설계합니다

LLM 스트리밍 API의 현재

2026년 현재, 주요 LLM 제공자들은 모두 스트리밍 API를 지원합니다. 그러나 각각의 이벤트 형식과 구조는 상당히 다릅니다. 이 차이를 이해하는 것은 멀티모델 시스템을 구축할 때 필수적입니다.

OpenAI 스트리밍 형식

OpenAI의 Chat Completions API는 SSE 기반으로 delta 객체에 토큰을 담아 전송합니다.

OpenAI SSE 이벤트 예시
text
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 스트리밍 형식

Anthropic의 Messages API는 보다 구조화된 이벤트 타입을 사용합니다.

Anthropic SSE 이벤트 예시
text
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 스트리밍 형식

Google의 Gemini API는 generateContent의 스트리밍 버전에서 각 청크를 완전한 JSON 객체로 전달합니다.

Gemini SSE 이벤트 예시
text
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}}

API 형식 비교

특성OpenAIAnthropicGoogle
토큰 위치delta.contentdelta.textparts[0].text
종료 신호[DONE] 이벤트message_stop 이벤트finishReason: "STOP"
이벤트 타입단일 (data만)구조화 (7+ 이벤트)단일 (data만)
사용량 정보마지막 청크실시간 업데이트마지막 청크
도구 호출스트리밍 지원스트리밍 지원스트리밍 지원

통합 스트리밍 파서

멀티모델 시스템에서는 각 API의 차이를 추상화하는 통합 파서가 필요합니다.

unified-stream-parser.ts
typescript
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이 불완전하다는 것입니다.

스트리밍 중 JSON 상태 변화
text
토큰 1: {"na
토큰 2: me": "
토큰 3: 홍길동",
토큰 4:  "age":
토큰 5:  30, "
토큰 6: skills
토큰 7: ": ["Py
토큰 8: thon"]}

각 단계에서 JSON은 불완전합니다. 이를 해결하는 파셜 JSON 파싱(Partial JSON Parsing) 전략이 필요합니다.

partial-json-parser.ts
typescript
/**
 * 불완전한 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;
  }
}
Tip

프로덕션에서는 partial-json 같은 검증된 라이브러리를 사용하는 것이 안전합니다. 직접 구현은 에지 케이스(이스케이프된 따옴표, 중첩 구조 등)를 놓치기 쉽습니다.

React 스트리밍 UI 렌더링

스트리밍 데이터를 React 컴포넌트로 렌더링하는 데는 몇 가지 고려사항이 있습니다.

기본 스트리밍 컴포넌트

StreamingMessage.tsx
tsx
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을 활용한 배치 처리가 효과적입니다.

batched-token-renderer.ts
typescript
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

Vercel AI SDK는 LLM 스트리밍 UI를 구축하기 위한 프레임워크 수준의 추상화를 제공합니다. 여러 LLM 제공자의 차이를 내부적으로 처리하며, React 훅으로 간결한 인터페이스를 제공합니다.

ai-chat-with-sdk.tsx
tsx
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>
  );
}

서버 측은 다음과 같이 구현합니다.

app/api/chat/route.ts
typescript
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();
}
Info

Vercel AI SDK는 내부적으로 SSE를 사용하며, 토큰 스트리밍 외에도 도구 호출 결과, 사용량 정보 등 메타데이터도 스트리밍합니다. useChat, useCompletion, useObject 등 용도별 훅을 제공합니다.

에러 처리와 복구

스트리밍 중 발생할 수 있는 오류와 대응 전략을 정리합니다.

오류 유형원인대응 전략
네트워크 끊김Wi-Fi 전환, 일시적 장애자동 재연결 + 마지막 토큰부터 재요청
429 Rate LimitAPI 호출 한도 초과지수 백오프 + 사용자에게 대기 안내
500 서버 오류추론 서버 장애다른 모델/서버로 폴백
타임아웃모델 응답 지연타임아웃 설정 + 부분 응답 보존
불완전 JSON생성 중단으로 JSON 미완성파셜 파싱으로 가용 데이터 추출
resilient-stream.ts
typescript
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 스트리밍 응답을 실제로 처리하고 렌더링하는 방법을 살펴보았습니다.

  • OpenAI, Anthropic, Google 각각의 스트리밍 형식은 구조가 다르며, 멀티모델 시스템에서는 통합 파서가 필요합니다
  • 구조화된 JSON 출력의 스트리밍에는 파셜 파싱 전략이 필수적입니다
  • React에서 스트리밍 UI를 렌더링할 때 requestAnimationFrame 배치 처리로 성능을 최적화합니다
  • Vercel AI SDK는 useChat 등의 훅으로 스트리밍 UI 구축을 크게 단순화합니다
  • 네트워크 오류, 레이트 리미팅, 타임아웃에 대한 복원력 있는 에러 처리가 프로덕션의 핵심입니다

다음 장에서는 프론트엔드를 넘어, 백엔드 추론 파이프라인을 어떻게 설계하는지 다룹니다. vLLM의 스트리밍 입력, Continuous Batching, 시맨틱 캐싱, 멀티모달 실시간 처리 등 추론 서버 아키텍처의 핵심을 살펴보겠습니다.

이 글이 도움이 되셨나요?

관련 주제 더 보기

#streaming#ai

관련 글

아키텍처

6장: 실시간 추론 파이프라인 설계

vLLM의 스트리밍 입력, Continuous Batching, 시맨틱 캐싱, 추론 라우터, 멀티모달 실시간 처리 등 백엔드 추론 파이프라인의 핵심 아키텍처를 다룹니다.

2026년 3월 28일·17분
아키텍처

4장: gRPC Streaming — 고성능 백엔드 통신

HTTP/2 기반 gRPC의 4가지 스트리밍 모드, Protobuf 직렬화, 마이크로서비스 간 추론 파이프라인 구현을 다룹니다. gRPC-Web의 제약과 Python/Go 구현 예제를 포함합니다.

2026년 3월 24일·16분
아키텍처

7장: 이벤트 소싱과 CQRS 패턴

이벤트 소싱과 CQRS 패턴의 원리를 살펴보고, AI 시스템에서의 적용 사례를 다룹니다. 대화 이력 관리, 에이전트 상태 추적, 시간 여행 디버깅, Kafka와 EventStoreDB 활용을 포함합니다.

2026년 3월 30일·15분
이전 글4장: gRPC Streaming — 고성능 백엔드 통신
다음 글6장: 실시간 추론 파이프라인 설계

댓글

목차

약 15분 남음
  • 학습 목표
  • LLM 스트리밍 API의 현재
    • OpenAI 스트리밍 형식
    • Anthropic 스트리밍 형식
    • Google Gemini 스트리밍 형식
    • API 형식 비교
  • 통합 스트리밍 파서
  • 구조화된 출력 스트리밍
  • React 스트리밍 UI 렌더링
    • 기본 스트리밍 컴포넌트
    • 성능 최적화: 배치 업데이트
  • Vercel AI SDK
  • 에러 처리와 복구
  • 정리