Vercel AI SDK 심화 가이드 — AI 앱 개발의 표준 패턴

Vercel AI SDK 심화 가이드 — AI 앱 개발의 표준 패턴

이 글의 핵심

Vercel AI SDK로 프로덕션급 AI 앱을 만들 때 필요한 고급 패턴을 정리합니다. 클라이언트 훅 설계, 스트리밍·중단, 멀티스텝 도구 호출, 멀티 프로바이더, Edge 최적화, RAG, 실전 챗봇 아키텍처까지 한 흐름으로 다룹니다.

이 글의 핵심

Vercel AI SDK는 서버의 streamText / generateText 계열클라이언트의 useChat / useCompletion을 같은 스트리밍 프로토콜로 묶어, LLM 앱에서 반복되던 보일러플레이트를 줄여 줍니다. 입문 글에서 다룬 기본 예제를 넘어서, 프로덕션에서 마주치는 상태 관리·중단·도구·멀티 프로바이더·Edge·RAG를 “표준 패턴”으로 정리한 것이 이 문서의 목표입니다.

전제: Next.js App Router·Route Handler, React 클라이언트 컴포넌트, TypeScript에 익숙하다고 가정합니다. 패키지 이름은 ai, @ai-sdk/openai 등 최신 AI SDK 계열을 기준으로 설명하며, 프로젝트의 SDK 메이저 버전에 따라 메서드명(toAIStreamResponse / toDataStreamResponse 등)은 릴리스 노트와 맞춰 주세요.


1. 아키텍처 관점에서 본 AI SDK

고급 패턴을 이해하려면 요청이 어떻게 흐르는지 먼저 고정하는 것이 좋습니다.

  1. 브라우저: useChat 또는 useCompletion이 동일한 API 경로로 POST합니다. 본문에는 메시지 또는 프롬프트, 세션 식별자, 추가 컨텍스트가 실릴 수 있습니다.
  2. Route Handler: streamText(또는 도구·구조화 출력이 필요하면 generateText와 조합)로 모델을 호출하고, UI 스트림 응답으로 변환해 반환합니다.
  3. 스트림: 텍스트 델타가 순차적으로 도착하고, 클라이언트 훅이 메시지 배열이나 completion 문자열을 갱신합니다.

이 구조에서 “고급”은 대부분 (A) 서버에서 스트림·신호·도구 정책을 어떻게 통제할지, (B) 클라이언트에서 요청 본문·에러·중단·후처리를 어떻게 묶을지의 두 축으로 나뉩니다.


2. useChat 고급 패턴

2.1 API 경로·헤더·공통 페이로드

useChat은 기본적으로 /api/chat으로 요청합니다. 운영 환경에서는 다음을 자주 커스터마이즈합니다.

  • api: 여러 채널(내부 지식베이스, 고객 지원)을 한 앱에서 쓸 때 엔드포인트를 분리합니다.
  • headers: 인증 토큰, 테넌트 ID, 실험 플래그를 실어 보냅니다. 서버 Route에서 검증·감사 로그에 사용합니다.
  • body: 클라이언트만 아는 값(예: UI 언어, 선택된 문서 ID)을 서버로 넘깁니다. 민감한 비즈니스 규칙은 클라이언트에 두지 말고, 서버에서 권한 검사 후 컨텍스트를 붙이는 편이 안전합니다.

2.2 세션·초기 메시지·키 안정화

  • id: 채팅 세션 식별자로 쓰면, 동일 탭·사용자에 대해 메시지 상태를 안정적으로 복원하기 쉽습니다. 서버 측 세션 저장소(Redis, DB)와 키를 맞출 때 유용합니다.
  • initialMessages: 서버에서 불러온 과거 대화를 주입해 하이드레이션합니다. 이때 메시지 ID 형식은 서버·클라이언트 간 계약으로 맞춥니다.

2.3 요청 본문 가공 (experimental_prepareRequestBody 등)

