Skip to Content
WebTypeScriptTypeScript 중급14. zod 폼 스키마로 입력 검증 다시 만들기

이번 편의 결과물: 거래 입력 폼이 TransactionFormSchema로 검증되고, 필드별 에러 메시지가 화면에 표시됩니다. · 다루는 개념: refine/superRefine, 스키마 합성·재사용, 폼 에러 메시지와 05편 FormErrors 매핑 타입 연결

이 편에서 만드는 파일

expense-tracker/src/ ├── schemas/ │ └── transactionForm.ts + (TransactionFormSchema, toFormErrors) └── ui/ └── transactionForm.ts ~ (제출 검증을 zod safeParse로 교체, 에러 메시지 표시)

지금까지 ui/transactionForm.tshandleCreateSubmitFormData에서 값을 꺼내 바로 handlers.onCreate에 넘겼습니다. 값이 비어 있거나 형식이 틀려도 제출 시점에는 아무 표시가 없었습니다. 이 편에서는 13편에서 만든 스키마를 재사용해 폼 전용 스키마를 만들고, 실패하면 필드 옆에 에러를 보여줍니다.

개념 정리

refine과 superRefine

함수쓰임
.refine(검사함수, 메시지)스키마 전체 값 하나를 보고 참/거짓 하나만 판단할 때
.superRefine((값, ctx) => ...)여러 필드를 함께 보거나, 필드별로 서로 다른 오류를 여러 개 등록해야 할 때

refine은 간단합니다.

const passwordSchema = z.string().refine((value) => value.length >= 8, { error: '비밀번호는 8자 이상이어야 합니다', })

이 폼은 “금액이 크면 메모가 있어야 한다”처럼 필드 두 개를 함께 봐야 하는 규칙이 있어 superRefine을 씁니다. superRefine 안에서는 ctx.addIssue로 오류가 발생한 필드 경로(path)와 메시지를 직접 지정합니다.

스키마 합성 — 13편 스키마 재사용

TransactionFormSchema를 처음부터 새로 쓰지 않고, 13편의 ExpenseTransactionSchema/IncomeTransactionSchema에서 id를 뺀 뒤(omit) 폼에서 문자열로 들어오는 amount만 숫자로 바꾸도록(extend) 고쳐 씁니다. 같은 카테고리 제약(z.enum)을 다시 선언하지 않아도 됩니다.

FormErrors와 zod 에러 연결

05편에서 이미 이런 매핑 타입을 만들어 뒀습니다.

// src/lib/types/utility.ts (05편에서 이미 작성 — 재사용만 합니다) export type FormErrors<T> = { [K in keyof T as `${string & K}Error`]?: string }

zod의 검증 실패 결과(ZodError)는 z.flattenError로 필드 이름별 메시지 배열(fieldErrors)을 얻을 수 있습니다. 이 배열의 첫 메시지를 FormErrors가 기대하는 필드명Error 키에 옮겨 담으면 두 타입이 이어집니다.

실습

1. 폼 스키마 작성

// src/schemas/transactionForm.ts import { z } from 'zod' import { ExpenseTransactionSchema, IncomeTransactionSchema } from './transaction.ts' import type { FormErrors } from '../lib/types/utility.ts' const AMOUNT_FIELD = z.coerce.number().positive('금액은 0보다 커야 합니다') const ExpenseDraftSchema = ExpenseTransactionSchema.omit({ id: true }).extend({ amount: AMOUNT_FIELD, }) const IncomeDraftSchema = IncomeTransactionSchema.omit({ id: true }).extend({ amount: AMOUNT_FIELD, }) export const TransactionFormSchema = z .discriminatedUnion('type', [ExpenseDraftSchema, IncomeDraftSchema]) .superRefine((values, ctx) => { if (values.amount >= 1_000_000 && values.memo.trim() === '') { ctx.addIssue({ code: 'custom', path: ['memo'], message: '100만원 이상 거래는 메모를 반드시 입력하세요', }) } }) export type TransactionFormValues = z.infer<typeof TransactionFormSchema> export function toFormErrors(error: z.ZodError): FormErrors<TransactionFormValues> { const flattened = z.flattenError(error) const errors: Record<string, string> = {} for (const [field, messages] of Object.entries(flattened.fieldErrors)) { if (messages !== undefined && messages.length > 0) { errors[`${field}Error`] = messages[0] } } return errors as FormErrors<TransactionFormValues> }

FormData에서 꺼낸 amount는 항상 문자열이므로, omit으로 뺀 뒤 z.coerce.number()로 다시 붙였습니다. type, date, category, memo는 13편 스키마의 검증 규칙을 그대로 물려받습니다.

2. 폼에 에러 표시 영역 추가

