Skip to Content
WebTypeScriptTypeScript 중급13. zod로 런타임 검증과 타입 단일화하기

이번 편의 결과물: schemas/transaction.ts의 zod 스키마가 Transaction 타입의 유일한 출처가 되고, 시드 데이터가 스키마와 다르면 콘솔에 명확한 오류가 출력됩니다. · 다루는 개념: zod 스키마 정의, discriminatedUnion, brand, z.infer, parse/safeParse

이 편에서 만드는 파일

expense-tracker/ └── src/ ├── models/ │ ├── expense.ts ~ (카테고리 배열을 as const 튜플로 정리, Transaction 관련 인터페이스 삭제) │ └── index.ts ~ (Transaction 타입을 schemas/transaction.ts에서 재노출) ├── schemas/ │ └── transaction.ts + (zod 스키마, TransactionId 브랜드, z.infer 타입) └── services/ └── createTransaction.ts ~ (TransactionSchema.parse로 조립 결과까지 검증) tests/ └── schemas.test.ts + (스키마 통과·실패 케이스)

12편까지는 models/expense.tsTransaction 인터페이스를 손으로 선언하고, API 응답이나 시드 데이터가 그 모양과 실제로 맞는지는 isTransaction, assertIsTransaction이라는 별도 함수로 검증했습니다. 이 편에서는 zod 스키마 하나로 두 가지를 한 번에 얻습니다.

개념 정리

12편까지의 한계

참고로 12편의 수동 가드는 이런 모습이었습니다.

// (12편, 참고용 — 이번 편에서 삭제) function isTransaction(value: unknown): value is Transaction { if (typeof value !== 'object' || value === null) return false const record = value as Record<string, unknown> if (typeof record.id !== 'string' || typeof record.date !== 'string') return false if (typeof record.amount !== 'number' || typeof record.memo !== 'string') return false if (record.type === 'expense') return EXPENSE_CATEGORIES.includes(record.category as never) if (record.type === 'income') return INCOME_CATEGORIES.includes(record.category as never) return false }

필드 다섯 개짜리 타입인데도 이미 여덟 줄입니다. 필드가 늘어나면 Transaction 인터페이스와 isTransaction 함수를 매번 같이 고쳐야 하고, 둘 중 하나만 고치면 타입과 실제 검증이 어긋납니다.

zod 스키마 — 선언 한 번으로 타입과 검증을 함께

zod는 스키마를 값으로 선언하면 그 스키마로 값을 검증할 수도 있고, z.infer로 정적 타입을 뽑아낼 수도 있습니다.

함수역할
z.object({...})프로퍼티 이름과 각각의 zod 스키마로 객체 모양을 정의
.extend({...})기존 객체 스키마에 프로퍼티를 추가하거나 덮어씀
z.literal(값)정확히 그 값 하나만 허용
z.enum(배열)배열에 담긴 문자열 중 하나만 허용(리터럴 유니온으로 추론됨)
z.discriminatedUnion(태그, [스키마...])태그 프로퍼티 값으로 여러 객체 스키마 중 하나를 고름
z.infer<typeof 스키마>스키마가 통과시키는 값의 정적 타입을 뽑아냄

z.enum이 리터럴 유니온으로 추론되려면 넘기는 배열이 길이가 고정된 튜플이어야 합니다. 지금까지 models/expense.ts의 카테고리 배열은 readonly ExpenseCategory[]처럼 일반 배열 타입이었으므로, 이 편에서 as const로 튜플로 정리합니다.

brand로 타입 수준 구분 만들기

id처럼 문자열이지만 다른 문자열과 섞이면 안 되는 값에는 .brand()를 붙입니다. .brand()는 런타임에는 아무 검사도 추가하지 않고, 컴파일 타임에만 “이 문자열은 평범한 string이 아니라 브랜드가 찍힌 값”이라고 구분합니다.

const TransactionIdSchema = z.string().brand<'TransactionId'>()

이 앱은 crypto.randomUUID()가 항상 표준 형식의 문자열을 만들어 주므로, 형식 검사(uuid 형식 제한)는 따로 붙이지 않고 브랜드만 적용합니다.

parse와 safeParse

메서드검증 실패 시
.parse(값)ZodError를 던짐(throw)
.safeParse(값)예외 없이 { success: false, error }를 반환

