학습 자료

Next.js API Route + 스트리밍 UI (Part 7 시작)


Article

Part 6까지 우리가 만든 건 전부 터미널 안에서만 움직였다. npx tsx agent.ts 를 치고, You> 뒤에 타이핑하고, 콘솔에 답이 찍히는 식이었다. 좋은 어시스턴트지만, 나 말고는 아무도 못 쓴다.

오늘은 그걸 웹으로 꺼낸다. Next.js 로 API 를 만들고, 브라우저에서 질문을 보내면 20강·22강에서 만든 검색 로직이 서버에서 돌아가 답을 낸다. 그리고 그 답은 한 번에 뚝 떨어지지 않고, Claude 가 답을 만드는 대로 글자 단위로 화면에 흘러 들어온다.

지금까지 vs 오늘
  1. 20~22강터미널 CLI. readline 으로 질문 받고 console.log 로 답 출력
  2. 23강 — 오늘Next.js Route Handler + 브라우저. fetch 로 질문 보내고 스트림으로 답 받는다
검색 로직(lib.ts, vectors.json)은 그대로 재사용한다. 오늘 새로운 건 "어디서 실행되고, 어떻게 전달되는가" 뿐이다.

새 프로젝트 — 이번엔 Next.js 다

lesson-22lib.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
package.json (devDependencies, 실제 고정한 버전)
  • "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.bodygetReader() 로 열어 조각이 오는 대로 화면에 이어붙이는 부분이다.

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일까지 이월할 수 있습니다." }
    ]
  }'
터미널 — history 를 실은 후속 질문
  • 문서에서 찾을 수 없습니다.
  • 문서에는 반차가 오전·오후 중 선택할 수 있다는 정보만 있고, 반차를 사용할 수 있는 구체적인 시간(예: 오전 9시~12시, 오후 1시~6시 등)은 명시되어 있지 않습니다.

검색이 엉뚱한 문서를 가져오지도, Claude 가 지어내지도 않았다. 문서에 진짜 없는 내용이라 정직하게 "찾을 수 없다"고 답했는데, 19강에서 만든 그라운딩 규칙이 API Route 를 거쳐도 그대로 지켜진다는 뜻이다.

지금까지 만든 것

my-ai-assistant/ (ai-lab-b, lesson-23)
  • my-ai-assistant
    • app/page.tsx입력창 + 스트리밍 답변 화면 (오늘)
    • app/layout.tsx루트 레이아웃
    • app/api/chat/route.ts검색 + Claude 스트리밍 Route Handler (오늘)
    • lib.ts청킹 + 임베딩 + 코사인 유사도 (16~22강과 동일)
    • vectors.json저장된 벡터
    • docs/사내 규정 문서 3개
    • .env.example
    • package.json

정리하면

20~22강의 검색·그라운딩·메모리 로직을 그대로 Next.js Route Handler 안으로 옮기고, Claude 의 스트리밍 응답을 ReadableStream 으로 브라우저까지 이어 curl 로 실제 확인했다. 그 과정에서 TypeScript 7 이 Next 16 빌드를 깨뜨리는 실제 버전 충돌도 마주치고 고쳤다.

다시 짚어보기
  1. 01

    Next.js 프로젝트를 만들고 typescript 버전 문제를 실측으로 잡았다

    typescript 7.0.2 는 next build 를 원인 모를 에러로 깨뜨렸다. 6.0.3 으로 고정해 해결했다.

  2. 02

    app/api/chat/route.ts 에 검색 + Claude 호출을 담았다

    20~22강의 검색·그라운딩 로직을 그대로 재사용하고, messages.stream() 으로 Claude 를 불렀다.

  3. 03

    stream.on("text", ...) 로 글자 조각을 ReadableStream 에 실었다

    SDK 이벤트가 도착할 때마다 controller.enqueue() 로 응답 스트림에 밀어 넣었다.

  4. 04

    브라우저에서 fetch + getReader() 로 스트림을 읽었다

    조각이 도착할 때마다 상태를 갱신해, 타이핑하듯 답이 나타나는 화면을 만들었다.

  5. 05

    curl 로 실제 API 를 두드려 그라운딩까지 확인했다

    history 를 실은 후속 질문에서도 검색·정직한 답변이 그대로 동작했다.

    curl -N -X POST http://localhost:3000/api/chat -d '{...}'

이제 우리 어시스턴트는 브라우저에서 실시간으로 답하는 웹 앱이 됐다. 하지만 아직 남은 게 있다. 지금은 요청이 실패하면 그냥 죽고, 검색은 서버가 켜질 때마다 파일을 다시 읽고, 아무 배포 설정도 없다. 다음 24강, Course B 의 마지막 강의에서 에러 처리·재시도·캐싱을 더하고 실제로 배포해 이 편을 마친다.

Next.js API Route + 스트리밍 UI (Part 7 시작) — 디코드랩(DCODELAB)