TanStack AI: 프레임워크에 종속되지 않는 AI SDK

TanStack AI: 프레임워크에 종속되지 않는 AI SDK

AI 기능을 웹 앱에 붙이는 일이 요즘 정말 흔해졌습니다. 채팅 인터페이스를 만들거나, LLM에게 도구를 쥐여주거나, 스트리밍 응답을 화면에 뿌리거나. 그런데 막상 시작하면 고민이 생깁니다. OpenAI를 쓸지 Anthropic을 쓸지, React인지 Vue인지, Next.js인지 다른 프레임워크인지에 따라 코드가 전부 달라지거든요.

TanStack QueryTanStack Router로 유명한 TanStack 생태계에서 이 문제를 해결하려고 나온 게 바로 TanStack AI입니다. 스스로를 “AI 도구의 스위스”라고 소개할 정도로 특정 프로바이더나 프레임워크에 종속되지 않는 걸 핵심 가치로 내세우는데요. 오늘 쓰는 코드가 내일 프로바이더를 바꿔도 그대로 돌아가게 만들겠다는 겁니다.

이번 글에서는 TanStack AI의 핵심 개념을 살펴보고, 서버에서 채팅 엔드포인트를 만들고 React 클라이언트에서 연결하는 것부터 도구 시스템과 Code Mode까지 예제와 함께 알아보겠습니다.

TanStack AI가 풀려는 문제

AI SDK를 쓸 때 가장 흔한 불만이 뭘까요? 프레임워크를 정하고, 클라우드 프로바이더를 고르면 어느새 그 생태계에 갇혀 있는 자신을 발견하게 됩니다. OpenAI 전용 코드를 잔뜩 짜놨는데 Anthropic으로 바꾸고 싶으면? Next.js에 최적화된 SDK를 쓰고 있는데 TanStack Start로 옮기고 싶으면?

TanStack AI는 이 종속성 문제를 어댑터 패턴으로 해결합니다. 코어 로직은 하나고, AI 프로바이더별로 어댑터만 갈아끼우는 구조예요.

import { openaiText } from "@tanstack/ai-openai";
import { anthropicText } from "@tanstack/ai-anthropic";
import { geminiText } from "@tanstack/ai-google";

// 어댑터만 바꾸면 프로바이더 전환 끝
const adapter = openaiText("gpt-5.2");
// const adapter = anthropicText("claude-sonnet-4-6");
// const adapter = geminiText("gemini-2.5-pro");

현재 공식 어댑터로 OpenAI, Anthropic, Google Gemini, Ollama, Grok(xAI), Groq, ElevenLabs, fal.ai, OpenRouter를 지원합니다. 클라이언트 쪽도 React, Vue, Svelte, Solid, Preact용 패키지가 각각 있어서 프레임워크를 자유롭게 고를 수 있고요.

패키지 설치

React 기준으로 시작해보겠습니다. 코어 패키지와 React 어댑터, 그리고 사용할 AI 프로바이더 어댑터를 함께 설치합니다.

bun add @tanstack/ai @tanstack/ai-react @tanstack/ai-openai

npm을 쓴다면 npm install로 같은 패키지를 설치하면 됩니다. Anthropic이나 Gemini를 쓰고 싶으면 @tanstack/ai-anthropic이나 @tanstack/ai-google로 바꾸면 되고요.

패키지 구조를 정리하면 이렇습니다.

  • @tanstack/ai — 프레임워크/프로바이더에 독립적인 코어
  • @tanstack/ai-react — React용 훅(useChat 등)
  • @tanstack/ai-vue — Vue용 컴포저블
  • @tanstack/ai-svelte — Svelte용 스토어
  • @tanstack/ai-solid — Solid용 시그널
  • @tanstack/ai-openai — OpenAI 어댑터
  • @tanstack/ai-anthropic — Anthropic 어댑터
  • @tanstack/ai-google — Google Gemini 어댑터

코어와 클라이언트와 프로바이더가 완전히 분리되어 있기 때문에 필요한 것만 골라서 설치할 수 있습니다.

서버 쪽 채팅 엔드포인트

TanStack AI의 서버 API는 chat() 함수 하나로 시작합니다. 이 함수가 메시지 오케스트레이션과 스트리밍을 전부 처리해주는데요. 반환값이 AsyncIterable 스트림이라서 응답을 단어 단위로 실시간 전달할 수 있습니다.

TanStack Start에서는 이렇게 씁니다.

app/routes/api/chat.ts
import { chat, toServerSentEventsResponse } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import { createFileRoute } from "@tanstack/react-router";

