je개발 회고

[ 데이터 품질 관리 ] (3) Frontend : Zod 스키마와 API 검증

Je-chan 2025. 8. 18. 14:40

Zod 스키마와 API 검증

이 글에서는 이번 데이터 품질 관리 시스템에서 핵심 로직이라 할 수 있는 Zod를 활용한 런타임 타입 검증 시스템의 실전 구현 방법을 다룹니다. TypeScript의 컴파일 타임 타입 체크만으로는 해결할 수 없는 런타임 타입 안전성 문제를 어떻게 해결했는지 상세히 설명합니다.

🤔 왜 Zod인가? - 기술 선택의 배경

문제 상황: TypeScript만으로는 부족하다

TypeScript는 컴파일 타임에 타입 안전성을 보장하지만, 런타임에서 외부 API로부터 받는 데이터의 타입은 보장할 수 없습니다. 다음과 같은 상황에서 문제가 발생합니다.

// TypeScript 인터페이스 정의
interface ApiResponseData {
  totalValue: number;
  timestamp: string;
  status: number;
}

// API 응답이 인터페이스와 다를 때 문제 발생
const fetchData = async (): Promise<ApiResponseData> => {
  const response = await fetch('/api/data');
  return response.json(); // 💥 타입 단언이지만 실제 검증은 없음
};

// 실제 런타임 데이터
const actualApiResponse = {
  totalValue: null,           // number 대신 null
  timestamp: undefined,       // string 대신 undefined  
  status: "active"            // number 대신 string
};

// 결과: 컴파일은 성공하지만 런타임에 크래시
const data = await fetchData();
console.log(data.totalValue.toFixed(2)); // 💥 Cannot read property 'toFixed' of null
TypeScript 타입 시스템의 한계
TypeScript는 컴파일 타임에만 존재하는 타입 시스템입니다. JavaScript로 변환된 후에는 모든 타입 정보가 사라지기 때문에, 런타임에서 실제로 받는 데이터가 예상한 타입과 다를 수 있습니다.

기술 선택지 비교 분석

런타임 타입 검증을 위한 여러 라이브러리를 비교 분석한 결과입니다.

라이브러리 장점 단점 선택 이유
Zod • TypeScript 타입 자동 추론
• 풍부한 변환 기능
• 체이닝 문법
• 활발한 커뮤니티
• 번들 크기 (2.7KB gzipped)
• 학습 곡선
✅ 채택: 타입 추론의 강력함
Yup • 성숙한 라이브러리
• 폼 검증에 특화
• TypeScript 지원 부족
• 타입 추론 한계
❌ 타입 안전성 부족
Joi • 매우 강력한 검증
• 상세한 에러 메시지
• 브라우저 지원 부족
• 큰 번들 크기
❌ 클라이언트 부적합
io-ts • 함수형 프로그래밍
• 완벽한 타입 안전성
• 가파른 학습 곡선
• 복잡한 문법
❌ 팀 생산성 저하

Zod 선택의 결정적 이유

Zod의 가장 큰 장점은 스키마에서 TypeScript 타입을 자동으로 추론해준다는 점입니다.

// Zod의 타입 자동 추론 마법
const apiDataSchema = z.object({
  totalValue: z.coerce.number(),
  timestamp: z.string(),
  status: z.number()
});

// TypeScript 타입이 자동으로 추론됨! 🎉
type ApiDataType = z.infer<typeof apiDataSchema>;
// 결과: { totalValue: number; timestamp: string; status: number; }

// 스키마 = 타입 = 런타임 검증이 모두 하나로 통합
const validateApiData = (data: unknown): ApiDataType => {
  return apiDataSchema.parse(data); // 검증 + 타입 가드 + 변환
};
Zod 선택의 결정적 이유들
  1. Single Source of Truth: 스키마 하나로 타입 + 검증 + 문서화가 모두 가능합니다
  2. 개발자 경험: IDE 자동완성, 리팩토링 지원이 완벽합니다
  3. 점진적 도입: 기존 코드에 단계적으로 적용할 수 있습니다
  4. 성능: 프로덕션에서 검증 로직을 선택적으로 제거할 수 있습니다

