Zod 심화 가이드 — 고급 검증과 타입 추론
이 글의 핵심
이 글은 Zod로 복잡한 입력을 안전하게 다루는 고급 패턴을 다룹니다. 스키마 조합과 discriminated union, refine·superRefine·에러 맵, transform·preprocess, 성능·React Hook Form·tRPC·API 검증 설계까지 한 흐름으로 연결합니다.
이 글의 핵심
Zod는 TypeScript 생태계에서 런타임 스키마와 정적 타입 추론을 동시에 제공하는 도구입니다. 단순한 z.string()·z.number()를 넘어서, 실무에서는 여러 스키마를 조합하고, 조건부 형태(discriminated union)를 표현하며, 커스텀 규칙과 일관된 오류 메시지를 요구합니다. 또한 변환(transform)과 전처리(preprocess)로 «검증과 정규화»를 한 파이프라인에 두는 경우가 많습니다.
이 글은 다음을 목표로 합니다.
- 복잡한 스키마 조합으로 재사용 가능한 «검증 모듈»을 만드는 방법
refine/superRefine과 에러 맵으로 도메인 규칙과 사용자 향 메시지를 분리하는 방법discriminatedUnion으로 조건부 스키마를 안전하게 표현하는 방법transform·preprocess·pipe로 파싱 결과 타입을 바꾸는 패턴- 성능을 해치지 않는 설계 체크리스트
- React Hook Form, tRPC와의 통합 및 API 경계에서의 검증 시스템 구축
아래 예제는 Zod 3 계열 API를 기준으로 합니다. 프로젝트의 Zod 메이저 버전에 따라 세부 시그니처가 다를 수 있으므로, 설치된 버전의 공식 문서와 함께 확인하시기 바랍니다.
1. 전제: 왜 «고급» 패턴이 필요한가
프로덕션 입력은 단일 평면 객체가 아닙니다. API 버전 차이, 폼 단계별 필드, 권한에 따른 선택 필드, 외부 시스템에서 오는 기형 JSON이 동시에 존재합니다. 이때 필요한 것은 다음과 같습니다.
- 단일 진실 공급원: 같은 도메인 개념에 대해 타입과 런타임 규칙이 어긋나지 않게 할 것
- 오류의 지역성: 어느 필드에서 왜 실패했는지 클라이언트·서버 로그·사용자 메시지에 일관되게 전달할 것
- 경계에서의 파싱: 요청 단위로 한 번 검증하고, 내부 도메인 로직은 이미 신뢰된 데이터만 받을 것
Zod는 이런 요구를 스키마 조합과 세밀한 Issue 제어로 충족시킬 수 있습니다.
2. 복잡한 스키마 조합
2.1 기본 조합: merge, extend, 교차
z.object()끼리 merge하면 우측이 동명 키를 덮어씁니다. extend는 한 객체 스키마에 필드를 덧붙이는 형태로 읽기 쉽습니다. 타입 수준에서 교집합이 필요하면 z.intersection(또는 이를 대체하는 조합 패턴)을 쓸 수 있으나, 같은 키에 서로 다른 스키마가 있으면 모순이 생기므로 설계 시 주의가 필요합니다.
import { z } from 'zod';
const Timestamps = z.object({
createdAt: z.coerce.date(),
updatedAt: z.coerce.date(),
});
const SoftDelete = z.object({
deletedAt: z.coerce.date().nullable(),
});
const BaseUser = z.object({
id: z.string().uuid(),
email: z.string().email(),
});
export const UserRow = BaseUser.merge(Timestamps).merge(SoftDelete);
export const UserCreate = BaseUser.pick({ email: true }).extend({
password: z.string().min(12),
});
설명: pick·omit·partial·required는 키 수준에서 스키마를 잘라 재사용하기 좋습니다. CRUD별로 같은 엔티티를 표현할 때 동일한 Base에서 파생시키면, 필드 추가 시 한곳만 수정하면 됩니다.
2.2 and / 교차 타입과 주의점
두 객체 스키마를 and로 엮으면 교차 타입으로 추론됩니다. 다만 런타임 검증은 각 스키마를 순차 적용하는 방식이므로, 동일 키에 대한 제약이 논리적으로 모순이면 실패 지점이 직관적이지 않을 수 있습니다. 교차는 키가 겹치지 않거나, 겹친다면 동일한 제약일 때 가장 안전합니다.
2.3 배열·레코드·튜플로 표현력 높이기
복합 입력은 z.array, z.record, z.tuple로 구조를 명시하는 편이 낫습니다. 특히 고정 길이나 순서 의미가 있으면 tuple이 유리합니다.
const Coordinate = z.tuple([z.number(), z.number()]);
const FeatureFlags = z.record(z.string(), z.boolean());
const Paginated = <T extends z.ZodTypeAny>(item: T) =>
z.object({
items: z.array(item),
nextCursor: z.string().nullable(),
});
2.4 brand와 nominal typing에 가까운 표현
문자열이지만 도메인상 다른 식별자라면 z.string().uuid()만으로는 혼동이 남습니다. brand를 쓰면 TypeScript에서 구조가 같아도 타입을 구분할 수 있어, 함수 인자 실수를 줄입니다.
const UserId = z.string().uuid().brand<'UserId'>();
const OrgId = z.string().uuid().brand<'OrgId'>();
type UserId = z.infer<typeof UserId>;
type OrgId = z.infer<typeof OrgId>;
주의: brand는 타입 수준 강화이며, 런타임 값은 여전히 문자열입니다. 외부 입력은 반드시 해당 스키마로 파싱해야 합니다.
3. 커스텀 검증과 에러 메시지
3.1 refine: 단순 불변식
필드 간 관계가 단순하면 refine이 간결합니다. 예를 들어 종료일이 시작일 이후 같은 규칙입니다.
const DateRange = z
.object({
startsAt: z.coerce.date(),
endsAt: z.coerce.date(),
})
.refine((d) => d.endsAt >= d.startsAt, {
message: '종료 시각은 시작 시각 이후여야 합니다.',
path: ['endsAt'],
});
path를 지정하면 어느 필드에 매핑할지 제어할 수 있어, 폼 라이브러리와 연동할 때 유리합니다.
3.2 superRefine: 여러 오류·필드별 Issue
한 번의 검증에서 여러 필드에 서로 다른 코드로 이슈를 남기려면 superRefine이 적합합니다. ctx.addIssue로 code, path, message를 세밀하게 설정합니다.
const PasswordChange = z
.object({
next: z.string(),
confirm: z.string(),
})
.superRefine((val, ctx) => {
if (val.next.length < 12) {
ctx.addIssue({
code: z.ZodIssueCode.too_small,
minimum: 12,
type: 'string',
inclusive: true,
path: ['next'],
message: '비밀번호는 12자 이상이어야 합니다.',
});
}
if (val.next !== val.confirm) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['confirm'],
message: '비밀번호 확인이 일치하지 않습니다.',
});
}
});
실무 팁: 서버에서는 message뿐 아니라 code나 params 성격의 커스텀 데이터를 fatal·union 이슈와 함께 쓰는 팀도 있습니다. 클라이언트는 코드로 i18n 키를 고르고, message는 폴백으로 둡니다.
3.3 전역·지역 에러 맵: setErrorMap과 .errorMap()
프로젝트 전체에서 동일한 한국어 메시지로 통일하려면 z.setErrorMap을 고려합니다. 특정 스키마만 다르게 하려면 해당 스키마에 지역 errorMap을 붙입니다.
const koStringErrorMap: z.ZodErrorMap = (issue, ctx) => {
if (issue.code === z.ZodIssueCode.invalid_type) {
return { message: `올바른 타입이 아닙니다. 기대: ${issue.expected}, 실제: ${issue.received}` };
}
if (issue.code === z.ZodIssueCode.invalid_string && issue.validation === 'email') {
return { message: '이메일 형식이 올바르지 않습니다.' };
}
return { message: ctx.defaultError };
};
z.setErrorMap(koStringErrorMap);
에러 맵은 DX와 사용자 언어를 맞추는 데 효과적이지만, 도메인별 문구는 superRefine 쪽이 더 명확할 때가 많습니다. 역할을 나누는 것을 권장합니다.
4. 조건부 스키마: discriminatedUnion
4.1 왜 discriminated union인가
여러 객체 형태를 z.union([A, B, C])로만 묶으면, Zod는 후보를 순회하며 파싱을 시도합니다. 판별 필드가 있을 때는 discriminatedUnion이 분기에 상수 시간에 가깝게 동작하고, 실패 메시지도 판별자 기준으로 정리되기 쉽습니다.
const SlackPayload = z.discriminatedUnion('type', [
z.object({
type: z.literal('message'),
channel: z.string(),
text: z.string().min(1),
}),
z.object({
type: z.literal('reaction'),
channel: z.string(),
name: z.string(),
}),
]);
type SlackPayload = z.infer<typeof SlackPayload>;
type 필드가 리터럴 유니온으로 좁혀지므로, TypeScript 추론도 분기마다 정확해집니다.
4.2 판별자 설계
판별자는 외부 계약에서 안정적인 값이어야 합니다. 문자열 리터럴이 가장 흔하고, 숫자·enum 스타일도 가능합니다. 사용자 입력 문자열을 판별자로 쓰는 것은 오타·대소문자 문제로 불안정할 수 있어, API에서는 명시적 enum을 권장합니다.
4.3 union + refine과의 선택
판별 필드가 없고 형태만으로 구분해야 한다면 union과 refine을 조합해야 합니다. 이 경우 성능과 오류 가독성이 떨어질 수 있으므로, 가능하면 명시적 태그 필드를 계약에 추가하는 편이 장기적으로 유리합니다.
5. 변환과 전처리: transform, preprocess, pipe
5.1 transform: 파싱 이후 정규화
외부 데이터는 문자열·숫자 혼재, 공백, 대소문자 불일치가 흔합니다. transform으로 정규화된 출력 타입을 고정하면, 이후 로직이 단순해집니다.
const Email = z.string().trim().toLowerCase().email();
const Quantity = z
.string()
.transform((s) => s.trim())
.pipe(z.coerce.number().int().positive());
pipe는 이전 단계의 출력을 다음 스키마의 입력으로 넘깁니다. 문자열 → 숫자처럼 타입이 바뀌는 단계를 명시적으로 쪼갤 때 유용합니다.
5.2 preprocess: 들어오기 전에 손보기
JSON으로는 항상 문자열인 숫자 필드, “true”/“false” 문자열 불리언 등을 다룰 때 preprocess가 적합합니다.
const Boolish = z.preprocess((val) => {
if (val === 'true' || val === true) return true;
if (val === 'false' || val === false) return false;
return val;
}, z.boolean());
주의: preprocess는 타입이 불명확한 입력을 받으므로, 지나치게 관대해지면 실패 지점이 멀어집니다. 가능한 좁은 입력에만 적용하고, 나머지는 엄격한 스키마로 걸러내는 편이 안전합니다.
5.3 기본값·대체값: default, catch
서버 내부 생성 레코드에는 default가 잘 맞고, 부분적 복구가 필요하면 catch를 쓸 수 있습니다. 다만 API 입력 검증에서 catch로 무음 복구하는 것은 보안·감사 측면에서 위험할 수 있어, 신뢰 경계 안쪽에서만 제한적으로 사용하는 것이 좋습니다.
6. 성능 최적화
6.1 경계에서 한 번만 파싱
가장 효과가 큰 최적화는 요청당 단일 safeParse/parse입니다. 컨트롤러·핸들러에서 이미 통과한 객체를 다시 전체 스키마로 검증하는 중복은 피합니다. 내부 함수는 z.infer<typeof Schema> 타입만 받도록 분리하세요.
6.2 discriminatedUnion과 일반 union
가능하면 판별자 기반 union을 사용합니다. 후보 스키마가 많고 입력이 큰 경우, 일반 union의 순차 시도 비용이 누적될 수 있습니다.
6.3 superRefine 비용
superRefine 안에서 동기 DB 조회를 넣는 패턴은 편해 보이지만, 처리량이 높으면 병목이 됩니다. 존재 검증은 가능하면 DB 제약·서비스 계층으로 옮기고, Zod에는 형태 검증과 가벼운 규칙만 남기는 구성이 일반적입니다. 비동기 refine을 쓰는 경우도 있으나, 프레임워크 지원 여부를 반드시 확인해야 합니다.
6.4 재귀적 구조: lazy
트리·중첩 카테고리처럼 재귀 타입은 z.lazy로 표현합니다. 정의 순환을 끊어 스택 한계를 피합니다.
type Json = string | number | boolean | null | Json[] | { [k: string]: Json };
const JsonSchema: z.ZodType<Json> = z.lazy(() =>
z.union([
z.string(),
z.number(),
z.boolean(),
z.null(),
z.array(JsonSchema),
z.record(JsonSchema),
]),
);
6.5 스키마 재사용과 객체 생성 비용
스키마 상수는 모듈 로드 시 한 번 만들고 재사용합니다. 요청마다 z.object({...})를 동적으로 새로 생성하면 GC 부담이 될 수 있습니다. 다만 소규모 객체는 실측 전까지 미세 최적화로 볼 수 있습니다.
7. React Hook Form과 통합
@hookform/resolvers의 zodResolver를 쓰면, 폼 값이 스키마를 통과할 때만 제출 단계로 진행하기 쉽습니다.
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
const FormSchema = z.object({
title: z.string().min(1, '제목을 입력하세요.'),
body: z.string().min(10, '본문은 10자 이상입니다.'),
});
type FormValues = z.infer<typeof FormSchema>;
export function EditorForm() {
const form = useForm<FormValues>({
resolver: zodResolver(FormSchema),
defaultValues: { title: '', body: '' },
mode: 'onBlur',
});
return (
<form onSubmit={form.handleSubmit((data) => console.log(data))}>
{/* 필드 바인딩 생략 */}
</form>
);
}
포인트
mode:onChange는 입력 중 검증이 잦아집니다. 긴 폼은onBlur나onSubmit위주로 두고, 중요 필드만trigger로 부분 검증합니다.- 기본값:
defaultValues가 스키마와 맞지 않으면 초기 렌더에서 불일치가 납니다. discriminated union이면 태그 필드부터 초기화하세요. - 서버 스키마 분리: 클라이언트는 UX용 메시지, 서버는 보안·권한 규칙을 추가한 상위 스키마를 두는 이중화가 흔합니다.
8. tRPC와 통합
tRPC에서는 procedure 입력에 Zod를 직접 연결하는 패턴이 널리 쓰입니다. 입력 타입이 프로시저 시그니처에 녹아들고, 런타임에서 자동 검증됩니다.
import { z } from 'zod';
import { publicProcedure, router } from '../trpc';
const listInput = z.object({
orgId: z.string().uuid(),
cursor: z.string().optional(),
limit: z.number().min(1).max(100).default(20),
});
export const itemRouter = router({
list: publicProcedure.input(listInput).query(async ({ ctx, input }) => {
// input은 z.infer<typeof listInput>
return ctx.repo.items.list(input);
}),
});
미들웨어에서 컨텍스트를 강화한 뒤, procedure 입력 스키마는 비즈니스 식별자 위주로 유지하는 구성이 읽기 쉽습니다. 출력 스키마를 별도로 두는 팀도 있는데, 이는 과도한 데이터 노출 방지와 계약 문서화에 도움이 됩니다.
9. 실전 API 검증 시스템
9.1 계층: 전송 → 도메인 → 영속
실무에서는 다음처럼 나눕니다.
- 전송 계층: JSON body·query·headers·multipart를 각각 스키마로 파싱
- 도메인 계층: 권한·상태 전이·불변식은 서비스에서 검증 (Zod만으로 부족한 부분)
- 영속 계층: DB 제약과 트랜잭션
Zod는 주로 1번에 집중하고, 2번은 도메인 로직과 함께 설계합니다.
9.2 safeParse와 HTTP 상태 코드
컨트롤러에서는 safeParse로 결과를 나누고, 실패 시 400 Bad Request와 구조화된 바디를 반환하는 패턴이 일반적입니다.
import { z } from 'zod';
const Body = z.object({ email: z.string().email() });
export function parseOr400(data: unknown) {
const r = Body.safeParse(data);
if (!r.success) {
return {
ok: false as const,
status: 400,
issues: r.error.flatten(),
};
}
return { ok: true as const, data: r.data };
}
flatten은 폼 필드 매핑에 편하고, format·커스텀 직렬화는 API 표준에 맞춥니다.
9.3 버전별 스키마와 호환
공개 API는 버전 필드 또는 콘텐츠 타입으로 스키마를 선택합니다. discriminatedUnion으로 version: 1 | 2를 두고, 내부에서는 최신 도메인 모델로 정규화하는 어댑터를 둡니다.
9.4 로깅과 보안
검증 실패 로그에는 원본 전체 바디를 남기지 않는 정책이 필요할 수 있습니다(PII·토큰). 필드 마스킹된 요약만 기록하세요.
10. 정리
Zod의 고급 패턴은 한 가지 목표로 수렴합니다. «불신하는 경계»에서 데이터를 걸러내고, 타입 추론으로 내부는 단순하게 유지하는 것입니다. discriminatedUnion으로 조건부 형태를 명시하고, superRefine으로 도메인 규칙을 표현하며, transform·preprocess로 정규화를 파이프라인에 넣으면 실무 요구에 잘 맞습니다. React Hook Form과 tRPC는 각각 폼과 프로시저 입력이라는 경계에 Zod를 붙이기 좋고, API 전체로는 계층형 검증과 오류 응답 표준을 함께 설계하는 것이 안전합니다.
배포 전에는 git add, commit, push 후 npm run deploy를 실행하는 워크플로를 따르시기 바랍니다.
참고로 알아두면 좋은 점
- Zod 스키마는 런타임 라이브러리입니다. 번들 크기가 민감한 클라이언트에서는 스키마 분할·지연 로딩을 검토합니다.
- 동일 스키마를 클라이언트·서버에서 공유하려면 모노레포 패키지나 경로 별칭으로 import 경로를 맞춥니다.
- 테스트에는 대표적인 성공·실패 케이스와 판별자 경계를 반드시 포함합니다.