이번 편의 결과물: 책 스키마가 zod 하나로 정의되고, 폼 검증 타입과 API 응답 타입이 같은 스키마에서 파생됩니다. · 다루는 개념: z.infer로 도출한 타입을 폼·API 타입에 그대로 사용, 스키마 하나로 검증과 타입을 통일
05편의 BookFormPage.tsx는 zodResolver로 입력값을 검증했고, 12편의 services/books.ts는 useQuery·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>pages에 z.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.ts의 Book, 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>의 BookInput과 zodResolver(bookInputSchema)의 검증 대상이 같은 스키마에서 나와, 스키마 하나만 고치면 폼 타입과 검증 규칙이 함께 바뀝니다.
4. 실행
npm run dev확인
- 책 등록 폼에서 쪽수에 문자를 섞어 입력하면
pages필드에 오류 메시지가 뜹니다. - 정상 값으로 등록하면 목록에 새 책이 나타나고, 상세 페이지에서 필드 값이 폼에 그대로 채워집니다.
- 세 파일 어디에도
interface Book이 남아 있지 않고,bookInputSchema에 없는 필드 이름으로register를 호출하면 타입 오류가 바로 표시됩니다.
직접 해보기
bookSchema에 평점 필드rating: z.number().min(0).max(5).optional()을 추가하고,BookFormPage.tsx에 입력 필드를 하나 더 붙여보세요.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()를 쓴다 |
| 두 스키마의 필드가 슬금슬금 어긋남 | 각각 처음부터 따로 작성함 | bookSchema를 bookInputSchema.extend(...)로 파생시킨다 |