Skip to Content
WebTypeScriptTypeScript 실무13. zod로 API 스키마와 타입 단일화

이번 편의 결과물: 책 스키마가 zod 하나로 정의되고, 폼 검증 타입과 API 응답 타입이 같은 스키마에서 파생됩니다. · 다루는 개념: z.infer로 도출한 타입을 폼·API 타입에 그대로 사용, 스키마 하나로 검증과 타입을 통일

05편의 BookFormPage.tsxzodResolver로 입력값을 검증했고, 12편의 services/books.tsuseQuery·useMutation으로 책을 조회했습니다. 문제는 두 편의 타입이 따로 정의됐다는 것입니다. 폼 쪽은 zod 스키마에서 뽑은 타입을, API 쪽은 직접 쓴 interface Book을 써서 필드가 어긋나도 컴파일러가 못 잡습니다. 이 편에서 zod 스키마 하나로 합칩니다.

이 편에서 만드는 파일

bookshelf-ts/src/ ├── schemas/ │ └── book.ts (~, bookInputSchema·bookSchema로 재작성, 타입도 함께 export) ├── services/ │ └── books.ts (~, 수동 Book interface 제거, schemas/book의 타입과 파싱 재사용) └── routes/ └── BookFormPage.tsx (~, 폼 타입을 BookInput으로 교체)

개념 정리

지금까지의 문제

지금 services/books.ts에는 이런 식의 수동 타입이 있었습니다.

// (수정 전) services/books.ts 일부 interface Book { id: string title: string author: string pages: number }

schemas/book.ts의 폼 검증용 zod 스키마에서 뽑은 타입은 이 interface Book과 이름은 비슷해도 서로 다른 선언입니다. 서버가 필드를 빼먹거나 이름을 바꿔도 interface는 값을 검사하지 않으므로 런타임에야 드러납니다.

z.infer로 타입 도출

zod 스키마를 만들면 그 스키마가 통과시키는 값의 타입을 z.infer로 그대로 뽑아낼 수 있습니다. 스키마와 타입이 항상 같은 소스에서 나오므로 둘이 어긋날 수 없습니다.

const bookInputSchema = z.object({ title: z.string().min(1) }) type BookInput = z.infer<typeof bookInputSchema>

extend로 관련 스키마 파생

폼에서 받는 값(BookInput, id 없음)과 서버 응답(Book, id 있음)은 필드가 거의 같습니다. extend로 필드를 더한 새 스키마를 만들면 중복 선언 없이 관계를 표현합니다.

const bookSchema = bookInputSchema.extend({ id: z.string() })

parse로 API 응답을 실제로 검증

fetch 응답은 타입 단언 없이는 unknown입니다. schema.parse(data)를 쓰면 컴파일 타임 타입뿐 아니라 런타임에도 실제 응답이 스키마와 맞는지 확인하고, 어긋나면 그 자리에서 오류를 던집니다.

실습

1. 스키마 재작성

// bookshelf-ts/src/schemas/book.ts import { z } from 'zod' export const bookStatusValues = ['wish', 'reading', 'done'] as const export const bookInputSchema = z.object({ title: z.string().min(1, '제목을 입력하세요'), author: z.string().min(1, '저자를 입력하세요'), pages: z.coerce.number().int().positive('쪽수는 1 이상이어야 합니다'), status: z.enum(bookStatusValues), memo: z.string().optional().default(''), coverUrl: z.string().optional(), }) export const bookSchema = bookInputSchema.extend({ id: z.string(), }) export const bookListSchema = z.array(bookSchema) export type BookStatus = (typeof bookStatusValues)[number] export type BookInput = z.infer<typeof bookInputSchema> export type Book = z.infer<typeof bookSchema>

pagesz.coerce.number()를 쓴 이유는 input[type=number] 값도 문자열로 들어올 수 있어서입니다. 파싱 단계에서 숫자로 바꾸면 폼과 API 타입이 항상 일치합니다.

2. 서비스 계층을 스키마 기반으로 재작성

// bookshelf-ts/src/services/books.ts import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { bookListSchema, bookSchema, type Book, type BookInput } from '../schemas/book' const API_BASE = '/api/books' export const bookKeys = { all: ['books'] as const, detail: (id: string) => ['books', id] as const, } async function requestBook(url: string, init?: RequestInit): Promise<Book> { const response = await fetch(url, init) if (!response.ok) throw new Error('요청이 실패했습니다') return bookSchema.parse(await response.json()) } async function fetchBooks(): Promise<Book[]> { const response = await fetch(API_BASE) if (!response.ok) throw new Error('책 목록을 불러오지 못했습니다') return bookListSchema.parse(await response.json()) } async function fetchBook(id: string): Promise<Book> { return requestBook(`${API_BASE}/${id}`) } async function createBook(input: BookInput): Promise<Book> { return requestBook(API_BASE, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input), }) } async function updateBook(id: string, input: BookInput): Promise<Book> { return requestBook(`${API_BASE}/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input), }) } async function deleteBook(id: string): Promise<void> { await fetch(`${API_BASE}/${id}`, { method: 'DELETE' }) } export function useBooksQuery() { return useQuery({ queryKey: bookKeys.all, queryFn: fetchBooks }) } export function useBookQuery(id: string) { return useQuery({ queryKey: bookKeys.detail(id), queryFn: () => fetchBook(id) }) } export function useCreateBookMutation() { const queryClient = useQueryClient() return useMutation({ mutationFn: createBook, onSuccess: () => { queryClient.invalidateQueries({ queryKey: bookKeys.all }) }, }) } export function useUpdateBookMutation(id: string) { const queryClient = useQueryClient() return useMutation({ mutationFn: (input: BookInput) => updateBook(id, input), onSuccess: () => { queryClient.invalidateQueries({ queryKey: bookKeys.all }) queryClient.invalidateQueries({ queryKey: bookKeys.detail(id) }) }, }) } export function useDeleteBookMutation() { const queryClient = useQueryClient() return useMutation({ mutationFn: deleteBook, onSuccess: () => { queryClient.invalidateQueries({ queryKey: bookKeys.all }) }, }) }