🏗️ 스키마 설계 패턴과 실전 예시

1. 기본 스키마 패턴

환경에 따라 다른 검증 전략을 적용하여 개발 환경에서는 엄격하게, 운영 환경에서는 유연하게 검증할 수 있습니다.

// src/utilities/validation/schemas/apiData.zod.ts

// 🎯 패턴 1: 엄격한 검증 (개발 환경)
export const strictApiDataSchema = z.object({
  id: z.number().int().positive(),                    // 양의 정수만
  timestamp: z.string().datetime(),                   // ISO 8601 형식만
  value: z.number().min(0),                          // 음수 불허
  status: z.enum(['ACTIVE', 'INACTIVE', 'PENDING']), // 정확한 값만
  score: z.number().finite(),                        // 무한대 값 불허
});

// 🎯 패턴 2: 관대한 검증 (운영 환경)
export const lenientApiDataSchema = z.object({
  id: z.coerce.number(),           // 문자열도 숫자로 변환
  timestamp: z.union([             // 다양한 날짜 형식 허용
    z.string(),
    z.date(),
    z.number().transform(n => new Date(n))
  ]),
  value: z.coerce.number().nullable(), // null 허용
  status: z.string().default('ACTIVE'), // 기본값 제공
  score: z.coerce.number().default(0),  // 기본값 제공
});

// 🎯 패턴 3: 환경별 스키마 선택
export const apiDataSchema = process.env.NODE_ENV === 'development' 
  ? strictApiDataSchema 
  : lenientApiDataSchema;

2. 복잡한 데이터 구조 처리

실제 애플리케이션에서는 중첩된 객체와 배열이 포함된 복잡한 데이터 구조를 다뤄야 합니다.

// 중첩 객체와 배열이 포함된 복잡한 API 응답
export const complexDataSchema = z.object({
  metadata: z.object({
    timestamp: z.coerce.date(),
    source: z.string(),
    version: z.string().regex(/^\d+\.\d+\.\d+$/) // 시맨틱 버저닝
  }),
  
  items: z.array(
    z.object({
      id: z.string(),
      name: z.string(),
      price: z.number().positive(),
      quantity: z.number().min(0),
      discount: z.number().min(0).max(100), // 0-100% 범위
      category: z.enum(['ELECTRONICS', 'CLOTHING', 'BOOKS', 'OTHER']),
      lastUpdated: z.coerce.date().optional(),
    })
  ).min(1), // 최소 1개 아이템은 있어야 함
  
  summary: z.object({
    totalItems: z.number(),
    totalValue: z.number(),
    averagePrice: z.number(),
    // 계산된 필드는 검증으로 일관성 체크
  }).refine(data => data.totalValue >= 0, {
    message: "총 가격은 음수일 수 없습니다"
  })
});

3. 날짜/시간 처리의 복잡함

웹 애플리케이션에서 시간 데이터는 매우 중요하므로 엄격하게 검증해야 합니다.

// 다양한 형식의 날짜 입력을 허용하되 검증은 엄격하게
export const timeAwareDataSchema = z.object({
  createdAt: z.custom<Date>((val) => {
    // 다양한 형식의 날짜 입력 허용하되 검증은 엄격하게
    const date = dayjs(val);
    
    if (!date.isValid()) {
      throw new Error(`Invalid date format: ${val}`);
    }
    
    // 비즈니스 규칙: 미래 데이터는 허용하지 않음
    if (date.isAfter(dayjs())) {
      throw new Error("Future dates are not allowed");
    }
    
    // 비즈니스 규칙: 너무 오래된 데이터도 의심스러움
    if (date.isBefore(dayjs().subtract(1, 'year'))) {
      throw new Error("Data older than 1 year is suspicious");
    }
    
    return true;
  }).transform((val) => dayjs(val).toDate()),
  
  // 시간 범위 데이터 처리
  timeRange: z.object({
    start: z.coerce.date(),
    end: z.coerce.date()
  }).refine(data => data.start < data.end, {
    message: "시작 시간이 종료 시간보다 빨라야 합니다"
  })
});

