Til_0127

Zod 복잡한 폼 검증 메서드 완벽 가이드

1. transform - 데이터 변환

검증 후 데이터를 원하는 형태로 변환할 때 사용합니다.

// 문자열을 숫자로 변환
const ageSchema = z
  .string()
  .transform((val) => parseInt(val, 10))
  .refine((val) => !isNaN(val), '숫자를 입력해주세요')

// 날짜 문자열을 Date 객체로
const dateSchema = z.string().transform((str) => new Date(str))

// 여러 단계 변환
const priceSchema = z
  .string()
  .transform((val) => val.replace(/,/g, '')) // 쉼표 제거
  .transform((val) => parseFloat(val)) // 숫자 변환
  .refine((val) => val > 0, '0보다 커야 합니다')

2. preprocess - 검증 전 전처리

검증 전에 데이터를 정제하거나 변환할 때 사용합니다.

// 공백 제거 후 검증
const trimmedString = z.preprocess(
  (val) => (typeof val === 'string' ? val.trim() : val),
  z.string().min(1, '필수 입력입니다'),
)

// 빈 문자열을 null로 변환
const optionalEmail = z.preprocess(
  (val) => (val === '' ? null : val),
  z.string().email().nullable(),
)

// 숫자 문자열을 자동으로 숫자로
const numberFromString = z.preprocess(
  (val) => (typeof val === 'string' ? Number(val) : val),
  z.number().positive(),
)

3. discriminatedUnion - 조건부 스키마

특정 필드 값에 따라 다른 스키마를 적용할 때 사용합니다.

const paymentSchema = z.discriminatedUnion('method', [
  z.object({
    method: z.literal('card'),
    cardNumber: z.string().length(16),
    cvv: z.string().length(3),
  }),
  z.object({
    method: z.literal('bank'),
    accountNumber: z.string(),
    bankName: z.string(),
  }),
  z.object({
    method: z.literal('cash'),
    // 추가 필드 없음
  }),
])

// 사용 예시
// method가 'card'면 cardNumber, cvv 필수
// method가 'bank'면 accountNumber, bankName 필수

4. pipe - 스키마 체이닝

여러 스키마를 순차적으로 적용할 때 사용합니다.

// 문자열 검증 후 변환, 그 후 숫자 검증
const schema = z
  .string()
  .min(1)
  .pipe(z.coerce.number())
  .pipe(z.number().min(0).max(100))

// 날짜 변환 후 검증
const futureDate = z
  .string()
  .pipe(z.coerce.date())
  .pipe(z.date().min(new Date(), '미래 날짜여야 합니다'))

5. partial / required / pick / omit - 스키마 조작

기존 스키마를 부분적으로 수정할 때 사용합니다.

const userSchema = z.object({
  name: z.string(),
  email: z.string().email(),
  age: z.number(),
  phone: z.string(),
})

// 모든 필드를 optional로
const partialUser = userSchema.partial()
// { name?: string, email?: string, ... }

// 특정 필드만 optional로
const partialEmail = userSchema.partial({ email: true })

// 모든 필드를 required로 (optional을 제거)
const requiredUser = userSchema.required()

// 특정 필드만 선택
const loginSchema = userSchema.pick({ email: true, password: true })

// 특정 필드 제외
const publicUser = userSchema.omit({ password: true })

6. merge / extend - 스키마 합성

여러 스키마를 결합할 때 사용합니다.

const baseUser = z.object({
  name: z.string(),
  email: z.string().email(),
})

const timestamps = z.object({
  createdAt: z.date(),
  updatedAt: z.date(),
})

// 두 스키마 병합
const userWithTimestamps = baseUser.merge(timestamps)

// extend는 merge의 별칭
const extendedUser = baseUser.extend({
  age: z.number(),
  phone: z.string(),
})

7. passthrough / strict / strip - 추가 속성 처리

스키마에 정의되지 않은 필드를 어떻게 처리할지 결정합니다.

const strictSchema = z
  .object({
    name: z.string(),
  })
  .strict() // 추가 필드 있으면 에러

const passthroughSchema = z
  .object({
    name: z.string(),
  })
  .passthrough() // 추가 필드 허용, 통과

const stripSchema = z
  .object({
    name: z.string(),
  })
  .strip() // 추가 필드 제거 (기본값)

8. catchall - 동적 키 검증

키를 미리 알 수 없는 객체를 검증할 때 사용합니다.

// 모든 추가 필드는 문자열이어야 함
const dynamicSchema = z
  .object({
    id: z.number(),
  })
  .catchall(z.string())

// 사용 예시: { id: 1, anyKey: "value", anotherKey: "value2" }

// 실전 예시: 다국어 지원
const i18nSchema = z
  .object({
    id: z.string(),
  })
  .catchall(z.string()) // ko, en, ja 등 동적 언어 키

9. lazy - 재귀적 스키마

자기 자신을 참조하는 스키마를 만들 때 사용합니다.

// 트리 구조
type Category = {
  name: string
  subcategories?: Category[]
}

const categorySchema: z.ZodType<Category> = z.lazy(() =>
  z.object({
    name: z.string(),
    subcategories: z.array(categorySchema).optional(),
  }),
)

// 댓글과 대댓글
const commentSchema: z.ZodType<any> = z.lazy(() =>
  z.object({
    text: z.string(),
    replies: z.array(commentSchema).optional(),
  }),
)

10. brand - 타입 브랜딩

같은 타입이지만 의미적으로 구분이 필요할 때 사용합니다.

const userId = z.string().brand<'UserId'>()
const productId = z.string().brand<'ProductId'>()

type UserId = z.infer<typeof userId>
type ProductId = z.infer<typeof productId>

// TypeScript 레벨에서 구분됨
function getUser(id: UserId) {
  /* ... */
}
function getProduct(id: ProductId) {
  /* ... */
}

// getUser(productId); // 타입 에러!

실전 조합 예시

복잡한 회원가입 폼에서 여러 메서드를 조합한 예시입니다.

// 복잡한 회원가입 폼
const signupSchema = z
  .object({
    // 전처리 + 검증
    email: z.preprocess(
      (val) => (typeof val === 'string' ? val.trim().toLowerCase() : val),
      z.string().email('올바른 이메일을 입력해주세요'),
    ),

    // 변환 + 검증
    age: z
      .string()
      .transform((val) => parseInt(val, 10))
      .refine((val) => !isNaN(val) && val >= 18, '18세 이상이어야 합니다'),

    password: z.string().min(8),
    confirmPassword: z.string(),

    // 조건부 필드
    accountType: z.enum(['personal', 'business']),
  })
  .and(
    z.discriminatedUnion('accountType', [
      z.object({
        accountType: z.literal('personal'),
        firstName: z.string(),
        lastName: z.string(),
      }),
      z.object({
        accountType: z.literal('business'),
        companyName: z.string(),
        businessNumber: z.string(),
      }),
    ]),
  )
  .refine((data) => data.password === data.confirmPassword, {
    message: '비밀번호가 일치하지 않습니다',
    path: ['confirmPassword'],
  })

마치며

이런 메서드들을 조합하면 거의 모든 복잡한 폼 검증을 처리할 수 있습니다!