이번 편의 결과물: 거래 입력 폼이 TransactionFormSchema로 검증되고, 필드별 에러 메시지가 화면에 표시됩니다. · 다루는 개념: refine/superRefine, 스키마 합성·재사용, 폼 에러 메시지와 05편 FormErrors 매핑 타입 연결
이 편에서 만드는 파일
expense-tracker/src/
├── schemas/
│ └── transactionForm.ts + (TransactionFormSchema, toFormErrors)
└── ui/
└── transactionForm.ts ~ (제출 검증을 zod safeParse로 교체, 에러 메시지 표시)지금까지 ui/transactionForm.ts의 handleCreateSubmit은 FormData에서 값을 꺼내 바로 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만원 이상 거래는 메모를 반드시 입력하세요”가 보입니다.
- 모든 값을 올바르게 입력하면 에러 문구가 사라지고 거래가 목록에 추가됩니다.
직접 해보기
- 수정 폼(
editForm)의 제출 핸들러에도 같은 방식으로 검증을 붙여 봅니다.TransactionFormSchema대신date,amount,memo만 검증하는 별도 스키마가 필요합니다. 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.addIssue의 path를 잘못된 필드 이름으로 지정함 | 실제 입력 필드의 name과 path 문자열을 정확히 맞춘다 |
| 지출·수입 스키마를 매번 새로 선언 | 13편 스키마를 재사용하지 않고 처음부터 다시 작성함 | omit/extend로 기존 스키마를 합성한다 |