고급 시나리오에서는 “훅이 보내는 JSON 그대로”가 아니라, 서버가 기대하는 스키마로 바꿔야 합니다. 예를 들어 메시지에 클라이언트 전용 필드를 제거하거나, RAG용 metadata만 추려 보낼 수 있습니다. SDK 버전별로 옵션 이름이 experimental_ 접두사를 달 수 있으므로, 프로젝트에 설치된 타입 정의를 기준으로 맞추면 됩니다.

2.4 수명 주기 훅: onFinish, onError

  • onFinish: 한 턴의 응답이 끝났을 때 호출됩니다. 분석 이벤트, 토큰 사용량 로깅, DB에 어시스턴트 메시지 저장 등에 쓰입니다.
  • onError: 네트워크 오류·4xx/5xx·스트림 파싱 실패 시 사용자에게 토스트를 띄우거나, 재시도 UI를 열 수 있습니다.

프로덕션에서는 에러 메시지를 사용자에게 그대로 노출하지 않는 것이 일반적입니다. 내부 상세는 로그에만 남기고, UI에는 일반화된 문구를 씁니다.

2.5 스트리밍 중단: stopAbortSignal

사용자가 “중지”를 누르면 진행 중인 스트림을 취소해야 합니다. useChat은 보통 stop() 메서드를 제공하며, 이는 내부적으로 fetchAbortSignal과 연결됩니다. 서버 Route에서는 streamText에 전달한 abortSignal(또는 동등한 옵션)과 맞물려, 모델 제공자 호출도 중단되는 흐름을 구성할 수 있습니다.

중단은 단순히 UI를 멈추는 것이 아니라 비용·지연·동시 요청 한도에도 영향을 줍니다. “중지 후 같은 메시지 재전송” 같은 경계 조건도 함께 설계합니다.

2.6 도구·멀티스텝과의 결합

도구 호출이 포함되면 한 번의 사용자 입력에 모델 → 도구 → 모델이 연쇄됩니다. 클라이언트에서는 isLoading뿐 아니라 “도구 실행 중” 상태를 별도로 표시하면 체감 품질이 좋아집니다. 서버에서 maxSteps로 상한을 두면 무한 루프성 연쇄를 막을 수 있습니다.


3. useCompletion 고급 패턴

useCompletion단일 입력 → 단일 스트리밍 completion에 최적화되어 있습니다. 채팅이 아닌 에디터 보조, 코드 생성, 요약 폼 등에 적합합니다.

3.1 api, body, headers

채팅과 동일하게 엔드포인트·공통 헤더·추가 필드를 붙일 수 있습니다. 예를 들어 “선택한 코드 범위”나 “출력 형식(JSON/Markdown)”을 body에 실어 서버에서 시스템 프롬프트를 조립합니다.

3.2 부분 결과와 후처리

completion 문자열은 스트리밍 중 계속 누적됩니다. 고급 UI에서는 토큰 단위 하이라이트, 스트리밍 중에는 미리보기만, 완료 후에만 포맷터 적용 같은 패턴을 씁니다. onFinish에서 최종 문자열을 검증하거나, 안전 필터를 통과시키는 것도 일반적입니다.

3.3 useChat과의 선택 기준

  • 대화 맥락·메시지 목록·역할이 중요하면 useChat.
  • 한 번에 하나의 생성 결과가 중심이면 useCompletion.

둘 다 같은 백엔드에서 streamText를 쓸 수 있으므로, API 설계를 먼저 고정하고 훅만 바꾸는 방식이 유지보수에 유리합니다.


4. 스트리밍과 중단 처리 — 서버·클라이언트 계약

4.1 서버: streamText와 응답 변환

Route Handler에서 흔한 형태는 다음과 같습니다.

// app/api/chat/route.ts
import { openai } from '@ai-sdk/openai';
import { streamText } from 'ai';

export const runtime = 'edge'; // Edge 사용 시 (환경에 맞게 조정)

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

  const result = await streamText({
    model: openai('gpt-4o'),
    messages,
    // abortSignal: req.signal  // 클라이언트 중단과 연동할 때
  });

  return result.toAIStreamResponse();
}