export const Route = createFileRoute("/api/chat")({
  server: {
    handlers: {
      POST: async ({ request }) => {
        const { messages, conversationId } = await request.json();

        const stream = chat({
          adapter: openaiText("gpt-5.2"),
          messages,
          conversationId,
        });

        return toServerSentEventsResponse(stream);
      },
    },
  },
});

Next.js의 Route Handler에서도 거의 같은 코드를 쓸 수 있습니다.

app/api/chat/route.ts
import { chat, toServerSentEventsResponse } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";

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

  const stream = chat({
    adapter: openaiText("gpt-5.2"),
    messages,
    conversationId,
  });

  return toServerSentEventsResponse(stream);
}

chat() 함수에 어댑터와 메시지를 넘기고, toServerSentEventsResponse()로 SSE(Server-Sent Events) 응답을 만드는 게 전부입니다. 프레임워크가 달라도 코어 로직은 동일하죠.

여기서 눈여겨볼 건 conversationId입니다. 같은 대화를 이어가려면 클라이언트가 이 ID를 유지해서 보내면 되는 거라 별도의 세션 관리 코드가 필요 없습니다.

React 클라이언트에서 채팅 연결

서버 엔드포인트가 준비되면 클라이언트에서는 useChat 훅으로 연결합니다. 메시지 상태 관리, 스트리밍 수신, 로딩 상태 추적을 이 훅이 전부 알아서 해줍니다.

components/Chat.tsx
import { useState } from "react";
import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";

export function Chat() {
  const [input, setInput] = useState("");

  const { messages, sendMessage, isLoading } = useChat({
    connection: fetchServerSentEvents("/api/chat"),
  });

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (input.trim() && !isLoading) {
      sendMessage(input);
      setInput("");
    }
  };

  return (
    <div>
      <div>
        {messages.map((message) => (
          <div key={message.id}>
            <strong>{message.role === "assistant" ? "AI:" : "나:"}</strong>
            {message.parts.map((part, idx) => {
              if (part.type === "thinking") {
                return (
                  <p key={idx} style={{ color: "gray", fontStyle: "italic" }}>
                    💭 {part.content}
                  </p>
                );
              }
              if (part.type === "text") {
                return <p key={idx}>{part.content}</p>;
              }
              return null;
            })}
          </div>
        ))}
      </div>

      <form onSubmit={handleSubmit}>
        <input
          value={input}
          onChange={(e) => setInput(e.target.value)}
          placeholder="메시지를 입력하세요..."
          disabled={isLoading}
        />
        <button type="submit" disabled={!input.trim() || isLoading}>
          보내기
        </button>
      </form>
    </div>
  );
}

useChat이 반환하는 것들을 살펴보면 messages는 사용자와 AI 간의 메시지 배열이고, sendMessage는 사용자의 메시지를 낙관적으로 추가한 뒤 서버로 전송하는 함수입니다. isLoading은 AI가 응답 중인지를 알려주고요.

메시지 안에 parts 배열이 있는 게 재미있는데요. 텍스트뿐 아니라 thinking(추론 과정)도 별도의 파트로 내려올 수 있어서 사용자에게 AI가 어떻게 생각하고 있는지 보여줄 수 있습니다.

connectionfetchServerSentEvents("/api/chat")를 넘기는 것도 깔끔합니다. SSE 연결을 추상화해서 트랜스포트 레이어를 교체하기 쉽게 만들어뒀거든요.

타입 안전한 도구 정의

LLM에게 외부 기능을 쥐여주는 “도구(tool)” 시스템이 TanStack AI에서 특히 잘 설계되어 있습니다. Zod 스키마로 입력을 정의하면 타입스크립트가 나머지를 다 잡아주거든요.

도구를 만드는 과정은 두 단계입니다. 먼저 도구의 정의를 작성하고, 그 다음에 서버 구현을 붙이는 거예요.

lib/tools.ts
import { toolDefinition } from "@tanstack/ai";
import { z } from "zod";

// 1단계: 도구 정의 (클라이언트와 서버 모두에서 사용)
export const searchInternetDef = toolDefinition({
  name: "search_internet",
  description: "인터넷에서 최신 정보를 검색합니다.",
  inputSchema: z.object({
    query: z.string().describe("검색어"),
    maxResults: z.number().optional().describe("최대 결과 수"),
  }),
});