4. 에러 메시지 커스터마이징

사용자에게 친화적인 에러 메시지를 제공하여 문제 해결을 쉽게 만들 수 있습니다.

// 사용자 친화적인 에러 메시지
export const userFriendlySchema = z.object({
  price: z.number({
    required_error: "가격 정보가 필요합니다",
    invalid_type_error: "가격은 숫자여야 합니다"
  }).min(0, "가격은 0 이상이어야 합니다"),
  
  productName: z.string({
    required_error: "상품명이 필요합니다"
  }).min(1, "상품명은 비어있을 수 없습니다")
    .max(100, "상품명은 100자를 초과할 수 없습니다"),
    
  location: z.object({
    lat: z.number().min(-90).max(90, "위도는 -90도에서 90도 사이여야 합니다"),
    lng: z.number().min(-180).max(180, "경도는 -180도에서 180도 사이여야 합니다")
  })
});

🎭 고급 Zod 패턴과 트릭

1. 조건부 스키마 (Conditional Schema)

데이터 타입에 따라 다른 검증 규칙을 적용해야 하는 경우에 사용하는 패턴입니다.

// 제품 타입에 따라 다른 검증 규칙 적용
const baseProductSchema = z.object({
  id: z.string(),
  name: z.string(),
  type: z.enum(['DIGITAL', 'PHYSICAL', 'SERVICE', 'SUBSCRIPTION']),
});

const productSchema = baseProductSchema.and(
  z.discriminatedUnion('type', [
    // 디지털 제품만의 특별한 필드
    z.object({
      type: z.literal('DIGITAL'),
      fileSize: z.number().positive(),
      downloadLimit: z.number().min(1).max(10), // 다운로드 제한
      format: z.enum(['PDF', 'MP4', 'ZIP']),
    }),
    
    // 물리적 제품만의 특별한 필드
    z.object({
      type: z.literal('PHYSICAL'),
      weight: z.number().positive(),
      dimensions: z.object({        // 제품 크기
        width: z.number().positive(),
        height: z.number().positive(),
        depth: z.number().positive(),
      }),
      shippingCost: z.number().min(0),
    }),
    
    // 서비스
    z.object({
      type: z.literal('SERVICE'),
      duration: z.number().positive(), // 서비스 기간 (시간)
      location: z.string(),            // 서비스 제공 위치
    }),
    
    // 구독 서비스
    z.object({
      type: z.literal('SUBSCRIPTION'),
      billingCycle: z.enum(['MONTHLY', 'YEARLY']),
      features: z.array(z.string()).min(1), // 구독 특징들
    })
  ])
);
조건부 스키마의 장점
TypeScript 타입 추론도 매우 정확하게 이루어집니다. type === 'DIGITAL'인 경우에만 fileSize, downloadLimit 등의 필드에 접근할 수 있습니다.

2. 스키마 재사용과 확장

기본 스키마를 만들고 이를 확장하여 코드 중복을 줄이고 일관성을 유지할 수 있습니다.

// 기본 타임스탬프 스키마
const timestampedSchema = z.object({
  createdAt: z.coerce.date(),
  updatedAt: z.coerce.date(),
});

// 기본 메타데이터 스키마
const metadataSchema = z.object({
  id: z.string().uuid(),
  version: z.number().int().positive(),
});

// 조합하여 새로운 스키마 생성
const dataWithMetadataSchema = timestampedSchema
  .merge(metadataSchema)
  .merge(z.object({
    value: z.number(),
    description: z.string(),
  }));

// 특정 필드만 선택
const dataSummarySchema = dataWithMetadataSchema.pick({
  id: true,
  value: true,
  updatedAt: true
});

// 특정 필드만 제외
const dataWithoutMetadataSchema = dataWithMetadataSchema.omit({
  id: true,
  version: true,
  createdAt: true,
  updatedAt: true
});

3. 성능 최적화 패턴

스키마 검증은 성능에 영향을 줄 수 있으므로 적절한 최적화가 필요합니다.

