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 코드 패턴.
🎯 왜 자연어 → 구조화 투두인가
비전공자가 만드는 첫 투두앱은 보통 빈 입력 + 추가 버튼이다. 사용자가 "내일 오후 3시까지 회의 자료 정리해야 함, 1시간 정도 걸릴 듯" 같이 말하듯 입력하면 그 한 줄이 통째로 title에 들어간다. 마감 시점·우선순위·예상 시간 같은 구조 정보는 사람이 다시 폼 5개를 채워 넣어야 한다. 이 마찰이 "투두 앱 안 쓰게 되는" 가장 흔한 이탈 지점이다.
이 마찰을 LLM이 한 줄에 풀어준다. 자연어 한 줄 → 구조화 객체. 그런데 LLM에게 "JSON으로 답해줘"라고 하면 응답 형식이 매번 미묘하게 다르고 파싱이 깨진다. AI SDK v6는 generateText에 output: Output.object({ schema })를 붙이는 통합 API를 도입해 LLM 출력이 항상 Zod 스키마에 맞춰져 들어오고, TypeScript 타입까지 자동으로 추론된다. 30분이면 production-ready 흐름이 완성된다.
⚖️ 자유 텍스트 vs Output.object() — 무엇이 다른가
같은 generateText에 output 옵션 하나로 모드가 바뀐다. v6 이전의 별도 generateObject 함수는 제거되고 단일 진입점으로 통합됐다.
| 항목 | 기본 generateText | generateText + Output.object() |
|---|---|---|
| 반환 필드 | text: string | output: z.infer<typeof Schema> |
| 파싱 책임 | 호출자 (regex/JSON.parse) | SDK 내부 (구조화 출력 모드) |
| 응답 일관성 | 모델·온도에 따라 흔들림 | 스키마 강제로 안정 |
| 타입 안전 | string에서 출발 | Zod 스키마에서 자동 추론 |
| 추가 비용 | 없음 | 없음 (토큰 소비 동일) |
| 도구 호출 결합 | OK | OK (같은 호출에서 동시 사용) |
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 | 스키마 + 프롬프트 포함 |
| 호출당 비용 | 약 .0007 | 1,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% 입력 마찰이 사라지면서 사용자가 진짜로 다시 여는 투두앱이 완성된다.