interface Book이 사라지고 schemas/book.tsBook, BookInput만 남았습니다. useQuery·useMutation의 타입은 fetchBooks·createBook 등의 반환 타입에서 흘러와, 제네릭을 직접 쓴 곳이 없습니다.

3. 폼 페이지에서 같은 타입 재사용

// bookshelf-ts/src/routes/BookFormPage.tsx import { useNavigate, useParams } from 'react-router' import { useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { bookInputSchema, bookStatusValues, type BookInput } from '../schemas/book' import { useBookQuery, useCreateBookMutation, useUpdateBookMutation } from '../services/books' export default function BookFormPage() { const { bookId } = useParams() const navigate = useNavigate() const isEditMode = bookId !== undefined const bookQuery = useBookQuery(bookId ?? '') const createMutation = useCreateBookMutation() const updateMutation = useUpdateBookMutation(bookId ?? '') const { register, handleSubmit, formState: { errors, isSubmitting }, } = useForm<BookInput>({ resolver: zodResolver(bookInputSchema), values: isEditMode && bookQuery.data ? bookQuery.data : undefined, }) async function onSubmit(values: BookInput) { if (isEditMode) { await updateMutation.mutateAsync(values) } else { await createMutation.mutateAsync(values) } navigate('/books') } if (isEditMode && bookQuery.isLoading) { return <p>불러오는 중입니다...</p> } return ( <form onSubmit={handleSubmit(onSubmit)}> <label htmlFor="title">제목</label> <input id="title" {...register('title')} /> {errors.title && <p role="alert">{errors.title.message}</p>} <label htmlFor="author">저자</label> <input id="author" {...register('author')} /> {errors.author && <p role="alert">{errors.author.message}</p>} <label htmlFor="pages">쪽수</label> <input id="pages" type="number" {...register('pages')} /> {errors.pages && <p role="alert">{errors.pages.message}</p>} <label htmlFor="status">상태</label> <select id="status" {...register('status')}> {bookStatusValues.map((status) => ( <option key={status} value={status}> {status} </option> ))} </select> <label htmlFor="memo">메모</label> <textarea id="memo" {...register('memo')} /> <button type="submit" disabled={isSubmitting}> {isEditMode ? '수정 저장' : '등록'} </button> </form> ) }

useForm<BookInput>BookInputzodResolver(bookInputSchema)의 검증 대상이 같은 스키마에서 나와, 스키마 하나만 고치면 폼 타입과 검증 규칙이 함께 바뀝니다.

4. 실행

npm run dev

확인

  • 책 등록 폼에서 쪽수에 문자를 섞어 입력하면 pages 필드에 오류 메시지가 뜹니다.
  • 정상 값으로 등록하면 목록에 새 책이 나타나고, 상세 페이지에서 필드 값이 폼에 그대로 채워집니다.
  • 세 파일 어디에도 interface Book이 남아 있지 않고, bookInputSchema에 없는 필드 이름으로 register를 호출하면 타입 오류가 바로 표시됩니다.

직접 해보기

  1. bookSchema에 평점 필드 rating: z.number().min(0).max(5).optional()을 추가하고, BookFormPage.tsx에 입력 필드를 하나 더 붙여보세요.
  2. bookInputSchema.partial()updateBookInputSchema를 만들어, 상태만 바꾸는 수정처럼 일부 필드만 보내는 요청에 적용해보세요.

정답 보기

// schemas/book.ts (일부 변경) export const bookInputSchema = z.object({ // ...title, author, pages, status, memo, coverUrl은 그대로 rating: z.number().min(0).max(5).optional(), }) export const updateBookInputSchema = bookInputSchema.partial() export type UpdateBookInput = z.infer<typeof updateBookInputSchema>

partial()은 모든 필드를 선택적으로 만든 새 스키마를 반환합니다. 원본은 그대로 두고 별도 타입으로 파생시켜, 등록 폼과 부분 수정 요청이 각자 맞는 검증 강도를 가집니다.

자주 하는 실수

증상원인고치는 법
응답 필드가 바뀌었는데 빌드가 통과함interface만 선언하고 실제 파싱은 안 함schema.parse(data)로 응답을 검증한다
pages에 문자열을 넣으면 오류가 남z.number()만 쓰고 coerce를 빼먹음문자열로 들어올 수 있는 값은 z.coerce.number()를 쓴다
두 스키마의 필드가 슬금슬금 어긋남각각 처음부터 따로 작성함bookSchemabookInputSchema.extend(...)로 파생시킨다

확인 문제

문제 14지선다
이 편 이전 상태(interface Book과 별도 zod 스키마)의 문제점은
문제 24지선다
z.infer의 역할은
문제 34지선다
bookInputSchema.extend로 bookSchema를 만드는 이유는
문제 44지선다
fetchBooks에서 bookListSchema.parse(data)를 호출하는 이유는

참고 자료

Last updated on