// 🐌 비효율적인 방법: 매번 새로운 스키마 생성
const createSchemaEveryTime = (strictMode: boolean) => {
  return z.object({
    value: strictMode ? z.number() : z.coerce.number(),
    name: z.string()
  });
};

// 🚀 효율적인 방법: 스키마 캐싱
const strictSchema = z.object({
  value: z.number(),
  name: z.string()
});

const lenientSchema = z.object({
  value: z.coerce.number(),
  name: z.string()
});

const getOptimizedSchema = (strictMode: boolean) => {
  return strictMode ? strictSchema : lenientSchema;
};

// 🚀 더 효율적인 방법: Lazy 평가
const lazyComplexSchema = z.lazy(() => 
  z.object({
    children: z.array(lazyComplexSchema).optional(), // 재귀 참조
    value: z.number()
  })
);

4. 에러 핸들링과 복구 전략

검증 실패 시에도 애플리케이션이 계속 동작할 수 있도록 우아한 에러 처리 전략이 필요합니다.

// 검증 실패 시 우아한 처리
export const safeValidateWithFallback = <T>(
  data: unknown,
  schema: z.ZodSchema<T>,
  fallback: T
): { success: true; data: T } | { success: false; data: T; errors: string[] } => {
  try {
    const validatedData = schema.parse(data);
    return { success: true, data: validatedData };
  } catch (error) {
    if (error instanceof z.ZodError) {
      // 상세한 에러 정보 추출
      const errors = error.errors.map(err => 
        `${err.path.join('.')}: ${err.message}`
      );
      
      // 부분적 복구 시도
      const partialData = attemptPartialRecovery(data, schema, fallback);
      
      return { 
        success: false, 
        data: partialData, 
        errors 
      };
    }
    
    // 예상치 못한 에러는 fallback 반환
    return { 
      success: false, 
      data: fallback, 
      errors: ['Unexpected validation error'] 
    };
  }
};

// 부분적 데이터 복구 시도
const attemptPartialRecovery = <T>(
  data: any,
  schema: z.ZodSchema<T>,
  fallback: T
): T => {
  // 스키마에서 필수 필드와 선택적 필드 구분
  const result = { ...fallback };
  
  if (typeof data === 'object' && data !== null) {
    Object.keys(fallback).forEach(key => {
      if (data[key] !== undefined && data[key] !== null) {
        try {
          // 개별 필드 검증 시도
          const fieldSchema = (schema as any).shape[key];
          if (fieldSchema) {
            result[key] = fieldSchema.parse(data[key]);
          }
        } catch {
          // 개별 필드 검증 실패해도 계속 진행
        }
      }
    });
  }
  
  return result;
};

🔧 실제 프로젝트 적용 사례

케이스 1: 사용자 프로필 데이터 검증

사용자 프로필 API에서 받아오는 데이터에 문제가 많은 경우의 검증 사례입니다.

// src/utilities/validation/schemas/userProfile.zod.ts

// 실제 API 응답 예시 (문제가 많은 데이터)
const problematicApiResponse = {
  "userId": "12345",
  "email": "user@example.com",
  "profileComplete": "true",              // boolean이 아닌 문자열
  "score": null,                          // null 값
  "preferences": {
    "theme": "invalid_theme",             // 잘못된 enum 값
    "notifications": "error_value"        // 잘못된 값
  }
};

// 강건한 검증 스키마
export const userProfileSchema = z.object({
  userId: z.string().min(1, "사용자 ID는 필수입니다"),
  
  email: z.string()
    .email("올바른 이메일 형식이 아닙니다")
    .transform(email => email.toLowerCase()),
    
  profileComplete: z.coerce.boolean()
    .transform(val => val ?? false), // null을 false로 변환
    
  score: z.coerce.number()
    .min(0, "점수는 음수일 수 없습니다")
    .max(100, "점수는 100을 초과할 수 없습니다")
    .nullable()
    .transform(val => val ?? 0), // null을 0으로 변환
    
  preferences: z.object({
    theme: z.enum(['LIGHT', 'DARK', 'AUTO'])
      .catch('LIGHT'), // 잘못된 값은 기본값으로 대체
    notifications: z.coerce.boolean()
      .catch(true) // 변환 실패 시 true로 대체
  })
});

