ChatGPT나 Claude를 사용해 보셨다면 텍스트가 한 글자씩 타이핑되듯 출력되는 것을 경험해 보셨을 것입니다. 이런 UX는 단순히 시각적 효과가 아닙니다. LLM이 토큰 단위로 응답을 생성하는 특성을 활용한 스트리밍 응답 방식입니다.
LLM 응답은 수 초에서 수십 초까지 걸릴 수 있습니다. 사용자가 빈 화면을 보며 기다리게 하는 것보다, 생성되는 즉시 텍스트를 보여주는 것이 훨씬 나은 경험을 제공합니다.
이 글에서는 Next.js와 React를 사용해 Claude API 기반 챗봇을 직접 구현하면서 SSE(Server-Sent Events) 스트리밍의 동작 원리와 실전 구현 전략을 살펴봅니다.
# 다운로드
pnpm install
# 테스트 코드 실행
pnpm test
# 개발 환경 실행
pnpm dev
# 빌드
pnpm build
# 빌드 후 실행
pnpm start- LLM API를 활용한 서비스 개발에 관심 있는 개발자
- 실시간 스트리밍 UI를 구현하고 싶은 프론트엔드 개발자
- Next.js 환경에서 SSE를 적용하고 싶은 개발자
일반적인 REST API 호출 방식을 생각해 봅시다.
클라이언트 → 요청 → 서버 → (처리) → 완전한 응답 → 클라이언트
이 방식에서 클라이언트는 서버가 모든 처리를 완료할 때까지 기다려야 합니다. LLM의 경우 긴 응답을 생성하는 데 10초 이상 걸릴 수 있어, 사용자는 그동안 아무것도 볼 수 없습니다.
SSE(Server-Sent Events)는 서버에서 클라이언트로 단방향 실시간 데이터 스트림을 전송하는 HTTP 기반 프로토콜입니다.
클라이언트 → 요청 → 서버 → 데이터1 → 데이터2 → ... → 데이터N → 종료
↓ ↓ ↓
즉시 표시 즉시 표시 즉시 표시
LLM은 토큰(단어 또는 단어 조각) 단위로 응답을 생성합니다. SSE를 사용하면 토큰이 생성될 때마다 즉시 클라이언트에 전달할 수 있습니다.
| 특성 | SSE | WebSocket |
|---|---|---|
| 통신 방향 | 단방향 (서버 → 클라이언트) | 양방향 |
| 프로토콜 | HTTP | WS (별도 프로토콜) |
| 연결 복잡도 | 낮음 | 높음 |
| 자동 재연결 | 브라우저 내장 지원 | 직접 구현 필요 |
| LLM 응답 용도 | 적합 | 과도함 |
LLM 응답 스트리밍은 서버에서 클라이언트로의 단방향 전송이므로 SSE가 적합합니다. WebSocket은 채팅방처럼 양방향 실시간 통신이 필요한 경우에 사용합니다.
SSE는 텍스트 기반의 단순한 프로토콜입니다. 각 이벤트는 field: value 형식으로 전송됩니다.
data: {"type": "content_block_delta", "delta": {"text": "안녕"}}
data: {"type": "content_block_delta", "delta": {"text": "하세요"}}
data: [DONE]
주요 필드:
data: 이벤트 데이터 (필수)event: 이벤트 타입 (선택)id: 이벤트 ID (선택, 재연결 시 사용)retry: 재연결 대기 시간 (선택)
각 이벤트는 빈 줄(\n\n)로 구분됩니다.
SSE 응답에는 다음 헤더가 필요합니다.
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream', // SSE 명시
'Cache-Control': 'no-cache', // 캐싱 방지
Connection: 'keep-alive', // 연결 유지
},
});브라우저에서 SSE를 소비하는 방법은 두 가지입니다.
1. EventSource API (전통적인 방식)
const eventSource = new EventSource('/api/notifications');
eventSource.onmessage = (event) => {
console.log(event.data); // 자동 파싱
};
eventSource.onerror = () => {
// 자동 재연결 시도
};
eventSource.close(); // 연결 종료EventSource는 브라우저 내장 API로, 이벤트 파싱과 재연결을 자동으로 처리합니다.
2. fetch + ReadableStream (이 글에서 사용하는 방식)
const response = await fetch('/api/chat', {
method: 'POST',
body: JSON.stringify({ messages }),
signal: abortController.signal,
});
const reader = response.body.getReader();
// 수동으로 스트림 읽기 및 파싱...왜 LLM API에서는 fetch를 사용해야 할까요?
| 특성 | EventSource | fetch + ReadableStream |
|---|---|---|
| HTTP 메서드 | GET만 지원 | POST, GET 등 모두 지원 |
| 요청 본문 | 전송 불가 | JSON 등 전송 가능 |
| 헤더 커스터마이징 | 불가 | 가능 |
| 이벤트 파싱 | 자동 | 수동 (버퍼 기반) |
| 자동 재연결 | 내장 지원 | 직접 구현 필요 |
| 중단 제어 | .close() |
AbortController |
LLM API는 대화 히스토리(messages 배열)를 POST 본문으로 전송해야 합니다. EventSource는 GET 요청만 지원하므로 사용할 수 없습니다. 따라서 이 글에서는 fetch + ReadableStream 방식으로 구현합니다.
LLM SSE의 특징: 요청-응답 패턴
일반적인 SSE는 한 번 연결하면 서버가 지속적으로 이벤트를 푸시하는 장기 연결 방식입니다. 반면 LLM API의 SSE는 요청-응답 패턴을 따릅니다.
일반 SSE (알림, 피드):
연결 ─────────────────────────────────────────▶ (계속 유지)
← 이벤트1 ← 이벤트2 ← 이벤트3 ...
LLM SSE (메시지 단위):
요청1 ──▶ ← 토큰 ← 토큰 ← 토큰 ← [DONE] ──▶ 연결 종료
요청2 ──▶ ← 토큰 ← 토큰 ← [DONE] ──▶ 연결 종료
하나의 메시지 생성이 완료되면(message_stop 이벤트) 연결을 명시적으로 종료합니다. 이 방식은 다음과 같은 이점이 있습니다.
- 리소스 관리: 불필요한 연결을 유지하지 않아 서버/클라이언트 리소스 절약
- 에러 격리: 한 요청의 문제가 다른 요청에 영향을 주지 않음
- 상태 명확성: 각 요청의 시작과 끝이 명확하여 UI 상태 관리가 단순해짐
참고: 단순한 알림이나 실시간 피드처럼 서버에서 지속적으로 데이터를 푸시하는 경우에는
EventSource가 더 간편합니다.
Claude API를 직접 브라우저에서 호출하면 API 키가 노출됩니다. 따라서 백엔드를 통한 프록시 패턴이 필요합니다.
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ 브라우저 │ ──▶ │ Next.js │ ──▶ │ Claude │
│ (React) │ │ API Route │ │ API │
│ │ ◀── │ (프록시) │ ◀── │ │
└─────────────┘ └─────────────┘ └─────────────┘
fetch ReadableStream SSE Stream
+ AbortController
데이터 흐름:
- 브라우저에서 사용자 메시지와 함께 API Route 호출
- API Route가 Claude API에 스트리밍 요청
- Claude API의 SSE 응답을 그대로 클라이언트에 전달
- 브라우저에서 스트림을 읽어 UI 업데이트
Next.js App Router의 Route Handler로 SSE 프록시를 구현합니다.
src/app/api/chat/route.ts
import { NextRequest } from 'next/server';
export async function POST(request: NextRequest) {
const body = await request.json();
const { messages, model } = body;
// 1. Claude API 호출 (스트리밍 모드)
const response = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': process.env.ANTHROPIC_API_KEY!,
'anthropic-version': '2023-06-01',
},
body: JSON.stringify({
model: model || 'claude-sonnet-4-20250514',
max_tokens: 4096,
stream: true, // 스트리밍 활성화
messages,
}),
});
if (!response.ok) {
const errorText = await response.text();
return new Response(JSON.stringify({ error: errorText }), {
status: response.status,
});
}
// 2. ReadableStream으로 SSE 프록시 구축
const stream = new ReadableStream({
async start(controller) {
const reader = response.body?.getReader();
if (!reader) {
controller.close();
return;
}
const decoder = new TextDecoder();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
// Claude API 응답을 그대로 클라이언트에 전달
const chunk = decoder.decode(value, { stream: true });
controller.enqueue(new TextEncoder().encode(chunk));
}
} finally {
controller.close();
}
},
});
// 3. SSE 헤더와 함께 응답
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
});
}-
stream: true: Claude API에 스트리밍 모드를 요청합니다. 이 옵션이 없으면 전체 응답이 완료될 때까지 기다립니다. -
ReadableStream: Web Streams API의 읽기 가능한 스트림입니다.start메서드 내에서 데이터를 점진적으로enqueue합니다. -
TextDecoder/TextEncoder: 바이너리 데이터(Uint8Array)와 문자열 간 변환을 담당합니다.{ stream: true }옵션은 멀티바이트 문자(한글 등)가 청크 경계에서 잘리는 문제를 방지합니다.
스트리밍 로직을 재사용 가능한 훅으로 분리합니다.
src/hooks/useStreamResponse.ts
'use client';
import { useState, useCallback, useRef } from 'react';
interface UseStreamResponseReturn {
streamText: string;
isStreaming: boolean;
error: string | null;
startStream: (
messages: Array<{ role: string; content: string }>,
) => Promise<string>;
abortStream: () => void;
}
export function useStreamResponse(): UseStreamResponseReturn {
const [streamText, setStreamText] = useState('');
const [isStreaming, setIsStreaming] = useState(false);
const [error, setError] = useState<string | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const abortStream = useCallback(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
}, []);
const startStream = useCallback(
async (messages) => {
// 이전 스트림 중단
abortStream();
abortControllerRef.current = new AbortController();
setIsStreaming(true);
setError(null);
setStreamText('');
let fullText = '';
try {
const response = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages }),
signal: abortControllerRef.current.signal, // 중단 시그널 연결
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
// ReadableStream 소비
const reader = response.body?.getReader();
if (!reader) throw new Error('No response body');
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
// 청크를 버퍼에 추가
buffer += decoder.decode(value, { stream: true });
// 줄 단위로 파싱
const lines = buffer.split('\n');
buffer = lines.pop() || ''; // 마지막 불완전한 줄은 버퍼에 유지
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6).trim();
if (data === '[DONE]') continue;
try {
const event = JSON.parse(data);
// 텍스트 델타 이벤트 처리
if (
event.type === 'content_block_delta' &&
event.delta?.type === 'text_delta'
) {
fullText += event.delta.text;
setStreamText(fullText);
}
} catch {
// JSON 파싱 실패 무시
}
}
}
}
return fullText;
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') {
return fullText; // 사용자가 중단한 경우
}
setError(err instanceof Error ? err.message : 'Unknown error');
throw err;
} finally {
setIsStreaming(false);
abortControllerRef.current = null;
}
},
[abortStream],
);
return { streamText, isStreaming, error, startStream, abortStream };
}SSE 스트림을 올바르게 파싱하려면 버퍼 기반 처리가 필요합니다.
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || ''; // 마지막 불완전한 줄은 버퍼에 유지왜 버퍼가 필요한가?
네트워크에서 데이터는 임의의 크기로 도착합니다. 하나의 SSE 이벤트가 여러 청크에 걸쳐 도착할 수 있습니다.
청크 1: "data: {\"type\": \"content_bl"
청크 2: "ock_delta\", \"delta\": {\"text\": \"안녕\"}}\n\n"
버퍼를 사용해 불완전한 줄을 보관하고, 다음 청크와 합쳐서 완전한 이벤트를 만들어야 합니다.
Claude API는 다양한 이벤트 타입을 전송합니다.
| 이벤트 타입 | 설명 |
|---|---|
message_start |
메시지 시작 |
content_block_start |
컨텐츠 블록 시작 |
content_block_delta |
텍스트 델타 (핵심) |
content_block_stop |
컨텐츠 블록 종료 |
message_delta |
메시지 메타데이터 변경 |
message_stop |
메시지 종료 |
실제 텍스트는 content_block_delta 이벤트의 delta.text에 담겨 옵니다.
{
"type": "content_block_delta",
"index": 0,
"delta": {
"type": "text_delta",
"text": "안녕하세요"
}
}파싱한 streamText를 React 컴포넌트에서 렌더링하는 방법입니다.
// src/components/chat/MessageList.tsx
interface MessageListProps {
messages: Message[];
streamingContent: string; // 스트리밍 중인 텍스트
isLoading: boolean;
}
export function MessageList({
messages,
streamingContent,
isLoading,
}: MessageListProps) {
return (
<div className='flex flex-col gap-4'>
{/* 완료된 메시지들 */}
{messages.map((message) => (
<MessageBubble key={message.id} message={message} />
))}
{/* 스트리밍 중인 메시지 (실시간 타이핑 효과) */}
{isLoading && (
<div className='bg-gray-100 rounded-lg p-4'>
{streamingContent || <LoadingDots />}
</div>
)}
</div>
);
}타이핑 효과의 원리:
useStreamResponse훅에서setStreamText(fullText)가 호출될 때마다 React 상태가 변경됩니다.- 상태 변경은 컴포넌트 리렌더링을 트리거합니다.
- 토큰이 도착할 때마다 (수십 ms 간격) 상태가 업데이트되므로 사용자에게는 한 글자씩 타이핑되는 것처럼 보입니다.
토큰 도착 → setStreamText("안") → 리렌더링 → 화면: "안"
토큰 도착 → setStreamText("안녕") → 리렌더링 → 화면: "안녕"
토큰 도착 → setStreamText("안녕하세요") → 리렌더링 → 화면: "안녕하세요"
팁: 마크다운 렌더링이 필요한 경우
react-markdown라이브러리를 사용하면 스트리밍 중에도 실시간으로 마크다운이 렌더링됩니다.
사용자가 "정지" 버튼을 누르면 진행 중인 스트림을 즉시 중단해야 합니다.
const abortControllerRef = useRef<AbortController | null>(null);
// 스트림 시작 시
abortControllerRef.current = new AbortController();
await fetch('/api/chat', {
signal: abortControllerRef.current.signal,
// ...
});
// 중단 시
const abortStream = () => {
abortControllerRef.current?.abort();
};AbortController.abort()를 호출하면 fetch가 AbortError를 throw합니다. 이를 catch해서 정상적인 중단으로 처리합니다.
} catch (err) {
if (err instanceof Error && err.name === "AbortError") {
return fullText; // 정상 중단, 에러 아님
}
setError(err.message);
}API 에러는 크게 세 단계에서 발생합니다.
1단계: HTTP 에러 (fetch 실패)
if (!response.ok) {
const errorData = await response.json();
// 에러 타입에 따른 사용자 친화적 메시지
switch (errorData.type) {
case 'rate_limit_error':
throw new Error('요청 한도를 초과했습니다. 잠시 후 다시 시도해주세요.');
case 'authentication_error':
throw new Error('API 인증에 실패했습니다.');
default:
throw new Error(errorData.message);
}
}2단계: 스트림 중 에러
Claude API는 스트리밍 중에도 에러 이벤트를 전송할 수 있습니다.
if (event.type === 'error') {
throw new Error(event.error?.message || '스트리밍 중 에러 발생');
}3단계: 네트워크 에러
try {
const { done, value } = await reader.read();
} catch (err) {
// 네트워크 연결 끊김 등
setError('네트워크 연결이 끊어졌습니다.');
}TypeScript로 이벤트 타입을 정의하면 안전하게 파싱할 수 있습니다.
// src/types/chat.ts
export interface StreamEvent {
type: string;
index?: number;
delta?: {
type: string;
text?: string;
};
content_block?: {
type: string;
text?: string;
};
}
export interface Message {
id: string;
role: 'user' | 'assistant';
content: string;
createdAt: Date;
}Claude API는 web_search 도구를 통해 실시간 웹 검색 기능을 제공합니다. 스트리밍 응답에 검색 결과를 통합하는 방법을 살펴봅니다.
웹 검색을 활성화하려면 tools 배열과 베타 헤더를 추가합니다.
// src/app/api/chat/route.ts
export async function POST(request: NextRequest) {
const { messages, model, webSearchEnabled } = await request.json();
// API 요청 본문 구성
const requestBody: Record<string, unknown> = {
model: model || 'claude-sonnet-4-20250514',
max_tokens: 4096,
stream: true,
messages,
};
// 웹 검색이 활성화된 경우 도구 추가
if (webSearchEnabled) {
requestBody.tools = [
{
type: 'web_search_20250305',
name: 'web_search',
max_uses: 5, // 한 응답당 최대 검색 횟수
},
];
}
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'x-api-key': process.env.ANTHROPIC_API_KEY!,
'anthropic-version': '2023-06-01',
};
// 웹 검색 사용 시 베타 헤더 추가
if (webSearchEnabled) {
headers['anthropic-beta'] = 'web-search-2025-03-05';
}
const response = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers,
body: JSON.stringify(requestBody),
});
// ... 이하 스트림 프록시 코드 동일
}웹 검색이 활성화되면 기존 텍스트 이벤트 외에 추가 이벤트가 전송됩니다.
| 이벤트 | 블록 타입 | 설명 |
|---|---|---|
content_block_start |
server_tool_use |
검색 도구 호출 시작 |
content_block_delta |
input_json_delta |
검색 쿼리 (점진적 JSON) |
content_block_start |
web_search_tool_result |
검색 결과 반환 |
content_block_delta |
text_delta + citations |
텍스트와 인용 정보 |
검색 흐름:
1. server_tool_use 시작 → 검색 쿼리 파싱
2. web_search_tool_result → 검색 결과 (URL, 제목)
3. text_delta + citations → 검색 결과를 인용한 응답 텍스트
검색 쿼리와 결과를 파싱하도록 훅을 확장합니다.
// src/hooks/useStreamResponse.ts (확장)
interface SearchQuery {
query: string;
results: { url: string; title: string }[];
}
interface Citation {
type: 'web_search_result_location';
url: string;
title: string;
cited_text: string;
}
// 상태 추가
const [searchQueries, setSearchQueries] = useState<SearchQuery[]>([]);
const [citations, setCitations] = useState<Citation[]>([]);
// 파싱 로직 내부
let currentSearchQuery: string | null = null;
let currentSearchId: string | null = null;
let partialJsonBuffer = ''; // 점진적 JSON 누적
for (const line of lines) {
if (line.startsWith('data: ')) {
const event = JSON.parse(line.slice(6));
// 1. 검색 도구 호출 시작
if (event.type === 'content_block_start') {
const block = event.content_block;
if (block?.type === 'server_tool_use' && block?.name === 'web_search') {
currentSearchId = block.id;
}
// 2. 검색 결과 반환
if (block?.type === 'web_search_tool_result' && block?.content) {
const results = block.content
.filter((r) => r.type === 'web_search_result')
.map((r) => ({ url: r.url, title: r.title }));
if (currentSearchQuery && results.length > 0) {
setSearchQueries((prev) => [
...prev,
{
query: currentSearchQuery,
results,
},
]);
}
currentSearchQuery = null;
}
}
// 3. 검색 쿼리 점진적 파싱 (input_json_delta)
if (event.type === 'content_block_delta') {
if (event.delta?.type === 'input_json_delta' && currentSearchId) {
partialJsonBuffer += event.delta.partial_json || '';
try {
const input = JSON.parse(partialJsonBuffer);
if (input.query) currentSearchQuery = input.query;
} catch {
// 아직 불완전한 JSON - 계속 누적
}
}
// 4. 텍스트와 함께 인용 정보 추출
if (event.delta?.type === 'text_delta') {
fullText += event.delta.text;
setStreamText(fullText);
if (event.delta.citations) {
setCitations((prev) => [...prev, ...event.delta.citations]);
}
}
}
// 5. 블록 종료 시 버퍼 리셋
if (event.type === 'content_block_stop') {
partialJsonBuffer = '';
}
}
}검색 쿼리는 input_json_delta 이벤트로 조각나서 전송됩니다.
이벤트 1: {"partial_json": "{\"quer"}
이벤트 2: {"partial_json": "y\": \"Next.js"}
이벤트 3: {"partial_json": " SSE\"}"}
버퍼에 누적하면서 JSON.parse를 시도하고, 성공하면 완전한 쿼리를 추출합니다.
검색 결과와 인용을 UI에 표시하는 컴포넌트입니다.
// src/components/chat/SearchResults.tsx
interface SearchResultsProps {
queries: SearchQuery[];
}
export function SearchResults({ queries }: SearchResultsProps) {
if (queries.length === 0) return null;
return (
<div className='mb-4 text-sm'>
{queries.map((q, i) => (
<details key={i} className='mb-2'>
<summary className='cursor-pointer text-gray-600'>
🔍 "{q.query}" 검색 결과 ({q.results.length}건)
</summary>
<ul className='mt-2 ml-4 space-y-1'>
{q.results.map((r, j) => (
<li key={j}>
<a
href={r.url}
target='_blank'
rel='noopener noreferrer'
className='text-blue-600 hover:underline'
>
{r.title}
</a>
</li>
))}
</ul>
</details>
))}
</div>
);
}MessageBubble에 통합:
// src/components/chat/MessageBubble.tsx
export function MessageBubble({ message, searchQueries, citations }) {
return (
<div className='...'>
{/* 검색 결과 표시 */}
{searchQueries && <SearchResults queries={searchQueries} />}
{/* 메시지 본문 */}
<MarkdownRenderer content={message.content} />
{/* 인용 출처 표시 */}
{citations && citations.length > 0 && (
<div className='mt-2 pt-2 border-t text-xs text-gray-500'>
<span>출처: </span>
{citations.map((c, i) => (
<a key={i} href={c.url} className='text-blue-500 mr-2'>
[{i + 1}] {c.title}
</a>
))}
</div>
)}
</div>
);
}1. 사용자: "Next.js 16의 새로운 기능은?"
└─ webSearchEnabled: true
2. Claude API
├─ server_tool_use: web_search 호출
├─ input_json_delta: {"query": "Next.js 16 new features"}
├─ web_search_tool_result: [{url, title}, ...]
└─ text_delta + citations: "Next.js 16에서는... [1]"
3. 프론트엔드
├─ searchQueries 상태 업데이트 → 검색 결과 UI
├─ streamText 상태 업데이트 → 타이핑 효과
└─ citations 상태 업데이트 → 출처 링크
1. 사용자 입력
└─ "안녕하세요"
2. useChat.sendMessage()
├─ 사용자 메시지를 messages 배열에 추가
└─ useStreamResponse.startStream() 호출
3. startStream()
├─ fetch("/api/chat", { messages, signal })
└─ AbortController 연결
4. API Route (route.ts)
├─ Claude API 호출 (stream: true)
└─ ReadableStream으로 SSE 프록시
5. Claude API
└─ data: {"type": "content_block_delta", "delta": {"text": "안"}}
data: {"type": "content_block_delta", "delta": {"text": "녕"}}
data: {"type": "content_block_delta", "delta": {"text": "하세요"}}
data: [DONE]
6. startStream() - 스트림 소비
├─ 버퍼 기반 줄 파싱
├─ JSON.parse로 이벤트 파싱
└─ setStreamText(fullText) → UI 업데이트
7. React 컴포넌트
└─ streamText 표시 → "안" → "안녕" → "안녕하세요"
-
SSE는 LLM 응답에 최적화된 프로토콜입니다. 단방향 스트리밍만 필요하므로 WebSocket보다 단순하고 효율적입니다.
-
LLM SSE는 요청-응답 패턴입니다. 메시지 생성이 완료되면 연결을 종료하여 리소스를 관리합니다.
-
EventSource 대신 fetch를 사용합니다. POST 요청으로 대화 히스토리를 전송해야 하기 때문입니다.
-
버퍼 기반 파싱이 필요합니다. 네트워크 청크 경계가 이벤트 경계와 일치하지 않기 때문입니다.
-
React 상태 업데이트로 타이핑 효과를 구현합니다. 토큰이 도착할 때마다
setStreamText를 호출하면 리렌더링으로 실시간 표시됩니다. -
웹 검색은 점진적 JSON 파싱이 필요합니다.
input_json_delta로 조각나서 오는 검색 쿼리를 버퍼에 누적해야 합니다.
- MDN: Server-Sent Events
- Anthropic Claude API - Streaming
- Anthropic Claude API - Web Search
- Next.js Route Handlers



