Zod를 활용한 런타임 스키마 검증 가이드
Zod 라이브러리로 TypeScript 프로젝트의 입력 검증을 강화하는 방법. 스키마 정의, 타입 추론, API 검증, 폼 검증을 다룹니다.
왜 런타임 검증이 필요한가
TypeScript의 타입 시스템은 컴파일 타임에만 동작합니다. 외부 API 응답, 사용자 폼 입력, 환경변수처럼 런타임에 들어오는 데이터는 아무리 타입을 잘 선언해 두어도 실제 값의 형태를 보장하지 않습니다. as unknown as MyType 캐스팅은 컴파일 에러를 숨길 뿐이고, 프로덕션 환경에서 예상치 못한 undefined 나 null 이 흘러들어 장애로 이어집니다.
Zod는 이 간극을 메워주는 TypeScript-first 런타임 검증 라이브러리입니다. 스키마를 한 번 선언하면 런타임 검증과 TypeScript 타입 추론을 동시에 얻을 수 있어, 타입 정의를 두 군데에 유지할 필요가 없습니다. 2025년 출시된 Zod v4는 파싱 속도 14배 향상, 번들 크기 57% 감소, 타입 컴파일 속도 20배 향상이라는 성능 도약을 이루었습니다.
Zod 기초 — 스키마 정의와 타입 추론
가장 흔한 사용 패턴입니다. z.object()로 스키마를 선언하고 z.infer로 타입을 추출합니다.
import { z } from 'zod'const UserSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1, '이름을 입력해주세요').max(50),
email: z.string().email('올바른 이메일 형식이 아닙니다'),
age: z.number().int().min(0).max(150).optional(),
role: z.enum(['admin', 'editor', 'viewer']).default('viewer'),
createdAt: z.coerce.date(), // 문자열 → Date 자동 변환
})
// 별도 interface 선언 없이 타입 추론
type User = z.infer<typeof UserSchema>
z.coerce.date()는 API 응답에서 자주 등장하는 ISO 문자열을 자동으로 Date 객체로 변환합니다. z.enum()은 유니온 타입과 허용값 목록을 동시에 정의합니다.
.parse() vs .safeParse() — 실전 선택 기준
Zod는 두 가지 검증 진입점을 제공합니다.
// parse(): 실패 시 예외를 던짐// → 유효하지 않은 데이터가 절대 통과하면 안 되는 경계점에 사용
try {
const user = UserSchema.parse(rawData)
// user의 타입이 User로 좁혀짐
} catch (error) {
if (error instanceof z.ZodError) {
console.error(error.issues) // 상세 에러 목록
}
}
// safeParse(): 예외 없이 결과 객체를 반환
// → API 핸들러, 폼 검증처럼 에러를 응답으로 돌려줘야 할 때 사용
const result = UserSchema.safeParse(rawData)
if (!result.success) {
// result.error는 ZodError
const fieldErrors = result.error.issues.map((issue) => ({
path: issue.path.join('.'),
message: issue.message,
}))
return Response.json({ errors: fieldErrors }, { status: 400 })
}
// result.data의 타입이 User로 확정됨
console.log(result.data.email)
규칙은 단순합니다. 실패가 프로그래밍 오류에 해당하면 parse(), 실패가 예상 가능한 사용자 입력 오류이면 safeParse()를 씁니다.
API 응답 검증 — fetch와 Zod 조합
외부 API를 호출할 때 응답 타입을 as 로 단언하는 것은 위험합니다. Zod 스키마를 통과시키면 런타임 안전성과 타입 추론을 동시에 확보합니다.
const PostSchema = z.object({id: z.number(),
title: z.string(),
body: z.string(),
userId: z.number(),
})
const PostListSchema = z.array(PostSchema)
type Post = z.infer<typeof PostSchema>
async function fetchPosts(): Promise<Post[]> {
const response = await fetch('https://jsonplaceholder.typicode.com/posts')
if (!response.ok) {
throw new Error(
HTTP ${response.status})}
const raw = await response.json()
// 응답 형태가 예상과 다를 경우 ZodError 발생 → 빠른 실패
const posts = PostListSchema.parse(raw)
return posts
}
// 실패를 에러 대신 null로 처리하는 안전한 변형
async function fetchPostSafe(id: number): Promise<Post | null> {
const response = await fetch(
/api/posts/${id})if (!response.ok) return null
const result = PostSchema.safeParse(await response.json())
return result.success ? result.data : null
}
React Hook Form + Zod — 폼 검증 실전
@hookform/resolvers 의 zodResolver를 사용하면 Zod 스키마가 React Hook Form의 검증 엔진으로 작동합니다. 클라이언트와 서버가 동일한 스키마를 공유할 수 있어 검증 로직이 한 곳에 모입니다.
// schemas/signup.ts — 클라이언트/서버 공유import { z } from 'zod'
export const SignupSchema = z.object({
email: z.string().email('이메일 형식이 올바르지 않습니다'),
password: z
.string()
.min(8, '비밀번호는 8자 이상이어야 합니다')
.regex(/[A-Z]/, '대문자를 하나 이상 포함해야 합니다')
.regex(/[0-9]/, '숫자를 하나 이상 포함해야 합니다'),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: '비밀번호가 일치하지 않습니다',
path: ['confirmPassword'], // 어느 필드에 에러를 표시할지 지정
})
export type SignupInput = z.infer<typeof SignupSchema>
// components/SignupForm.tsx'use client'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { SignupSchema, type SignupInput } from '@/schemas/signup'
export function SignupForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<SignupInput>({
resolver: zodResolver(SignupSchema),
})
const onSubmit = async (data: SignupInput) => {
// data는 이미 검증·타입 안전이 보장된 상태
await fetch('/api/signup', {
method: 'POST',
body: JSON.stringify(data),
})
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email')} placeholder="이메일" />
{errors.email && <p>{errors.email.message}</p>}
<input {...register('password')} type="password" placeholder="비밀번호" />
{errors.password && <p>{errors.password.message}</p>}
<input {...register('confirmPassword')} type="password" placeholder="비밀번호 확인" />
{errors.confirmPassword && <p>{errors.confirmPassword.message}</p>}
<button type="submit" disabled={isSubmitting}>가입하기</button>
</form>
)
}
환경변수 검증 — 앱 시작 시 빠른 실패
환경변수 누락은 개발 단계에서 잡아야 합니다. t3-env 패턴을 직접 구현하거나 @t3-oss/env-nextjs 패키지를 활용하면 앱 부트 타임에 검증을 강제할 수 있습니다.
// env.ts — 직접 구현 방식import { z } from 'zod'
const ServerEnvSchema = z.object({
DATABASE_URL: z.string().url('DATABASE_URL이 올바른 URL 형식이 아닙니다'),
JWT_SECRET: z.string().min(32, 'JWT_SECRET은 32자 이상이어야 합니다'),
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
})
const ClientEnvSchema = z.object({
NEXT_PUBLIC_API_URL: z.string().url(),
NEXT_PUBLIC_GA_ID: z.string().optional(),
})
// 앱 시작 시 즉시 검증 — 실패하면 프로세스 종료
function validateEnv() {
const serverResult = ServerEnvSchema.safeParse(process.env)
if (!serverResult.success) {
console.error('환경변수 검증 실패:')
serverResult.error.issues.forEach((issue) => {
console.error(
${issue.path.join('.')}: ${issue.message})})
process.exit(1)
}
return serverResult.data
}
export const env = validateEnv()
// 이후 env.DATABASE_URL 등으로 타입 안전하게 접근
# @t3-oss/env-nextjs 패키지를 사용하는 경우
npm install @t3-oss/env-nextjs zod
// env.ts — t3-env 패키지 사용import { createEnv } from '@t3-oss/env-nextjs'
import { z } from 'zod'
export const env = createEnv({
server: {
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
},
client: {
NEXT_PUBLIC_API_URL: z.string().url(),
},
runtimeEnv: {
DATABASE_URL: process.env.DATABASE_URL,
JWT_SECRET: process.env.JWT_SECRET,
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
},
})
Discriminated Unions — 복잡한 상태 모델링
여러 형태를 가질 수 있는 데이터를 z.discriminatedUnion()으로 모델링하면 분기 파싱이 일반 z.union()보다 훨씬 빠릅니다. 판별자 키를 먼저 확인한 후 해당 브랜치만 검사하기 때문입니다.
// API 응답이 성공/실패 두 형태를 가지는 경우const ApiResponseSchema = z.discriminatedUnion('status', [
z.object({
status: z.literal('success'),
data: z.object({
id: z.string(),
name: z.string(),
}),
}),
z.object({
status: z.literal('error'),
code: z.string(),
message: z.string(),
}),
])
type ApiResponse = z.infer<typeof ApiResponseSchema>
function handleResponse(raw: unknown) {
const result = ApiResponseSchema.safeParse(raw)
if (!result.success) {
throw new Error('예상치 못한 응답 형식')
}
// TypeScript가 status 값에 따라 타입을 자동으로 좁혀줌
if (result.data.status === 'success') {
console.log(result.data.data.id) // data 필드 접근 가능
} else {
console.error(result.data.code, result.data.message) // error 필드 접근 가능
}
}
.transform()과 .refine() — 변환과 검증 조합
Zod v4에서는 .refine()을 체이닝 중 어느 위치에서나 사용할 수 있습니다. v3에서는 ZodEffects로 래핑되어 이후 메서드 체이닝이 불가능했던 제약이 해소되었습니다.
// .transform(): 파싱과 동시에 데이터를 변환const PriceSchema = z.string()
.regex(/^\d+(\.\d{1,2})?$/, '올바른 가격 형식이 아닙니다')
.transform((val) => parseFloat(val)) // string → number
// .refine(): 커스텀 검증 로직 추가
const PhoneSchema = z.string()
.regex(/^010-\d{4}-\d{4}$/, '010-XXXX-XXXX 형식이어야 합니다')
.refine(
(val) => !BLOCKED_NUMBERS.includes(val),
{ message: '사용할 수 없는 번호입니다' }
)
// .transform() + .refine() 조합 — 날짜 문자열을 파싱하고 미래 날짜만 허용
const FutureDateSchema = z.string()
.datetime({ message: 'ISO 8601 형식이어야 합니다' })
.transform((val) => new Date(val))
.refine((date) => date > new Date(), {
message: '미래 날짜를 입력해주세요',
})
// v4 신규: .overwrite() — 타입을 바꾸지 않는 변환 (transform보다 가볍게 동작)
const TrimmedStringSchema = z.string().overwrite((val) => val.trim())
Next.js Server Actions에서의 Zod 활용
Server Actions는 클라이언트에서 직접 호출되므로 서버 측 검증이 필수입니다. safeParse()로 입력을 검증하고 구조화된 응답을 반환하는 패턴이 표준입니다.
// app/actions/createPost.ts'use server'
import { z } from 'zod'
const CreatePostSchema = z.object({
title: z.string().min(1, '제목을 입력해주세요').max(200),
content: z.string().min(10, '내용은 10자 이상이어야 합니다'),
tags: z.array(z.string().max(20)).max(5).default([]),
})
type ActionResult =
| { success: true; postId: string }
| { success: false; errors: Record<string, string> }
export async function createPost(formData: FormData): Promise<ActionResult> {
const rawData = {
title: formData.get('title'),
content: formData.get('content'),
tags: formData.getAll('tags'),
}
const result = CreatePostSchema.safeParse(rawData)
if (!result.success) {
const errors: Record<string, string> = {}
result.error.issues.forEach((issue) => {
const key = issue.path[0]?.toString() ?? 'general'
errors[key] = issue.message
})
return { success: false, errors }
}
// result.data는 타입 안전 — DB 저장 로직으로 전달
const post = await db.posts.create({ data: result.data })
return { success: true, postId: post.id }
}
에러 메시지 한글화 — z.setErrorMap
기본 에러 메시지는 영어입니다. z.setErrorMap()으로 전역 에러 메시지를 한국어로 교체하면 별도 메시지 선언 없이도 기본값이 한국어로 출력됩니다.
// lib/zodLocale.tsimport { z, ZodIssueCode } from 'zod'
z.setErrorMap((issue, ctx) => {
switch (issue.code) {
case ZodIssueCode.invalid_type:
if (issue.received === 'undefined') return { message: '필수 항목입니다' }
return { message:
${issue.expected} 타입이어야 합니다}case ZodIssueCode.too_small:
if (issue.type === 'string') {
return { message:
최소 ${issue.minimum}자 이상 입력해주세요}}
if (issue.type === 'number') {
return { message:
${issue.minimum} 이상의 값을 입력해주세요}}
break
case ZodIssueCode.too_big:
if (issue.type === 'string') {
return { message:
최대 ${issue.maximum}자까지 입력 가능합니다}}
break
case ZodIssueCode.invalid_string:
if (issue.validation === 'email') return { message: '올바른 이메일 형식이 아닙니다' }
if (issue.validation === 'url') return { message: '올바른 URL 형식이 아닙니다' }
break
}
return { message: ctx.defaultError }
})
// app/layout.tsx 또는 진입점에서 한 번만 import
import '@/lib/zodLocale'
성능 최적화 — 대용량 데이터와 재귀 스키마
// z.lazy(): 재귀 타입 정의 (카테고리 트리, 댓글 대댓글 등)type Category = {
id: string
name: string
children: Category[]
}
const CategorySchema: z.ZodType<Category> = z.lazy(() =>
z.object({
id: z.string(),
name: z.string(),
children: z.array(CategorySchema),
})
)
// 대량 배열 검증 — 실패 즉시 중단 (abortEarly 효과)
const BulkSchema = z.array(UserSchema).max(1000, '한 번에 최대 1000개까지 처리 가능합니다')
// z.preprocess(): 검증 전 데이터를 전처리 — 외부 데이터 정제에 유용
const CoercedBooleanSchema = z.preprocess(
(val) => {
if (typeof val === 'string') return val === 'true' || val === '1'
return val
},
z.boolean()
)
// "true", "1" → true, "false", "0" → false 자동 변환
Zod v4 주요 변경사항 요약
Zod v4(2025년 출시)는 기존 v3 대비 API 호환성을 대부분 유지하면서 내부를 대폭 개선했습니다.
invalid_type_error/required_error파라미터 제거 → 통합된error파라미터 사용@zod/mini서브패키지 도입 — 메서드 체이닝 대신 함수형 API, 1.9 KB gzip.toJSONSchema()내장 — OpenAPI 문서 자동화에 활용.meta()+ Registry — 스키마에 런타임 메타데이터(description, title 등) 부착.overwrite()신규 — 타입을 변경하지 않는 가벼운 변환 메서드
// v4 신규: JSON Schema 변환import { z } from 'zod'
const schema = z.object({
name: z.string().meta({ description: '사용자 이름' }),
age: z.number().int().min(0),
})
const jsonSchema = schema.toJSONSchema()
// OpenAPI 스펙 생성, 동적 폼 렌더링 등에 활용 가능
정리
Zod는 TypeScript 프로젝트에서 런타임 안전성을 확보하는 가장 실용적인 도구입니다. 스키마 한 번 선언으로 타입 추론, 런타임 검증, JSON Schema 변환까지 처리할 수 있습니다. API 응답 경계에는 .safeParse()를 사용하고, 폼은 zodResolver로 React Hook Form과 연결하고, 환경변수는 앱 시작 시 parse()로 즉시 검증하는 세 가지 패턴을 프로젝트 표준으로 정착시키면 예방 가능한 런타임 에러를 대부분 제거할 수 있습니다. Zod v4의 성능 개선과 새로운 API를 적극 활용해 더 안전하고 빠른 애플리케이션을 만드세요.
관련 글
이 글은 AI K LINK 콘텐츠팀이 작성하였으며, AI 도구의 도움을 받아 리서치 및 초안 작성이 이루어졌습니다. 최종 발행 전 전문 에디터의 검수를 거칩니다. 내용에 대한 문의는 contact@aiklink.com으로 보내주세요.