// 검증 함수
export const validateUserProfile = <T>(
  data: T
): Promise<ValidationResult<T>> => {
  return validateApiResponseInBackground(
    data,
    userProfileSchema,
    "getUserProfile",
    {
      // 사용자 데이터는 민감하므로 엄격한 검증
      enableStrictValidation: true
    }
  );
};

케이스 2: 주문 데이터 검증

전자상거래 시스템에서 주문 데이터를 검증하는 복잡한 케이스입니다.

// src/utilities/validation/schemas/orderData.zod.ts

// 주문 데이터는 정확성이 매우 중요함
export const orderDataSchema = z.object({
  orderId: z.string().uuid("올바른 주문 ID 형식이 아닙니다"),
  
  items: z.array(
    z.object({
      productId: z.string().min(1),
      name: z.string().min(1, "상품명은 필수입니다"),
      price: z.coerce.number()
        .min(0, "가격은 음수일 수 없습니다")
        .max(1000000, "가격이 비정상적으로 높습니다"),
      quantity: z.coerce.number()
        .int("수량은 정수여야 합니다")
        .min(1, "수량은 1개 이상이어야 합니다")
        .max(999, "수량이 너무 많습니다"),
      category: z.string().default("기타")
    })
  ).min(1, "주문에는 최소 1개의 상품이 있어야 합니다"),
  
  shippingInfo: z.object({
    address: z.string().min(5, "주소는 5자 이상이어야 합니다"),
    zipCode: z.string()
      .regex(/^\d{5}(-\d{4})?$/, "올바른 우편번호 형식이 아닙니다"),
    recipientName: z.string().min(1, "수령인 이름은 필수입니다")
  }),
  
  payment: z.object({
    method: z.enum(['CARD', 'BANK_TRANSFER', 'MOBILE']),
    amount: z.coerce.number().min(0),
    currency: z.literal('KRW').default('KRW')
  }),
  
  orderDate: z.coerce.date()
    .refine(date => date <= new Date(), "주문 날짜는 미래일 수 없습니다")
  
}).refine(data => {
  // 비즈니스 로직 검증: 총 주문 금액과 결제 금액이 일치해야 함
  const totalItemPrice = data.items.reduce((sum, item) => 
    sum + (item.price * item.quantity), 0
  );
  return Math.abs(totalItemPrice - data.payment.amount) < 0.01;
}, {
  message: "총 상품 금액과 결제 금액이 일치하지 않습니다"
});

케이스 3: 실시간 데이터 스트림 검증

실시간으로 들어오는 데이터 스트림에 대한 검증 시스템입니다.

// src/utilities/validation/schemas/realtimeData.zod.ts

// 실시간 데이터 배열 검증
export const realtimeDataStreamSchema = z.array(
  z.object({
    timestamp: z.coerce.date(),
    metric: z.string().min(1),
    value: z.coerce.number().finite(),
    tags: z.record(z.string()).optional(),
    source: z.string().min(1)
  }).refine(data => {
    // 데이터가 5분 이내의 것인지 확인
    const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000);
    return data.timestamp >= fiveMinutesAgo;
  }, "데이터가 너무 오래되었습니다")
).transform(dataPoints => {
  // 시간순 정렬
  return dataPoints.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
}).refine(data => data.length > 0, {
  message: "실시간 데이터가 비어있습니다"
});

// 실시간 데이터 검증 함수
export const validateRealtimeDataStream = <T>(
  data: T
): Promise<ValidationResult<T>> => {
  return validateApiResponseInBackground(
    data,
    realtimeDataStreamSchema,
    "getRealtimeDataStream",
    {
      // 실시간 데이터는 빠른 처리가 중요
      enableDateFiltering: true,
      toleranceMs: 5 * 60 * 1000, // 5분 허용 오차
      enableRealTimeFiltering: true
    }
  );
};

🎯 성능 최적화와 실무 팁

1. 스키마 컴파일 최적화

스키마를 매번 새로 생성하지 않고 캐싱하여 성능을 향상시킬 수 있습니다.

