Next.js API Route + 스트리밍 UI (Part 7 시작)
Article
Part 6까지 우리가 만든 건 전부 터미널 안에서만 움직였다. npx tsx agent.ts 를 치고, You> 뒤에 타이핑하고, 콘솔에 답이 찍히는 식이었다. 좋은 어시스턴트지만, 나 말고는 아무도 못 쓴다.
오늘은 그걸 웹으로 꺼낸다. Next.js 로 API 를 만들고, 브라우저에서 질문을 보내면 20강·22강에서 만든 검색 로직이 서버에서 돌아가 답을 낸다. 그리고 그 답은 한 번에 뚝 떨어지지 않고, Claude 가 답을 만드는 대로 글자 단위로 화면에 흘러 들어온다.
- 20~22강터미널 CLI. readline 으로 질문 받고 console.log 로 답 출력
- 23강 — 오늘Next.js Route Handler + 브라우저. fetch 로 질문 보내고 스트림으로 답 받는다
새 프로젝트 — 이번엔 Next.js 다
lesson-22 의 lib.ts, vectors.json, docs 는 그대로 옮겨온다. 여기에 Next.js 를 새로 얹는다.
mkdir my-ai-assistant && cd my-ai-assistant
npm init -y
npm i next@16.2.10 react react-dom @anthropic-ai/sdk
npm i -D typescript @types/node @types/react @types/react-dom
- "typescript": "^6.0.3"
- "@types/node": "^26.1.1"
- "@types/react": "^19.2.17"
- "@types/react-dom": "^19.2.3"
Route Handler — Next.js 안의 API
Next.js 에서는 app/api/이름/route.ts 파일 하나가 곧 API 엔드포인트다. POST 라는 이름으로 함수를 내보내면, 그 경로로 오는 POST 요청을 이 함수가 처리한다.
// app/api/chat/route.ts
export async function POST(request: Request) {
const { question, history } = await request.json();
// ... 검색 + Claude 호출 ...
}
검색은 20강 그대로, 답은 스트리밍으로
질문이 오면 22강까지와 똑같이 벡터 검색부터 한다 (직전 질문을 검색어에 더하는 20강의 보정도 그대로 가져온다). 다른 건 Claude 를 부르는 방식으로, messages.create() 대신 messages.stream() 을 쓴다.
const encoder = new TextEncoder();
const body = new ReadableStream({
start(controller) {
const stream = client.messages.stream({
model: "claude-haiku-4-5",
max_tokens: 500,
system: SYSTEM_PROMPT,
messages,
});
stream.on("text", (delta) => controller.enqueue(encoder.encode(delta)));
stream.on("end", () => controller.close());
stream.on("error", (err) => controller.error(err));
},
});
return new Response(body, { headers: { "Content-Type": "text/plain; charset=utf-8" } });
client.messages.stream() 은 5강에서 본 스트리밍과 같은 원리다. 다만 5강에선 그 글자 조각을 바로 console.log 로 찍었다면, 오늘은 controller.enqueue() 로 브라우저로 보낼 ReadableStream 에 그대로 밀어 넣는다. stream.on("text", ...) 가 글자 조각(delta)이 도착할 때마다 불리는 콜백이다.
브라우저 쪽 — fetch 로 스트림을 읽는다
app/page.tsx 는 입력창 하나와, 스트리밍되는 답을 담을 자리 하나가 전부다. 핵심은 response.body 를 getReader() 로 열어 조각이 오는 대로 화면에 이어붙이는 부분이다.
const res = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ question, history }),
});
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let full = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
full += decoder.decode(value, { stream: true });
setAnswer(full); // 조각이 도착할 때마다 화면을 갱신한다
}
서버가 ReadableStream 으로 응답하고, 브라우저가 그 스트림을 한 조각씩 읽어 상태(answer)를 갱신한다. 이게 "타이핑하듯 답이 나타나는" 효과의 전부다. 특별한 라이브러리도, WebSocket 도 필요 없다.
빌드 — 실제로 돌려서 확인한다
typescript 를 6.0.3 으로 고정하고 다시 빌드한다.
npm run build
- ▲ Next.js 16.2.10 (Turbopack)
- ✓ Compiled successfully in 2.3s
- Running TypeScript ...
- Finished TypeScript in 2.2s ...
- Collecting page data using 3 workers ...
- ✓ Generating static pages using 3 workers (4/4) in 162ms
- Route (app)
- ┌ ○ /
- ├ ○ /_not-found
- └ ƒ /api/chat
- ○ (Static) prerendered as static content
- ƒ (Dynamic) server-rendered on demand
홈(/)은 정적(○)으로, /api/chat 은 동적(ƒ)으로 잡혔다. 당연하다. 홈은 입력창만 있는 고정된 화면이고, /api/chat 은 매 요청마다 다른 질문을 받아 Claude 를 호출하니 미리 만들어둘 수 없다.
실행 — 서버를 띄우고 진짜 질문을 보낸다
npm run dev
- ▲ Next.js 16.2.10 (Turbopack)
- - Local: http://localhost:3000
- ✓ Ready in 441ms
브라우저 대신 curl 로 API 를 직접 두드려본다. -N 은 응답이 오는 대로 바로바로 출력하라는 옵션이다(버퍼링 없이).
curl -N -X POST http://localhost:3000/api/chat \
-H "Content-Type: application/json" \
-d '{"question":"야근 식대는 얼마까지 인정돼?","history":[]}'
- 참고 문서에 따르면, 야근 식대는 2만 원까지 인정됩니다.
이번엔 history 에 이전 대화를 실어 보낸다. 20강에서 만든 "직전 질문을 검색어에 더하는" 보정이 여기서도 똑같이 동작하는지 확인한다.
curl -N -X POST http://localhost:3000/api/chat \
-H "Content-Type: application/json" \
-d '{
"question": "그럼 반차는 언제부터 언제까지 써야 돼?",
"history": [
{ "role": "user", "content": "연차를 못 쓰면 다음 해로 이월할 수 있나요?" },
{ "role": "assistant", "content": "네, 제한적으로 가능합니다. 미사용 연차는 다음 해로 최대 5일까지 이월할 수 있습니다." }
]
}'
- 문서에서 찾을 수 없습니다.
- 문서에는 반차가 오전·오후 중 선택할 수 있다는 정보만 있고, 반차를 사용할 수 있는 구체적인 시간(예: 오전 9시~12시, 오후 1시~6시 등)은 명시되어 있지 않습니다.
검색이 엉뚱한 문서를 가져오지도, Claude 가 지어내지도 않았다. 문서에 진짜 없는 내용이라 정직하게 "찾을 수 없다"고 답했는데, 19강에서 만든 그라운딩 규칙이 API Route 를 거쳐도 그대로 지켜진다는 뜻이다.
지금까지 만든 것
- my-ai-assistant
- app/page.tsx
- app/layout.tsx
- app/api/chat/route.ts
- lib.ts
- vectors.json
- docs/
- .env.example
- package.json
정리하면
20~22강의 검색·그라운딩·메모리 로직을 그대로 Next.js Route Handler 안으로 옮기고, Claude 의 스트리밍 응답을 ReadableStream 으로 브라우저까지 이어 curl 로 실제 확인했다. 그 과정에서 TypeScript 7 이 Next 16 빌드를 깨뜨리는 실제 버전 충돌도 마주치고 고쳤다.
- 01
Next.js 프로젝트를 만들고 typescript 버전 문제를 실측으로 잡았다
typescript 7.0.2 는 next build 를 원인 모를 에러로 깨뜨렸다. 6.0.3 으로 고정해 해결했다.
- 02
app/api/chat/route.ts 에 검색 + Claude 호출을 담았다
20~22강의 검색·그라운딩 로직을 그대로 재사용하고, messages.stream() 으로 Claude 를 불렀다.
- 03
stream.on("text", ...) 로 글자 조각을 ReadableStream 에 실었다
SDK 이벤트가 도착할 때마다 controller.enqueue() 로 응답 스트림에 밀어 넣었다.
- 04
브라우저에서 fetch + getReader() 로 스트림을 읽었다
조각이 도착할 때마다 상태를 갱신해, 타이핑하듯 답이 나타나는 화면을 만들었다.
- 05
curl 로 실제 API 를 두드려 그라운딩까지 확인했다
history 를 실은 후속 질문에서도 검색·정직한 답변이 그대로 동작했다.
curl -N -X POST http://localhost:3000/api/chat -d '{...}'
이제 우리 어시스턴트는 브라우저에서 실시간으로 답하는 웹 앱이 됐다. 하지만 아직 남은 게 있다. 지금은 요청이 실패하면 그냥 죽고, 검색은 서버가 켜질 때마다 파일을 다시 읽고, 아무 배포 설정도 없다. 다음 24강, Course B 의 마지막 강의에서 에러 처리·재시도·캐싱을 더하고 실제로 배포해 이 편을 마친다.