AI

Next.js App Router Edge Runtime에서 Claude API 스트리밍 한국어 깨짐 해결

Next.js App Router Edge Runtime에서 Claude API 스트리밍 한국어 깨짐 해결

Next.js App Router로 Claude API 스트리밍을 붙였는데, 한국어가 ?로 깨져서 나왔어요. 로컬에서는 멀쩡한데 Vercel에 배포하면 터지는 그 패턴, 맞죠? 원인은 생각보다 명확해요. Edge Runtime의 인코딩 처리 방식 때문이에요.

핵심 요약

  • Edge Runtime은 Node.js Buffer API를 지원하지 않아서, UTF-8 멀티바이트 문자(한국어 포함)가 바이트 경계에서 잘리면 깨진 문자로 출력된다.
  • Next.js App Router의 Response 스트리밍은 TextEncoder/TextDecoder를 써야 안전하며, Content-Type: text/event-stream; charset=utf-8 헤더 명시가 필수다.
  • Claude API의 stream: true 옵션은 SSE 청크를 순차 전송하는데, 각 청크가 멀티바이트 경계에서 잘릴 수 있어 클라이언트 측 TextDecoder 설정이 핵심이다.
  • Vercel Edge Network 기준으로 TransformStream + TextDecoderStream 조합이 가장 안정적인 해결책으로 확인됐다.

Edge Runtime에서 문자가 깨지는 이유

Node.js 환경에서는 Buffer.from(chunk).toString('utf-8')처럼 익숙한 방식으로 인코딩을 처리해요. 그런데 Edge Runtime은 달라요. Cloudflare Workers와 동일한 V8 기반 런타임이라서 Node.js의 Buffer, fs, path 같은 API를 쓸 수 없어요. Next.js 공식 문서에 따르면 Edge Runtime은 Web Standard API만 지원해요.

문제는 여기서 시작돼요.

Claude API 스트리밍 응답을 받으면 데이터가 청크 단위로 와요. 영어는 한 글자가 1바이트라 청크가 어디서 잘려도 문자가 온전해요. 반면 한국어는 UTF-8 기준으로 글자당 3바이트예요. 청크 경계가 글자 중간에 걸리면, 그 글자는 절반짜리 바이트 덩어리가 되어 디코딩할 수 없는 상태가 돼요. 그래서 ?로 출력되는 거예요.

로컬에서 안 깨지는 이유도 간단해요. next dev는 기본적으로 Node.js 런타임으로 실행되거든요. Buffer가 있어서 알아서 처리해줘요. 배포 환경에서 Edge Runtime으로 전환되는 순간 문제가 터지는 거예요.


문제가 생기는 코드 패턴

// ❌ 이렇게 하면 한국어 깨져요
export const runtime = 'edge';

export async function POST(req: Request) {
  const stream = await anthropic.messages.stream({
    model: 'claude-opus-4-5',
    max_tokens: 1024,
    messages: [{ role: 'user', content: '안녕하세요' }],
  });

  return new Response(stream.toReadableStream(), {
    headers: { 'Content-Type': 'text/event-stream' }, // charset 빠짐
  });
}

여기서 두 가지가 문제예요. 첫째, Content-Typecharset=utf-8이 없어요. 브라우저가 인코딩을 추측하다가 틀리기도 해요. 둘째, 클라이언트에서 청크를 받을 때 단순히 Uint8ArrayString으로 변환하면 멀티바이트 문자가 잘려요.

// ❌ 클라이언트 - 이것도 위험해요
reader.read().then(({ value }) => {
  const text = String.fromCharCode(...value); // 멀티바이트 무시
  setOutput(prev => prev + text);
});

String.fromCharCode는 바이트를 그냥 문자로 변환해요. UTF-8 멀티바이트 시퀀스를 해석하지 않으니까 한국어가 다 깨지죠.


해결책 세 가지 비교

방법복잡도Edge 호환권장 상황
TextDecoderStream (Web API)낮음✅ 완전대부분의 경우
TransformStream + 수동 디코딩중간✅ 완전커스텀 파싱 필요 시
Node.js 런타임으로 전환낮음N/AEdge 불필요 시

방법 1: TextDecoderStream — 가장 깔끔해요

// ✅ 서버 - route.ts
export const runtime = 'edge';