// ❌ 성능이 좋지 않은 방법
const createDynamicSchema = (options: ValidationOptions) => {
  return z.object({
    field1: options.strict ? z.number() : z.coerce.number(),
    field2: options.includeField2 ? z.string() : z.string().optional(),
    // 매번 새로운 객체 생성으로 메모리 낭비
  });
};

// ✅ 성능 최적화된 방법
const schemaCache = new Map<string, z.ZodSchema>();

const getOptimizedSchema = (options: ValidationOptions) => {
  const cacheKey = `${options.strict}-${options.includeField2}`;
  
  if (!schemaCache.has(cacheKey)) {
    const schema = z.object({
      field1: options.strict ? z.number() : z.coerce.number(),
      field2: options.includeField2 ? z.string() : z.string().optional(),
    });
    schemaCache.set(cacheKey, schema);
  }
  
  return schemaCache.get(cacheKey)!;
};

2. 부분 검증 전략

큰 객체의 경우 중요한 필드를 먼저 검증하고, 나머지는 나중에 검증하는 전략입니다.

// 큰 객체는 필수 필드만 먼저 검증
const quickValidationSchema = z.object({
  id: z.string(),
  timestamp: z.coerce.date(),
  status: z.enum(['SUCCESS', 'ERROR']),
  // 중요한 필드만 먼저 검증
});

const fullValidationSchema = quickValidationSchema.extend({
  detailedData: z.object({
    // 복잡한 중첩 객체
    items: z.array(z.object({
      // ... 많은 필드들
    })),
    analytics: z.object({
      // ... 복잡한 계산 결과들
    })
  }).optional()
});

// 단계적 검증
export const validateApiData = async <T>(
  data: T
): Promise<ValidationResult<T>> => {
  // 1단계: 빠른 필수 검증
  try {
    quickValidationSchema.parse(data);
  } catch (error) {
    // 필수 검증 실패 시 즉시 로깅
    return validateApiResponseInBackground(data, quickValidationSchema, "quickCheck");
  }
  
  // 2단계: 상세 검증 (백그라운드)
  return validateApiResponseInBackground(data, fullValidationSchema, "fullCheck");
};

3. 에러 누적과 배치 처리

여러 API 응답을 한 번에 검증하여 효율성을 높일 수 있습니다.

// 여러 API 응답을 한 번에 검증
export const batchValidateApiResponses = async (
  responses: Array<{ name: string; data: unknown; schema: z.ZodSchema }>
): Promise<ValidationResult[]> => {
  const results = await Promise.allSettled(
    responses.map(async ({ name, data, schema }) => {
      try {
        const validated = schema.parse(data);
        return { success: true, name, data: validated };
      } catch (error) {
        return { success: false, name, data, error };
      }
    })
  );
  
  // 실패한 검증들을 배치로 로깅
  const failures = results
    .filter((result): result is PromiseRejectedResult => result.status === 'rejected')
    .map(result => result.reason);
    
  if (failures.length > 0) {
    // 한 번에 여러 에러를 로깅
    batchLogger.addBatch(failures);
  }
  
  return results.map(result => 
    result.status === 'fulfilled' ? result.value : { success: false, error: result.reason }
  );
};

🐛 흔한 실수와 해결책

1. 타입 강제 변환의 함정

❌ 위험한 패턴
const dangerousSchema = z.object({
  value: z.coerce.number() // 모든 것을 숫자로 변환하려 시도
});

dangerousSchema.parse({ value: "definitely not a number" }); // NaN 반환
✅ 안전한 패턴
const safeSchema = z.object({
  value: z.string()
    .regex(/^\d+(\.\d+)?$/, "숫자 형식이 아닙니다")
    .transform(val => parseFloat(val))
    .refine(val => !isNaN(val), "유효한 숫자가 아닙니다")
});

2. 성능 최적화 vs 검증 엄격성

환경별로 다른 검증 전략을 적용하여 개발 시에는 엄격하게, 운영 시에는 성능을 고려한 검증을 수행합니다.

