Next.js App Router 빠른 시작

Next.js App Router 빠른 시작 (Next.js App Router Quickstart)

AI SDK는 AI 기반 애플리케이션을 만드는 데 도움을 주는 강력한 TypeScript 라이브러리예요. 이 빠른 시작에서는 스트리밍 채팅 UI를 가진 간단한 에이전트를 만드는 과정을 통해, 여러분 프로젝트에서 AI SDK를 쓰는 데 핵심이 되는 개념과 기법을 배워볼 거예요.

출처: 문서

본문

이 빠른 시작 튜토리얼에서는 스트리밍 채팅 사용자 인터페이스를 가진 간단한 에이전트를 구축합니다. 그 과정에서 여러분 프로젝트에서 SDK를 사용할 때 기본이 되는 핵심 개념과 기법을 배우게 됩니다.

프롬프트 엔지니어링과 HTTP 스트리밍 개념이 익숙하지 않다면, 선택적으로 이 문서들을 먼저 읽어도 좋습니다.

전제 조건 (Prerequisites)

이 빠른 시작을 따라 하려면 다음이 필요합니다:

Vercel AI Gateway API 키가 아직 없다면, Vercel 웹사이트에서 가입해서 얻을 수 있습니다.

애플리케이션 생성 (Create Your Application)

새 Next.js 애플리케이션을 만드는 것부터 시작합니다. 이 명령은 my-ai-app이라는 새 디렉토리를 만들고 그 안에 기본 Next.js 애플리케이션을 설정합니다.

App Router와 Tailwind CSS를 사용할지 물어볼 때 **예(yes)**를 선택하세요. Next.js Pages Router 빠른 시작 가이드를 찾고 있다면 여기에서 찾을 수 있습니다.

pnpm create next-app@latest my-ai-app

새로 만든 디렉토리로 이동합니다:

cd my-ai-app

의존성 설치 (Install dependencies)

ai와 @ai-sdk/react, 즉 AI 패키지와 AI SDK의 React 훅을 설치합니다. AI SDK의 Vercel AI Gateway 프로바이더는 ai 패키지에 포함되어 있습니다. 또한 도구 입력을 정의하는 데 사용하는 스키마 검증 라이브러리인 zod도 설치합니다.

이 가이드는 하나의 API 키로 서로 다른 프로바이더의 수백 개 모델에 접근할 수 있는 Vercel AI Gateway 프로바이더를 사용하지만, 패키지를 설치하면 어떤 프로바이더나 모델로든 바꿀 수 있어요. 사용 가능한 AI SDK 프로바이더를 확인해보세요.

pnpm add ai @ai-sdk/react zod

AI Gateway API 키 구성 (Configure your AI Gateway API key)

프로젝트 루트에 .env.local 파일을 만들고 AI Gateway API 키를 추가합니다. 이 키는 Vercel AI Gateway와 애플리케이션을 인증합니다.

touch .env.local

.env.local 파일을 편집합니다:

AI_GATEWAY_API_KEY=xxxxxxxxx

xxxxxxxxx를 실제 Vercel AI Gateway API 키로 바꾸세요.

AI SDK의 Vercel AI Gateway 프로바이더가 기본적으로 AI_GATEWAY_API_KEY 환경 변수를 사용합니다.

라우트 핸들러 생성 (Create a Route Handler)

라우트 핸들러 app/api/chat/route.ts를 만들고 다음 코드를 추가합니다:

import {
  streamText,
  UIMessage,
  convertToModelMessages,
  createUIMessageStreamResponse,
  toUIMessageStream,
} from 'ai';
__PROVIDER_IMPORT__;

export async function POST(req: Request) {
  const { messages }: { messages: UIMessage[] } = await req.json();

  const result = streamText({
    model: __MODEL__,
    messages: await convertToModelMessages(messages),
  });

  return createUIMessageStreamResponse({
    stream: toUIMessageStream({ stream: result.stream }),
  });
}

