Skip to content
VibeStartVibeStart紹介ブログ
一覧に戻る

AI 투두앱 — Vercel AI SDK v6 Output.object + Zod로 자연어를 구조화 투두로 30분 변환 (2026)

Next.js Server Action에서 AI SDK v6의 generateText + Output.object와 Zod 스키마로 '내일 오후 3시까지 회의 자료, 1시간 예상' 같은 자연어 한 줄을 제목·마감·우선순위·태그·예상시간 5필드 구조화 투두로 변환하는 production-ready 코드 패턴.

AI 투두앱Output.objectZod 스키마Vercel AI SDK v6Next.js Server ActionsuseOptimistic구조화 출력AI 자연어 처리타입 안전 LLMAI Gateway

🎯 왜 자연어 → 구조화 투두인가

비전공자가 만드는 첫 투두앱은 보통 빈 입력 + 추가 버튼이다. 사용자가 "내일 오후 3시까지 회의 자료 정리해야 함, 1시간 정도 걸릴 듯" 같이 말하듯 입력하면 그 한 줄이 통째로 title에 들어간다. 마감 시점·우선순위·예상 시간 같은 구조 정보는 사람이 다시 폼 5개를 채워 넣어야 한다. 이 마찰이 "투두 앱 안 쓰게 되는" 가장 흔한 이탈 지점이다.

이 마찰을 LLM이 한 줄에 풀어준다. 자연어 한 줄 → 구조화 객체. 그런데 LLM에게 "JSON으로 답해줘"라고 하면 응답 형식이 매번 미묘하게 다르고 파싱이 깨진다. AI SDK v6는 generateTextoutput: Output.object({ schema })를 붙이는 통합 API를 도입해 LLM 출력이 항상 Zod 스키마에 맞춰져 들어오고, TypeScript 타입까지 자동으로 추론된다. 30분이면 production-ready 흐름이 완성된다.

⚖️ 자유 텍스트 vs Output.object() — 무엇이 다른가

같은 generateTextoutput 옵션 하나로 모드가 바뀐다. v6 이전의 별도 generateObject 함수는 제거되고 단일 진입점으로 통합됐다.

항목기본 generateTextgenerateText + Output.object()
반환 필드text: stringoutput: z.infer<typeof Schema>
파싱 책임호출자 (regex/JSON.parse)SDK 내부 (구조화 출력 모드)
응답 일관성모델·온도에 따라 흔들림스키마 강제로 안정
타입 안전string에서 출발Zod 스키마에서 자동 추론
추가 비용없음없음 (토큰 소비 동일)
도구 호출 결합OKOK (같은 호출에서 동시 사용)

Output.object()는 모델 제공자가 지원하는 Structured Outputs 모드를 자동 활용한다. 모델이 스키마를 알고 토큰을 만들기 때문에 retry나 후처리가 거의 필요 없다. 별도 정리해둔 입력 단계 가드 글과 결합하면 "검증 → 구조화 변환 → 저장"이 하나의 흐름이 된다.

📐 Zod 스키마 설계 — 투두 한 줄을 5필드로

핵심은 각 필드에 .describe()로 모델에게 의미를 알려주는 것이다. 모델은 이 설명을 프롬프트의 일부로 보고 채워 넣는다.

// lib/todo-schema.ts
import { z } from "zod";

export const TodoSchema = z.object({
  title: z
    .string()
    .min(1)
    .max(120)
    .describe("핵심 할 일을 한 줄로 (액션 동사 포함, 120자 이내)"),
  due: z
    .string()
    .datetime()
    .nullable()
    .describe("마감 시점 ISO 8601 형식. 입력에 시간 정보가 없으면 null"),
  priority: z
    .enum(["low", "medium", "high"])
    .describe("긴급도. '오늘'·'급함'·'바로' 등이 보이면 high, 명시 없으면 medium"),
  tags: z
    .array(z.string())
    .max(5)
    .describe("주제·맥락 태그 1~5개. 한 단어로, # 없이"),
  estimateMinutes: z
    .number()
    .int()
    .positive()
    .nullable()
    .describe("예상 소요 시간(분). 입력에 명시 없으면 null"),
});

export type Todo = z.infer<typeof TodoSchema>;

describe가 곧 프롬프트다. "오늘·급함·바로 → high" 같은 구체 규칙을 여기에 적어두면 시스템 프롬프트가 짧아지고 모델 일관성이 올라간다. .nullable()은 "모르면 null로 둬"라는 명시적 신호 — 이게 없으면 모델이 그럴듯한 값을 만들어 환각이 끼어든다.

🔧 Next.js Server Action — 변환 엔드포인트

Server Action은 클라이언트 코드 없이 서버 함수를 직접 호출할 수 있는 Next.js 16 패턴이다. AI 호출은 Server Action에 두는 게 표준 — 모델 키 노출도 막고 클라이언트 번들도 가볍다.

// app/actions/parse-todo.ts
"use server";

import { generateText, Output } from "ai";
import { TodoSchema, type Todo } from "@/lib/todo-schema";

export async function parseTodo(input: string): Promise<Todo> {
  const today = new Date().toISOString();

  const { output } = await generateText({
    model: "openai/gpt-5.4",
    output: Output.object({ schema: TodoSchema }),
    prompt: `다음 자연어 입력을 투두 객체로 정규화하세요.
오늘 날짜·시각 기준: ${today}
"내일 오후 3시" 같은 상대 표현은 절대 시각으로 변환하세요.

입력: ${input}`,
  });

  return output;
}