앱 시작 시 시드 데이터처럼 “여기서 실패하면 바로 알아야 하는” 곳에는 parse를, 사용자 입력처럼 “실패해도 화면에 에러만 보여주면 되는” 곳에는 safeParse를 씁니다. 폼 입력 검증은 다음 편에서 safeParse로 다룹니다.

실습

1. 카테고리 배열을 as const 튜플로 정리

// src/models/expense.ts export const CURRENCY: 'KRW' = 'KRW' export const EXPENSE_CATEGORIES = ['food', 'transport', 'housing', 'shopping', 'etc-expense'] as const export const INCOME_CATEGORIES = ['salary', 'bonus', 'interest', 'etc-income'] as const export const ALL_CATEGORIES: readonly string[] = [...new Set([...INCOME_CATEGORIES, ...EXPENSE_CATEGORIES])] export type ExpenseCategory = (typeof EXPENSE_CATEGORIES)[number] export type IncomeCategory = (typeof INCOME_CATEGORIES)[number]

12편까지 있던 BaseRecord, ExpenseTransaction, IncomeTransaction, Transaction 인터페이스는 이 파일에서 지웁니다. 이 타입들은 이제 스키마에서 나옵니다.

2. schemas/transaction.ts 작성

// src/schemas/transaction.ts import { z } from 'zod' import { EXPENSE_CATEGORIES, INCOME_CATEGORIES } from '../models/expense.ts' const TransactionIdSchema = z.string().brand<'TransactionId'>() const BaseFieldsSchema = z.object({ id: TransactionIdSchema, date: z.string().min(1, '날짜를 입력하세요'), amount: z.number().positive('금액은 0보다 커야 합니다'), memo: z.string(), }) export const ExpenseTransactionSchema = BaseFieldsSchema.extend({ type: z.literal('expense'), category: z.enum(EXPENSE_CATEGORIES), }) export const IncomeTransactionSchema = BaseFieldsSchema.extend({ type: z.literal('income'), category: z.enum(INCOME_CATEGORIES), }) export const TransactionSchema = z.discriminatedUnion('type', [ ExpenseTransactionSchema, IncomeTransactionSchema, ]) export type TransactionId = z.infer<typeof TransactionIdSchema> export type ExpenseTransaction = z.infer<typeof ExpenseTransactionSchema> export type IncomeTransaction = z.infer<typeof IncomeTransactionSchema> export type Transaction = z.infer<typeof TransactionSchema> export function toTransactionId(value: string): TransactionId { return TransactionIdSchema.parse(value) }

BaseFieldsSchemaextend로 나눠 type, category만 다르게 정의하고, discriminatedUnion으로 다시 합칩니다. toTransactionId는 07편에서 손으로 만들었던 브랜딩 헬퍼를 zod 버전으로 바꾼 것입니다.

3. 모델 배럴 갱신

// src/models/index.ts export { CURRENCY, EXPENSE_CATEGORIES, INCOME_CATEGORIES, ALL_CATEGORIES } from './expense.ts' export type { ExpenseCategory, IncomeCategory } from './expense.ts' export type { Transaction, ExpenseTransaction, IncomeTransaction, TransactionId } from '../schemas/transaction.ts'

models/expense.ts는 이제 카테고리 값만 갖고 있고, Transaction 계열 타입은 전부 schemas/transaction.ts에서 옵니다. 다른 파일은 여전히 ../models 하나만 보고 Transaction을 가져다 쓸 수 있습니다.

4. createTransaction 갱신

// src/services/createTransaction.ts import { TransactionSchema, toTransactionId } from '../schemas/transaction.ts' import type { Transaction } from '../models/index.ts' import type { NewTransactionDraft } from '../ui/transactionForm.ts' export function createTransaction(draft: NewTransactionDraft): Transaction { return TransactionSchema.parse({ ...draft, id: toTransactionId(crypto.randomUUID()) }) }

as Transaction 단언 대신 TransactionSchema.parse를 거치므로, draft에 스키마와 어긋나는 값이 섞여 있으면 이 함수 자리에서 바로 예외가 발생합니다.

5. main.ts에서 시드 데이터 검증

// src/main.ts (발췌 — 시드 데이터를 불러오는 부분만, 나머지는 12편과 동일) import { z } from 'zod' import { TransactionSchema } from './schemas/transaction.ts' import seedRaw from './data/seedTransactions.json' import type { Transaction } from './models/index.ts' let seedTransactions: Transaction[] try { seedTransactions = z.array(TransactionSchema).parse(seedRaw) } catch (error) { if (error instanceof z.ZodError) { console.error('시드 데이터 검증 실패:\n' + z.prettifyError(error)) } throw error }