toAIStreamResponse() 이름은 SDK 버전에 따라 toDataStreamResponse() 등으로 바뀔 수 있습니다. 클라이언트 훅과 같은 “데이터 스트림” 계약을 쓰는지 확인해야 합니다.

4.2 클라이언트 중단과 req.signal

브라우저가 요청을 abort하면 Requestsignal이 취소됩니다. 서버에서 이 신호를 모델 호출에 전달하면, 불필요한 토큰 생성을 끊을 수 있습니다. 다만 프로바이더·런타임에 따라 중단이 즉시 반영되지 않을 수 있어, 비용 상한은 서버 측에서 별도로 두는 것이 안전합니다.

4.3 서버 전용 소비: fullStream

스트림을 UI가 아니라 로그·변환기·2차 처리로 보내야 할 때는 fullStream을 순회하는 패턴이 쓰입니다. 예를 들어 특정 이벤트(도구 호출 시작·종료)만 필터링해 관측 시스템으로 보낼 수 있습니다. 운영 환경에서는 PII 마스킹 정책을 먼저 정합니다.


5. Function Calling과 Tools

5.1 tool()과 Zod 스키마

도구는 설명(description)·입력 스키마·실행 함수의 세 부분으로 구성됩니다. Zod로 스키마를 정의하면 타입 안전성과 런타임 검증을 동시에 가져갈 수 있습니다.

import { openai } from '@ai-sdk/openai';
import { streamText, tool } from 'ai';
import { z } from 'zod';

async function getWeather(location: string) {
  // 외부 API 대역—타임아웃·에러 매핑은 실무에서 필수
  return { location, temperatureC: 22, condition: 'clear' };
}

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

  const result = await streamText({
    model: openai('gpt-4o'),
    messages,
    tools: {
      weather: tool({
        description: '주어진 도시의 현재 날씨를 조회합니다.',
        parameters: z.object({
          location: z.string().describe('도시 이름, 예: Seoul'),
        }),
        execute: async ({ location }) => getWeather(location),
      }),
    },
    maxSteps: 5,
  });

  return result.toAIStreamResponse();
}

실무 포인트: execute 안에서는 권한 검사, 입력 정규화, 외부 API 타임아웃, 레이트 리밋을 반드시 고려합니다. LLM이 생성한 인자는 항상 “신뢰할 수 있는 입력”이 아닙니다.

5.2 멀티스텝과 maxSteps

도구 결과를 다시 모델에 넣어 답을 이어가는 흐름에서 maxSteps로 최대 라운드 수를 제한합니다. 제한이 없으면 비용 폭주나 의도치 않은 반복 호출로 이어질 수 있습니다.

5.3 toolChoice와 제어

모델이 반드시 도구를 써야 하는지, 아예 쓰지 말아야 하는지를 정책으로 제어할 수 있습니다. 예를 들어 결제·삭제 같은 위험 도구는 “항상 사용자 확인 후에만 실행” 같은 서버 측 게이트와 함께 써야 합니다.


6. 다양한 프로바이더: OpenAI, Anthropic, Mistral

AI SDK의 이점은 모델 생성 부분만 프로바이더 팩토리로 교체할 수 있다는 점입니다. 운영에서는 다음 패턴이 흔합니다.

6.1 팩토리 함수로 단일화

import { openai } from '@ai-sdk/openai';
import { anthropic } from '@ai-sdk/anthropic';
import { mistral } from '@ai-sdk/mistral';
import { streamText } from 'ai';

type ProviderName = 'openai' | 'anthropic' | 'mistral';

function getModel(provider: ProviderName, modelId: string) {
  switch (provider) {
    case 'openai':
      return openai(modelId);
    case 'anthropic':
      return anthropic(modelId);
    case 'mistral':
      return mistral(modelId);
    default:
      throw new Error(`unsupported provider: ${provider}`);
  }
}

// streamText({ model: getModel('anthropic', 'claude-3-5-sonnet-20241022'), ... })