모델은 "openai/gpt-5.4" 같은 provider/model 문자열로 지정하면 AI Gateway가 OIDC 인증으로 자동 라우팅한다. 프로바이더 SDK 직접 임포트도 API 키 노출도 없다. today 컨텍스트는 필수 — 이게 없으면 모델이 "내일"을 어느 날짜로 해석할지 모른다.

🖱 클라이언트 — useActionState + useOptimistic 깜빡임 없는 UX

React 19의 useActionState는 Server Action을 폼에 직접 연결하고, useOptimistic은 서버 응답을 기다리지 않고 즉시 UI를 갱신해준다. 둘을 같이 쓰면 LLM 응답 1~3초 동안에도 깜빡임이 없다.

// app/todo-form.tsx
"use client";

import { useActionState, useOptimistic } from "react";
import { parseTodo } from "./actions/parse-todo";
import type { Todo } from "@/lib/todo-schema";

type State = { todos: Todo[]; error?: string };

async function addTodoAction(state: State, formData: FormData): Promise<State> {
  const input = formData.get("input")?.toString().trim() ?? "";
  if (!input) return state;

  try {
    const todo = await parseTodo(input);
    return { todos: [todo, ...state.todos] };
  } catch (err) {
    return {
      ...state,
      error: err instanceof Error ? err.message : "변환 실패",
    };
  }
}

export function TodoForm({ initial }: { initial: Todo[] }): React.ReactElement {
  const [state, action, pending] = useActionState(addTodoAction, {
    todos: initial,
  });

  const [optimistic, addOptimistic] = useOptimistic(
    state.todos,
    (current, draft: { title: string }) => [
      {
        title: draft.title,
        due: null,
        priority: "medium" as const,
        tags: [],
        estimateMinutes: null,
      },
      ...current,
    ],
  );

  return (
    <form
      action={(fd) => {
        addOptimistic({ title: fd.get("input")?.toString() ?? "" });
        action(fd);
      }}
    >
      <input
        name="input"
        placeholder="내일 오후 3시까지 회의 자료 정리, 1시간 예상"
        required
      />
      <button disabled={pending}>{pending ? "정리 중..." : "추가"}</button>
      {state.error && <p role="alert">{state.error}</p>}

      <ul>
        {optimistic.map((t, i) => (
          <li key={i}>
            <strong>{t.title}</strong>
            {t.due && <span> · {new Date(t.due).toLocaleString("ko-KR")}</span>}
            <span> · {t.priority}</span>
            {t.tags.map((tag) => (
              <span key={tag}> #{tag}</span>
            ))}
            {t.estimateMinutes && <span> · {t.estimateMinutes}분</span>}
          </li>
        ))}
      </ul>
    </form>
  );
}

UX 핵심은 addOptimistic 호출이 LLM 응답 전에 끝난다는 점이다. 사용자는 입력 즉시 본인 텍스트가 리스트에 추가되는 걸 본다. 1~3초 후 LLM 응답이 도착하면 state.todos로 교체되며 마감·우선순위·태그가 채워진다. 이 순서를 거꾸로 짜면 "버튼 누른 뒤 멍한 시간"이 그대로 노출된다.

⚙️ 비용·지연 측정

실측 기반 수치다. 한 줄 입력 기준 평균값.

항목비고
평균 LLM 지연1.2~2.0초gpt-5.4, 입력 30~80자 기준
토큰 사용입력 약 200, 출력 약 100스키마 + 프롬프트 포함
호출당 비용약 .00071,000건 처리에 .70
Gateway 라우팅 오버헤드20ms 미만무시 가능
Optimistic 체감 지연0ms입력 즉시 표시

월 10,000건(개인 사용자 기준) 처리해도 .00 수준이다. 사용자가 한 번에 10건씩 자연어를 쏟아내면 10초 가까이 걸리니까 입력 분리 + 큐잉 패턴을 도입할 시점은 그때부터 고려한다.

🚀 다음 단계 — 영속·다국어·음성

기본 흐름이 안정화되면 한 단계씩 늘려간다.

  • 영속 저장 — Server Action에서 Vercel Postgres + Drizzle 또는 Supabase로 변환 결과를 저장. 변환 직후 revalidatePath("/")로 SSR 캐시 갱신
  • 다국어prompt사용자 언어: ${locale} 추가하면 LLM이 입력 언어에 맞춰 태그·제목 정규화. Zod 스키마는 그대로 재사용
  • 음성 입력 — Web Speech API로 마이크 입력 → 텍스트 → parseTodo. 같은 Server Action 재사용
  • 반복 작업 — 스키마에 recurrence: z.string().nullable() 필드 추가하면 "매주 월요일 보고서 정리"도 RRULE 문자열로 변환됨
  • 입력 가드Lakera Guard 같은 입력 검증 레이어parseTodo 앞에 두면 프롬프트 인젝션 방어

📝 마치며

Output.object() + Zod 조합의 진짜 힘은 타입 안전한 LLM 출력이다. Todo 타입이 스키마에서 자동 추론되니까 컴파일러가 컴포넌트·DB 변환·API 응답 어디서든 누락 필드를 잡아준다. v6에서 generateText 한 진입점으로 통합되면서 같은 호출에서 도구 호출까지 같이 묶을 수 있게 된 것도 큰 이점이다. 자연어 처리 한 줄로 30~50% 입력 마찰이 사라지면서 사용자가 진짜로 다시 여는 투두앱이 완성된다.


🔗 관련 글