이 코드에서 어떤 일이 일어나는지 살펴보죠:

  1. 비동기 POST 요청 핸들러를 정의하고 요청 본문에서 messages를 추출합니다. messages 변수는 사용자와 챗봇 사이의 대화 기록을 담고 있으며, 다음 생성을 위해 챗봇에 필요한 맥락을 제공합니다. messages는 UIMessage 타입으로, 애플리케이션 UI에서 사용하도록 설계되었습니다 — 전체 메시지 기록과 타임스탬프 같은 관련 메타데이터를 담고 있습니다.
  2. ai 패키지에서 가져온 streamText를 호출합니다. 이 함수는 model 프로바이더와 messages(1단계에서 정의)를 포함한 설정 객체를 인자로 받습니다. 추가 설정을 전달해 모델의 동작을 더 커스터마이즈할 수도 있습니다. messages 키는 ModelMessage[] 배열을 기대합니다. 이 타입은 타임스탬프나 발신자 정보 같은 메타데이터를 포함하지 않는다는 점에서 UIMessage와 다릅니다. 이 타입들을 변환하려면 UI 특정 메타데이터를 제거하고 UIMessage[] 배열을 모델이 기대하는 ModelMessage[] 형식으로 변환하는 convertToModelMessages 함수를 사용합니다.
  3. streamText 함수는 StreamTextResult를 반환합니다. 그 stream을 toUIMessageStream에 전달하고 createUIMessageStreamResponse와 함께 반환하여 스트리밍된 응답 객체를 만듭니다.
  4. 마지막으로 결과를 클라이언트에 반환해 응답을 스트리밍합니다.

이 라우트 핸들러는 /api/chat에 POST 요청 엔드포인트를 만듭니다.

프로바이더 선택 (Choosing a Provider)

AI SDK는 퍼스트파티, OpenAI 호환, 커뮤니티 패키지를 통해 수십 개의 모델 프로바이더를 지원합니다.

이 빠른 시작은 기본 글로벌 프로바이더인 Vercel AI Gateway 프로바이더를 사용합니다. 즉 모델 설정에서 간단한 문자열로 모델에 접근할 수 있다는 뜻입니다:

model: __MODEL__;

게이트웨이 프로바이더를 명시적으로 import해서 사용하는 다른 동등한 두 가지 방법도 있습니다:

// Option 1: Import from 'ai' package (included by default)
import { gateway } from 'ai';
model: gateway('anthropic/claude-sonnet-5');

// Option 2: Install and import from '@ai-sdk/gateway' package
import { gateway } from '@ai-sdk/gateway';
model: gateway('anthropic/claude-sonnet-5');

다른 프로바이더 사용하기 (Using other providers)

다른 프로바이더를 사용하려면 패키지를 설치하고 프로바이더 인스턴스를 만드세요. 예를 들어 OpenAI를 직접 사용하려면:

pnpm add @ai-sdk/openai
import { openai } from '@ai-sdk/openai';

model: openai('gpt-6-astra');

글로벌 프로바이더 업데이트 (Updating the global provider)

기본 글로벌 프로바이더를 변경하면 애플리케이션 전체에서 문자열 모델 참조가 선호하는 프로바이더를 사용하게 할 수 있습니다. 프로바이더 관리에 대해 자세히 알아보세요.

애플리케이션 전체에서 프로바이더를 어떻게 관리하고 싶은지에 가장 잘 맞는 방식을 선택하세요.

UI 연결하기 (Wire up the UI)

이제 LLM을 쿼리할 수 있는 라우트 핸들러가 생겼으니 프론트엔드를 설정할 차례입니다. AI SDK의 UI 패키지는 채팅 인터페이스의 복잡성을 하나의 훅 useChat으로 추상화합니다.

채팅 메시지 목록을 보여주고 사용자 메시지 입력을 제공하려면 루트 페이지(app/page.tsx)를 다음 코드로 업데이트합니다:

'use client';

import { useChat } from '@ai-sdk/react';
import { useState } from 'react';