<!-- ui/transactionForm.ts의 root.innerHTML 템플릿, 각 입력 아래에 추가 --> <select name="category" id="category-select"></select> <span class="field-error" id="category-error"></span> <input id="date" name="date" type="date" required /> <span class="field-error" id="date-error"></span> <input name="amount" type="number" required /> <span class="field-error" id="amount-error"></span> <input name="memo" type="text" placeholder="메모" /> <span class="field-error" id="memo-error"></span>

3. handleCreateSubmit을 zod 검증으로 교체

// src/ui/transactionForm.ts (발췌 — handleCreateSubmit만, 나머지는 12편과 동일) import { TransactionFormSchema, toFormErrors } from '../schemas/transactionForm.ts' const FORM_FIELDS = ['date', 'amount', 'memo', 'category'] as const function showFieldErrors(root: HTMLElement, errors: Partial<Record<string, string>>): void { for (const field of FORM_FIELDS) { const errorEl = root.querySelector<HTMLSpanElement>(`#${field}-error`) if (errorEl !== null) { errorEl.textContent = errors[`${field}Error`] ?? '' } } } function handleCreateSubmit(event: SubmitEvent): void { event.preventDefault() const formData = new FormData(form) const raw = Object.fromEntries(formData.entries()) const result = TransactionFormSchema.safeParse(raw) if (!result.success) { showFieldErrors(root, toFormErrors(result.error)) return } showFieldErrors(root, {}) handlers.onCreate(result.data) form.reset() refreshCategoryOptions() }

이전에는 formData.get('type'), String(...), Number(...)를 하나씩 손으로 꺼내 NewTransactionDraft를 직접 조립했습니다. 이제 TransactionFormSchema.safeParse(raw)가 그 조립과 검증을 한 번에 하고, 성공하면 result.data가 이미 올바른 모양의 값입니다.

4. 실행

npm run dev

확인

  • 날짜를 비운 채 제출하면 날짜 입력 아래에 “날짜를 입력하세요”가 보입니다.
  • 수입을 고르고 지출 카테고리(예: 식비)를 그대로 두면 카테고리 아래에 에러가 보입니다.
  • 금액을 100만원 이상으로 입력하고 메모를 비운 채 제출하면 메모 아래에 “100만원 이상 거래는 메모를 반드시 입력하세요”가 보입니다.
  • 모든 값을 올바르게 입력하면 에러 문구가 사라지고 거래가 목록에 추가됩니다.

직접 해보기

  1. 수정 폼(editForm)의 제출 핸들러에도 같은 방식으로 검증을 붙여 봅니다. TransactionFormSchema 대신 date, amount, memo만 검증하는 별도 스키마가 필요합니다.
  2. refine을 이용해 “날짜는 오늘보다 미래일 수 없다”는 규칙을 TransactionFormSchema에 추가해 봅니다.

정답 보기

1번은 ExpenseTransactionSchema.pick({ date: true, amount: true, memo: true })처럼 pick으로 세 필드만 뽑은 스키마를 만들고, 지출·수입 스키마의 세 필드가 동일하므로 하나만 있으면 충분합니다.

2번은 superRefine 블록 안에 다음을 추가합니다.

if (values.date > new Date().toISOString().slice(0, 10)) { ctx.addIssue({ code: 'custom', path: ['date'], message: '미래 날짜는 입력할 수 없습니다' }) }

자주 하는 실수

증상원인고치는 법
금액을 입력해도 항상 검증 실패amount 필드에 z.coerce.number()를 빠뜨려 문자열이 그대로 검사됨폼에서 오는 문자열 필드는 z.coerce로 원하는 타입으로 바꾼다
에러 메시지가 지워지지 않고 계속 남음성공 시 showFieldErrors(root, {}) 호출을 빠뜨림성공 분기에서도 에러 표시 영역을 초기화한다
superRefine의 오류가 엉뚱한 필드 아래에 표시됨ctx.addIssuepath를 잘못된 필드 이름으로 지정함실제 입력 필드의 namepath 문자열을 정확히 맞춘다
지출·수입 스키마를 매번 새로 선언13편 스키마를 재사용하지 않고 처음부터 다시 작성함omit/extend로 기존 스키마를 합성한다

확인 문제

문제 14지선다
refine과 superRefine 중 여러 필드를 함께 검사하며 필드마다 다른 오류를 등록해야 할 때 알맞은 것은
문제 24지선다
TransactionFormSchema가 13편의 ExpenseTransactionSchema를 omit과 extend로 재사용하는 이유는
문제 34지선다
z.flattenError가 반환하는 값에서 필드별 에러 메시지를 담고 있는 프로퍼티는
문제 44지선다
amount 필드에 z.coerce.number()를 쓰는 이유는
문제 54지선다
이 편에서 만든 toFormErrors 함수의 역할은

참고 자료

Last updated on