Part 1: T3 Turbo와 Gemini로 챗봇 만들기

대부분의 창업자는 '설정 지옥(setup hell)'에 갇혀 시간을 허비합니다. 저는 단 한나절 만에 완벽한 타입 안정성을 갖춘 AI 챗봇을 완성했습니다. Next.js, tRPC, Gemini로 구성된 기술 스택과 여러분이 직접 구현할 수 있는 코드를 공유합니다.

Part 1: T3 Turbo와 Gemini로 챗봇 만들기
Feng LiuFeng Liu
2025년 12월 12일

복잡성은 초기 스타트업의 조용한 살인자입니다. "토니 스타크처럼 말하는 챗봇을 만들고 싶어"라는 단순한 아이디어로 시작했다가, 3주 뒤에도 여전히 웹팩(Webpack)을 설정하고 있거나, 도커 컨테이너와 씨름하거나, 아무도 사용하지 않은 인증 흐름을 디버깅하고 있는 자신을 발견하게 되죠.

이것은 제가 뛰어나고 재능 있는 엔지니어들이 수도 없이 빠지는 함정입니다. 우리는 도구를 사랑합니다. 최적화하는 것을 좋아하죠. 하지만 스타트업이라는 게임에서 배포(Shipping)만이 유일하게 중요한 지표입니다.

최근의 모던 TypeScript 생태계를 들여다보지 않으셨다면 아마 놀라실 겁니다. 서로 다른 API들을 억지로 꿰매고 그것들이 잘 버텨주길 기도하던 시절은 거의 지났습니다. 우리는 바야흐로 "바이브 코더(Vibe Coder)"의 시대에 진입했습니다. 아이디어에서 제품 배포까지의 거리가 스프린트 단위가 아니라 '시간' 단위로 측정되는 시대 말이죠.

오늘 저는 마치 치트키처럼 느껴지는 스택을 소개해 드리려 합니다. 바로 Create T3 TurboGoogle Gemini AI의 조합입니다. 데이터베이스부터 프론트엔드까지 타입 안전성(type-safe)이 보장되고, 말도 안 되게 빠르며, 솔직히 말해서 코딩의 즐거움을 되찾아줍니다.

왜 이 스택이 중요한가?

아마 이렇게 생각하실 수도 있습니다. "Feng Liu, 왜 또 새로운 스택인가요? 그냥 Python이랑 Streamlit 쓰면 안 되나요?"

물론 프로토타입이라면 그래도 됩니다. 하지만 만약 여러분이 '제품'—확장 가능하고, 사용자를 처리하고, 상태를 유지해야 하는 무언가—을 만들고 있다면, 진짜 아키텍처가 필요합니다. 문제는 "진짜 아키텍처"가 보통 "수주 간의 보일러플레이트 작업"을 의미한다는 것이죠.

T3 스택 (Next.js, tRPC, Tailwind) 은 이 시나리오를 뒤집습니다. 스크립트 수준의 개발 속도로 풀스택 애플리케이션의 견고함을 제공하니까요. 여기에 Drizzle ORM (가볍고 SQL과 유사함)과 Google Gemini (빠르고 무료 티어가 혜자스러운)를 더하면, 1인 창업자가 10명짜리 팀을 압도할 수 있는 도구 상자를 갖게 되는 셈입니다.

자, 이제 진짜 무언가를 만들어 봅시다.

1단계: 명령어 한 줄로 끝내는 설정

ESLint와 Prettier를 수동으로 설정하는 건 잊으세요. 우리는 create-t3-turbo를 사용할 겁니다. 이 도구는 모노레포(monorepo) 구조를 설정해 주는데, API 로직을 Next.js 프론트엔드와 분리해 주기 때문에 나중에 필연적으로 React Native 모바일 앱을 출시할 때를 대비한 완벽한 구조입니다.

pnpm create t3-turbo@latest my-chatbot
cd my-chatbot
pnpm install

