Claude API 스트리밍 응답을 Next.js App Router 서버 컴포넌트에 붙이며 겪은 삽질 후기

처음엔 금방 될 것 같았어요. Claude API 스트리밍 응답을 Next.js App Router에 붙이는 작업. 공식 문서도 있고, 예제도 있고. 그런데 실제로 해보니 열 개 넘는 삽질을 거쳤어요. App Router가 표준이 된 지금, 이 구현 패턴을 제대로 이해하면 시간을 절반은 아낄 수 있어요.
핵심 요약
- Next.js App Router에서 Claude API 스트리밍 응답을 구현할 때 서버 컴포넌트에서 직접 스트림을 소비하는 패턴은 동작하지 않아요. Route Handler를 경유해야 해요.
ReadableStream을TransformStream으로 변환하지 않으면 Edge Runtime에서 메모리 누수가 발생해요.- Anthropic SDK의
stream()메서드는 내부적으로 SSE 포맷을 쓰는데, App Router의StreamingTextResponse유틸과 결합하면 헤더 충돌이 나요.- Route Handler에서
Response객체를 직접 만들어 반환하는 방식이 현재 가장 안정적인 패턴이에요.- CLAUDE.md로 프로젝트 컨텍스트를 사전에 구조화하면 API 호출당 반복 토큰을 줄일 수 있어요. 실무 경험 기준으로 30% 이상 감소를 체감했어요.
이게 왜 지금 문제가 되냐면
Vercel이 2025년 하반기에 App Router를 공식 권장 방식으로 못 박으면서, Pages Router 기반 AI 챗봇들이 대거 마이그레이션 중이에요. 그런데 App Router는 기존 React 패러다임과 꽤 다르게 동작해요. 서버 컴포넌트는 브라우저에 전달되지 않는 코드를 실행하고, 클라이언트 컴포넌트와의 경계가 생각보다 훨씬 엄격해요. 여기에 실시간 데이터인 스트리밍을 얹으면 예상치 못한 충돌이 곳곳에서 터져요.
Claude 3.5 Sonnet 이후 스트리밍 응답 속도가 크게 빨라졌고, 스트리밍 없는 AI 채팅 UI는 이제 구식처럼 느껴지죠. 그래서 이걸 구현하려는 시도가 늘고 있는데, 막히는 지점들이 패턴처럼 반복돼요.
삽질 1: 서버 컴포넌트에서 직접 스트림 읽기
많은 개발자가 처음에 이런 코드를 써요.
// app/chat/page.tsx (서버 컴포넌트)
import Anthropic from "@anthropic-ai/sdk";
export default async function ChatPage() {
const client = new Anthropic();
const stream = await client.messages.stream({ ... });
for await (const chunk of stream) {
console.log(chunk);
}
}
결과는 빌드 에러거나, 런타임에서 빈 화면이에요. 서버 컴포넌트는 반환값이 JSX여야 하는데, 비동기 스트림을 소비하면서 데이터를 내려보낼 방법이 없어요. 컴포넌트 자체가 스트리밍 프로토콜이 아니니까요.
Route Handler(app/api/...)를 거쳐야 하는 이유가 여기 있어요. 흐름은 이래요.
클라이언트 컴포넌트 → fetch('/api/chat') → Route Handler → Anthropic SDK → 스트림 응답
Route Handler는 Web API Response 객체를 직접 반환할 수 있고, ReadableStream을 body로 쓸 수 있어요.
// app/api/chat/route.ts
import Anthropic from "@anthropic-ai/sdk";
export async function POST(req: Request) {
const { messages } = await req.json();
const client = new Anthropic();
const stream = new ReadableStream({
async start(controller) {
const response = await client.messages.stream({
model: "claude-3-5-sonnet-20241022",
max_tokens: 1024,
messages,
});
for await (const chunk of response) {
if (
chunk.type === "content_block_delta" &&
chunk.delta.type === "text_delta"
) {
controller.enqueue(new TextEncoder().encode(chunk.delta.text));
}
}
controller.close();
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/plain; charset=utf-8",
"Transfer-Encoding": "chunked",
},
});
}
이 방식이 현재 가장 안정적으로 동작해요.
삽질 2: Edge Runtime vs Node.js Runtime
런타임 선택에서 두 번째로 많이 막혀요.
| 기준 | Edge Runtime | Node.js Runtime |
|---|---|---|
| Anthropic SDK 호환 | ❌ 일부 메서드 미지원 | ✅ 완전 지원 |
| 응답 지연(Cold Start) | ~50ms | ~300ms |
| 메모리 한도 | 128MB | 1024MB+ |
stream() 메서드 | 불안정 | 안정 |
| 추천 용도 | 짧은 텍스트 처리 | AI 스트리밍 응답 |
export const runtime = 'edge'를 실수로 추가했다가 crypto 모듈 에러, Buffer 미지원 에러가 연속으로 터지는 케이스가 꽤 많아요. Anthropic SDK는 Node.js 환경을 가정하고 만들어진 부분이 있거든요.
Edge Runtime이 필요하다면 Anthropic SDK 대신 fetch로 REST API를 직접 호출하고 SSE 파싱을 수동으로 구현하는 게 나아요. 번거롭지만 예측 가능하게 동작해요.
클라이언트에서 스트림을 받는 방법도 맞춰야 해요.
// app/chat/ChatComponent.tsx (클라이언트 컴포넌트)
"use client";
async function sendMessage(text: string) {
const res = await fetch("/api/chat", {
method: "POST",
body: JSON.stringify({ messages: [{ role: "user", content: text }] }),
});
const reader = res.body?.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader!.read();
if (done) break;
const chunk = decoder.decode(value);
setResponse((prev) => prev + chunk);
}
}
getReader()로 읽으면 토큰이 도착할 때마다 화면에 붙어나오는 효과가 나요.
삽질 3: 반복 컨텍스트 비용
삽질을 겪다 보면 또 다른 문제가 생겨요. 매 요청마다 프로젝트 맥락을 반복해서 넘기는 비용이에요.
CLAUDE.md 파일을 프로젝트 루트에 두면 Claude Code 환경에서 자동으로 참조하는 컨텍스트가 설정돼요. 프로젝트 구조, 코딩 컨벤션, API 엔드포인트 목록을 여기 넣어두면 “이 프로젝트가 무엇인지” 설명하는 메시지를 매번 넘길 필요가 없어요. 시스템 프롬프트에 2,000토큰짜리 컨텍스트를 매 요청마다 넣는 대신 CLAUDE.md로 분리하면 실제 대화 토큰 사용량이 체감될 정도로 줄어요.
지금 시작한다면
- Route Handler를 Node.js Runtime으로 먼저 구현하세요. Edge는 나중 일이에요.
- Vercel AI SDK(
ai패키지)를 먼저 시도해보세요. 스트리밍 처리의 절반이 추상화돼요. Anthropic SDK를 직접 다루기 전에 써볼 만해요. "use client"위치를 신경 쓰세요. 위치 하나로 앱 전체가 클라이언트 번들에 들어가는 사고가 흔해요.
참고로, Vercel이 AI 스트리밍 전용 미들웨어 레이어를 준비 중이라는 신호가 있어요. ai 패키지 업데이트 속도가 빨라지고 있고, App Router 통합도 매 릴리즈마다 개선되고 있거든요. 지금처럼 ReadableStream을 수동으로 다루는 방식이 6개월 뒤엔 구식이 될 수도 있어요.
마무리
App Router에서 Claude 스트리밍 구현은 처음엔 간단해 보이지만, 실제론 세 층위의 이해가 필요해요.
- React 서버 컴포넌트의 렌더링 모델 — 스트림을 직접 소비할 수 없어요
- Web Streams API —
ReadableStream,TextEncoder,getReader() - 런타임 제약 — Edge와 Node.js의 차이
이 세 가지를 이해하고 나면 나머지는 코드 조각 맞추는 수준이에요.
지금 App Router로 AI 스트리밍을 붙이다가 막힌 지점이 어디인가요? 댓글로 남겨주시면 다음 글에서 다뤄볼게요.
참고자료
- Lobehub
- Supabase auth-helpers 말고 @supabase/ssr 써야 하는 이유 (Next.js App Router 기준) - 꾸리
- [Claude] CLAUDE.md 작성법 - 프로젝트별 최적화 컨텍스트 만들기