export async function POST(req: Request) {
  const encoder = new TextEncoder();

  const readableStream = new ReadableStream({
    async start(controller) {
      const stream = anthropic.messages.stream({
        model: 'claude-opus-4-5',
        max_tokens: 1024,
        messages: [{ role: 'user', content: await req.text() }],
      });

      for await (const chunk of stream) {
        if (
          chunk.type === 'content_block_delta' &&
          chunk.delta.type === 'text_delta'
        ) {
          controller.enqueue(encoder.encode(chunk.delta.text));
        }
      }
      controller.close();
    },
  });

  return new Response(readableStream, {
    headers: {
      'Content-Type': 'text/event-stream; charset=utf-8', // ← 이게 핵심
      'Cache-Control': 'no-cache',
    },
  });
}
// ✅ 클라이언트 - { stream: true } 옵션이 포인트
const decoder = new TextDecoder('utf-8', { fatal: false });

while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  const text = decoder.decode(value, { stream: true }); // stream: true 필수
  setOutput(prev => prev + text);
}

{ stream: true } 옵션이 핵심이에요. 이걸 넣어야 TextDecoder가 청크 경계에서 잘린 멀티바이트 시퀀스를 다음 청크까지 버퍼에 들고 있다가, 온전한 문자가 완성되는 시점에 출력해줘요. 빠진다면, 경계에서 잘린 바이트를 그냥 처리하려다 깨져요.

방법 2: TransformStream — 더 세밀하게 제어할 때

SSE 형식으로 가공하거나 메타데이터를 붙여야 한다면 TransformStream이 맞아요.

const { readable, writable } = new TransformStream();
const writer = writable.getWriter();
const encoder = new TextEncoder();

(async () => {
  for await (const chunk of stream) {
    if (chunk.type === 'content_block_delta') {
      const data = `data: ${JSON.stringify({ text: chunk.delta.text })}\n\n`;
      await writer.write(encoder.encode(data));
    }
  }
  await writer.close();
})();

방법 3: Node.js 런타임 전환 — Edge가 필수가 아닐 때

Edge Runtime이 꼭 필요하지 않다면 이게 제일 빠른 해결책이에요.

export const runtime = 'nodejs'; // 명시적으로 Node.js로

실제로 AI 스트리밍처럼 응답 자체가 수 초 걸리는 경우라면, Edge Runtime이 주는 수십 밀리초 지연 감소가 UX에 거의 영향 없어요. 한국어 깨짐이 훨씬 큰 문제니까요.


상황별 체크리스트

로컬은 되는데 Vercel 배포 후 깨지는 경우: export const runtime = 'edge' 선언이 있는지 확인하세요. 있다면 클라이언트 TextDecoder{ stream: true } 옵션 추가가 첫 번째예요.

일부 문자만 깨지는 경우: 특정 청크 크기에서만 재현되는 패턴이에요. fatal: true로 설정하면 디코딩 실패 시 예외가 발생해서 어느 지점에서 깨지는지 정확히 알 수 있어요. 디버깅할 때만 fatal: true로 바꿔보는 걸 권장해요.

Anthropic SDK를 쓰는데도 깨지는 경우: @anthropic-ai/sdk 0.20 이후 버전은 Edge Runtime용 Web Streams API를 내부적으로 지원해요. 그런데 SDK가 서버 쪽을 처리해줘도 클라이언트 디코딩은 개발자 몫이에요. { stream: true } 옵션은 여전히 필요해요.


정리하면

  • Edge Runtime에서 한국어가 깨지는 건 버그가 아니라 Web Standard API의 특성이에요.
  • TextDecoder{ stream: true, fatal: false } 두 옵션 넣으면 대부분 해결돼요.
  • Content-Type: text/event-stream; charset=utf-8 헤더 명시는 선택이 아니에요.
  • Edge Runtime이 꼭 필요한 게 아니라면 Node.js 런타임이 훨씬 단순해요.

지금 쓰는 TextDecoder 인스턴스에 stream: true 옵션 들어가 있는지 먼저 확인해보세요. 없다면, 그게 깨짐의 원인일 가능성이 높아요.


이 글에서 다룬 코드는 Next.js 15.x, @anthropic-ai/sdk 0.24.x, Vercel Edge Runtime 기준이에요. SDK 버전별로 스트림 API가 달라질 수 있으니 공식 문서와 함께 확인하세요.

참고자료

  1. Claude Code 기능 10개, 중요한 순서대로 정리했다 (1/2) - DEV Community
  2. Routing: API Routes | Next.js
  3. Next.js Optimization Recipes | Cursor, Claude Code & Codex | Developer Toolkit

Photo by Vitaly Gariev on Unsplash