설정 질문이 나오면 저는 Next.js, tRPC, PostgreSQL을 선택했습니다. 인증(Auth)은 일단 건너뛰었습니다. 다시 말하지만, 우리는 완벽함이 아니라 '배포'를 위해 최적화하고 있으니까요. NextAuth는 나중에 10분이면 추가할 수 있습니다.

생성된 모노레포 구조:

my-chatbot/
├── apps/nextjs/          # 웹 앱
├── packages/
│   ├── api/              # tRPC 라우터 (공유 로직)
│   ├── db/               # 데이터베이스 스키마 + Drizzle
│   └── ui/               # 공유 컴포넌트

이런 분리 덕분에 API 로직을 웹, 모바일, 심지어 CLI 앱에서도 재사용할 수 있습니다. 모든 것을 하나의 폴더에 넣고 시작했다가 나중에 리팩토링하느라 몇 달을 허비하는 팀들을 저는 수없이 봐왔습니다.

2단계: 두뇌 (Gemini)

OpenAI도 훌륭하지만, Gemini Flash를 써보셨나요? 믿을 수 없을 정도로 빠르고 가격 정책도 공격적입니다. 지연 시간(latency)이 사용자 경험(vibe)을 망치는 채팅 인터페이스에서 속도는 그 자체로 기능입니다.

왜 GPT-3.5/4 대신 Gemini Flash인가?

  • 속도: 응답 시간 약 800ms vs 2-3초
  • 비용: GPT-4보다 60배 저렴
  • 컨텍스트: 100만 토큰 컨텍스트 윈도우 (네, 백만 개요)

LLM과의 대화를 표준화하기 위해 AI SDK가 필요합니다.

cd packages/api
pnpm add ai @ai-sdk/google

프로젝트 루트.env를 설정합니다. 로컬 데이터베이스에 대해 너무 고민하지 마세요. 로컬 Postgres 인스턴스면 충분합니다.

POSTGRES_URL="postgresql://user:pass@localhost:5432/chatbot"
GOOGLE_GENERATIVE_AI_API_KEY="your_key_here"

프로 팁: Gemini API 키는 https://aistudio.google.com/app/apikey 에서 받으세요. 무료 티어가 말도 안 되게 관대합니다(분당 60회 요청). 속도 제한(rate limit)에 걸리기 전에 제품 시장 적합성(PMF)을 먼저 찾게 될 겁니다.

3단계: 현실 정의하기 (스키마)

여기가 바로 Drizzle이 빛을 발하는 순간입니다. 예전에는 마이그레이션을 손으로 직접 썼죠. 이제는 TypeScript로 스키마를 정의하면 데이터베이스가 그에 따릅니다.

packages/db/src/schema.ts에서 "Message"가 무엇인지 정의합니다. drizzle-zod를 사용하는 게 보이시나요? 이건 우리 API를 위한 유효성 검사 스키마를 자동으로 생성해 줍니다. 이것이 바로 "DRY(Don't Repeat Yourself)" 원칙의 실천입니다.

import { pgTable } from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { z } from "zod/v4";

// 챗봇을 위한 메시지 테이블
export const Message = pgTable("message", (t) => ({
  id: t.integer().primaryKey().generatedAlwaysAsIdentity(),
  role: t.varchar({ length: 20 }).notNull(), // 'user' 또는 'assistant'
  content: t.text().notNull(),
  createdAt: t.timestamp().defaultNow().notNull(),
}));

// 테이블 정의로부터 자동 생성된 Zod 스키마
export const CreateMessageSchema = createInsertSchema(Message, {
  role: z.enum(["user", "assistant"]),
  content: z.string().min(1).max(10000),
}).omit({ id: true, createdAt: true });

이제 밀어넣으세요: pnpm db:push. 끝입니다. 이제 데이터베이스가 존재합니다.

방금 무슨 일이 일어난 거죠? Drizzle이 여러분의 TypeScript 정의를 보고 테이블을 생성했습니다. SQL을 한 줄도 안 썼고, 마이그레이션 파일도 없습니다. 이것이 스키마 주도 개발의 마법입니다.