export default function Chat() {
  const [input, setInput] = useState('');
  const { messages, sendMessage } = useChat();
  return (
    <div className="flex flex-col w-full max-w-md py-24 mx-auto stretch">
      {messages.map(message => (
        <div key={message.id} className="whitespace-pre-wrap">
          {message.role === 'user' ? 'User: ' : 'AI: '}
          {message.parts.map((part, i) => {
            switch (part.type) {
              case 'text':
                return <div key={`${message.id}-${i}`}>{part.text}</div>;
            }
          })}
        </div>
      ))}

      <form
        onSubmit={e => {
          e.preventDefault();
          sendMessage({ text: input });
          setInput('');
        }}
      >
        <input
          className="fixed dark:bg-zinc-900 bottom-0 w-full max-w-md p-2 mb-8 border border-zinc-300 dark:border-zinc-800 rounded shadow-xl"
          value={input}
          placeholder="Say something..."
          onChange={e => setInput(e.currentTarget.value)}
        />
      </form>
    </div>
  );
}

파일 맨 위에 "use client" 지시문을 추가하는 것을 잊지 마세요. 이렇게 해야 JavaScript로 상호작용을 추가할 수 있습니다.

이 페이지는 기본적으로 앞서 만든 POST API 라우트(/api/chat)를 사용하는 useChat 훅을 활용합니다. 이 훅은 사용자 입력과 폼 제출을 처리하는 함수와 상태를 제공합니다. useChat 훅은 여러 유틸리티 함수와 상태 변수를 제공합니다:

  • messages - 현재 채팅 메시지(id, role, parts 속성을 가진 객체의 배열)
  • sendMessage - 채팅 API에 메시지를 보내는 함수

컴포넌트는 로컬 상태(useState)를 사용해 입력 필드 값을 관리하고, 입력 텍스트로 sendMessage를 호출한 다음 입력 필드를 비우는 방식으로 폼 제출을 처리합니다.

LLM의 응답은 메시지 parts 배열을 통해 접근합니다. 각 메시지는 모델이 응답에서 생성한 모든 것을 나타내는 순서화된 parts 배열을 담고 있습니다. 이 파츠에는 평문 텍스트, 추론 토큰 등 나중에 보게 될 것들이 포함될 수 있습니다. parts 배열은 모델 출력의 순서를 보존하므로, 각 구성 요소를 생성된 순서대로 표시하거나 처리할 수 있습니다.

애플리케이션 실행 (Running Your Application)

이것으로 챗봇을 만드는 데 필요한 모든 것을 구축했습니다! 애플리케이션을 시작하려면 다음 명령을 사용합니다:

pnpm run dev

브라우저로 이동해 http://localhost:3000을 엽니다. 입력 필드가 보일 겁니다. 메시지를 입력해보고 AI 챗봇이 실시간으로 응답하는 것을 확인해보세요! AI SDK는 Next.js로 AI 채팅 인터페이스를 빠르고 쉽게 구축하게 해줍니다.

챗봇을 도구로 강화하기 (Enhance Your Chatbot with Tools)

대규모 언어 모델(LLM)은 놀라운 생성 능력을 갖추고 있지만, 명확한 작업(예: 수학)이나 외부 세계와 상호작용(예: 날씨 가져오기)에서는 어려움을 겪습니다. 여기서 도구(tools)가 등장합니다.

도구는 LLM이 호출할 수 있는 동작입니다. 이 동작의 결과는 다음 응답에서 고려되도록 LLM에 다시 보고될 수 있습니다.

예를 들어 사용자가 현재 날씨를 물어보면, 도구가 없다면 모델은 훈련 데이터에 기반한 일반적인 정보만 제공할 수 있습니다. 하지만 날씨 도구가 있다면 최신의 위치별 날씨 정보를 가져와 제공할 수 있습니다.

간단한 날씨 도구를 추가해서 챗봇을 강화해봅시다.

라우트 핸들러 업데이트 (Update Your Route Handler)

새 날씨 도구를 포함하도록 app/api/chat/route.ts 파일을 수정합니다:

import {
  streamText,
  UIMessage,
  convertToModelMessages,
  tool,
  createUIMessageStreamResponse,
  toUIMessageStream,
} from 'ai';
__PROVIDER_IMPORT__;
import { z } from 'zod';