// 환경별 검증 전략
const getValidationStrategy = () => {
  const env = process.env.NODE_ENV;
  
  switch (env) {
    case 'development':
      return {
        schema: strictSchema,           // 엄격한 검증
        async: false,                   // 동기 검증 (즉시 에러 확인)
        throwOnError: true              // 에러 시 즉시 중단
      };
      
    case 'testing':
      return {
        schema: strictSchema,
        async: true,                    // 비동기 검증
        throwOnError: false,            // 에러 로깅만
        sampleRate: 1.0                 // 100% 검증
      };
      
    case 'production':
      return {
        schema: lenientSchema,          // 관대한 검증
        async: true,
        throwOnError: false,
        sampleRate: 0.1                 // 10%만 검증 (성능 고려)
      };
  }
};

3. 메모리 누수 방지

❌ 메모리 누수 위험
const memoryLeakSchema = z.array(
  z.object({
    largeData: z.string().transform(str => {
      // 큰 객체를 계속 생성하여 메모리에 누적
      return new Array(10000).fill(str);
    })
  })
);
✅ 메모리 효율적인 방법
const memoryEfficientSchema = z.array(
  z.object({
    data: z.string()
      .max(1000, "데이터가 너무 큽니다")
      .transform(str => str.slice(0, 100)) // 크기 제한
  })
).max(1000, "배열이 너무 큽니다"); // 배열 크기 제한

🔮 고급 활용과 확장

1. 동적 스키마 생성

API 설정에 따라 동적으로 스키마를 생성하는 고급 패턴입니다.

// API 설정에 따라 동적으로 스키마 생성
const createDynamicSchema = (config: ApiConfig) => {
  const baseSchema = z.object({
    id: z.string(),
    name: z.string(),
  });
  
  // 설정에 따라 필드 추가
  let schema = baseSchema;
  
  if (config.includeLocation) {
    schema = schema.extend({
      latitude: z.number().min(-90).max(90),
      longitude: z.number().min(-180).max(180),
    });
  }
  
  if (config.includeRealTimeData) {
    schema = schema.extend({
      currentValue: z.number().min(0),
      lastUpdated: z.coerce.date(),
    });
  }
  
  if (config.includeMetadata) {
    schema = schema.extend({
      metadata: z.object({
        version: z.string(),
        source: z.string(),
        tags: z.array(z.string()).optional(),
      }),
    });
  }
  
  return schema;
};

2. 스키마 버전 관리

API 버전별로 다른 스키마를 관리하고 자동으로 적절한 스키마를 선택하는 방법입니다.

// API 버전별 스키마 관리
const dataSchemaV1 = z.object({
  value: z.number(),
  timestamp: z.string(),
});

const dataSchemaV2 = z.object({
  dataValue: z.number(),        // 필드명 변경
  timestamp: z.coerce.date(),   // 타입 강화
  metadata: z.object({          // 새 필드 추가
    version: z.literal("2.0"),
    source: z.string(),
  }),
});

const dataSchemaV3 = dataSchemaV2.extend({
  category: z.string(),         // 새 필드
  tags: z.array(z.string()),    // 새 필드
});

// 버전 자동 감지 및 적절한 스키마 적용
const detectAndValidateData = (data: unknown) => {
  // 버전 감지 로직
  if (hasField(data, 'metadata.version')) {
    const version = (data as any).metadata.version;
    switch (version) {
      case "3.0": return dataSchemaV3.parse(data);
      case "2.0": return dataSchemaV2.parse(data);
    }
  }
  
  // fallback to v1
  return dataSchemaV1.parse(data);
};
마무리
이 가이드를 통해 Zod를 활용한 타입 안전한 런타임 검증 시스템을 구축할 수 있습니다. 실제 프로덕션 환경에서 발생하는 다양한 데이터 품질 문제들을 효과적으로 해결할 수 있으며, TypeScript의 컴파일 타임 안전성과 Zod의 런타임 안전성을 결합하여 견고하고 안정적인 애플리케이션을 구축할 수 있습니다.

특히 환경별 검증 전략과 성능 최적화 패턴을 적절히 활용한다면, 개발 생산성과 운영 안정성을 모두 확보할 수 있을 것입니다.