Zod로 풀스택 타입 검증: 프론트엔드부터 백엔드까지 단일 스키마

프론트엔드

ZodTypeScript스키마 검증풀스택API 검증

이 글은 누구를 위한 것인가

  • API 요청/응답을 안전하게 검증하고 싶은 팀
  • 환경변수 타입 안전성을 Zod로 보장하려는 개발자
  • 프론트엔드 폼 검증과 백엔드 API 검증에 같은 스키마를 쓰려는 팀

들어가며

TypeScript는 컴파일 시점 타입 안전성을 제공하지만 런타임에는 검증하지 않는다. Zod는 런타임에 데이터를 검증하고 TypeScript 타입을 동시에 생성한다. 하나의 스키마로 폼 검증, API 검증, 환경변수 검증을 모두 처리한다.

이 글은 bluefoxdev.kr의 Zod 풀스택 검증 가이드 를 참고하여 작성했습니다.


1. Zod 고급 패턴

[Zod 핵심 메서드]

.parse(): 성공 시 값 반환, 실패 시 throw
.safeParse(): { success: true, data } 또는 { success: false, error }
.parseAsync(): 비동기 검증
.shape: 객체 내부 스키마 접근
.partial(): 모든 필드를 optional로
.required(): 모든 필드를 required로
.pick({ field: true }): 일부 필드만 선택
.omit({ field: true }): 일부 필드 제외
.extend({ newField: z.string() }): 필드 추가
.merge(otherSchema): 두 스키마 합치기

.transform(): 값 변환 (타입 변경 가능)
.preprocess(): 검증 전 전처리
.refine(): 커스텀 검증 (타입 변경 없음)
.superRefine(): 복잡한 커스텀 검증

[Zod 추론 패턴]
  z.infer<typeof schema>: 스키마에서 타입 추론
  z.input<typeof schema>: input 타입 (transform 전)
  z.output<typeof schema>: output 타입 (transform 후)

2. Zod 풀스택 구현 패턴

// 공유 스키마 (프론트/백엔드 공통 패키지)
// packages/schemas/src/product.ts
import { z } from 'zod';

// 기본 스키마
export const productSchema = z.object({
  id: z.string().cuid().optional(),
  
  name: z.string()
    .min(2, { message: '상품명은 2자 이상 입력해주세요' })
    .max(100, { message: '상품명은 100자 이하로 입력해주세요' })
    .trim(),  // 앞뒤 공백 제거
  
  // 문자열 입력 → 숫자로 변환
  price: z.coerce.number()
    .positive({ message: '가격은 0보다 커야 합니다' })
    .transform(val => Math.round(val)),  // 소수점 제거
  
  // 빈 문자열을 undefined로 처리
  description: z.string().trim()
    .transform(val => val || undefined)
    .optional(),
  
  category: z.enum(['electronics', 'clothing', 'food', 'books'], {
    errorMap: () => ({ message: '유효하지 않은 카테고리입니다' }),
  }),
  
  // 태그: 쉼표 구분 문자열 → 배열 변환
  tags: z.preprocess(
    (val) => typeof val === 'string' ? val.split(',').map(t => t.trim()).filter(Boolean) : val,
    z.array(z.string().min(1)).max(10)
  ),
  
  stock: z.coerce.number().int().min(0).default(0),
  
  isPublished: z.boolean().default(false),
  
  // 날짜: 문자열 → Date 변환
  createdAt: z.coerce.date().optional(),
});

// 생성용 스키마 (id, createdAt 제외)
export const createProductSchema = productSchema.omit({ id: true, createdAt: true });

// 수정용 스키마 (모든 필드 optional)
export const updateProductSchema = createProductSchema.partial();

// 쿼리 파라미터 스키마
export const productQuerySchema = z.object({
  category: z.enum(['electronics', 'clothing', 'food', 'books']).optional(),
  search: z.string().min(1).optional(),
  page: z.coerce.number().int().positive().default(1),
  limit: z.coerce.number().int().min(1).max(100).default(20),
  sortBy: z.enum(['name', 'price', 'createdAt']).default('createdAt'),
  order: z.enum(['asc', 'desc']).default('desc'),
});

// 타입 추론
export type Product = z.infer<typeof productSchema>;
export type CreateProduct = z.infer<typeof createProductSchema>;
export type ProductQuery = z.infer<typeof productQuerySchema>;
// 백엔드 API 검증
// app/api/products/route.ts

import { NextRequest } from 'next/server';
import { productQuerySchema, createProductSchema } from '@myapp/schemas';