export async function POST(req: Request) {
  const { messages }: { messages: UIMessage[] } = await req.json();

  const result = streamText({
    model: __MODEL__,
    messages: await convertToModelMessages(messages),
    tools: {
      weather: tool({
        description: 'Get the weather in a location (fahrenheit)',
        inputSchema: z.object({
          location: z.string().describe('The location to get the weather for'),
        }),
        execute: async ({ location }) => {
          const temperature = Math.round(Math.random() * (90 - 32) + 32);
          return {
            location,
            temperature,
          };
        },
      }),
    },
  });

  return createUIMessageStreamResponse({
    stream: toUIMessageStream({ stream: result.stream }),
  });
}

이 업데이트된 코드에서:

  1. ai 패키지에서 tool 함수를, 스키마 검증을 위해 zod에서 z를 import합니다.
  2. weather 도구가 있는 tools 객체를 정의합니다. 이 도구는:
    • 모델이 언제 사용해야 하는지 이해하는 데 도움이 되는 description을 가집니다.
    • Zod 스키마를 사용하여 inputSchema를 정의하며, 이 도구를 실행하려면 location 문자열이 필요하다고 지정합니다. 모델은 대화의 맥락에서 이 입력을 추출하려고 시도합니다. 추출할 수 없으면 누락된 정보를 사용자에게 물어봅니다.
    • 날씨 데이터를 가져오는 것을 시뮬레이션하는(이 경우 무작위 온도를 반환하는) execute 함수를 정의합니다. 이것은 서버에서 실행되는 비동기 함수이므로 외부 API에서 실제 데이터를 가져올 수 있습니다.

이제 챗봇은 사용자가 묻는 어떤 위치에 대해서도 날씨 정보를 "가져올" 수 있습니다. 모델이 날씨 도구를 사용해야 한다고 판단하면 필요한 입력과 함께 도구 호출을 생성합니다. 그러면 execute 함수가 자동으로 실행되고, 도구 출력이 messages에 tool 메시지로 추가됩니다.

"뉴욕 날씨 어때?" 같은 질문을 해보고 모델이 새 도구를 어떻게 사용하는지 확인해보세요.

UI에서 빈 응답을 보셨나요? 이것은 텍스트 응답을 생성하는 대신 모델이 도구 호출을 생성했기 때문입니다. message.parts 배열의 tool-weather 파트를 통해 클라이언트에서 도구 호출과 이후의 도구 결과에 접근할 수 있습니다.

도구 파츠는 항상 tool-{toolName}이라 이름 붙습니다. 여기서 {toolName}은 도구를 정의할 때 사용한 키입니다. 이 경우 도구를 weather로 정의했으므로 파츠 타입은 tool-weather입니다.

UI 업데이트 (Update the UI)

UI에서 도구 호출을 표시하려면 app/page.tsx 파일을 업데이트합니다:

'use client';

import { useChat } from '@ai-sdk/react';
import { useState } from 'react';

export default function Chat() {
  const [input, setInput] = useState('');
  const { messages, sendMessage } = useChat();
  return (
    <div className="flex flex-col w-full max-w-md py-24 mx-auto stretch">
      {messages.map(message => (
        <div key={message.id} className="whitespace-pre-wrap">
          {message.role === 'user' ? 'User: ' : 'AI: '}
          {message.parts.map((part, i) => {
            switch (part.type) {
              case 'text':
                return <div key={`${message.id}-${i}`}>{part.text}</div>;
              case 'tool-weather':
                return (
                  <pre key={`${message.id}-${i}`}>
                    {JSON.stringify(part, null, 2)}
                  </pre>
                );
            }
          })}
        </div>
      ))}

      <form
        onSubmit={e => {
          e.preventDefault();
          sendMessage({ text: input });
          setInput('');
        }}
      >
        <input
          className="fixed dark:bg-zinc-900 bottom-0 w-full max-w-md p-2 mb-8 border border-zinc-300 dark:border-zinc-800 rounded shadow-xl"
          value={input}
          placeholder="Say something..."
          onChange={e => setInput(e.currentTarget.value)}
        />
      </form>
    </div>
  );
}

이 변경으로 UI가 서로 다른 메시지 파츠를 처리하도록 업데이트합니다. 텍스트 파츠는 이전처럼 텍스트 내용을 표시합니다. 날씨 도구 호출은 도구 호출과 그 결과의 JSON 표현을 표시합니다.