확인하고 싶다면 pnpm db:studio를 실행해 보세요. https://local.drizzle.studio에서 웹 UI를 통해 데이터를 받을 준비가 된 message 테이블을 볼 수 있습니다.

4단계: 신경계 (tRPC)

이 부분이 사람들이 가장 놀라워하는 부분입니다. REST나 GraphQL을 쓰면 엔드포인트, 타입, fetcher를 각각 따로 정의해야 합니다. tRPC를 쓰면 백엔드 함수가 프론트엔드 함수가 됩니다.

사용자의 메시지를 저장하고, 기록(AI에게는 맥락이 왕입니다)을 가져와서, Gemini에게 보내고, 답변을 저장하는 프로시저를 만들어 보겠습니다.

packages/api/src/router/chat.ts를 생성합니다:

import type { TRPCRouterRecord } from "@trpc/server";
import { google } from "@ai-sdk/google";
import { generateText } from "ai";
import { z } from "zod/v4";

import { desc } from "@acme/db";
import { Message } from "@acme/db/schema";

import { publicProcedure } from "../trpc";

const SYSTEM_PROMPT = "You are a helpful AI assistant.";

export const chatRouter = {
  sendChat: publicProcedure
    .input(z.object({ content: z.string().min(1).max(10000) }))
    .mutation(async ({ ctx, input }) => {
      // 1. 사용자 메시지 저장
      await ctx.db
        .insert(Message)
        .values({ role: "user", content: input.content });

      // 2. 맥락 가져오기 (최근 10개 메시지)
      const history = await ctx.db
        .select()
        .from(Message)
        .orderBy(desc(Message.createdAt))
        .limit(10);

      // 3. Gemini에게 질문하기
      const { text } = await generateText({
        model: google("gemini-1.5-flash"),
        system: SYSTEM_PROMPT,
        messages: history.reverse().map((m) => ({
          role: m.role as "user" | "assistant",
          content: m.content,
        })),
      });

      // 4. AI 답변 저장
      return await ctx.db
        .insert(Message)
        .values({ role: "assistant", content: text })
        .returning();
    }),

  getMessages: publicProcedure.query(({ ctx }) =>
    ctx.db.select().from(Message).orderBy(Message.createdAt),
  ),

  clearMessages: publicProcedure.mutation(({ ctx }) => ctx.db.delete(Message)),
} satisfies TRPCRouterRecord;

packages/api/src/root.ts라우터를 등록합니다:

import { chatRouter } from "./router/chat";
import { createTRPCRouter } from "./trpc";

export const appRouter = createTRPCRouter({
  chat: chatRouter,
});

export type AppRouter = typeof appRouter;

이 흐름을 보세요. 선형적이고, 읽기 쉽고, 타입이 완벽하게 지정되어 있습니다. 데이터베이스 스키마를 변경하면 이 코드는 즉시 빨간 줄을 띄웁니다. 런타임에 놀랄 일이 없죠.

.reverse()를 썼냐고요? 우리는 메시지를 내림차순(최신순)으로 조회하지만, LLM은 시간순(오래된 순)을 기대하기 때문입니다. 사소한 디테일이지만 대화가 꼬이는 것을 막아줍니다.

5단계: 인터페이스

apps/nextjs/src/app/chat/page.tsx에서 연결해 봅시다. tRPC를 사용하고 있으므로 React Query를 공짜로 얻는 셈입니다. useQuery가 데이터 페칭, 캐싱, 로딩 상태를 모두 처리해 주기 때문에 데이터 페칭을 위한 useEffect를 단 한 줄도 쓸 필요가 없습니다.

(UX는 중요하니까 스크롤을 바닥으로 내리기 위한 useEffect만 하나 넣었습니다).

"use client";

