이번 편의 결과물: 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.ts에 Transaction 인터페이스를 손으로 선언하고, 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)
}BaseFieldsSchema를 extend로 나눠 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.json의category값 하나를 오타로 바꾼 뒤npm run dev를 실행하면, 콘솔에 어떤 필드가 왜 틀렸는지 알려주는 오류가 출력됩니다. 확인 후 원래 값으로 되돌립니다.
직접 해보기
seedTransactions.json의category를 오타로 바꿔 콘솔 오류 메시지를 직접 확인해 보고, fundamentals 13편에서 같은 오타가 왜 잡히지 않았는지 비교해 봅니다.BaseFieldsSchema의amount를z.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'를 추가한다 |
| 브랜드 타입에 일반 문자열을 그냥 대입하려다 오류 | TransactionId는 string과 다른 타입이라 바로 대입 불가 | toTransactionId(value)를 거쳐 브랜드를 붙인다 |