이제 날씨에 대해 물어보면, 채팅 인터페이스에 도구 호출과 그 결과가 표시되는 것을 볼 수 있습니다.

멀티스텝 도구 호출 활성화 (Enabling Multi-Step Tool Calls)

도구가 이제 채팅 인터페이스에 표시되는데도 모델이 이 정보를 사용해 원래 질문에 답하지 않는다는 점을 눈치챘을 겁니다. 이것은 모델이 도구 호출을 생성하면 기술적으로 생성이 완료된 것이기 때문입니다.

이를 해결하려면 stopWhen을 사용하여 멀티스텝 도구 호출을 활성화할 수 있습니다. 기본적으로 stopWhen은 isStepCount(1)로 설정되어 있어, 도구 결과가 있을 때 첫 번째 스텝 이후 생성이 중지됩니다. 이 조건을 변경하면 지정한 중지 조건이 충족될 때까지 모델이 도구 결과를 자동으로 다시 보내 추가 생성을 트리거하게 할 수 있습니다. 이 경우 모델이 날씨 도구 결과를 사용해 원래 질문에 답할 수 있도록 계속 생성하기를 원합니다.

라우트 핸들러 업데이트 (Update Your Route Handler)

stopWhen 조건을 포함하도록 app/api/chat/route.ts 파일을 수정합니다:

import {
  streamText,
  UIMessage,
  convertToModelMessages,
  tool,
  isStepCount,
  createUIMessageStreamResponse,
  toUIMessageStream,
} from 'ai';
__PROVIDER_IMPORT__;
import { z } from 'zod';

export async function POST(req: Request) {
  const { messages }: { messages: UIMessage[] } = await req.json();

  const result = streamText({
    model: __MODEL__,
    messages: await convertToModelMessages(messages),
    stopWhen: isStepCount(5),
    tools: {
      weather: tool({
        description: 'Get the weather in a location (fahrenheit)',
        inputSchema: z.object({
          location: z.string().describe('The location to get the weather for'),
        }),
        execute: async ({ location }) => {
          const temperature = Math.round(Math.random() * (90 - 32) + 32);
          return {
            location,
            temperature,
          };
        },
      }),
    },
    onStepEnd: ({ toolResults }) => {
      console.log(toolResults);
    },
  });

  return createUIMessageStreamResponse({
    stream: toUIMessageStream({ stream: result.stream }),
  });
}

이 업데이트된 코드에서:

  1. stopWhen을 isStepCount 5가 될 때로 설정하여, 모델이 어떤 주어진 생성에 대해 최대 5개의 "스텝"을 사용할 수 있게 합니다.
  2. 상호작용 각 스텝의 toolResults를 기록하는 onStepEnd 콜백을 추가하여, 모델의 도구 사용을 이해하는 데 도움을 줍니다.

브라우저로 돌아가 어떤 위치의 날씨에 대해 물어보세요. 이제 모델이 날씨 도구 결과를 사용해 질문에 답하는 것을 볼 수 있습니다.

stopWhen: isStepCount(5)를 설정함으로써 모델이 어떤 주어진 생성에 대해 최대 5개의 "스텝"을 사용할 수 있게 합니다. 이것은 더 복잡한 상호작용을 가능하게 하고, 필요하다면 여러 스텝에 걸쳐 정보를 수집·처리할 수 있게 해줍니다. 온도를 섭씨에서 화씨로 변환하는 도구를 하나 더 추가해보면 이를 직접 확인할 수 있습니다.

도구 하나 더 추가 (Add another tool)

온도를 화씨에서 섭씨로 변환하는 새 도구를 추가하도록 app/api/chat/route.ts 파일을 업데이트합니다:

import {
  streamText,
  UIMessage,
  convertToModelMessages,
  tool,
  isStepCount,
  createUIMessageStreamResponse,
  toUIMessageStream,
} from 'ai';
__PROVIDER_IMPORT__;
import { z } from 'zod';