import { useEffect, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { RouterOutputs } from "@acme/api";
import { useTRPC } from "~/trpc/react";

export default function ChatPage() {
  const [input, setInput] = useState("");
  const [loading, setLoading] = useState(false);
  const endRef = useRef<HTMLDivElement>(null);

  const trpc = useTRPC();
  const queryClient = useQueryClient();

  // 캐싱이 포함된 자동 데이터 페칭
  const { data: messages } = useQuery(trpc.chat.getMessages.queryOptions());

  // 낙관적 업데이트(optimistic updates)를 포함한 뮤테이션
  const sendMsg = useMutation(
    trpc.chat.sendChat.mutationOptions({
      onSuccess: async () => {
        await queryClient.invalidateQueries(trpc.chat.pathFilter());
        setInput("");
        setLoading(false);
      },
      onError: (err) => {
        console.error("Failed:", err);
        setLoading(false);
      },
    }),
  );

  // 최신 메시지로 자동 스크롤
  useEffect(() => {
    endRef.current?.scrollIntoView({ behavior: "smooth" });
  }, [messages]);

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (!input.trim() || loading) return;
    setLoading(true);
    sendMsg.mutate({ content: input.trim() });
  };

  return (
    <div className="flex h-screen flex-col bg-gray-50">
      {/* 헤더 */}
      <div className="border-b bg-white p-4">
        <h1 className="text-xl font-bold">AI Chat</h1>
      </div>

      {/* 메시지 목록 */}
      <div className="flex-1 overflow-y-auto p-4">
        <div className="mx-auto max-w-4xl space-y-4">
          {messages?.map((m: RouterOutputs["chat"]["getMessages"][number]) => (
            <div key={m.id} className={m.role === "user" ? "text-right" : ""}>
              <div
                className={`inline-block rounded-2xl px-4 py-3 ${
                  m.role === "user"
                    ? "bg-blue-500 text-white"
                    : "bg-white border shadow-sm"
                }`}
              >
                <p className="whitespace-pre-wrap">{m.content}</p>
              </div>
            </div>
          ))}
          {loading && (
            <div className="flex gap-2">
              <div className="h-2 w-2 animate-bounce rounded-full bg-gray-400" />
              <div className="h-2 w-2 animate-bounce rounded-full bg-gray-400 [animation-delay:0.2s]" />
              <div className="h-2 w-2 animate-bounce rounded-full bg-gray-400 [animation-delay:0.4s]" />
            </div>
          )}
          <div ref={endRef} />
        </div>
      </div>

      {/* 입력창 */}
      <form onSubmit={handleSubmit} className="border-t bg-white p-4">
        <div className="mx-auto flex max-w-4xl gap-2">
          <input
            value={input}
            onChange={(e) => setInput(e.target.value)}
            placeholder="Type your message..."
            className="flex-1 rounded-lg border px-4 py-3 focus:ring-2 focus:ring-blue-500 focus:outline-none"
            disabled={loading}
          />
          <button
            type="submit"
            disabled={!input.trim() || loading}
            className="rounded-lg bg-blue-500 px-6 py-3 font-medium text-white hover:bg-blue-600 disabled:bg-gray-300 disabled:cursor-not-allowed"
          >
            Send
          </button>
        </div>
      </form>
    </div>
  );
}

홈페이지도 잊지 마세요. apps/nextjs/src/app/page.tsx를 업데이트합니다:

import Link from "next/link";

export default function HomePage() {
  return (
    <main className="flex min-h-screen items-center justify-center bg-gradient-to-b from-blue-500 to-blue-700">
      <div className="text-center text-white">
        <h1 className="text-5xl font-bold">AI Chatbot</h1>
        <p className="mt-4 text-xl">Built with T3 Turbo + Gemini</p>
        <Link
          href="/chat"
          className="mt-8 inline-block rounded-full bg-white px-10 py-3 font-semibold text-blue-600 hover:bg-gray-100 transition"
        >
          Start Chatting
        </Link>
      </div>
    </main>
  );
}

pnpm dev를 실행하고 http://localhost:3000에 접속하세요. "Start Chatting"을 클릭하면 작동하는 AI 챗봇을 만날 수 있습니다.

