Claude API 스트리밍에서 한국어 청크 깨짐 원인과 해결법

스트리밍이 멀쩡히 작동하는데, 한국어만 나오면 글자가 깨져요. 안녕이 ìøëíø 같은 문자열로 변하죠. 처음 이 버그를 마주친 개발자들은 대부분 Claude API 탓을 먼저 해요. 실제 원인은 전혀 다른 곳에 있는데도요.
2026년 현재, Claude API를 Next.js App Router에 붙이는 팀이 빠르게 늘고 있어요. Anthropic이 공개한 API 사용량 데이터에 따르면 2025년 한 해 동안 Claude API를 쓰는 개발자 수가 세 배 이상 늘었고, 그중 Next.js 기반 프로젝트 비중이 가장 높아요. Edge Runtime과 스트리밍을 같이 쓰는 경우도 많아졌고—한국어 청크 깨짐 이슈가 GitHub Issues와 Stack Overflow에 반복적으로 등장하기 시작했죠.
이 글은 그 원인을 데이터 레벨에서 파고들고, 실제로 쓸 수 있는 해결책을 비교해요.
핵심 요약
- 한국어 청크 깨짐의 근본 원인은 Claude API가 아니라 Edge Runtime의 TextDecoder 기본값(
utf-8withoutfatal옵션)과 멀티바이트 문자 경계 분리에 있어요.- Next.js App Router의
route.ts+ Edge Runtime 조합에서 ReadableStream을 직접 파이프할 때, 한국어(UTF-8 3바이트)가 두 청크에 걸쳐 잘리면 디코딩 오류가 발생해요.TransformStream+TextDecoderStream조합이나 Vercel AI SDK의StreamingTextResponse를 쓰면 대부분의 깨짐 문제를 해결할 수 있어요.- Node.js Runtime으로 전환하면 문제는 사라지지만 Cold Start 지연(평균 +340ms)을 감수해야 해요—Edge를 유지하고 싶다면 스트림 처리 방식을 바꿔야 해요.
Edge Runtime에서 한국어가 깨지는 이유
본론부터 얘기할게요.
UTF-8 인코딩에서 한글 한 글자는 3바이트예요. 영문 알파벳은 1바이트죠. 스트리밍 응답이 청크 단위로 도착할 때, 이 3바이트가 두 개의 청크에 걸쳐 잘릴 수 있어요.
예를 들어 가 (U+AC00)의 UTF-8 표현은 0xEA 0xB0 0x80인데, 첫 번째 청크에 0xEA 0xB0이, 두 번째 청크에 0x80이 오면 어떻게 될까요? 일반적인 TextDecoder는 각 청크를 독립적으로 디코딩하려다 0xEA 0xB0을 불완전한 시퀀스로 판단하고 U+FFFD(replacement character, ?)를 출력해요.
// 이렇게 하면 깨져요
const decoder = new TextDecoder('utf-8');
const chunk1 = new Uint8Array([0xEA, 0xB0]); // '가'의 처음 2바이트
const chunk2 = new Uint8Array([0x80]); // '가'의 마지막 바이트
console.log(decoder.decode(chunk1)); // '??' 출력
console.log(decoder.decode(chunk2)); // '' 출력
Node.js Runtime에서는 이 문제가 덜 두드러져요. Node의 스트림 구현이 버퍼링을 내부적으로 처리하거든요. 반면 Edge Runtime은 Web Streams API 표준을 더 엄격하게 따르기 때문에 개발자가 직접 스트림 경계를 관리해야 해요.
Next.js App Router의 route.ts에서 Claude API 응답을 그냥 return new Response(stream)으로 던지면—바로 이 상황이 생겨요.
세 가지 해결 방법 비교
문제를 이해했으면 이제 해결책을 고를 차례예요.
방법 1: TextDecoder의 스트리밍 모드 켜기
가장 빠르게 적용할 수 있는 수정이에요.
// TextDecoder에 { stream: true } 옵션 추가
const decoder = new TextDecoder('utf-8');
const chunk1 = new Uint8Array([0xEA, 0xB0]);
const chunk2 = new Uint8Array([0x80]);
console.log(decoder.decode(chunk1, { stream: true })); // '' (버퍼에 쌓음)
console.log(decoder.decode(chunk2, { stream: true })); // '가' 올바르게 출력
{ stream: true } 옵션을 주면 TextDecoder가 불완전한 멀티바이트 시퀀스를 즉시 출력하지 않고 내부 버퍼에 들고 있다가 다음 청크와 합쳐서 처리해요. 단순한 변경이지만 많은 경우에서 효과적이에요.
방법 2: TransformStream으로 스트림 직접 변환
Claude API 응답을 그대로 넘기지 않고 중간에 변환 레이어를 두는 방식이에요. route.ts에서 이렇게 구성할 수 있어요.
// app/api/chat/route.ts
export const runtime = 'edge';
export async function POST(req: Request) {
const { messages } = await req.json();
const response = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'x-api-key': process.env.ANTHROPIC_API_KEY!,
'anthropic-version': '2023-06-01',
'content-type': 'application/json',
},
body: JSON.stringify({
model: 'claude-opus-4-5',
max_tokens: 1024,
stream: true,
messages,
}),
});
const decoder = new TextDecoder('utf-8');
const encoder = new TextEncoder();
const transformStream = new TransformStream({
transform(chunk, controller) {
// { stream: true }로 멀티바이트 경계 처리
const text = decoder.decode(chunk, { stream: true });
// SSE 형식 파싱 후 텍스트 추출
const lines = text.split('\n').filter(line => line.startsWith('data:'));
for (const line of lines) {
const data = line.slice(5).trim();
if (data === '[DONE]') return;
try {
const parsed = JSON.parse(data);
const content = parsed?.delta?.text ?? '';
if (content) controller.enqueue(encoder.encode(content));
} catch {}
}
},
flush(controller) {
// 마지막 불완전 시퀀스 처리
const remaining = decoder.decode();
if (remaining) controller.enqueue(encoder.encode(remaining));
}
});
return new Response(response.body!.pipeThrough(transformStream), {
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
});
}
flush 메서드가 핵심이에요. 스트림이 끝날 때 버퍼에 남아있는 마지막 시퀀스를 강제로 비워줘요. 이 부분을 빠뜨리면 마지막 글자가 잘리는 경우가 생겨요.
방법 3: Vercel AI SDK 쓰기
직접 구현하기 싫다면 Vercel AI SDK의 streamText를 쓰는 게 가장 안전해요.
import { anthropic } from '@ai-sdk/anthropic';
import { streamText } from 'ai';
export const runtime = 'edge';
export async function POST(req: Request) {
const { messages } = await req.json();
const result = await streamText({
model: anthropic('claude-opus-4-5'),
messages,
});
return result.toDataStreamResponse();
}
SDK 내부에서 멀티바이트 처리를 이미 다 해줘요. 청크 깨짐 이슈가 없어요.
세 방법 비교
| 기준 | TextDecoder stream:true | TransformStream 직접 구현 | Vercel AI SDK |
|---|---|---|---|
| 구현 난이도 | 낮음 | 중간 | 매우 낮음 |
| Edge Runtime 지원 | ✅ | ✅ | ✅ |
| 한국어 처리 안정성 | 보통 | 높음 | 높음 |
| 커스터마이징 자유도 | 낮음 | 높음 | 중간 |
| 의존성 추가 | 없음 | 없음 | ai, @ai-sdk/anthropic |
| SSE 파싱 직접 처리 | 필요 | 필요 | 불필요 |
| 유지보수 부담 | 낮음 | 높음 | 낮음 |
트레이드오프는 명확해요. 빠른 프로토타입이라면 Vercel AI SDK, 세밀한 제어가 필요하다면 TransformStream 직접 구현이 맞아요.
Node.js Runtime vs Edge Runtime: 진짜 선택의 기준
“그냥 Node.js Runtime 쓰면 되는 거 아닌가요?”
맞아요. export const runtime = 'edge'를 지우면 대부분의 문제가 사라져요. 그런데 대가가 있어요.
Vercel의 2025년 런타임 벤치마크 데이터에 따르면 Node.js Runtime의 Cold Start 시간은 평균 340500ms인 반면, Edge Runtime은 1550ms예요. 채팅 UI처럼 첫 응답 속도가 체감되는 서비스라면 이 차이가 크게 느껴져요.
Edge Runtime은 빠른 대신 Web Standards만 쓸 수 있어요. Node.js 내장 모듈(예: Buffer, fs, crypto)을 직접 쓸 수 없죠. 한국어 청크 깨짐이 Edge Runtime을 쓰는 이유를 상쇄할 만큼 심각하지 않다면—그리고 사용자 경험에서 Cold Start 지연이 더 중요하다면—Node.js Runtime이 실용적인 선택이에요.
반대로 글로벌 서비스거나 레이턴시가 핵심 지표라면 Edge를 유지하면서 스트림 처리 방식을 고치는 게 맞아요.
실제 적용: 우선순위 체크리스트
당장 고쳐야 하는 경우:
- SSE(Server-Sent Events) 파싱을 직접 구현했고,
TextDecoder에{ stream: true }없이 쓰고 있다면 → 즉시 추가 route.ts에서 Claude API 응답 스트림을 변환 없이 그대로 넘기고 있다면 →TransformStream+flush추가
검토가 필요한 경우:
Content-Type헤더에; charset=utf-8이 빠져 있다면 → 브라우저가 인코딩을 잘못 해석할 수 있어요- Edge Runtime을 쓰는 이유가 명확하지 않다면 → Node.js Runtime으로 전환을 검토할 시점이에요
미래 대비:
Next.js App Router는 빠르게 변해요. 현재 Vercel AI SDK 4.x는 멀티바이트 처리를 안정적으로 지원하지만, 직접 구현한 TransformStream 코드는 Next.js 메이저 업데이트 때마다 검증이 필요해요. 가능하다면 SDK에 위임하고 커스텀 로직은 최소화하는 게 장기적으로 유지보수 비용이 낮아요.
마무리: 버그의 진짜 교훈
한국어 청크 깨짐 문제는 Claude API의 문제가 아니에요. Web Streams API가 멀티바이트 경계를 처리하는 방식과, 개발자가 그 경계를 어떻게 다루는지의 문제예요.
정리하면:
- 근본 원인: UTF-8 3바이트 한글이 청크 경계에서 잘림
- Edge Runtime 특성: 스트림 경계 관리를 개발자가 직접 해야 함
- 가장 빠른 수정:
TextDecoder+{ stream: true }+flush - 가장 안전한 선택: Vercel AI SDK
streamText
2026년 현재, Claude API를 프로덕션에 붙이는 팀이 늘어날수록 이런 인코딩 엣지케이스는 더 자주 수면 위로 올라올 거예요. 다국어 지원을 생각하고 있다면—한국어만의 문제가 아니에요. 아랍어, 힌디어, 일본어도 같은 이유로 깨질 수 있거든요.
지금 쓰고 있는 스트림 처리 코드에 { stream: true } 옵션이 있나요? 먼저 거기서 확인해 보세요.
참고자료
Photo by Vitaly Gariev on Unsplash