6.2 환경 변수와 배포 단위

  • API 키는 프로바이더별로 분리해 저장합니다.
  • 지역·규제에 따라 특정 프로바이더만 허용하는 정책이 있을 수 있습니다.
  • 모델 ID는 문자열 상수로 빼서 오타·변경에 대비합니다.

6.3 기능 차이 추상화

도구 스키마·메시지 형식·컨텍스트 길이는 프로바이더마다 다릅니다. 팀 내에서는 “우리 앱이 의존하는 최소 공통 기능”만 노출하는 어댑터 레이어를 두면, 모델 교체 비용이 줄어듭니다.


7. Edge Runtime 최적화

7.1 export const runtime = 'edge'

Edge는 낮은 콜드 스타트글로벌 배포에 유리합니다. AI SDK Route에 Edge를 쓰면 사용자와 가까운 곳에서 스트림이 열리는 효과를 기대할 수 있습니다.

7.2 제약과 회피 패턴

  • Node 전용 모듈(fs, 일부 네이티브 의존성)은 Edge에서 동작하지 않습니다. 문서 전처리·무거운 DB 드라이버는 별도 Node Route백그라운드 워커로 분리합니다.
  • 실행 시간·메모리 한도는 플랫폼마다 다릅니다. 긴 RAG 파이프라인은 검색만 Edge, 재랭킹·대용량 컨텍스트 조립은 Node로 나누는 식의 분할이 흔합니다.

7.3 번들 크기

프로바이더 SDK를 한 Route에 모두 넣으면 번들이 비대해질 수 있습니다. 프로바이더별로 Route를 나누거나, 동적 import를 검토합니다.


8. RAG 통합

RAG(검색 증강 생성)는 검색 품질프롬프트 설계가 곧 제품 품질입니다. Vercel AI SDK 입장에서는 최종적으로 messages근거가 된 컨텍스트를 넣어 streamText를 호출하면 됩니다.

8.1 파이프라인 개요

  1. 사용자 질의 임베딩 (embed)
  2. 벡터 DB·하이브리드 검색으로 관련 청크 조회
  3. 컨텍스트 문자열 조립(출처 메타데이터 포함)
  4. 시스템 프롬프트에 “근거만 답하라, 없으면 모른다고 하라” 정책 명시
  5. streamText로 응답
import { openai } from '@ai-sdk/openai';
import { embed, streamText } from 'ai';

export async function POST(req: Request) {
  const { messages } = await req.json();
  const lastUser = [...messages].reverse().find((m: { role: string }) => m.role === 'user');
  const query = typeof lastUser?.content === 'string' ? lastUser.content : '';

  const { embedding } = await embed({
    model: openai.embedding('text-embedding-3-small'),
    value: query,
  });

  // 아래는 예시: 실제로는 Supabase pgvector, Pinecone, Weaviate 등 클라이언트 호출
  const chunks = await fakeVectorSearch(embedding);

  const context = chunks
    .map((c, i) => `[#${i + 1}] ${c.title}\n${c.text}`)
    .join('\n\n');

  const result = await streamText({
    model: openai('gpt-4o'),
    messages: [
      {
        role: 'system',
        content:
          'You are a support assistant. Answer only from the context. If insufficient, say you do not know.\n\n' +
          context,
      },
      ...messages,
    ],
  });

  return result.toAIStreamResponse();
}

async function fakeVectorSearch(_embedding: number[]) {
  return [
    { title: '환불 정책', text: '디지털 상품은 결제 후 7일 이내 미사용 시 환불 가능합니다.' },
  ];
}

8.2 청크 설계·메타데이터

  • 청크 크기·오버랩은 검색 정확도와 비용의 트레이드오프입니다.
  • 문서 ID·버전·URL을 메타데이터로 저장해 UI에서 출처 링크를 렌더링합니다.
  • 하이브리드 검색(키워드 + 벡터)은 엔터프라이즈 문서에서 재현율을 크게 개선합니다.

8.3 환각 완화