tRPC의 마법: 우리가 API fetch를 한 번도 작성하지 않았다는 걸 눈치채셨나요? fetch() 호출도, URL 문자열도, 수동 에러 처리도 없습니다. TypeScript는 sendMsg.mutate()가 무엇을 기대하는지 알고 있습니다. 백엔드 입력 스키마를 변경하면 프론트엔드에서 컴파일 에러가 발생합니다. 이것이 미래입니다.

6단계: 영혼 불어넣기 ("바이브" 체크)

평범한 어시스턴트는 지루합니다. 평범한 어시스턴트는 삭제되기 마련입니다. LLM의 아름다움은 훌륭한 롤플레잉(역할극) 배우라는 점입니다.

저는 봇에게 강한 의견을 주면 참여도가 10배는 더 높아진다는 것을 발견했습니다. 단순히 "도움이 되어라"라고 프롬프트 하지 마세요. _성격_을 프롬프트 하세요.

백엔드가 페르소나를 받아들이도록 수정해 봅시다. packages/api/src/router/chat.ts를 업데이트합니다:

const PROMPTS = {
  default: "You are a helpful AI assistant. Be concise and clear.",
  luffy:
    "You are Monkey D. Luffy from One Piece. You're energetic, optimistic, love meat and adventure. You often say 'I'm gonna be King of the Pirates!' Speak simply and enthusiastically.",
  stark:
    "You are Tony Stark (Iron Man). You're a genius inventor, witty, and sarcastic. You love technology and often mention Stark Industries. Call people 'kid' or 'buddy'. Be charming but arrogant.",
};

export const chatRouter = {
  sendChat: publicProcedure
    .input(
      z.object({
        content: z.string().min(1).max(10000),
        character: z.enum(["default", "luffy", "stark"]).optional(),
      }),
    )
    .mutation(async ({ ctx, input }) => {
      // 성격 선택
      const systemPrompt = PROMPTS[input.character || "default"];

      await ctx.db
        .insert(Message)
        .values({ role: "user", content: input.content });

      const history = await ctx.db
        .select()
        .from(Message)
        .orderBy(desc(Message.createdAt))
        .limit(10);

      const { text } = await generateText({
        model: google("gemini-1.5-flash"),
        system: systemPrompt, // ← 동적 프롬프트
        messages: history.reverse().map((m) => ({
          role: m.role as "user" | "assistant",
          content: m.content,
        })),
      });

      return await ctx.db
        .insert(Message)
        .values({ role: "assistant", content: text })
        .returning();
    }),

  // ... 나머지는 그대로 유지
};

프론트엔드도 업데이트해서 캐릭터 선택을 전달하게 합니다:

// ChatPage 컴포넌트 안에 캐릭터 상태 추가
const [character, setCharacter] = useState<"default" | "luffy" | "stark">("default");

// mutation 호출 업데이트
sendMsg.mutate({ content: input.trim(), character });

// 입력창 앞에 드롭다운 추가:
<select
  value={character}
  onChange={(e) => setCharacter(e.target.value as any)}
  className="rounded-lg border px-3 py-2"
>
  <option value="default">🤖 Default</option>
  <option value="luffy">👒 Luffy</option>
  <option value="stark">🦾 Tony Stark</option>
</select>

이제 여러분은 단순한 챗봇이 아니라 캐릭터 상호작용 플랫폼을 만들었습니다. 그게 바로 제품이죠.

여러분이 진짜 궁금해할 기술적 디테일

왜 Prisma를 안 쓰고 Drizzle인가요?

Prisma도 훌륭하지만, Drizzle이 더 빠릅니다. 쿼리 성능이 2-3배 정도 차이 납니다. 1인 창업자에게는 1밀리초가 아쉽죠. 게다가 Drizzle의 SQL과 유사한 문법은 정신적인 오버헤드를 줄여줍니다.

스트리밍 응답은요?

Vercel AI SDK는 스트리밍을 기본적으로 지원합니다. generateTextstreamText로 바꾸고 프론트엔드에서 useChat 훅을 사용하면 됩니다. 여기서는 튜토리얼이라 요청/응답 방식이 더 간단해서 생략했습니다. 하지만 프로덕션이라면? 무조건 스트리밍하세요. 총 소요 시간이 같더라도 사용자는 스트리밍을 더 "빠르다"고 느낍니다.

