Zod 심화 — preprocess, custom, transform, pipe 제대로 이해하기

이명진·2026년 1월 15일

TIL

목록 보기
20/22

어제 Zod 기본 정리를 하면서
preprocess, custom, transform, pipe 같은 API를 처음 봤을 때는
“다 비슷한 역할 아닌가?”라는 생각이 들었다.

처음엔
-> 그냥 취향껏 하나 골라 쓰는 건가?
-> 다 커스텀하는 방법 아닌가?

하지만 더 알아보니 각각 사용하는 의미와 목적이 완전히 달랐다.
이번 글에서는 이 차이를 명확하게 정리해보려고 한다.


한눈에 보는 예제 코드

// 1. 스키마 전처리
const pre1 = z.preprocess(val => {
  return typeof val === 'string' ? val.toUpperCase() : val
}, z.string())

pre1.parse('abc') // 'ABC'
pre1.parse(123)   // ❌

// 2. 스키마 사용자 정의
const cus1 = z.custom(val => {
  return typeof val === 'string' ? /[A-Z]/.test(val) : false
})

cus1.parse('ABC') // 'ABC'
cus1.parse(123)   // ❌

// 3. transform 단독 사용
const tra1 = z.transform(val => {
  return typeof val === 'string' ? val.toUpperCase() : val
})

tra1.parse('abc') // 'ABC'
tra1.parse(123)   // 123 ❗

// 4. schema.transform
const tra2 = z.string().transform(val => val.toUpperCase())

tra2.parse('abc') // 'ABC'
tra2.parse(123)   // ❌

// 5. pipe
const pip1 = z.pipe(
  z.string(),
  z.transform(val => val.length)
)

pip1.parse('abc') // 3
pip1.parse(123)   // ❌

const pip2 = z.string().pipe(
  z.transform(val => val.length)
)

pip2.parse('abc') // 3
pip2.parse(123)   // ❌

1️⃣ z.preprocess

-> 검증 이전에 개입

z.preprocess(fn, schema)

역할

  • 입력값을 검증 전에 정리
  • 타입이 엉망이어도 일단 받아줌 (unknown)
    언제 쓸까 ?
  • query string -> number
  • null -> default 값
  • 문자열 정규화(trim, lower/upper case)
    📌 “검증을 가능하게 만들기 위한 사전 정리”
z.preprocess(v => Number(v), z.number())

2️⃣ z.custom

-> 검증만 직접 정의

z.custom(validator)

역할

  • “이 값이 유효한지”만 판단
  • 변환 ❌

특징

  • 반환값은 입력 그대로
  • TypeScript 타입 추론 거의 포기
    📌 “Zod로 표현하기 힘든 특수 규칙”
-> 웬만하면 refine가 더 낫다

3️⃣ z.transform (⚠️ 단독 사용)

z.transform(fn)

이건 사실상 비추천이다.

tra1.parse(123) // 123 ❗

왜 문제인가?

  • 검증이 없음
  • any -> any 파이프라인
  • 잘못된 타입이 그대로 통과됨
    📌 “검증 없는 변환기”
-> 실무에서는 거의 사용하지 않음
-> 디버깅 지옥의 원인이 된다

4️⃣ schema.transform

-> 검증 후 변환 (정석)

z.string().transform(...)

특징

  • 타입 검증 ✅
  • 변환 ✅
  • 타입 추론 완벽 ✅
z.string()
  .min(1)
  .transform(v => v.toUpperCase())

📌 가장 많이 쓰는 패턴이라고 한다

5️⃣ z.pipe / schema.pipe

-> 스키마 -> 스키마 연결

z.string().pipe(z.number())

z.string().pipe(
  z.transform(v => v.length)
)

역할

  • 입력 타입과 출력 타입이 완전히 다를 때
  • 단계적 검증 / 변환
    📌 “파이프라인을 명시적으로 나누고 싶을 때”

정리 하는 표

API검증 전검증변환타입 안전주 용도
preprocess입력 정리
custom특수 검증
transform (단독)❌ 거의 안 씀
schema.transform정석
pipe단계 분리

refine vs superRefine 차이

refine

  • 단일 값 검증
  • boolean 또는 조건 기반
z.string().refine(v => v.length > 3)

superRefine

  • 여러 필드 조합 검증
  • 에러를 직접 추가 가능
z.object({
  password: z.string(),
  confirm: z.string(),
}).superRefine((data, ctx) => {
  if (data.password !== data.confirm) {
    ctx.addIssue({
      path: ['confirm'],
      message: '비밀번호가 일치하지 않습니다',
      code: z.ZodIssueCode.custom,
    })
  }
})

📌 필드 간 관계 검증은 superRefine

마무리

처음에는 다 비슷해 보였던 Zod API들이
알고 보니 각자 명확한 책임과 쓰임새를 가지고 있었다.

  • preprocess -> 입력 정리
  • custom -> 특수 검증
  • schema.transform -> 정석
  • pipe -> 단계 분리
    이걸 구분해서 쓰는 순간
Zod가 단순한 validation 라이브러리가 아니라
입력 경계를 설계하는 도구라는 게 느껴졌다.

출처

profile
프론트엔드 개발자 초보에서 고수까지!

0개의 댓글