fundamentals 13편에서는 seedRaw as Transaction[]로 단언만 하고 실제 값은 검사하지 않았습니다. 이제 parse가 실제 값을 검사하고, 실패하면 z.prettifyError로 사람이 읽을 수 있는 오류를 콘솔에 남깁니다.

6. 테스트 작성

// tests/schemas.test.ts import { describe, expect, it } from 'vitest' import { TransactionSchema } from '../src/schemas/transaction.ts' describe('TransactionSchema', () => { it('유효한 지출 거래를 통과시킨다', () => { const result = TransactionSchema.safeParse({ id: 'test-1', type: 'expense', date: '2026-09-01', category: 'food', amount: 12000, memo: '점심', }) expect(result.success).toBe(true) }) it('type과 category가 어긋나면 실패한다', () => { const result = TransactionSchema.safeParse({ id: 'test-2', type: 'income', date: '2026-09-01', category: 'food', amount: 12000, memo: '', }) expect(result.success).toBe(false) }) })

7. 실행

npm run test npm run typecheck npm run dev

확인

  • npm run test에서 schemas.test.ts의 두 케이스가 모두 통과합니다.
  • npm run typecheck가 오류 없이 끝납니다.
  • 브라우저 화면은 12편과 동일하게 시드 거래가 그대로 보입니다.
  • seedTransactions.jsoncategory 값 하나를 오타로 바꾼 뒤 npm run dev를 실행하면, 콘솔에 어떤 필드가 왜 틀렸는지 알려주는 오류가 출력됩니다. 확인 후 원래 값으로 되돌립니다.

직접 해보기

  1. seedTransactions.jsoncategory를 오타로 바꿔 콘솔 오류 메시지를 직접 확인해 보고, fundamentals 13편에서 같은 오타가 왜 잡히지 않았는지 비교해 봅니다.
  2. BaseFieldsSchemaamountz.number().positive()에서 z.number().nonnegative()로 바꾸면 어떤 값(0)까지 추가로 허용되는지 schemas.test.ts에 케이스를 하나 더 추가해 확인합니다.

정답 보기

1번은 zod가 category 값이 EXPENSE_CATEGORIES/INCOME_CATEGORIES에 속하지 않는다는 것을 실제로 검사하기 때문에 즉시 오류가 납니다. as Transaction[] 단언은 컴파일 타임에만 작동해 실제 값을 검사하지 않았던 것과 대조적입니다.

2번은 nonnegative()로 바꾸면 amount: 0도 통과합니다. 아래 케이스를 추가하면 확인할 수 있습니다.

it('금액 0을 허용하는지 확인한다', () => { const result = TransactionSchema.safeParse({ id: 'test-3', type: 'expense', date: '2026-09-01', category: 'food', amount: 0, memo: '', }) expect(result.success).toBe(false) // positive()를 유지하면 여전히 실패 })

자주 하는 실수

증상원인고치는 법
z.enum에 배열을 넘겼는데 타입이 string으로만 추론됨배열이 as const 튜플이 아니라 일반 배열 타입임카테고리 배열을 as const로 선언한다
시드 데이터가 틀렸는데도 앱이 조용히 깨짐parse 대신 아무 검증 없이 값을 그대로 사용실패를 바로 알아야 하는 곳은 parse, 화면에만 보여주면 되는 곳은 safeParse를 쓴다
models/index.ts에서 Transaction을 찾을 수 없다는 오류배럴에서 schemas/transaction.ts로부터 재노출하는 줄을 빠뜨림export type { Transaction, ... } from '../schemas/transaction.ts'를 추가한다
브랜드 타입에 일반 문자열을 그냥 대입하려다 오류TransactionIdstring과 다른 타입이라 바로 대입 불가toTransactionId(value)를 거쳐 브랜드를 붙인다

확인 문제

문제 14지선다
zod 스키마 하나로 타입과 런타임 검증을 함께 얻을 수 있는 이유는
문제 24지선다
brand를 적용한 TransactionId 타입에 대한 설명으로 옳은 것은
문제 34지선다
parse와 safeParse의 차이는
문제 44지선다
이 편에서 EXPENSE_CATEGORIES를 as const로 바꾼 이유는
문제 54지선다
12편의 isTransaction, assertIsTransaction을 이 편에서 삭제한 이유는

참고 자료

Last updated on