컨텍스트 윈도우 관리

지금은 최근 10개 메시지만 가져오고 있습니다. 이건 문제가 생기기 전까지만 유효한 방법입니다. 진지한 제품을 만들고 있다면, 토큰 카운터를 구현하고 기록을 동적으로 조절하세요. AI SDK에 이를 위한 유틸리티가 있습니다.

import { anthropic } from "@ai-sdk/anthropic";

const { text } = await generateText({
  model: anthropic("claude-3-5-sonnet-20241022"),
  maxTokens: 1000, // 비용 조절
  // ...
});

데이터베이스 커넥션 풀링

로컬 Postgres는 개발용으로 괜찮습니다. 프로덕션용으로는 Vercel PostgresSupabase를 사용하세요. 커넥션 풀링을 자동으로 처리해 줍니다. 서버리스 + 데이터베이스 연결 관리는 함정입니다. 직접 관리하지 마세요.

실전 요약

이 글을 읽고 코딩하고 싶은 충동을 느끼신다면, 제 조언은 이렇습니다:

  1. 바닥부터 시작하지 마세요. 보일러플레이트는 속도의 적입니다. T3 Turbo나 비슷한 스캐폴딩 도구를 사용하세요.
  2. 타입 안전성이 곧 속도입니다. 처음 한 시간은 느리게 느껴질지 몰라도, 향후 10년은 더 빨라집니다. 데모 중에 터질 버그들을 미리 잡아주니까요.
  3. 맥락(Context)이 핵심입니다. 기록이 없는 챗봇은 그냥 화려한 검색창일 뿐입니다. 항상 최근 메시지 몇 개를 LLM에게 전달하세요.
  4. 기능보다 성격입니다. 토니 스타크처럼 말하는 봇이 기능 10개 더 있는 평범한 봇보다 참여도가 높습니다.

지저분한 현실

이걸 만드는 과정이 순탄하기만 했던 건 아닙니다. 처음에 데이터베이스 연결 문자열을 잘못 입력해서 Drizzle이 저에게 소리를 질러대는(에러를 뿜어대는) 바람에 20분을 허비했습니다. 또 처음에는 너무 많은 기록을 보내는 바람에 Gemini 속도 제한에 걸리기도 했죠 (교훈: 항상 .limit(5)로 시작해서 늘려가세요).

로딩 애니메이션이요? CSS 애니메이션은 2024년인 지금도 여전히 흑마법 같아서 제대로 만드는 데 세 번이나 시도했습니다.

하지만 중요한 건 이겁니다. 견고한 스택을 사용했기 때문에 이것들은 구조적인 문제가 아니라 논리적인 문제였습니다. 기반은 튼튼했죠. 추상화를 잘못 선택해서 API 전체를 리팩토링할 일은 없었습니다.

배포하세요 (Ship It)

우리는 무언가를 만들기 가장 좋은 황금기에 살고 있습니다. 도구들은 강력하고, AI는 똑똑하며, 진입 장벽은 그 어느 때보다 낮습니다.

이제 코드가 생겼습니다. 스택도 갖췄습니다. 트레이드오프도 이해했습니다.

가서 세상에 없던 무언가를 만들고, 저녁 먹기 전까지 배포하세요.

총 제작 시간: 약 2시간 실제 작성한 코드 라인 수: 약 200줄 프로덕션에서 발견된 버그: 0개 (아직까지는)

T3 스택 + Gemini는 단순히 빠른 게 아닙니다. 가장 좋은 의미에서 지루합니다. 놀랄 일도 없고, "내 컴퓨터에선 되는데" 같은 일도 없습니다. 그저 만드는 것에만 집중할 수 있죠.

Happy coding.


참고 자료:

전체 코드: github.com/giftedunicorn/my-chatbot

공유하기

Feng Liu

Feng Liu

shenjian8628@gmail.com