컨텍스트에 없는 내용을 지어내는 문제는 프롬프트·온도·출력 검증뿐 아니라, 검색 결과가 비었을 때의 분기(“관련 문서를 찾지 못했습니다”)를 명시적으로 처리해야 완화됩니다.


9. 실전 AI 챗봇 구축 체크리스트

9.1 상태와 저장소

  • 세션 ID: 익명 세션 vs 로그인 사용자 ID에 따라 키 전략을 나눕니다.
  • 메시지 영속화: onFinish에서 사용자·어시스턴트 메시지를 DB에 저장합니다.
  • 동시성: 동일 세션에 대한 중복 전송 방지(버튼 비활성화, idempotency 키).

9.2 보안

  • 서버에서만 API 키·비밀을 사용합니다.
  • 도구 실행은 최소 권한·감사 로그·사용자 확인이 필요한 작업은 2단계 확인을 고려합니다.

9.3 관측·비용

  • 요청 ID를 클라이언트·서버·로그에 관통시킵니다.
  • 토큰 사용량을 프로바이더 대시보드와 자체 메트릭으로 이중 확인합니다.
  • 레이트 리밋(IP·유저·세션)으로 남용을 완화합니다.

9.4 UX

  • 스트리밍 중 스켈레톤·타이핑 인디케이터.
  • 중지재시도·복사·접근성(키보드 포커스)까지 포함하면 완성도가 올라갑니다.

10. 정리

주제핵심
useChat세션 id, body/headers, onFinish/onError, stop()으로 제어
useCompletion단일 생성·폼형 UI에 적합, 동일 Route와 공존 가능
스트리밍·중단서버 abortSignal + 클라이언트 abort, maxSteps로 도구 연쇄 상한
ToolsZod·권한·타임아웃·비용 한도가 실무의 본체
프로바이더팩토리로 교체, 키·모델 ID·규제를 배포 단위로 관리
Edge콜드 스타트·지연에 유리, Node 의존 작업은 분리
RAG검색·청크·출처·환각 분기가 제품 품질을 결정

Vercel AI SDK는 “LLM 호출”을 표준화된 스트리밍 UI 계약으로 가져가게 해 줍니다. 그 위에 보안·관측·비용·검색 품질을 얹었을 때 비로소 프로덕션 수준의 AI 앱이 됩니다. 입문 편에서 다룬 기본 예제와 함께 보면, 팀 내 아키텍처 문서와 코드 리뷰 기준으로도 활용할 수 있습니다.


같이 보면 좋은 글

  • Vercel AI SDK 완벽 가이드(동일 사이트 vercel-ai-sdk-complete-guide)
  • LangChain 완벽 가이드(에이전트·체인 설계 참고)

이 글에서 다루는 키워드

Vercel AI SDK, useChat, useCompletion, Streaming, Tools, OpenAI, Anthropic, Mistral, Edge Runtime, RAG, Next.js


자주 묻는 질문 (FAQ)

Q. SDK를 올렸더니 toAIStreamResponse가 없다고 나옵니다.

A. 메이저 버전 업그레이드 시 응답 헬퍼 이름이 바뀌는 경우가 있습니다. 공식 마이그레이션 가이드에 맞춰 toDataStreamResponse 등으로 교체하고, 클라이언트 훅과의 스트림 포맷 호환성을 확인하세요.

Q. 채팅과 RAG를 같은 Route에서 처리해도 되나요?

A. 가능합니다. 다만 검색·재랭킹이 무거우면 Route를 /api/chat(얇은 오케스트레이션)과 /api/retrieve(검색 전용)로 나누어 타임아웃과 장애 격리를 쉽게 할 수 있습니다.

Q. Mistral만 쓰는 제품도 Edge에 두면 될까요?

A. 프로바이더·리전·키 정책만 맞으면 가능합니다. 다만 임베딩·벡터 검색이 같은 요청에 묶이면 실행 시간이 길어질 수 있어, 검색은 별도 서비스로 분리하는 경우가 많습니다.