// 2단계: 서버 구현 (서버에서만 실행)
export const searchInternet = searchInternetDef.server(
  async ({ query, maxResults = 5 }) => {
    const response = await fetch("https://api.tavily.com/search", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${process.env.TAVILY_API_KEY}`,
      },
      body: JSON.stringify({ query, max_results: maxResults }),
    });

    return await response.json();
  },
);

toolDefinition()으로 도구의 이름, 설명, 입력 스키마를 선언하고, .server()로 실제 실행 코드를 붙입니다. Zod 스키마 덕분에 querystring이고 maxResultsnumber | undefined라는 걸 TypeScript가 자동으로 알아요. 런타임에서도 입력값을 스키마에 따라 검증하니까 잘못된 인자가 들어올 걱정이 없습니다.

정의와 구현을 분리한 데는 이유가 있습니다. 정의(searchInternetDef)는 클라이언트에도 보내서 UI에서 도구 호출 상태를 표시할 수 있게 하지만, 실제 API 키를 쓰는 구현 코드는 서버에만 남기는 거죠.

서버 채팅에 도구를 연결하는 건 tools 배열에 넣는 것만으로 끝납니다.

const stream = chat({
  adapter: openaiText("gpt-5.2"),
  messages,
  tools: [searchInternet],
});

도구 호출이 동작하는 흐름

도구가 연결된 상태에서 사용자가 “현재 F1 챔피언이 누구야?”라고 물어보면 이런 일이 일어납니다.

먼저 클라이언트가 메시지를 서버로 전송합니다. 서버는 대화 내용과 도구 정의를 AI 프로바이더에 넘기죠. AI 모델은 자기 학습 데이터가 오래됐다는 걸 알고 search_internet 도구를 호출하기로 결정합니다. 서버가 이 호출을 가로채서 실제로 검색을 수행하고 결과를 가져와요. 검색 결과가 다시 AI 모델에 전달되면, 모델은 이 새로운 정보를 바탕으로 최종 답변을 생성합니다. 이 답변이 스트리밍으로 클라이언트에 전달되고요.

이 전체 과정을 TanStack AI에서는 “에이전틱 사이클(agentic cycle)“이라고 부릅니다. 모델이 도구를 호출하고, 결과를 받고, 다시 생각하고, 필요하면 또 다른 도구를 호출하는 반복 루프예요. 모델이 “이제 충분하다”고 판단할 때까지 이 사이클이 계속됩니다.

클라이언트 도구와 승인 플로우

도구가 서버에서만 돌아가는 건 아닙니다. TanStack AI는 클라이언트에서 실행되는 도구도 지원합니다.

브라우저의 위치 정보를 가져오거나, 로컬 스토리지를 읽거나, 브라우저 API를 호출해야 하는 경우에 유용합니다. 서버까지 왕복할 필요 없이 클라이언트에서 바로 처리할 수 있으니까요.

더 흥미로운 건 “승인 플로우(approval flow)“입니다. 도구 호출 전에 사용자의 확인을 받을 수 있어요. 결제를 실행하거나 데이터를 삭제하는 것처럼 되돌리기 어려운 작업에서 “정말 실행할까요?” 하고 물어보는 거죠. 이게 바로 “human-in-the-loop” 패턴인데, AI가 모든 걸 자동으로 처리하되 중요한 결정에서는 사람이 개입할 수 있게 해줍니다.

프로바이더별 타입 안전성

TanStack AI에서 인상적인 기능 하나가 모델별 타입 안전성입니다. 같은 chat() 함수를 쓰더라도 어댑터에 따라 사용할 수 있는 옵션이 달라지거든요. 그걸 TypeScript가 컴파일 타임에 잡아줍니다.

const stream = chat({
  adapter: openaiText("gpt-5.2"),
  messages,
  reasoning: {
    effort: "medium",
    summary: "detailed",
  },
});

위 코드에서 reasoning 옵션은 OpenAI 모델에서만 쓸 수 있는 건데, 이걸 Anthropic 어댑터로 바꾸면 TypeScript가 바로 에러를 보여줍니다. 런타임까지 가서 “이 옵션은 지원하지 않습니다” 에러를 보는 일이 없어지는 거죠.

Code Mode: 도구 호출의 한계를 넘다

TanStack AI에서 가장 독특한 기능은 Code Mode입니다. LLM이 도구를 하나씩 호출하는 대신, TypeScript 코드를 작성해서 여러 도구를 한 번에 조합하는 방식이에요.

왜 이런 게 필요할까요? LLM의 일반적인 도구 호출에는 세 가지 근본적인 한계가 있습니다.

우선 도구를 하나 호출할 때마다 서버 왕복이 발생해서 느립니다. 그리고 LLM은 토큰을 예측하는 거지 연산을 실행하는 게 아니라서 수학 계산에서 틀리는 경우가 잦습니다. 마지막으로 N+1 문제를 인식하지 못해서 50명의 사용자 프로필을 가져올 때 API를 50번 순차적으로 호출하는 비효율이 생기고요.

Code Mode는 이런 문제를 해결합니다. 모델에게 execute_typescript라는 도구를 하나 주고, 모델이 코드를 작성하면 격리된 샌드박스에서 실행하는 거예요.

설정
import {
  createCodeMode,
  createNodeIsolateDriver,
} from "@tanstack/ai-code-mode";

const { tool, systemPrompt } = createCodeMode({
  driver: createNodeIsolateDriver(),
  tools: [getTopProducts, getProductRatings],
  timeout: 30_000,
});

const result = await chat({
  adapter: openaiText("gpt-5.4"),
  systemPrompts: ["당신은 데이터 분석 도우미입니다.", systemPrompt],
  tools: [tool],
  messages,
});

이렇게 설정하면 LLM이 “상위 5개 상품의 평균 평점을 구해줘”라는 요청을 받았을 때 이런 코드를 작성합니다.

LLM이 생성한 코드
const top = await external_getTopProducts({ limit: 5 });

const ratings = await Promise.all(
  top.products.map((p) => external_getProductRatings({ productId: p.id })),
);

return top.products.map((product, i) => {
  const scores = ratings[i].ratings.map((r) => r.score);
  const avg = scores.reduce((sum, s) => sum + s, 0) / scores.length;
  return {
    name: product.name,
    sales: product.totalSales,
    averageRating: Math.round(avg * 100) / 100,
  };
});

도구 호출 한 번으로 API 5개를 Promise.all로 병렬 호출하고, 평균 계산은 JavaScript 엔진이 정확하게 처리합니다. 일반적인 도구 호출이었다면 왕복 6번에 LLM이 산술까지 직접 해야 했을 텐데, Code Mode에서는 왕복 1번에 연산도 정확합니다.

Code Mode 샌드박스 런타임

LLM이 작성한 코드를 그냥 실행하면 위험하겠죠. Code Mode는 격리된 샌드박스에서 코드를 실행하는데, 파일 시스템이나 네트워크에 접근할 수 없습니다. 기존에 정의해둔 도구만 external_ 접두어가 붙은 함수로 사용할 수 있어요.

런타임 드라이버는 세 가지가 있습니다.

  • @tanstack/ai-isolate-node — 서버사이드 Node.js 환경용. C++ 네이티브 모듈을 써서 가장 빠르지만 브라우저에서는 못 씁니다
  • @tanstack/ai-isolate-quickjs — WASM 기반이라 브라우저와 엣지 환경 어디서든 돌아갑니다. 네이티브 의존성이 없어서 설치도 간편합니다
  • @tanstack/ai-isolate-cloudflare — Cloudflare Workers의 V8 Isolate 위에서 실행됩니다

서버에서 돌린다면 node 드라이버가 성능면에서 가장 좋고, 범용성이 필요하면 quickjs가 낫습니다.

Skills: 코드를 재사용 가능한 도구로

Code Mode에는 Skills라는 옵션 기능이 있습니다. LLM이 작성한 코드 중 잘 동작하는 걸 저장해두고, 나중에 같은 패턴이 필요하면 코드를 다시 쓰지 않고 바로 도구처럼 불러오는 거예요.

재밌는 건 신뢰 시스템입니다. 스킬이 처음 등록되면 “시험 기간”을 거치는데, 기본 설정 기준으로 10번 이상 실행돼서 90% 이상 성공해야 신뢰 단계가 올라갑니다. 충분히 검증된 스킬은 100번 이상 실행에 95% 이상 성공률을 달성하면 완전히 신뢰받는 도구가 되고요.

AI가 만든 코드를 무턱대고 믿는 게 아니라, 실행 이력으로 검증한 뒤에야 자동 실행을 허용하는 설계입니다.

마치며

TanStack AI는 “어댑터를 바꾸면 프로바이더가 바뀐다”는 단순한 원칙 위에 상당히 풍성한 기능을 쌓아 올린 라이브러리입니다. 서버의 chat() 함수와 클라이언트의 useChat 훅으로 기본 채팅을 빠르게 만들 수 있고, Zod 기반 도구 시스템으로 LLM에게 실세계 기능을 안전하게 쥐여줄 수 있습니다. Code Mode는 도구 호출의 한계를 넘어서 LLM이 코드를 작성하고 실행하게 해주는 독특한 접근이고요.

현재 알파 단계라서 API가 바뀔 가능성은 있지만, TanStack QueryTanStack Router가 그랬듯이 안정화되면 상당한 존재감을 보여줄 것 같습니다. 특히 프로바이더 전환의 자유가 중요한 팀이라면 지금부터 눈여겨볼 만합니다.

더 자세한 내용은 TanStack AI 공식 문서를 참고하세요.

This work is licensed under CC BY 4.0 CC BY

개발자를 위한 뉴스레터

달레가 정리한 AI 개발 트렌드와 직접 만든 콘텐츠를 전해드립니다.

Discord