export async function GET(request: NextRequest) {
  // URL 쿼리 파라미터 검증
  const rawParams = Object.fromEntries(request.nextUrl.searchParams);
  const result = productQuerySchema.safeParse(rawParams);
  
  if (!result.success) {
    return Response.json(
      { error: '잘못된 쿼리 파라미터', details: result.error.flatten() },
      { status: 400 }
    );
  }
  
  const { page, limit, category, search, sortBy, order } = result.data;
  
  const products = await db.product.findMany({
    where: {
      ...(category && { category }),
      ...(search && { name: { contains: search } }),
    },
    orderBy: { [sortBy]: order },
    skip: (page - 1) * limit,
    take: limit,
  });
  
  return Response.json({ products, page, limit });
}

export async function POST(request: NextRequest) {
  const body = await request.json();
  
  const result = createProductSchema.safeParse(body);
  
  if (!result.success) {
    // 필드별 에러 메시지 구조화
    return Response.json(
      {
        error: '입력값 검증 실패',
        fieldErrors: result.error.flatten().fieldErrors,
      },
      { status: 422 }
    );
  }
  
  const product = await db.product.create({ data: result.data });
  return Response.json(product, { status: 201 });
}
// 환경변수 검증 (빌드 시점에 실패!)
import { z } from 'zod';

const envSchema = z.object({
  NODE_ENV: z.enum(['development', 'test', 'production']),
  
  DATABASE_URL: z.string().url(),
  
  NEXTAUTH_SECRET: z.string().min(32, '최소 32자 필요'),
  NEXTAUTH_URL: z.string().url(),
  
  STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
  STRIPE_WEBHOOK_SECRET: z.string().startsWith('whsec_'),
  NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: z.string().startsWith('pk_'),
  
  REDIS_URL: z.string().url().optional(),
  
  // 숫자 파싱
  RATE_LIMIT_MAX: z.coerce.number().int().positive().default(100),
  SESSION_MAX_AGE: z.coerce.number().int().positive().default(30 * 24 * 60 * 60),
});

// 서버 시작 시 즉시 검증
export const env = envSchema.parse(process.env);

// 사용: env.DATABASE_URL (타입 안전!)
// 에러 메시지 국제화
import { z, ZodError } from 'zod';

const koreanErrors: Record<string, string> = {
  invalid_type: '유효하지 않은 타입입니다',
  too_small: '값이 너무 작습니다',
  too_big: '값이 너무 큽니다',
  invalid_string: '유효하지 않은 문자열입니다',
  invalid_enum_value: '허용되지 않는 값입니다',
};

function formatZodErrors(error: ZodError): Record<string, string> {
  return error.issues.reduce((acc, issue) => {
    const field = issue.path.join('.');
    acc[field] = issue.message;
    return acc;
  }, {} as Record<string, string>);
}

// API 응답에서 에러 처리
async function createProduct(data: unknown) {
  const result = createProductSchema.safeParse(data);
  
  if (!result.success) {
    const fieldErrors = formatZodErrors(result.error);
    throw new ValidationError(fieldErrors);
  }
  
  return result.data;
}

class ValidationError extends Error {
  constructor(public fieldErrors: Record<string, string>) {
    super('Validation failed');
  }
}

// 중첩 오브젝트 스키마
const addressSchema = z.object({
  street: z.string().min(1),
  city: z.string().min(1),
  postalCode: z.string().regex(/^\d{5}$/, '우편번호는 5자리 숫자'),
  country: z.string().length(2).toUpperCase(),
});

const userSchema = z.object({
  name: z.string().min(2),
  email: z.string().email(),
  age: z.number().int().min(0).max(150).optional(),
  address: addressSchema.optional(),
  
  // 유니온 타입
  role: z.discriminatedUnion('type', [
    z.object({ type: z.literal('admin'), permissions: z.array(z.string()) }),
    z.object({ type: z.literal('user'), tier: z.enum(['free', 'pro']) }),
  ]),
  
  // 상호 의존 검증
  password: z.string().min(8),
  confirmPassword: z.string(),
}).refine(
  data => data.password === data.confirmPassword,
  { message: '비밀번호가 일치하지 않습니다', path: ['confirmPassword'] }
);

const db = { product: { findMany: async (q: any) => [], create: async (d: any) => d } };

마무리

Zod의 핵심 가치는 "하나의 스키마, 두 가지 결과 (타입 + 런타임 검증)"다. createProductSchema.partial()로 수정용 스키마를, .omit()으로 생성용 스키마를 파생하면 중복 없이 일관된 검증을 유지한다. 환경변수 검증은 z.object().parse(process.env)를 앱 시작 시 실행해서 잘못된 환경변수로 인한 런타임 오류를 배포 시점에 잡는다. 프론트엔드와 백엔드가 같은 스키마 패키지를 공유하면 API 계약이 자동으로 강제된다.