thinking 스트림과 text 스트림을 분리해 UI에 단계적으로 노출하기
복잡한 질문에 Claude가 답하는 동안 사용자는 그냥 기다려야 할까요? Extended Thinking을 쓰다 보면 이 고민이 꽤 현실적으로 다가옵니다. 내부 추론이 길어질수록 첫 텍스트가 나오기까지 수십 초가 걸리기도 하고, 그 시간 동안 사용자는 아무 피드백 없이 빈 화면을 보게 됩니다.
스트리밍으로 thinking 블록과 text 블록을 분리해서 각각 다른 UI 영역에 노출하는 패턴이 이 문제를 푸는 방법입니다. Claude Code와 여러 에이전트형 IDE들이 이미 이 방식을 도입했고, 추론 집약적 워크로드가 늘어나면서 자연스러운 관용 패턴으로 자리 잡았습니다. 이 글에서는 왜 이렇게 설계해야 하는지, 실제 코드로 어떻게 구현하는지, 그리고 어디서 발목 잡힐 수 있는지를 정리해봅니다.
스트림 구조부터: thinking 블록은 어디서 오고 어떻게 흐르나
Extended Thinking을 활성화하면 Claude API 응답에는 두 종류의 콘텐츠 블록이 포함됩니다. thinking 타입과 text 타입입니다. 일반적인 (non-interleaved) Extended Thinking 모드에서는 thinking 블록이 먼저 완결된 뒤 text 블록이 시작되는 순서로 스트리밍됩니다.
스트리밍 모드에서는 SSE 이벤트로 두 가지 델타가 순서대로 도착합니다.
thinking_delta— Claude의 내부 추론 내용text_delta— 사용자에게 실제로 보여줄 응답
여기서 놓치기 쉬운 점 하나. content_block_stop은 thinking 블록만이 아니라 모든 블록이 끝날 때 발생합니다. 그래서 이 이벤트만 보고 "thinking이 끝났다"고 단정하면 안 됩니다. 각 이벤트에 함께 오는 index 필드와, content_block_start 시점에 기록해둔 블록 타입을 매핑해서 "index N이 thinking 블록이었고, 그 블록이 stop됐다"까지 확인해야 안전합니다.
어떤 thinking 모드가 나에게 맞을까
Extended Thinking은 요청 시 thinking: { type: "enabled", budget_tokens: N } 형태로 활성화합니다. 공식 스펙상 현재 필수 필드는 type과 budget_tokens 두 가지이며, 그 외 표시 방식과 관련한 옵션은 SDK 버전과 모델에 따라 달라질 수 있으니 사용 중인 @anthropic-ai/sdk 타입 정의를 먼저 확인하는 것이 안전합니다.
일부 최신 모델은 요청 복잡도에 따라 thinking 사용 여부와 깊이를 모델이 자율적으로 조절하는 방식(문서·릴리스 노트에서 "adaptive" 계열로 언급되는 기능)을 제공합니다. 이 경우 budget_tokens를 명시하는 대신 모델이 알아서 판단하므로 비용 예측성은 낮아지지만, 복잡도가 혼재한 워크로드에서는 세팅이 단순해집니다. 어떤 모델·API 버전이 이 방식을 지원하는지는 시점마다 변하므로 반드시 사용 중인 모델의 최신 문서에서 확인하세요.
정리하면 결정 흐름은 대략 이렇습니다.
백엔드: Node.js에서 SSE로 relay하기
프론트엔드가 Anthropic API를 직접 호출하면 API 키 노출 문제가 생기므로, 백엔드에서 Claude 스트림을 받아 클라이언트에 relay하는 구조가 일반적입니다.
아래는 개념적 예시입니다. 실제 필드명과 모델 ID는 사용 중인 SDK 버전에 맞춰 조정하세요.
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic();
type BlockKind = "thinking" | "text" | "redacted_thinking" | "other";
const blockKinds = new Map<number, BlockKind>();
export async function POST(req: Request) {
const { prompt } = await req.json();
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
const send = (obj: unknown) =>
controller.enqueue(encoder.encode(`data: ${JSON.stringify(obj)}\n\n`));
const anthropicStream = client.messages.stream({
model: "claude-sonnet-4-5",
max_tokens: 16000,
thinking: { type: "enabled", budget_tokens: 10000 },
messages: [{ role: "user", content: prompt }],
});
for await (const event of anthropicStream) {
if (event.type === "content_block_start") {
const t = event.content_block.type;
blockKinds.set(
event.index,
t === "thinking" || t === "text" || t === "redacted_thinking"
? t
: "other",
);
continue;
}
if (event.type === "content_block_delta") {
const kind = blockKinds.get(event.index);
if (kind === "redacted_thinking") continue;
if (event.delta.type === "thinking_delta") {
send({ type: "thinking", content: event.delta.thinking });
} else if (event.delta.type === "text_delta") {
send({ type: "text", content: event.delta.text });
}
continue;
}
if (event.type === "content_block_stop") {
const kind = blockKinds.get(event.index);
if (kind === "thinking") send({ type: "thinking_end" });
else if (kind === "text") send({ type: "text_end" });
blockKinds.delete(event.index);
continue;
}
if (event.type === "message_stop") {
controller.enqueue(encoder.encode(`data: [DONE]\n\n`));
controller.close();
}
}
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}포인트는 두 가지입니다.
content_block_start에서 index → 블록 타입 매핑을 세워두고,content_block_stop에서 그 매핑을 참조해 어떤 종류의 블록이 끝났는지 판별합니다.redacted_thinking블록은 델타 자체를 클라이언트로 흘려보내지 않습니다. 이 블록은 안전성 검토를 위해 암호화된 형태로 전달되는 내부 추론이므로 사용자에게 표시할 수 없습니다.
프론트엔드: 두 상태를 각자 흐르게 하기
React에서 hook으로 감싸면 UI 컴포넌트는 phase와 두 개의 텍스트 스트림만 바인딩하면 됩니다. 여기서 흔히 놓치는 함정이 하나 있습니다. useCallback 내부에서 phase 상태값을 참조하면 스테일 클로저에 걸려 조건 분기가 의도대로 동작하지 않습니다. 이 예시에서는 phase 전환을 서버가 명시적으로 보내주는 thinking_end 신호에 위임해서 그 문제를 아예 우회합니다. 그리고 SSE 파싱은 청크 경계 문제를 피하려고 버퍼를 누적해서 처리합니다.
import { useState, useRef, useCallback } from "react";
type Phase = "idle" | "thinking" | "responding" | "done";
export function useThinkingStream() {
const [phase, setPhase] = useState<Phase>("idle");
const [thinkingContent, setThinkingContent] = useState("");
const [textContent, setTextContent] = useState("");
const bufferRef = useRef("");
const handleEvent = useCallback((raw: string) => {
if (raw === "[DONE]") {
setPhase("done");
return;
}
let msg: { type: string; content?: string };
try {
msg = JSON.parse(raw);
} catch {
return;
}
switch (msg.type) {
case "thinking":
setThinkingContent((prev) => prev + (msg.content ?? ""));
break;
case "thinking_end":
setPhase("responding");
break;
case "text":
setTextContent((prev) => prev + (msg.content ?? ""));
break;
case "text_end":
break;
}
}, []);
const startStream = useCallback(
async (prompt: string) => {
setPhase("thinking");
setThinkingContent("");
setTextContent("");
bufferRef.current = "";
const response = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt }),
});
const reader = response.body!.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
bufferRef.current += decoder.decode(value, { stream: true });
let sepIndex: number;
while ((sepIndex = bufferRef.current.indexOf("\n\n")) !== -1) {
const rawEvent = bufferRef.current.slice(0, sepIndex);
bufferRef.current = bufferRef.current.slice(sepIndex + 2);
for (const line of rawEvent.split("\n")) {
if (line.startsWith("data: ")) handleEvent(line.slice(6));
}
}
}
},
[handleEvent],
);
return { phase, thinkingContent, textContent, startStream };
}여기서 확인해야 할 지점.
bufferRef에 스트림 청크를 누적하고,\n\n(SSE 이벤트 구분자)이 나타날 때마다 하나의 이벤트로 잘라 처리합니다. 이렇게 해야 하나의read()호출에 이벤트가 여러 개 들어오거나, 반대로 이벤트 하나가 두 청크에 걸쳐 있는 상황에서 JSON.parse 오류가 나지 않습니다.- phase 전환은 서버가 보내주는
thinking_end에 붙였습니다. 클라이언트 내부의 phase 값을 다시 읽어 판단하지 않으므로 스테일 클로저 문제와 무관합니다.
UI 컴포넌트: collapsible thinking 패널
Claude Code 계열의 UI가 채택한 "thinking 중엔 진행 표시, 완료 후엔 접힘 가능" 방식입니다.
function ThinkingPanel({
content,
phase,
}: {
content: string;
phase: string;
}) {
const [collapsed, setCollapsed] = useState(false);
const isActive = phase === "thinking";
if (!content && !isActive) return null;
return (
<div className="thinking-panel">
<button
className="thinking-header"
onClick={() => setCollapsed((c) => !c)}
>
<span className="thinking-icon">{isActive ? "⟳" : "✓"}</span>
<span>{isActive ? "추론 중..." : "추론 과정"}</span>
<span>{collapsed ? "▶" : "▼"}</span>
</button>
{!collapsed && (
<div className="thinking-body">
<pre className="thinking-text">{content}</pre>
</div>
)}
</div>
);
}thinking 중엔 스피너 느낌의 아이콘을, 완료 후엔 체크 아이콘을 보여주면 사용자가 현재 단계를 직관적으로 파악할 수 있습니다.
Interleaved Thinking: 도구 호출 사이에 사고를 끼우기
도구 호출을 반복하는 에이전트 시나리오에서는 도구 호출 앞뒤에 thinking 블록이 반복해서 들어가는 Interleaved Thinking 패턴이 유용합니다. 이 기능은 Anthropic이 베타 헤더로 제공하며, 지원 모델과 정확한 헤더 문자열은 시점에 따라 달라지므로 사용 전에 반드시 Anthropic 공식 문서의 Extended Thinking 페이지에서 현재 유효한 베타 헤더 문자열을 확인하세요. 헤더 문자열을 임의로 복붙하면 400 오류로 이어집니다.
이 패턴을 켜면 앞서 정리한 "thinking → text" 순서 가정이 깨집니다. 스트림에는 여러 개의 thinking 블록과 tool_use 블록이 번갈아 나타나므로, 프론트엔드 쪽에서도 phase를 단순한 thinking → responding 이분법이 아니라 블록 시퀀스 타임라인으로 다뤄야 합니다. 일반적인 챗봇 UI라면 굳이 필요하지 않고, 에이전트 대시보드처럼 의사결정 흐름을 노출하려는 경우에 도입 가치가 있습니다.
Redacted Thinking을 만났을 때
Anthropic은 안전성 검사가 발동한 일부 내부 추론을 redacted_thinking 블록으로 암호화해 응답에 포함시킵니다. 이 블록의 내용은 사용자에게 그대로 노출할 수 없고, 그렇다고 다음 대화 턴에서 이 블록을 제거하면 안 됩니다. multi-turn 대화에서 이전 assistant 메시지를 그대로 되돌려 보낼 때 redacted 블록도 원본 그대로 유지해야 합니다. 그렇지 않으면 thinking 컨텍스트가 깨져 응답 품질이 떨어질 수 있습니다.
앞서 백엔드 예시에서 blockKinds에 redacted_thinking을 기록하고 델타 전달만 건너뛴 이유가 이것입니다. 서버 측 대화 히스토리에는 원본 블록을 보존하고, 클라이언트로만 노출하지 않는 구조입니다.
트레이드오프: 결정 전에 짚어야 할 것들
| 항목 | 고려사항 |
|---|---|
| 비용 | thinking 토큰은 화면에 노출된 요약이 아니라 실제 내부 추론 전체가 과금 대상이 됩니다. 요약본 대비 상당히 많을 수 있으니 반드시 실측하고, budget_tokens 상한과 사용량 모니터링을 병행하세요 |
| 초기 지연 | 일반 Extended Thinking은 thinking 블록이 완결된 뒤 text가 시작되므로 첫 텍스트까지의 지연이 커집니다. 지연이 곧 UX 지표인 서비스라면 budget_tokens를 낮추거나, thinking을 아예 끄는 프로파일을 별도로 두는 편이 낫습니다 |
| 긴 응답 처리 | 큰 max_tokens 값에서는 스트리밍 사용이 요구되거나 강력히 권장되는 경우가 있습니다. 구체적인 임계값은 모델과 시점에 따라 다르므로 사용 중인 모델의 문서와 SDK 경고 메시지를 확인하세요 |
| 모델 호환성 | Extended Thinking과 Interleaved Thinking의 지원 모델은 다릅니다. 사용 중인 모델 ID의 릴리스 노트를 확인하세요 |
| UI 피로도 | thinking 내용은 길고 밀도가 높아 기본 노출 시 사용자 피로가 큽니다. 기본 접힘 + 진행 상태 인디케이터 조합이 무난합니다 |
언제 이 패턴이 필요 없을까
정리하면서 마지막으로 짚고 싶은 부분은 반대 방향입니다. 이 패턴이 불필요하거나 오히려 방해가 되는 경우도 있습니다.
- 단순 FAQ·짧은 응답 위주의 챗봇: thinking을 켜지 않은 편이 지연과 비용 모두에서 유리합니다. UI 분리 로직도 오버엔지니어링이 됩니다.
- thinking 노출이 부적절한 도메인: 법률·의료·투자 조언처럼 내부 추론 과정이 최종 답변으로 오인될 위험이 있는 도메인에서는 thinking을 사용자에게 노출하지 않는 편이 안전합니다. 백엔드에서는 활용하되 프론트로는 흘려보내지 않는 구조를 고려하세요.
- 응답 지연이 곧 KPI인 서비스: thinking을 노출하지 않을 거라면 애초에 활성화하지 않거나 최소 budget_tokens로 운용하는 편이 낫습니다.
- 단일 응답 스트리밍만으로 충분한 CLI 도구: 터미널처럼 순차 텍스트로 이어 붙여도 자연스러운 환경에서는 굳이 블록을 분리해 다른 영역에 그릴 필요가 없습니다.
thinking을 UI에 노출하는 결정은 추론 과정 자체가 사용자에게 신뢰 신호를 주는 워크로드에 한정해서 내리는 편이 좋습니다. 코드 리뷰, 계획 수립, 리서치 어시스턴트처럼 "왜 그런 결론에 도달했는지" 자체가 산출물의 일부인 경우가 대표적입니다. 그 외에는 조용히 뒤에서 사고하게 두고 결과만 잘 보여주는 편이 사용자에게도 지갑에도 친절합니다.