export async function POST(req: Request) {
  const { messages }: { messages: UIMessage[] } = await req.json();

  const result = streamText({
    model: __MODEL__,
    messages: await convertToModelMessages(messages),
    stopWhen: isStepCount(5),
    tools: {
      weather: tool({
        description: 'Get the weather in a location (fahrenheit)',
        inputSchema: z.object({
          location: z.string().describe('The location to get the weather for'),
        }),
        execute: async ({ location }) => {
          const temperature = Math.round(Math.random() * (90 - 32) + 32);
          return {
            location,
            temperature,
          };
        },
      }),
      convertFahrenheitToCelsius: tool({
        description: 'Convert a temperature in fahrenheit to celsius',
        inputSchema: z.object({
          temperature: z
            .number()
            .describe('The temperature in fahrenheit to convert'),
        }),
        execute: async ({ temperature }) => {
          const celsius = Math.round((temperature - 32) * (5 / 9));
          return {
            celsius,
          };
        },
      }),
    },
  });

  return createUIMessageStreamResponse({
    stream: toUIMessageStream({ stream: result.stream }),
  });
}

프론트엔드 업데이트 (Update Your Frontend)

새 온도 변환 도구를 렌더링하도록 app/page.tsx 파일을 업데이트합니다:

'use client';

import { useChat } from '@ai-sdk/react';
import { useState } from 'react';

export default function Chat() {
  const [input, setInput] = useState('');
  const { messages, sendMessage } = useChat();
  return (
    <div className="flex flex-col w-full max-w-md py-24 mx-auto stretch">
      {messages.map(message => (
        <div key={message.id} className="whitespace-pre-wrap">
          {message.role === 'user' ? 'User: ' : 'AI: '}
          {message.parts.map((part, i) => {
            switch (part.type) {
              case 'text':
                return <div key={`${message.id}-${i}`}>{part.text}</div>;
              case 'tool-weather':
              case 'tool-convertFahrenheitToCelsius':
                return (
                  <pre key={`${message.id}-${i}`}>
                    {JSON.stringify(part, null, 2)}
                  </pre>
                );
            }
          })}
        </div>
      ))}

      <form
        onSubmit={e => {
          e.preventDefault();
          sendMessage({ text: input });
          setInput('');
        }}
      >
        <input
          className="fixed dark:bg-zinc-900 bottom-0 w-full max-w-md p-2 mb-8 border border-zinc-300 dark:border-zinc-800 rounded shadow-xl"
          value={input}
          placeholder="Say something..."
          onChange={e => setInput(e.currentTarget.value)}
        />
      </form>
    </div>
  );
}

이 업데이트는 새 tool-convertFahrenheitToCelsius 파츠 타입을 처리하여 온도 변환 도구 호출과 결과를 UI에 표시합니다.

이제 "뉴욕 날씨 섭씨로 알려줘"라고 물어보면, 더 완전한 상호작용을 볼 수 있습니다:

  1. 모델이 뉴욕에 대해 날씨 도구를 호출합니다.
  2. 도구 출력이 표시되는 것을 볼 수 있습니다.
  3. 그다음 온도를 화씨에서 섭씨로 변환하기 위해 온도 변환 도구를 호출합니다.
  4. 모델은 그 정보를 사용해 뉴욕 날씨에 대한 자연어 응답을 제공합니다.

이 멀티스텝 접근 방식은 모델이 정보를 수집하고 더 정확하고 맥락에 맞는 응답을 만드는 데 사용할 수 있게 해서, 챗봇을 훨씬 더 유용하게 만듭니다.

이 간단한 예제는 도구가 모델의 능력을 어떻게 확장하는지 보여줍니다. 실제 API, 데이터베이스 또는 다른 어떤 외부 시스템과도 통합하는 더 복잡한 도구를 만들어, 모델이 실시간으로 실제 데이터에 접근·처리할 수 있게 할 수 있습니다. 도구는 모델의 지식 컷오프(knowledge cutoff)와 최신 정보 사이의 간극을 메워줍니다.

다음으로 어디로 갈까요? (Where to Next?)

AI SDK를 사용해 AI 챗봇을 구축했습니다! 여기서부터 탐험할 수 있는 몇 가지 경로가 있습니다:

더 알아보기 (Learn more)

전체 사이트맵