🤖

본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.

⚠️

본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.

이미지 로딩 중...

Zod 런타임 타입 검증 완벽 가이드 - 슬라이드 1/11
A

AI Generated

2025. 10. 30. · 18 Views

Zod 런타임 타입 검증 완벽 가이드

TypeScript의 타입 안정성을 런타임까지 확장하는 Zod 라이브러리를 마스터해보세요. API 응답 검증부터 폼 데이터 유효성 검사까지, 실무에서 바로 활용할 수 있는 완벽한 가이드입니다.


목차

  1. Zod 기본 개념 - TypeScript 타입을 런타임에서도
  2. 기본 타입 스키마 - 모든 검증의 시작점
  3. 객체와 배열 스키마 - 복잡한 데이터 구조 검증
  4. 선택적 필드와 기본값 - 유연한 데이터 처리
  5. 변환과 전처리 - 데이터를 원하는 형태로
  6. 고급 검증 패턴 - refine과 superRefine
  7. 유니온과 판별된 유니온 - 여러 타입 중 하나
  8. API 응답 검증 - 실전 패턴
  9. 폼 유효성 검사 통합 - React Hook Form과 함께
  10. 환경 변수 검증 - 안전한 설정 관리

1. Zod 기본 개념 - TypeScript 타입을 런타임에서도

시작하며

여러분이 API에서 받은 데이터를 사용하다가 갑자기 애플리케이션이 크래시되는 경험을 해본 적 있나요? TypeScript에서는 분명 User 타입으로 정의했는데, 실제로 서버에서 email 필드가 빠진 채로 데이터가 왔다면 런타임 에러가 발생합니다.

이런 문제는 TypeScript의 근본적인 한계에서 비롯됩니다. TypeScript의 타입 시스템은 컴파일 타임에만 존재하고, 실제 JavaScript로 변환되면 모든 타입 정보가 사라지기 때문입니다.

따라서 외부에서 들어오는 데이터(API 응답, 사용자 입력, 환경 변수 등)의 안정성을 보장할 수 없습니다. 바로 이럴 때 필요한 것이 Zod입니다.

Zod는 TypeScript의 타입 안정성을 런타임까지 확장하여, 실제로 들어오는 데이터가 우리가 기대하는 형태인지 검증할 수 있게 해줍니다.

개요

간단히 말해서, Zod는 TypeScript를 위한 스키마 선언 및 검증 라이브러리입니다. 한 번의 스키마 정의로 타입 추론과 런타임 검증을 동시에 얻을 수 있습니다.

실무에서는 외부 데이터를 다룰 때마다 타입 안정성이 필요합니다. API 응답이 예상과 다를 수 있고, 사용자가 폼에 잘못된 데이터를 입력할 수 있으며, 환경 변수가 누락될 수 있습니다.

예를 들어, 결제 시스템에서 금액 필드가 문자열로 들어온다면 심각한 버그가 발생할 수 있습니다. 기존에는 타입 가드 함수를 직접 작성하거나 별도의 검증 로직을 추가했다면, Zod를 사용하면 스키마 하나로 모든 것을 해결할 수 있습니다.

타입 정의와 검증 로직이 항상 동기화되어 유지보수도 훨씬 쉬워집니다. Zod의 핵심 특징은 세 가지입니다.

첫째, 제로 의존성으로 가볍고 빠릅니다. 둘째, TypeScript 타입을 자동으로 추론해주어 중복 코드가 없습니다.

셋째, 체이닝 문법으로 복잡한 검증 규칙도 직관적으로 표현할 수 있습니다. 이러한 특징들이 Zod를 현대 TypeScript 프로젝트의 필수 도구로 만들어줍니다.

코드 예제

import { z } from 'zod';

// 스키마 정의 - 타입과 검증 규칙을 동시에 선언
const UserSchema = z.object({
  id: z.number().positive(),
  email: z.string().email(),
  name: z.string().min(2),
  age: z.number().min(0).optional(),
});

// TypeScript 타입 자동 추론
type User = z.infer<typeof UserSchema>;

// 런타임 검증 - 데이터가 유효한지 확인
const result = UserSchema.safeParse({
  id: 1,
  email: 'user@example.com',
  name: 'John'
});

if (result.success) {
  console.log(result.data); // 검증된 데이터 사용
} else {
  console.error(result.error); // 상세한 에러 정보
}

설명

이것이 하는 일: Zod는 먼저 데이터의 구조와 제약조건을 스키마로 정의하고, 그 스키마를 바탕으로 TypeScript 타입을 자동 생성하며, 실제 데이터가 들어올 때 검증을 수행합니다. 첫 번째로, z.object()로 스키마를 정의하는 부분을 보겠습니다.

각 필드에 대해 타입(string, number 등)과 제약조건(email 형식, 최소값 등)을 체이닝 문법으로 명시합니다. 이렇게 정의된 스키마는 단순한 설정이 아니라 실행 가능한 검증 로직입니다.

optional()을 붙이면 해당 필드가 없어도 되고, 붙이지 않으면 필수 필드가 됩니다. 두 번째로, z.infer를 사용하면 스키마로부터 TypeScript 타입이 자동으로 추론됩니다.

이것이 Zod의 가장 강력한 기능 중 하나입니다. 타입 정의를 따로 작성할 필요가 없어 항상 스키마와 타입이 일치하게 됩니다.

만약 스키마를 수정하면 타입도 자동으로 업데이트되어 타입 안정성이 유지됩니다. 세 번째로, safeParse()를 호출하면 실제 검증이 수행됩니다.

parse()와 달리 safeParse()는 예외를 던지지 않고 결과 객체를 반환합니다. success가 true면 data 속성에 검증된 데이터가 들어있고, false면 error 속성에 어떤 필드가 왜 실패했는지 상세한 정보가 담깁니다.

마지막으로, 이 결과를 타입 가드처럼 사용하여 안전하게 데이터를 처리할 수 있습니다. 여러분이 이 코드를 사용하면 API 응답이 예상과 다를 때 즉시 감지할 수 있고, 타입 에러를 컴파일 타임과 런타임 모두에서 방지할 수 있으며, 명확한 에러 메시지로 디버깅 시간을 크게 단축할 수 있습니다.

특히 프론트엔드에서 백엔드 API를 호출할 때, 폼 데이터를 검증할 때, 환경 변수를 로드할 때 등 외부 데이터를 다루는 모든 상황에서 안정성을 보장받을 수 있습니다.

실전 팁

💡 safeParse() 대신 parse()를 사용하면 검증 실패 시 예외가 발생합니다. try-catch로 처리하고 싶다면 parse()를, 결과 객체로 처리하고 싶다면 safeParse()를 선택하세요.

💡 스키마 정의 시 타입을 먼저 정의하지 마세요. z.infer로 타입을 추론하는 것이 "단일 진실 공급원(Single Source of Truth)" 원칙에 부합하며 유지보수가 쉽습니다.

💡 개발 환경에서는 console.log(result.error.format())으로 에러를 보기 좋게 출력할 수 있습니다. 프로덕션에서는 Sentry 같은 모니터링 도구로 전송하세요.

💡 스키마는 재사용 가능한 모듈로 분리하세요. schemas/user.ts처럼 별도 파일로 관리하면 여러 곳에서 동일한 검증 로직을 사용할 수 있습니다.

💡 성능이 중요한 곳에서는 스키마를 미리 생성해두고 재사용하세요. 매번 새로 생성하면 불필요한 오버헤드가 발생합니다.


2. 기본 타입 스키마 - 모든 검증의 시작점

시작하며

여러분이 사용자 입력을 받을 때 "이메일 형식이 맞는지", "숫자가 양수인지", "문자열이 비어있지 않은지" 일일이 확인하는 코드를 작성해본 적 있나요? if문과 정규식이 가득한 검증 로직은 읽기도 어렵고 실수하기도 쉽습니다.

이런 반복적인 검증 로직은 코드베이스 전체에 중복으로 존재하게 되고, 검증 규칙이 바뀌면 여러 곳을 수정해야 합니다. 또한 각 검증이 실패했을 때 어떤 에러 메시지를 보여줄지도 일관성 있게 관리하기 어렵습니다.

바로 이럴 때 필요한 것이 Zod의 기본 타입 스키마입니다. 문자열, 숫자, 불린 등 기본 타입에 대한 검증을 선언적이고 간결하게 표현할 수 있습니다.

개요

간단히 말해서, Zod의 기본 타입 스키마는 JavaScript의 원시 타입들에 대한 검증 빌더입니다. z.string(), z.number(), z.boolean() 등으로 시작해서 체이닝으로 제약조건을 추가합니다.

실무에서는 거의 모든 입력 데이터가 이러한 기본 타입들로 구성됩니다. 회원가입 폼의 이메일과 비밀번호, 상품 주문의 수량과 가격, 설정 페이지의 토글 스위치까지 모두 기본 타입입니다.

예를 들어, 이커머스 사이트에서 상품 수량은 반드시 1 이상의 정수여야 하고, 할인율은 0에서 100 사이의 숫자여야 합니다. 기존에는 각 필드마다 if문으로 검증하고 에러 메시지를 관리했다면, Zod를 사용하면 스키마 정의만으로 모든 검증이 자동으로 수행됩니다.

코드가 훨씬 짧아지고 의도가 명확해집니다. Zod는 각 기본 타입마다 특화된 검증 메서드를 제공합니다.

문자열에는 email(), url(), uuid() 같은 형식 검증과 min(), max() 같은 길이 검증이 있고, 숫자에는 positive(), int(), multipleOf() 같은 수학적 제약이 있습니다. 불린은 검증이 단순하지만, 리터럴 타입으로 확장하면 특정 값만 허용할 수 있습니다.

이러한 풍부한 검증 메서드들이 대부분의 실무 요구사항을 커버합니다.

코드 예제

import { z } from 'zod';

// 문자열 검증 - 이메일, 길이, 정규식
const EmailSchema = z.string()
  .email('유효한 이메일을 입력해주세요')
  .min(5, '이메일은 최소 5자 이상이어야 합니다');

const PasswordSchema = z.string()
  .min(8, '비밀번호는 최소 8자 이상')
  .regex(/[A-Z]/, '대문자를 포함해야 합니다')
  .regex(/[0-9]/, '숫자를 포함해야 합니다');

// 숫자 검증 - 범위, 정수, 배수
const QuantitySchema = z.number()
  .int('정수만 입력 가능합니다')
  .positive('수량은 양수여야 합니다')
  .max(999, '최대 999개까지 주문 가능합니다');

const DiscountSchema = z.number()
  .min(0)
  .max(100)
  .multipleOf(5, '5% 단위로 설정해주세요');

// 불린 및 리터럴
const AgreeSchema = z.literal(true); // 반드시 true여야 함
const StatusSchema = z.enum(['active', 'inactive', 'pending']);

설명

이것이 하는 일: 각 기본 타입마다 시작점 메서드(z.string(), z.number() 등)가 있고, 여기에 검증 메서드를 체이닝하여 제약조건을 추가합니다. 각 메서드는 커스텀 에러 메시지를 받을 수 있습니다.

첫 번째로, 문자열 검증을 살펴보겠습니다. EmailSchema는 z.string()으로 시작해서 email()로 이메일 형식을 검증하고, min()으로 최소 길이를 체크합니다.

PasswordSchema는 regex()를 여러 번 체이닝하여 복잡한 비밀번호 정책을 구현합니다. 각 메서드에 전달하는 문자열 인자는 검증 실패 시 사용자에게 보여줄 에러 메시지입니다.

이렇게 하면 검증 로직과 에러 메시지가 한 곳에 모여 관리하기 쉽습니다. 두 번째로, 숫자 검증은 수학적 제약을 다룹니다.

QuantitySchema는 int()로 정수만 허용하고, positive()로 양수를 강제하며, max()로 상한선을 설정합니다. 실무에서 주문 수량이 소수이거나 음수가 되면 안 되므로 이런 검증이 필수입니다.

DiscountSchema는 multipleOf()를 사용하여 특정 배수만 허용합니다. 예를 들어 할인율을 5% 단위로만 설정하게 하려면 multipleOf(5)를 사용하면 됩니다.

세 번째로, 불린과 리터럴 타입은 특정 값만 허용할 때 유용합니다. AgreeSchema는 z.literal(true)로 정의되어 반드시 true여야만 통과합니다.

이용약관 동의처럼 반드시 체크해야 하는 필드에 사용합니다. StatusSchema는 z.enum()으로 정의하여 특정 문자열 목록 중 하나만 허용합니다.

이것도 TypeScript 타입으로 추론되어 자동완성이 작동합니다. 마지막으로, 이러한 기본 스키마들은 재사용 가능한 빌딩 블록입니다.

여러분이 복잡한 객체 스키마를 만들 때 이 기본 스키마들을 조합하게 됩니다. 예를 들어 회원가입 폼 스키마는 EmailSchema와 PasswordSchema를 필드로 포함할 것입니다.

여러분이 이 패턴을 사용하면 검증 로직이 중앙화되어 유지보수가 쉬워지고, 커스텀 에러 메시지로 사용자 경험이 개선되며, TypeScript 타입 추론으로 개발 생산성이 향상됩니다. 특히 폼 라이브러리(React Hook Form, Formik 등)와 결합하면 검증 로직을 전혀 작성하지 않고도 완전한 폼 유효성 검사를 구현할 수 있습니다.

실전 팁

💡 에러 메시지는 항상 명시적으로 작성하세요. 기본 메시지는 영어이고 기술적이어서 사용자에게 친화적이지 않습니다. 한국어로 구체적인 메시지를 제공하면 사용자 경험이 크게 개선됩니다.

💡 min(), max()는 문자열에서는 길이를, 숫자에서는 값을 검증합니다. 같은 메서드명이지만 타입에 따라 의미가 다르니 주의하세요.

💡 url() 검증은 프로토콜(http://, https://)이 필수입니다. 사용자가 "example.com"만 입력하면 실패하므로, 필요하다면 전처리에서 프로토콜을 추가하세요.

💡 정규식 검증이 여러 개 필요하면 체이닝하세요. 하나의 복잡한 정규식보다 여러 개의 간단한 정규식이 유지보수하기 쉽고 에러 메시지도 구체적으로 줄 수 있습니다.

💡 리터럴 타입은 타입 좁히기(Type Narrowing)에 유용합니다. z.literal('success')로 검증하면 TypeScript는 해당 값이 정확히 'success' 문자열임을 알게 됩니다.


3. 객체와 배열 스키마 - 복잡한 데이터 구조 검증

시작하며

여러분이 API에서 받은 사용자 목록 데이터를 처리하다가, 어떤 사용자 객체에는 name이 있고 어떤 객체에는 없어서 에러가 발생한 경험이 있나요? 또는 배열인 줄 알았는데 null이 왔거나, 빈 배열이어야 하는데 undefined였던 적은요?

실제 애플리케이션에서 다루는 데이터는 대부분 중첩된 객체와 배열의 조합입니다. 사용자 프로필은 객체이고, 사용자 목록은 배열이며, 각 사용자는 주소 객체와 태그 배열을 가질 수 있습니다.

이런 복잡한 구조를 수동으로 검증하려면 재귀적인 타입 가드 함수를 작성해야 하는데, 이는 매우 번거롭고 버그가 생기기 쉽습니다. 바로 이럴 때 필요한 것이 Zod의 객체와 배열 스키마입니다.

기본 타입 스키마를 조합하여 어떤 복잡한 데이터 구조도 선언적으로 검증할 수 있습니다.

개요

간단히 말해서, z.object()는 객체의 각 필드를 스키마로 정의하고, z.array()는 배열의 각 요소를 스키마로 정의합니다. 이들은 중첩될 수 있어 트리 구조의 데이터도 검증 가능합니다.

실무에서는 거의 모든 API 응답이 객체나 배열입니다. REST API의 GET /users는 사용자 객체의 배열을 반환하고, GET /users/:id는 단일 사용자 객체를 반환합니다.

GraphQL도 마찬가지로 중첩된 객체 구조를 반환합니다. 예를 들어, 블로그 포스트 API는 포스트 객체에 작성자 객체와 댓글 배열을 포함할 수 있습니다.

기존에는 인터페이스로 타입을 정의하고 별도의 검증 함수를 작성했다면, Zod를 사용하면 스키마 하나로 타입 정의와 검증을 모두 해결합니다. 또한 부분 검증(partial), 선택 필드(pick), 제외(omit) 같은 유틸리티로 기존 스키마를 재사용할 수 있습니다.

Zod의 객체 스키마는 강력한 조합성을 제공합니다. 작은 스키마들을 정의하고 merge()로 결합하거나, extend()로 확장할 수 있습니다.

배열 스키마는 min(), max()로 길이를 제한하고, nonempty()로 빈 배열을 거부할 수 있습니다. 이러한 조합성과 유연성이 실무의 다양한 요구사항을 충족시킵니다.

코드 예제

import { z } from 'zod';

// 중첩된 객체 스키마
const AddressSchema = z.object({
  street: z.string(),
  city: z.string(),
  zipCode: z.string().regex(/^\d{5}$/),
});

const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(),
  address: AddressSchema, // 객체 중첩
  tags: z.array(z.string()).min(1), // 문자열 배열
  isActive: z.boolean().default(true), // 기본값
});

// 배열 스키마
const UsersSchema = z.array(UserSchema)
  .nonempty('최소 1명의 사용자가 필요합니다');

// 스키마 변형 - partial, pick, omit
const UpdateUserSchema = UserSchema.partial(); // 모든 필드 optional
const UserSummarySchema = UserSchema.pick({
  id: true,
  name: true
}); // 일부 필드만

설명

이것이 하는 일: 객체 스키마는 각 속성에 대한 스키마를 정의하고, 배열 스키마는 모든 요소가 동일한 스키마를 따르는지 검증합니다. 스키마는 중첩되고 조합될 수 있습니다.

첫 번째로, AddressSchema는 독립적인 객체 스키마입니다. 우편번호는 정확히 5자리 숫자여야 하므로 regex()로 검증합니다.

이렇게 작은 단위의 스키마를 먼저 정의하면 재사용하기 쉽습니다. UserSchema에서는 address 필드의 타입으로 AddressSchema를 사용합니다.

이것이 바로 중첩(composition)입니다. Zod는 UserSchema를 검증할 때 자동으로 AddressSchema도 검증합니다.

두 번째로, UserSchema의 tags 필드는 z.array(z.string())로 정의되어 문자열 배열을 검증합니다. min(1)을 붙여서 빈 배열을 거부합니다.

실무에서 "최소 하나의 태그 필요" 같은 요구사항이 많은데, 이렇게 간단히 표현할 수 있습니다. isActive 필드는 default(true)를 사용하여 값이 없으면 자동으로 true를 할당합니다.

이는 optional과 다릅니다. optional은 undefined를 허용하지만, default는 undefined를 기본값으로 대체합니다.

세 번째로, UsersSchema는 UserSchema의 배열입니다. 이것이 바로 스키마의 재사용입니다.

nonempty()는 빈 배열을 거부하는데, API가 항상 최소 하나의 결과를 반환한다고 보장할 때 유용합니다. 배열 검증은 각 요소를 순회하며 모두 통과해야 성공합니다.

하나라도 실패하면 어떤 인덱스의 어떤 필드가 실패했는지 상세한 경로 정보를 제공합니다. 네 번째로, 스키마 변형 유틸리티를 봅시다.

partial()은 모든 필드를 optional로 만듭니다. 이는 PATCH 엔드포인트에서 유용합니다.

사용자 정보를 부분 업데이트할 때 모든 필드를 보내지 않아도 되기 때문입니다. pick()은 특정 필드만 선택하고, omit()은 특정 필드를 제외합니다.

이렇게 하면 기존 스키마를 재사용하면서 다양한 유즈케이스에 대응할 수 있습니다. 여러분이 이 패턴을 사용하면 복잡한 API 응답도 안전하게 처리할 수 있고, 스키마 재사용으로 코드 중복을 제거할 수 있으며, 명확한 에러 메시지로 어떤 데이터가 잘못되었는지 정확히 알 수 있습니다.

특히 마이크로서비스 아키텍처에서 여러 API를 조합할 때, 각 API 응답을 검증하면 데이터 무결성을 보장할 수 있습니다.

실전 팁

💡 큰 스키마를 작은 스키마로 분해하세요. AddressSchema처럼 독립적인 스키마를 만들면 여러 곳에서 재사용할 수 있고 테스트하기도 쉽습니다.

💡 z.array().min(1) 대신 z.array().nonempty()를 사용하면 의도가 더 명확합니다. 둘 다 빈 배열을 거부하지만, nonempty()가 가독성이 좋습니다.

💡 API 응답이 nullable일 수 있다면 z.object().nullable() 또는 z.object().nullish()를 사용하세요. nullish()는 null과 undefined를 모두 허용합니다.

💡 깊게 중첩된 객체의 특정 필드만 수정하려면 z.object().merge()를 사용하세요. extend()는 최상위 필드만 추가하지만, merge()는 깊은 병합을 수행합니다.

💡 성능이 중요하다면 배열 크기를 제한하세요. max(1000) 같은 상한선을 두면 악의적으로 큰 데이터를 보내는 공격을 방어할 수 있습니다.


4. 선택적 필드와 기본값 - 유연한 데이터 처리

시작하며

여러분이 API를 설계할 때 "이 필드는 선택사항인가 필수인가"를 고민해본 적 있나요? 또는 프론트엔드에서 폼 데이터를 보낼 때 일부 필드가 비어있을 수 있는 상황에서, 백엔드가 undefined를 어떻게 처리할지 불확실했던 경험은요?

실무에서는 모든 필드가 항상 존재한다고 가정할 수 없습니다. 사용자 프로필에서 전화번호는 선택사항일 수 있고, 상품 정보에서 할인율은 없을 수도 있습니다.

또한 설정값처럼 명시적으로 제공되지 않으면 기본값을 사용해야 하는 경우도 많습니다. 이런 경우들을 타입 안전하게 처리하려면 복잡한 타입 정의와 조건문이 필요합니다.

바로 이럴 때 필요한 것이 Zod의 optional, nullable, default입니다. 필드의 존재 여부와 기본값을 명확히 정의하여 예측 가능한 데이터 처리를 가능하게 합니다.

개요

간단히 말해서, optional()은 필드가 없어도 되게 만들고, nullable()은 null 값을 허용하며, default()는 값이 없을 때 기본값을 제공합니다. 이들은 조합될 수 있어 다양한 시나리오를 표현합니다.

실무에서는 이 세 가지를 명확히 구분해야 합니다. optional()은 필드 자체가 없는 것(undefined)을 허용하고, nullable()은 필드는 있지만 값이 null인 것을 허용합니다.

default()는 둘 다 처리하여 항상 값을 보장합니다. 예를 들어, API 응답에서 discount_rate 필드가 아예 없을 수도 있고(optional), 명시적으로 null일 수도 있으며(nullable), 없으면 0으로 처리하고 싶을 수도 있습니다(default).

기존에는 물음표(?)를 사용한 TypeScript 타입과 || 연산자를 사용한 기본값 처리를 각각 따로 구현했다면, Zod는 이를 스키마에 통합합니다. 검증과 동시에 기본값이 적용되어 일관성이 보장됩니다.

이 세 가지 수정자의 조합으로 복잡한 요구사항도 표현할 수 있습니다. nullish()는 optional과 nullable을 동시에 적용하고, optional().default()는 없으면 기본값을, 있으면 검증을 수행합니다.

이러한 유연성이 실무의 다양한 API 계약을 정확히 모델링할 수 있게 해줍니다.

코드 예제

import { z } from 'zod';

// optional - 필드가 없어도 됨 (undefined 허용)
const UserSchema = z.object({
  name: z.string(),
  phoneNumber: z.string().optional(), // 선택사항
});

// nullable - 필드는 있지만 null일 수 있음
const ProductSchema = z.object({
  name: z.string(),
  description: z.string().nullable(), // null 허용
});

// default - 값이 없으면 기본값 사용
const SettingsSchema = z.object({
  theme: z.enum(['light', 'dark']).default('light'),
  fontSize: z.number().default(16),
  notifications: z.boolean().default(true),
});

// 조합 - nullish() = optional + nullable
const CommentSchema = z.object({
  text: z.string(),
  author: z.string().nullish(), // undefined 또는 null
  likes: z.number().optional().default(0), // 없으면 0
});

// 검증 예시
const result = SettingsSchema.parse({});
// { theme: 'light', fontSize: 16, notifications: true }

설명

이것이 하는 일: 각 수정자는 필드의 존재 여부와 값의 허용 범위를 확장합니다. 이들은 TypeScript 타입에도 반영되어 타입 안정성을 유지합니다.

첫 번째로, optional()을 살펴봅시다. UserSchema의 phoneNumber는 optional()이므로 필드가 아예 없어도 검증을 통과합니다.

{ name: 'John' }과 { name: 'John', phoneNumber: '010-1234-5678' } 둘 다 유효합니다. 하지만 { name: 'John', phoneNumber: null }은 실패합니다.

optional()은 undefined만 허용하고 null은 허용하지 않기 때문입니다. TypeScript 타입으로는 phoneNumber?: string이 추론됩니다.

두 번째로, nullable()은 null 값을 명시적으로 허용합니다. ProductSchema의 description은 문자열이거나 null일 수 있습니다.

하지만 필드 자체가 없으면(undefined) 실패합니다. 이는 API 설계에서 "값이 없음"을 명시적으로 표현할 때 유용합니다.

TypeScript 타입으로는 description: string | null이 추론됩니다. 세 번째로, default()는 가장 강력한 기능입니다.

SettingsSchema의 모든 필드는 기본값을 가지므로, parse({})처럼 빈 객체를 전달해도 모든 기본값이 채워진 완전한 객체가 반환됩니다. 이는 사용자 설정, 환경 변수, 설정 파일 등에서 매우 유용합니다.

명시적으로 제공된 값이 있으면 그 값을 사용하고, 없으면 기본값을 사용합니다. 주의할 점은 default()가 적용되면 해당 필드는 더 이상 optional이 아니라는 것입니다.

항상 값이 보장되기 때문입니다. 네 번째로, 조합을 봅시다.

nullish()는 z.string().optional().nullable()의 축약형으로, undefined와 null을 모두 허용합니다. CommentSchema의 author는 있을 수도 없을 수도 있고, 있어도 null일 수 있습니다.

likes는 optional().default(0)으로 정의되어 필드가 없으면 0을 사용하지만, 명시적으로 전달된 값이 있으면 그 값을 검증합니다. 예를 들어 likes: -5를 전달하면 검증 실패할 수 있습니다(positive() 같은 검증이 있다면).

여러분이 이 패턴을 사용하면 API 계약을 명확히 정의할 수 있고, 기본값 처리 로직이 중앙화되어 버그가 줄어들며, TypeScript 타입이 자동으로 정확히 추론되어 타입 안정성이 향상됩니다. 특히 설정 파일을 파싱할 때 default()를 사용하면 부분적으로만 설정을 제공해도 항상 완전한 설정 객체를 얻을 수 있습니다.

실전 팁

💡 optional()과 nullable()은 다릅니다. optional은 JavaScript의 undefined, nullable은 null을 허용합니다. API가 어떤 것을 사용하는지 명확히 파악하고 선택하세요.

💡 default()는 parse 시점에 적용됩니다. safeParse()에서도 동일하게 작동하므로, 성공한 경우 data에 기본값이 채워진 객체가 들어있습니다.

💡 함수를 기본값으로 사용할 수 있습니다. z.string().default(() => new Date().toISOString())처럼 동적 기본값을 생성할 수 있습니다. 매번 parse할 때마다 함수가 호출됩니다.

💡 required()로 optional을 되돌릴 수 있습니다. 기존 스키마가 optional 필드를 가지고 있는데, 특정 유즈케이스에서는 필수로 만들고 싶을 때 사용하세요.

💡 nullish()를 남용하지 마세요. undefined와 null을 모두 허용하면 코드에서 두 경우를 모두 처리해야 합니다. 가능하면 하나만 사용하도록 API를 설계하세요.


5. 변환과 전처리 - 데이터를 원하는 형태로

시작하며

여러분이 API에서 문자열 "123"을 받았는데 숫자 123으로 사용하고 싶거나, 날짜 문자열 "2024-01-01"을 Date 객체로 변환하고 싶었던 적 있나요? 또는 사용자가 입력한 이메일 주소의 앞뒤 공백을 제거하고 소문자로 통일하고 싶은 경우는요?

실무에서는 데이터를 받은 그대로 사용하기보다 변환이 필요한 경우가 많습니다. HTML 폼은 모든 입력을 문자열로 전달하므로 숫자나 불린으로 변환해야 하고, API는 날짜를 ISO 문자열로 주므로 Date 객체로 파싱해야 합니다.

이런 변환 로직을 검증 로직과 별도로 관리하면 코드가 흩어지고 실수하기 쉽습니다. 바로 이럴 때 필요한 것이 Zod의 transform과 preprocess입니다.

검증과 변환을 하나의 스키마에 통합하여 항상 원하는 형태의 데이터를 얻을 수 있습니다.

개요

간단히 말해서, transform()은 검증 후 데이터를 변환하고, preprocess()는 검증 전에 데이터를 전처리합니다. coerce는 자주 사용되는 변환(문자열→숫자 등)을 위한 내장 헬퍼입니다.

실무에서는 외부 데이터가 항상 완벽한 형태로 들어오지 않습니다. URL 쿼리 파라미터는 모두 문자열이고, JSON은 Date를 표현할 수 없어 문자열로 직렬화됩니다.

또한 사용자 입력은 예측 불가능하여 정규화가 필요합니다. 예를 들어, 검색어의 앞뒤 공백을 제거하고, 이메일 주소를 소문자로 통일하며, "true"/"false" 문자열을 불린으로 변환해야 합니다.

기존에는 검증 전후로 별도의 변환 함수를 호출했다면, Zod는 이를 스키마에 통합합니다. 검증과 변환이 원자적으로 수행되어 중간 상태가 노출되지 않습니다.

Zod의 변환 기능은 타입 안전합니다. transform()의 반환 타입이 TypeScript 타입 추론에 반영되므로, 변환 전과 후의 타입이 다를 수 있습니다.

예를 들어 z.string().transform(Number)는 입력은 문자열이지만 출력은 숫자입니다. 이러한 타입 레벨의 추적이 변환 로직의 안정성을 보장합니다.

코드 예제

import { z } from 'zod';

// coerce - 자동 타입 변환
const QuerySchema = z.object({
  page: z.coerce.number(), // "123" → 123
  limit: z.coerce.number().default(10),
  isActive: z.coerce.boolean(), // "true" → true
});

// transform - 검증 후 변환
const EmailSchema = z.string()
  .email()
  .transform(val => val.toLowerCase().trim());

const DateSchema = z.string()
  .datetime()
  .transform(val => new Date(val)); // string → Date

// preprocess - 검증 전 전처리
const FlexibleNumberSchema = z.preprocess(
  (val) => typeof val === 'string' ? parseFloat(val) : val,
  z.number().positive()
);

// 복잡한 변환 - 계산과 재구조화
const PriceSchema = z.object({
  amount: z.number(),
  currency: z.string(),
}).transform(({ amount, currency }) => ({
  amount,
  currency,
  formatted: `${currency} ${amount.toLocaleString()}`,
}));

설명

이것이 하는 일: 변환 메서드들은 데이터 흐름의 다른 단계에서 작동합니다. preprocess는 가장 먼저, 검증은 중간에, transform은 가장 나중에 실행됩니다.

첫 번째로, coerce는 가장 간단한 변환입니다. QuerySchema의 page는 z.coerce.number()로 정의되어 문자열 "123"을 자동으로 숫자 123으로 변환합니다.

이는 URL 쿼리 파라미터를 처리할 때 매우 유용합니다. /api/users?page=2&limit=20 같은 URL에서 page와 limit는 문자열 "2", "20"으로 전달되는데, coerce가 자동으로 숫자로 변환합니다.

변환에 실패하면(예: "abc") 검증 에러가 발생합니다. z.coerce.boolean()은 "true", "false", "1", "0" 같은 값들을 불린으로 변환합니다.

두 번째로, transform()은 검증을 통과한 데이터를 변환합니다. EmailSchema는 먼저 email() 검증을 수행하고, 통과하면 소문자로 변환하고 공백을 제거합니다.

이렇게 하면 "User@Example.Com "과 "user@example.com"이 동일하게 처리되어 데이터 일관성이 보장됩니다. DateSchema는 datetime() 검증 후 문자열을 Date 객체로 변환합니다.

중요한 것은 변환 함수가 실행되는 시점에 이미 검증을 통과했다는 것입니다. 따라서 transform 함수 내부에서는 데이터가 유효하다고 가정할 수 있습니다.

세 번째로, preprocess()는 검증 전에 데이터를 정규화합니다. FlexibleNumberSchema는 문자열이 들어오면 parseFloat()으로 변환한 후 z.number().positive()로 검증합니다.

이는 입력 형식이 불확실한 외부 데이터를 다룰 때 유용합니다. 예를 들어 환경 변수는 항상 문자열인데, 숫자가 필요하다면 preprocess로 변환 후 검증할 수 있습니다.

preprocess의 반환값이 다음 스키마의 입력이 됩니다. 네 번째로, 복잡한 변환도 가능합니다.

PriceSchema는 amount와 currency를 받아서 formatted 필드를 추가로 생성합니다. transform은 새로운 객체를 반환하거나 기존 객체를 수정할 수 있습니다.

이는 계산된 필드를 추가하거나 데이터를 재구조화할 때 유용합니다. TypeScript는 transform 후의 타입을 정확히 추론하므로, formatted 필드에 자동완성이 작동합니다.

여러분이 이 패턴을 사용하면 변환 로직이 중앙화되어 일관성이 보장되고, 검증과 변환이 원자적으로 수행되어 중간 상태가 없으며, TypeScript 타입이 변환을 추적하여 타입 안정성이 유지됩니다. 특히 폼 제출이나 API 호출 전후에 데이터를 정규화하면 버그를 크게 줄일 수 있습니다.

실전 팁

💡 transform은 순수 함수여야 합니다. 부작용(API 호출, 상태 변경 등)이 있으면 안 됩니다. 동일한 입력에 대해 항상 동일한 출력을 반환해야 합니다.

💡 coerce는 느슨한 변환입니다. z.coerce.number()는 "123abc"를 123으로 변환합니다. 엄격한 검증이 필요하면 preprocess와 명시적 검증을 사용하세요.

💡 여러 transform을 체이닝할 수 있습니다. z.string().transform(trim).transform(toLowerCase)처럼 순차적으로 변환을 적용할 수 있습니다.

💡 transform 함수에서 예외를 던지지 마세요. 대신 검증 에러를 반환하려면 z.ZodError를 사용하거나, 변환 전에 검증을 더 엄격히 하세요.

💡 날짜 변환은 타임존에 주의하세요. ISO 문자열을 Date로 변환할 때 UTC와 로컬 시간의 차이를 고려해야 합니다. 필요하다면 date-fns나 dayjs 같은 라이브러리를 사용하세요.


6. 고급 검증 패턴 - refine과 superRefine

시작하며

여러분이 비밀번호 확인 필드가 비밀번호 필드와 일치하는지 검증하거나, 시작 날짜가 종료 날짜보다 앞서는지 확인해야 했던 적 있나요? 또는 특정 필드의 값에 따라 다른 필드의 검증 규칙이 달라지는 복잡한 로직을 구현해야 했던 경험은요?

실무에서는 단일 필드만 검증하는 것으로 충족되지 않는 경우가 많습니다. 여러 필드 간의 관계를 검증해야 하고, 비즈니스 규칙이 복잡할 수 있으며, 조건부 검증이 필요할 수 있습니다.

예를 들어, 할인 쿠폰 코드를 입력하면 할인 금액이 필수가 되고, 정기 결제를 선택하면 결제 주기 필드가 필수가 됩니다. 바로 이럴 때 필요한 것이 Zod의 refine과 superRefine입니다.

내장 검증 메서드로 표현할 수 없는 커스텀 로직을 구현할 수 있습니다.

개요

간단히 말해서, refine()은 커스텀 검증 함수를 추가하고, superRefine()은 더 복잡한 검증과 다중 에러를 처리합니다. 두 메서드 모두 전체 데이터에 접근할 수 있어 필드 간 관계를 검증할 수 있습니다.

실무에서는 비즈니스 로직이 복잡하여 단순한 타입 검증만으로는 부족합니다. "주문 금액이 10만원 이상이면 배송비 무료" 같은 규칙은 amount와 shippingFee 필드의 관계를 검증해야 합니다.

"쿠폰 사용 시 최소 주문 금액 제한" 같은 규칙은 조건부 검증이 필요합니다. 예를 들어, 이커머스 사이트에서 할인 쿠폰을 적용할 때 해당 쿠폰의 최소 주문 금액을 만족하는지 확인해야 합니다.

기존에는 스키마 검증 후 별도의 비즈니스 로직 검증을 수행했다면, Zod의 refine을 사용하면 모든 검증을 스키마에 통합할 수 있습니다. 검증이 실패하면 일관된 에러 형식으로 처리됩니다.

refine은 간단한 커스텀 검증에 적합하고, superRefine은 복잡한 로직과 다중 에러를 처리할 수 있습니다. superRefine에서는 ctx.addIssue()로 특정 필드에 에러를 추가할 수 있어, 여러 검증이 실패해도 모든 에러를 한 번에 반환할 수 있습니다.

이러한 유연성이 복잡한 폼과 비즈니스 규칙을 우아하게 표현할 수 있게 합니다.

코드 예제

import { z } from 'zod';

// refine - 간단한 커스텀 검증
const PasswordSchema = z.object({
  password: z.string().min(8),
  confirmPassword: z.string(),
}).refine(data => data.password === data.confirmPassword, {
  message: '비밀번호가 일치하지 않습니다',
  path: ['confirmPassword'], // 에러를 표시할 필드
});

// 날짜 범위 검증
const DateRangeSchema = z.object({
  startDate: z.string().datetime(),
  endDate: z.string().datetime(),
}).refine(data => new Date(data.startDate) < new Date(data.endDate), {
  message: '시작 날짜는 종료 날짜보다 빨라야 합니다',
  path: ['endDate'],
});

// superRefine - 복잡한 다중 검증
const OrderSchema = z.object({
  amount: z.number().positive(),
  couponCode: z.string().optional(),
  shippingFee: z.number().min(0),
}).superRefine((data, ctx) => {
  // 조건부 검증 - 쿠폰 사용 시 최소 금액
  if (data.couponCode && data.amount < 10000) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: '쿠폰 사용은 10,000원 이상 주문부터 가능합니다',
      path: ['amount'],
    });
  }

  // 비즈니스 규칙 - 10만원 이상 무료 배송
  if (data.amount >= 100000 && data.shippingFee > 0) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: '10만원 이상 주문은 배송비가 무료입니다',
      path: ['shippingFee'],
    });
  }
});

설명

이것이 하는 일: refine과 superRefine은 검증 후 추가 로직을 실행하며, 전체 객체에 접근하여 필드 간 관계를 검증합니다. 실패 시 지정된 필드에 에러를 추가합니다.

첫 번째로, PasswordSchema의 refine을 봅시다. 모든 필드가 개별 검증을 통과한 후 refine 함수가 실행됩니다.

함수는 전체 data 객체를 받아서 password와 confirmPassword를 비교합니다. true를 반환하면 검증 통과, false를 반환하면 실패입니다.

path 옵션으로 에러를 표시할 필드를 지정할 수 있습니다. 이렇게 하면 폼 UI에서 confirmPassword 필드에 에러 메시지가 표시됩니다.

path를 생략하면 객체 전체에 에러가 추가됩니다. 두 번째로, DateRangeSchema는 날짜 간의 관계를 검증합니다.

각 필드가 datetime 형식인지 먼저 확인한 후, refine에서 시작 날짜가 종료 날짜보다 앞서는지 검증합니다. 이는 예약 시스템, 기간 설정 등에서 자주 사용되는 패턴입니다.

날짜 문자열을 Date 객체로 변환하여 비교하는 것을 주목하세요. 문자열 비교는 타임존 문제로 부정확할 수 있습니다.

세 번째로, OrderSchema의 superRefine은 더 복잡합니다. 여러 검증 규칙을 하나의 함수 안에 작성하고, 각 규칙이 실패하면 ctx.addIssue()로 에러를 추가합니다.

첫 번째 규칙은 조건부 검증입니다. couponCode가 있을 때만 최소 금액을 확인합니다.

두 번째 규칙은 비즈니스 로직입니다. 주문 금액에 따라 배송비가 0이어야 한다는 규칙을 검증합니다.

superRefine의 장점은 여러 에러를 동시에 반환할 수 있다는 것입니다. refine은 첫 번째 실패에서 멈추지만, superRefine은 모든 규칙을 검사하여 모든 에러를 수집합니다.

네 번째로, addIssue의 구조를 이해하는 것이 중요합니다. code는 에러 타입을 나타내는데, 커스텀 검증에는 z.ZodIssueCode.custom을 사용합니다.

message는 사용자에게 보여줄 에러 메시지이고, path는 에러가 속한 필드의 경로입니다. 중첩된 객체의 경우 ['address', 'zipCode']처럼 배열로 경로를 지정할 수 있습니다.

여러분이 이 패턴을 사용하면 복잡한 비즈니스 규칙을 타입 안전하게 검증할 수 있고, 모든 검증 로직이 스키마에 중앙화되어 유지보수가 쉬우며, 일관된 에러 형식으로 UI에서 처리하기 편리합니다. 특히 superRefine은 복잡한 폼에서 모든 에러를 한 번에 표시할 수 있어 사용자 경험이 크게 개선됩니다.

실전 팁

💡 refine 함수는 순수해야 하고 빨라야 합니다. API 호출이나 무거운 계산을 하지 마세요. 검증은 동기적이고 즉각적이어야 합니다.

💡 path를 정확히 지정하면 UI에서 해당 필드에 에러를 표시할 수 있습니다. 폼 라이브러리와 통합할 때 특히 중요합니다.

💡 여러 refine을 체이닝할 수 있습니다. 각 refine은 독립적으로 실행되며, 하나가 실패하면 나머지는 실행되지 않습니다. 반면 superRefine은 모든 규칙을 검사합니다.

💡 복잡한 검증은 별도 함수로 분리하세요. refine((data) => validateBusinessRule(data))처럼 명명된 함수를 사용하면 테스트하기 쉽고 재사용할 수 있습니다.

💡 비동기 검증이 필요하면 refineAsync나 superRefineAsync를 사용할 수 없습니다(Zod는 동기만 지원). 대신 검증 후 별도의 비동기 검증을 수행하거나, parseAsync 대신 safeParseAsync를 사용하세요.


7. 유니온과 판별된 유니온 - 여러 타입 중 하나

시작하며

여러분이 API 응답이 성공일 때와 실패일 때 다른 구조를 가지거나, 결제 수단에 따라 필요한 필드가 달라지는 상황을 다뤄본 적 있나요? 또는 "문자열이거나 숫자"처럼 여러 타입 중 하나를 허용해야 하는 경우는요?

실무에서는 데이터가 항상 하나의 고정된 구조를 가지지 않습니다. API 응답은 { success: true, data: ...

} 또는 { success: false, error: ... } 형태일 수 있고, 이벤트는 타입에 따라 다른 페이로드를 가질 수 있습니다.

예를 들어, 결제 시스템에서 카드 결제는 cardNumber와 cvv를 요구하고, 계좌이체는 bankCode와 accountNumber를 요구합니다. 바로 이럴 때 필요한 것이 Zod의 union과 discriminatedUnion입니다.

여러 가능한 스키마 중 하나를 허용하며, 판별자(discriminator)를 사용하면 효율적으로 올바른 스키마를 선택할 수 있습니다.

개요

간단히 말해서, z.union()은 여러 스키마 중 하나를 허용하고, z.discriminatedUnion()은 특정 필드(판별자)의 값으로 어떤 스키마를 사용할지 결정합니다. 판별된 유니온이 성능과 에러 메시지 면에서 더 우수합니다.

실무에서는 유니온 타입이 매우 흔합니다. 함수의 반환값이 성공 또는 에러일 수 있고, 설정값이 불린 또는 'auto'일 수 있으며, ID가 숫자 또는 문자열일 수 있습니다.

예를 들어, 알림 시스템에서 알림 타입에 따라 다른 데이터를 포함할 수 있습니다. 이메일 알림은 subject와 body를 가지고, 푸시 알림은 title과 message를 가집니다.

기존에는 타입 가드 함수를 여러 개 작성하고 런타임에 타입을 좁혀야 했다면, Zod의 유니온은 이를 자동화합니다. discriminatedUnion을 사용하면 판별자 필드 하나로 즉시 올바른 스키마를 선택합니다.

유니온은 순서대로 각 스키마를 시도하지만, discriminatedUnion은 판별자를 먼저 확인하여 한 번에 정확한 스키마를 선택합니다. 따라서 성능이 더 좋고 에러 메시지도 더 명확합니다.

TypeScript의 판별된 유니온(Discriminated Union)과 완벽히 호환되어, 타입 좁히기(Type Narrowing)가 자동으로 작동합니다.

코드 예제

import { z } from 'zod';

// 단순 유니온 - 여러 타입 중 하나
const IdSchema = z.union([
  z.number(),
  z.string().uuid(),
]);

const ConfigSchema = z.union([
  z.boolean(),
  z.literal('auto'),
]);

// 판별된 유니온 - type 필드로 구분
const PaymentSchema = z.discriminatedUnion('method', [
  z.object({
    method: z.literal('card'),
    cardNumber: z.string().length(16),
    cvv: z.string().length(3),
    expiryDate: z.string(),
  }),
  z.object({
    method: z.literal('bank'),
    bankCode: z.string(),
    accountNumber: z.string(),
  }),
  z.object({
    method: z.literal('paypal'),
    email: z.string().email(),
  }),
]);

// API 응답 유니온
const ApiResponseSchema = z.discriminatedUnion('success', [
  z.object({
    success: z.literal(true),
    data: z.any(),
  }),
  z.object({
    success: z.literal(false),
    error: z.string(),
    code: z.string(),
  }),
]);

// 타입 추론 및 사용
type Payment = z.infer<typeof PaymentSchema>;
// Payment는 { method: 'card', cardNumber: string, ... } |
//          { method: 'bank', bankCode: string, ... } |
//          { method: 'paypal', email: string }

설명

이것이 하는 일: union은 각 스키마를 순서대로 시도하여 첫 번째로 통과하는 것을 선택하고, discriminatedUnion은 판별자 필드를 먼저 확인하여 정확한 스키마를 즉시 선택합니다. 첫 번째로, 단순 유니온을 살펴봅시다.

IdSchema는 숫자이거나 UUID 문자열을 허용합니다. 검증 시 먼저 z.number()를 시도하고, 실패하면 z.string().uuid()를 시도합니다.

둘 다 실패하면 모든 스키마의 에러를 결합하여 반환합니다. ConfigSchema는 불린이거나 'auto' 문자열을 허용합니다.

이는 "자동 설정" 옵션을 표현할 때 유용합니다. true/false로 명시적으로 켜거나 끄거나, 'auto'로 시스템이 결정하게 할 수 있습니다.

두 번째로, PaymentSchema는 판별된 유니온의 좋은 예시입니다. 'method' 필드가 판별자로, 이 값에 따라 어떤 스키마를 사용할지 결정됩니다.

method가 'card'면 첫 번째 객체 스키마를, 'bank'면 두 번째를, 'paypal'이면 세 번째를 사용합니다. 각 스키마는 method 필드에 리터럴 타입을 사용하여 정확히 하나의 값만 허용합니다.

이렇게 하면 Zod가 method 값을 보고 즉시 올바른 스키마를 선택할 수 있어 성능이 훨씬 좋습니다. 또한 에러 메시지도 명확합니다.

"method가 'card'인데 cardNumber가 없음"처럼 정확한 문제를 지적할 수 있습니다. 세 번째로, ApiResponseSchema는 성공/실패 응답을 모델링합니다.

success가 true면 data 필드가 있고, false면 error와 code 필드가 있습니다. 이는 실무에서 매우 흔한 패턴입니다.

TypeScript와 결합하면 타입 가드처럼 작동합니다. result.success를 확인하면 TypeScript는 자동으로 타입을 좁혀서 result.data 또는 result.error에 접근할 수 있게 합니다.

네 번째로, 타입 추론을 봅시다. z.infer로 추론된 Payment 타입은 TypeScript의 판별된 유니온입니다.

코드에서 payment.method를 확인하면, TypeScript는 자동으로 어떤 필드가 존재하는지 알게 됩니다. if (payment.method === 'card') 블록 안에서는 payment.cardNumber에 안전하게 접근할 수 있고, 다른 method의 필드는 존재하지 않는다는 것을 TypeScript가 보장합니다.

이것이 판별된 유니온의 핵심 장점입니다. 여러분이 이 패턴을 사용하면 복잡한 조건부 데이터 구조를 타입 안전하게 처리할 수 있고, 판별자로 효율적인 검증과 명확한 에러 메시지를 얻으며, TypeScript의 타입 좁히기와 자동완성이 완벽히 작동합니다.

특히 이벤트 주도 아키텍처나 상태 머신에서 이벤트/상태 타입에 따라 다른 페이로드를 가질 때 매우 유용합니다.

실전 팁

💡 가능하면 discriminatedUnion을 사용하세요. 단순 union보다 성능이 좋고 에러 메시지가 명확하며, TypeScript 타입 좁히기와 완벽히 호환됩니다.

💡 판별자는 리터럴 타입이어야 합니다. z.literal('card')처럼 정확히 하나의 값만 허용해야 Zod가 효율적으로 구분할 수 있습니다.

💡 유니온의 순서는 중요합니다. 더 구체적인 스키마를 먼저 배치하세요. z.union([z.string(), z.string().email()])이면 항상 첫 번째가 선택됩니다.

💡 .or() 메서드도 유니온을 만듭니다. z.string().or(z.number())는 z.union([z.string(), z.number()])와 동일하지만, 체이닝 문법으로 더 읽기 쉬울 수 있습니다.

💡 많은 스키마를 유니온으로 결합하면 성능이 저하될 수 있습니다. 10개 이상이라면 discriminatedUnion을 사용하거나 데이터 구조를 재설계하는 것을 고려하세요.


8. API 응답 검증 - 실전 패턴

시작하며

여러분이 외부 API를 호출할 때 응답 데이터를 그대로 믿고 사용하다가, 예상치 못한 필드 누락이나 타입 불일치로 프로덕션에서 에러가 발생한 경험 있나요? 또는 백엔드 API가 업데이트되면서 프론트엔드가 깨지는 상황을 겪어본 적은요?

실무에서 API 통신은 가장 취약한 지점입니다. 네트워크는 신뢰할 수 없고, 외부 API는 언제든 변경될 수 있으며, 문서와 실제 응답이 다를 수 있습니다.

TypeScript 인터페이스는 컴파일 타임에만 존재하므로, 런타임에 실제로 들어온 데이터가 인터페이스와 일치한다는 보장이 없습니다. 예를 들어, 사용자 목록 API가 갑자기 email 필드를 반환하지 않는다면, 이메일 전송 기능이 조용히 실패할 것입니다.

바로 이럴 때 필요한 것이 Zod를 활용한 API 응답 검증입니다. 모든 외부 데이터에 대해 런타임 검증을 수행하여, 예상과 다른 응답이 오면 즉시 감지하고 처리할 수 있습니다.

개요

간단히 말해서, API 클라이언트에 Zod 검증을 통합하여 모든 응답을 자동으로 검증하고, 실패 시 명확한 에러를 반환하는 패턴입니다. fetch 또는 axios 래퍼를 만들어 재사용 가능한 API 클라이언트를 구축합니다.

실무에서는 여러 API 엔드포인트를 호출하고, 각각 다른 응답 형식을 가집니다. REST API의 GET, POST, PATCH마다 응답이 다르고, 에러 응답도 별도로 처리해야 합니다.

예를 들어, 이커머스 앱은 상품 목록, 장바구니, 주문, 결제 등 수십 개의 API를 호출하는데, 각 응답을 안전하게 처리해야 합니다. 기존에는 API 호출 후 try-catch로 에러를 잡고, 데이터를 as로 캐스팅했다면, Zod를 사용하면 자동으로 검증되고 타입이 보장됩니다.

또한 검증 실패를 모니터링 시스템에 보고하여 API 변경을 조기에 감지할 수 있습니다. 이 패턴의 핵심은 "방어적 프로그래밍"입니다.

외부 시스템을 절대 믿지 않고, 모든 입력을 검증합니다. Zod 검증을 API 클라이언트 계층에 통합하면, 애플리케이션의 나머지 부분은 항상 유효한 데이터를 받는다고 가정할 수 있습니다.

이는 버그를 경계에서 차단하여 내부 로직을 깨끗하게 유지합니다.

코드 예제

import { z } from 'zod';

// API 응답 스키마 정의
const UserSchema = z.object({
  id: z.number(),
  email: z.string().email(),
  name: z.string(),
  createdAt: z.string().datetime(),
});

const UsersResponseSchema = z.object({
  data: z.array(UserSchema),
  total: z.number(),
  page: z.number(),
});

// 타입 안전한 API 클라이언트
async function fetchUsers(page: number) {
  const response = await fetch(`/api/users?page=${page}`);

  if (!response.ok) {
    throw new Error(`HTTP ${response.status}`);
  }

  const json = await response.json();

  // 런타임 검증 수행
  const result = UsersResponseSchema.safeParse(json);

  if (!result.success) {
    // 검증 실패 - 로깅 및 에러 처리
    console.error('API 응답 검증 실패:', result.error.format());
    throw new Error('Invalid API response');
  }

  return result.data; // 검증된 데이터 반환
}

// 재사용 가능한 래퍼 함수
async function fetchWithSchema<T>(
  url: string,
  schema: z.ZodSchema<T>
): Promise<T> {
  const response = await fetch(url);
  const json = await response.json();
  return schema.parse(json); // 검증 실패 시 예외 발생
}

// 사용 예시
const users = await fetchWithSchema('/api/users', UsersResponseSchema);
// users의 타입은 자동으로 추론됨

설명

이것이 하는 일: API 호출 후 응답을 즉시 Zod 스키마로 검증하고, 통과한 데이터만 애플리케이션에 전달합니다. 검증 실패는 에러로 처리되어 조기에 문제를 감지합니다.

첫 번째로, 스키마 정의를 봅시다. UserSchema는 단일 사용자의 구조를 정의하고, UsersResponseSchema는 API 응답 전체를 정의합니다.

실제 API는 보통 { data, total, page } 같은 래퍼 객체를 반환하므로, 이를 정확히 모델링합니다. createdAt은 datetime() 검증으로 ISO 8601 형식을 강제합니다.

만약 API가 다른 날짜 형식을 사용한다면 여기서 즉시 감지됩니다. 두 번째로, fetchUsers 함수는 전형적인 패턴입니다.

fetch로 데이터를 가져오고, HTTP 상태를 확인하며, JSON을 파싱한 후 Zod로 검증합니다. safeParse()를 사용하여 예외 대신 결과 객체를 받습니다.

검증 실패 시 console.error로 상세한 에러를 로깅합니다. 프로덕션에서는 Sentry, DataDog 같은 모니터링 도구로 전송하여 API 변경을 즉시 알 수 있습니다.

result.data는 검증을 통과한 안전한 데이터이며, TypeScript는 정확한 타입을 알고 있습니다. 세 번째로, fetchWithSchema는 재사용 가능한 제네릭 함수입니다.

URL과 스키마를 받아서 검증된 데이터를 반환합니다. 타입 파라미터 T는 스키마로부터 자동 추론되므로, 호출하는 쪽에서 타입을 명시할 필요가 없습니다.

parse()를 사용하므로 검증 실패 시 예외가 발생하는데, 이를 호출하는 쪽에서 try-catch로 처리할 수 있습니다. 이 패턴은 모든 API 엔드포인트에 일관되게 적용할 수 있습니다.

네 번째로, 실전 팁입니다. API 스키마는 별도 파일(schemas/api.ts)로 분리하여 프론트엔드와 백엔드에서 공유할 수 있습니다.

모노레포를 사용한다면 공통 패키지로 만들어 타입과 검증을 동기화합니다. 또한 개발 환경에서는 검증 실패를 콘솔에 표시하되, 프로덕션에서는 조용히 로깅하고 폴백 데이터를 반환하는 전략도 고려할 수 있습니다.

예를 들어, 사용자 목록을 가져오는데 실패하면 빈 배열을 반환하여 UI가 깨지지 않게 할 수 있습니다. 여러분이 이 패턴을 사용하면 API 변경을 즉시 감지할 수 있고, 런타임 타입 에러를 사전에 방지하며, 명확한 에러 메시지로 디버깅 시간을 크게 단축할 수 있습니다.

특히 마이크로서비스 환경에서 여러 팀의 API를 통합할 때, 계약(contract) 검증으로 팀 간 협업이 원활해집니다.

실전 팁

💡 모든 외부 API 호출에 Zod 검증을 적용하세요. 내부 API라도 마이크로서비스라면 검증을 권장합니다. 네트워크는 항상 신뢰할 수 없습니다.

💡 에러 응답도 스키마로 정의하세요. z.discriminatedUnion으로 성공/실패 응답을 구분하면 에러 처리가 타입 안전해집니다.

💡 개발 중에는 검증 에러를 즉시 확인하세요. console.error(result.error.format())로 어떤 필드가 왜 실패했는지 상세히 볼 수 있습니다.

💡 대량의 데이터를 다룬다면 검증 비용을 고려하세요. 천 개의 항목을 가진 배열을 검증하는 것은 비용이 들 수 있습니다. 필요하다면 샘플링 검증을 고려하세요.

💡 API 스키마 버전 관리를 하세요. API가 업데이트되면 스키마도 업데이트하고, 이전 버전과 호환성을 유지하려면 optional이나 default를 활용하세요.


9. 폼 유효성 검사 통합 - React Hook Form과 함께

시작하며

여러분이 복잡한 폼을 만들 때 각 필드의 유효성 검사 규칙을 일일이 작성하고, 에러 메시지를 관리하며, 제출 전 검증을 수동으로 구현해본 경험이 있나요? 또는 같은 검증 로직을 프론트엔드와 백엔드에서 중복으로 작성해야 했던 적은요?

실무에서 폼은 가장 복잡한 UI 컴포넌트 중 하나입니다. 회원가입, 결제, 설정 등 모든 곳에 폼이 있고, 각 필드는 다양한 검증 규칙을 가집니다.

실시간 검증, 포커스 이탈 시 검증, 제출 시 검증을 모두 처리해야 하며, 에러 메시지를 사용자 친화적으로 표시해야 합니다. 예를 들어, 회원가입 폼은 이메일 중복 확인, 비밀번호 강도 검사, 약관 동의 검증 등 수십 가지 규칙을 가질 수 있습니다.

바로 이럴 때 필요한 것이 Zod와 React Hook Form의 통합입니다. 스키마 하나로 모든 검증을 정의하고, 폼 라이브러리가 자동으로 검증을 수행하며, 에러를 표시합니다.

개요

간단히 말해서, @hookform/resolvers/zod를 사용하여 Zod 스키마를 React Hook Form의 검증 resolver로 등록합니다. 폼 제출 시 자동으로 Zod 검증이 수행되고, 실패한 필드에 에러 메시지가 표시됩니다.

실무에서는 폼 상태 관리와 검증이 복잡합니다. React Hook Form은 비제어 컴포넌트로 성능이 좋고, Zod는 타입 안전한 검증을 제공합니다.

둘을 결합하면 최고의 개발 경험을 얻을 수 있습니다. 예를 들어, 상품 등록 폼은 이름, 가격, 재고, 이미지, 카테고리, 옵션 등 많은 필드를 가지는데, Zod 스키마 하나로 모든 검증을 처리할 수 있습니다.

기존에는 각 필드마다 validate 함수를 작성하거나, yup 같은 다른 검증 라이브러리를 사용했다면, Zod는 TypeScript 타입 추론이 더 강력하고 문법이 더 직관적입니다. 또한 동일한 스키마를 백엔드에서도 사용하여 검증 로직을 공유할 수 있습니다.

이 통합의 핵심은 "단일 진실 공급원"입니다. Zod 스키마가 유일한 검증 정의이고, TypeScript 타입도 여기서 추론되며, React Hook Form이 이를 자동으로 적용합니다.

스키마를 수정하면 타입, 검증, 에러 메시지가 모두 자동으로 업데이트되어 일관성이 보장됩니다.

코드 예제

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

// 폼 스키마 정의
const SignUpSchema = z.object({
  email: z.string()
    .email('유효한 이메일을 입력해주세요')
    .min(5, '이메일은 최소 5자 이상이어야 합니다'),
  password: z.string()
    .min(8, '비밀번호는 최소 8자 이상')
    .regex(/[A-Z]/, '대문자를 포함해야 합니다')
    .regex(/[0-9]/, '숫자를 포함해야 합니다'),
  confirmPassword: z.string(),
  age: z.number()
    .int('정수를 입력해주세요')
    .min(14, '만 14세 이상만 가입 가능합니다'),
  terms: z.literal(true, {
    errorMap: () => ({ message: '약관에 동의해주세요' })
  }),
}).refine(data => data.password === data.confirmPassword, {
  message: '비밀번호가 일치하지 않습니다',
  path: ['confirmPassword'],
});

type SignUpForm = z.infer<typeof SignUpSchema>;

// React Hook Form과 통합
function SignUpPage() {
  const { register, handleSubmit, formState: { errors } } = useForm<SignUpForm>({
    resolver: zodResolver(SignUpSchema), // Zod resolver 등록
  });

  const onSubmit = (data: SignUpForm) => {
    // data는 이미 검증됨 - 안전하게 사용 가능
    console.log(data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('email')} />
      {errors.email && <span>{errors.email.message}</span>}

      <input type="password" {...register('password')} />
      {errors.password && <span>{errors.password.message}</span>}

      <input type="password" {...register('confirmPassword')} />
      {errors.confirmPassword && <span>{errors.confirmPassword.message}</span>}

      <input type="number" {...register('age', { valueAsNumber: true })} />
      {errors.age && <span>{errors.age.message}</span>}

      <input type="checkbox" {...register('terms')} />
      {errors.terms && <span>{errors.terms.message}</span>}

      <button type="submit">가입하기</button>
    </form>
  );
}

설명

이것이 하는 일: zodResolver는 Zod 스키마를 React Hook Form의 검증 함수로 변환합니다. 폼 제출 시 자동으로 Zod 검증이 수행되고, 에러는 errors 객체에 매핑됩니다.

첫 번째로, SignUpSchema는 회원가입 폼의 모든 필드와 검증 규칙을 정의합니다. 각 필드에 커스텀 에러 메시지를 제공하여 사용자 친화적입니다.

password는 여러 regex를 체이닝하여 복잡한 정책을 구현하고, age는 coerce 대신 valueAsNumber: true 옵션을 사용합니다. terms는 literal(true)로 반드시 체크해야 하며, errorMap으로 커스텀 메시지를 제공합니다.

refine()으로 비밀번호 확인을 추가하여 두 필드가 일치하는지 검증합니다. 두 번째로, useForm에 resolver를 전달합니다.

zodResolver(SignUpSchema)는 Zod 스키마를 React Hook Form이 이해하는 형식으로 변환합니다. 제네릭 타입 SignUpForm은 z.infer로 추론되어, register와 handleSubmit이 타입 안전해집니다.

formState.errors는 각 필드의 에러를 포함하는데, Zod 스키마의 path와 일치합니다. 예를 들어 errors.email.message는 이메일 검증 실패 시의 메시지입니다.

세 번째로, 폼 렌더링을 봅시다. register() 함수는 각 input을 React Hook Form에 등록합니다.

valueAsNumber 옵션은 HTML input의 문자열 값을 숫자로 변환합니다. 이것이 없으면 Zod의 z.number() 검증이 실패합니다.

errors 객체를 사용하여 각 필드 아래에 에러 메시지를 표시합니다. errors.email?.message처럼 옵셔널 체이닝을 사용하면 에러가 없을 때 undefined가 되어 안전합니다.

네 번째로, onSubmit은 검증을 통과한 후에만 호출됩니다. 따라서 data는 항상 유효하며, SignUpForm 타입이 보장됩니다.

data.email은 확실히 유효한 이메일이고, data.age는 14 이상의 정수입니다. 이 보장 덕분에 onSubmit 내부에서 추가 검증 없이 안전하게 API를 호출할 수 있습니다.

예를 들어 await createUser(data)처럼 직접 전달해도 됩니다. 다섯 번째로, 실전 활용입니다.

mode 옵션으로 검증 시점을 제어할 수 있습니다. mode: 'onBlur'는 포커스를 벗어날 때, mode: 'onChange'는 입력할 때마다 검증합니다.

기본값은 'onSubmit'으로 제출 시에만 검증합니다. 사용자 경험을 고려하여 선택하세요.

또한 setError()로 서버 에러를 폼에 추가할 수 있습니다. 예를 들어 이메일 중복 에러를 email 필드에 표시할 수 있습니다.

여러분이 이 패턴을 사용하면 폼 개발 시간이 크게 단축되고, 검증 로직이 중앙화되어 유지보수가 쉬우며, TypeScript 타입 안정성으로 버그가 줄어듭니다. 특히 복잡한 다단계 폼이나 동적 필드가 있는 폼에서 Zod의 유연성이 빛을 발합니다.

실전 팁

💡 valueAsNumber, valueAsDate 옵션을 사용하세요. HTML input은 항상 문자열을 반환하므로, Zod의 number나 date 검증을 위해서는 이 옵션이 필수입니다.

💡 mode 옵션으로 검증 시점을 조절하세요. onChange는 실시간 피드백을 주지만 너무 공격적일 수 있고, onBlur는 사용자가 입력을 마칠 때까지 기다립니다.

💡 defaultValues를 설정하면 초기값이 채워진 폼을 만들 수 있습니다. 편집 폼에서 기존 데이터를 로드할 때 유용합니다.

💡 watch()로 필드 값을 실시간으로 관찰할 수 있습니다. 한 필드의 값에 따라 다른 필드를 동적으로 보이거나 숨기는 조건부 폼을 만들 수 있습니다.

💡 zodResolver는 비동기를 지원하지 않습니다. 이메일 중복 확인 같은 비동기 검증은 onSubmit에서 별도로 처리하고, setError()로 폼에 에러를 추가하세요.


10. 환경 변수 검증 - 안전한 설정 관리

시작하며

여러분이 애플리케이션을 배포했는데 환경 변수가 잘못 설정되어 프로덕션이 다운되거나, API 키가 누락되어 서드파티 서비스 연동이 실패한 경험이 있나요? 또는 .env 파일의 오타를 찾느라 시간을 낭비한 적은요?

실무에서 환경 변수는 매우 중요하지만 가장 취약한 부분입니다. 데이터베이스 URL, API 키, 시크릿, 포트 번호 등 애플리케이션의 핵심 설정이 환경 변수에 담깁니다.

하지만 환경 변수는 모두 문자열이고, 타입 검증이 없으며, 오타나 누락을 런타임에 가서야 발견합니다. 예를 들어, DATABASE_URL을 DATABSE_URL로 잘못 쓰면 애플리케이션 시작 시 연결 실패로 크래시됩니다.

바로 이럴 때 필요한 것이 Zod를 활용한 환경 변수 검증입니다. 애플리케이션 시작 시 모든 환경 변수를 검증하여, 잘못된 설정을 조기에 발견하고 명확한 에러 메시지를 제공합니다.

개요

간단히 말해서, process.env를 Zod 스키마로 검증하여 타입 안전한 설정 객체를 생성합니다. 필수 환경 변수 누락이나 잘못된 형식을 즉시 감지하여 애플리케이션이 안전하게 시작되도록 보장합니다.

실무에서는 환경별로 다른 설정이 필요합니다. 개발, 스테이징, 프로덕션 환경마다 데이터베이스 URL, API 엔드포인트, 로그 레벨이 다릅니다.

또한 민감한 정보(API 키, 시크릿)는 절대 코드에 하드코딩하면 안 되고 환경 변수로 관리해야 합니다. 예를 들어, AWS, Stripe, SendGrid 등 여러 서드파티 서비스의 API 키를 안전하게 관리하고 검증해야 합니다.

기존에는 환경 변수를 직접 읽고 || 연산자로 기본값을 제공하거나, dotenv만 사용하여 타입 안정성이 없었다면, Zod를 추가하면 타입 안전하고 명확한 에러 메시지를 얻습니다. 애플리케이션이 시작되자마자 설정이 올바른지 확인되어 런타임 에러를 방지합니다.

이 패턴의 핵심은 "Fail Fast"입니다. 잘못된 설정으로 애플리케이션이 시작되는 것보다, 시작 시점에 명확한 에러를 보여주고 종료하는 것이 훨씬 안전합니다.

운영 중에 예상치 못한 에러가 발생하는 것보다, 배포 즉시 문제를 발견하는 것이 비용이 적습니다.

코드 예제

import { z } from 'zod';

// 환경 변수 스키마 정의
const EnvSchema = z.object({
  // Node 환경
  NODE_ENV: z.enum(['development', 'production', 'test'])
    .default('development'),
  PORT: z.coerce.number().default(3000),

  // 데이터베이스
  DATABASE_URL: z.string().url(),
  DATABASE_POOL_SIZE: z.coerce.number().int().positive().default(10),

  // API 키 (프로덕션에서는 필수)
  STRIPE_API_KEY: z.string().min(1),
  SENDGRID_API_KEY: z.string().min(1),

  // 선택적 설정
  LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
  ENABLE_ANALYTICS: z.coerce.boolean().default(false),

  // 조건부 필수 - 프로덕션에서만 필수
  SENTRY_DSN: z.string().url().optional(),
}).refine(data => {
  // 프로덕션에서는 SENTRY_DSN 필수
  if (data.NODE_ENV === 'production' && !data.SENTRY_DSN) {
    return false;
  }
  return true;
}, {
  message: 'SENTRY_DSN은 프로덕션 환경에서 필수입니다',
  path: ['SENTRY_DSN'],
});

// 타입 추론
type Env = z.infer<typeof EnvSchema>;

// 환경 변수 검증 및 로드
function loadEnv(): Env {
  const result = EnvSchema.safeParse(process.env);

  if (!result.success) {
    console.error('❌ 환경 변수 검증 실패:');
    console.error(result.error.format());
    process.exit(1); // 애플리케이션 종료
  }

  return result.data;
}

// 타입 안전한 설정 객체
export const env = loadEnv();

// 사용 예시
console.log(`서버가 포트 ${env.PORT}에서 실행 중입니다`);
// env.PORT는 number 타입으로 보장됨

설명

이것이 하는 일: EnvSchema는 모든 필요한 환경 변수를 정의하고, loadEnv()는 process.env를 검증하여 타입 안전한 객체를 반환합니다. 검증 실패 시 상세한 에러를 출력하고 프로세스를 종료합니다.

첫 번째로, EnvSchema 정의를 봅시다. NODE_ENV는 enum으로 정확히 세 가지 값만 허용하고, 기본값은 'development'입니다.

PORT는 z.coerce.number()로 문자열을 숫자로 변환합니다. 환경 변수는 항상 문자열이므로 coerce가 필수입니다.

DATABASE_URL은 url() 검증으로 유효한 URL인지 확인합니다. 잘못된 형식이면 데이터베이스 연결이 실패할 것이므로 미리 검증합니다.

DATABASE_POOL_SIZE는 정수이고 양수여야 하며 기본값은 10입니다. 두 번째로, API 키들은 min(1)로 빈 문자열을 거부합니다.

환경 변수가 정의되지 않으면 undefined이고, 빈 문자열 ""로 정의되면 존재하지만 무효합니다. min(1)은 실제로 값이 있는지 확인합니다.

LOG_LEVEL과 ENABLE_ANALYTICS는 선택적이지만 기본값을 가집니다. ENABLE_ANALYTICS는 z.coerce.boolean()로 "true", "false", "1", "0" 같은 문자열을 불린으로 변환합니다.

세 번째로, refine으로 조건부 검증을 구현합니다. SENTRY_DSN은 optional()이지만, NODE_ENV가 'production'이면 필수입니다.

이처럼 복잡한 비즈니스 규칙도 refine으로 표현할 수 있습니다. 프로덕션에서 모니터링 없이 실행되는 것을 방지하는 안전장치입니다.

네 번째로, loadEnv() 함수는 검증을 수행하고 결과를 처리합니다. safeParse()로 process.env를 검증하고, 실패하면 format()으로 보기 좋은 에러를 출력합니다.

format()은 각 필드의 에러를 중첩된 객체로 반환하여 어떤 환경 변수가 왜 실패했는지 명확히 보여줍니다. process.exit(1)은 애플리케이션을 즉시 종료합니다.

잘못된 설정으로 실행되는 것보다 조기 종료가 안전합니다. Docker나 Kubernetes에서는 이 exit code로 컨테이너 시작 실패를 감지합니다.

다섯 번째로, env 객체를 export합니다. 이제 애플리케이션의 모든 곳에서 process.env 대신 env를 사용합니다.

env.PORT는 타입이 number로 보장되고, env.NODE_ENV는 'development' | 'production' | 'test' 타입입니다. TypeScript의 자동완성과 타입 체크가 작동하여 오타를 즉시 발견할 수 있습니다.

예를 들어 env.DATABSE_URL을 쓰면 컴파일 에러가 발생합니다. 여러분이 이 패턴을 사용하면 환경 변수 오류를 배포 전에 발견할 수 있고, 타입 안전하게 설정을 사용하며, 명확한 에러 메시지로 디버깅이 쉬워집니다.

특히 CI/CD 파이프라인에서 환경 변수 검증이 실패하면 배포가 중단되어 프로덕션 장애를 사전에 방지할 수 있습니다.

실전 팁

💡 .env.example 파일을 제공하여 필요한 환경 변수 목록을 문서화하세요. 팀원들이 쉽게 로컬 환경을 설정할 수 있습니다.

💡 민감한 정보는 절대 git에 커밋하지 마세요. .env 파일을 .gitignore에 추가하고, .env.example만 커밋하세요.

💡 T3 Env 라이브러리를 사용하면 클라이언트/서버 환경 변수를 자동으로 분리하고, Next.js의 NEXT_PUBLIC_ 프리픽스를 자동으로 처리합니다.

💡 개발 환경에서는 dotenv 패키지로 .env 파일을 로드하세요. 프로덕션에서는 컨테이너 오케스트레이션 도구가 환경 변수를 주입합니다.

💡 환경 변수가 많다면 카테고리별로 스키마를 분리하고 merge()로 결합하세요. DatabaseSchema, ApiKeysSchema, FeatureFlagsSchema처럼 구조화하면 관리하기 쉽습니다.


#TypeScript#Zod#Schema#Validation#RuntimeCheck

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.

함께 보면 좋은 카드 뉴스

마이크로서비스 배포 완벽 가이드

Kubernetes를 활용한 마이크로서비스 배포의 핵심 개념부터 실전 운영까지, 초급 개발자도 쉽게 따라할 수 있는 완벽 가이드입니다. 실무에서 바로 적용 가능한 배포 전략과 노하우를 담았습니다.

Application Load Balancer 완벽 가이드

AWS의 Application Load Balancer를 처음 배우는 개발자를 위한 실전 가이드입니다. ALB 생성부터 ECS 연동, 헬스 체크, HTTPS 설정까지 실무에 필요한 모든 내용을 다룹니다. 초급 개발자도 쉽게 따라할 수 있도록 단계별로 설명합니다.

고객 상담 AI 시스템 완벽 구축 가이드

AWS Bedrock Agent와 Knowledge Base를 활용하여 실시간 고객 상담 AI 시스템을 구축하는 방법을 단계별로 학습합니다. RAG 기반 지식 검색부터 Guardrails 안전 장치, 프론트엔드 연동까지 실무에 바로 적용 가능한 완전한 시스템을 만들어봅니다.

에러 처리와 폴백 완벽 가이드

AWS API 호출 시 발생하는 에러를 처리하고 폴백 전략을 구현하는 방법을 다룹니다. ThrottlingException부터 서킷 브레이커 패턴까지, 실전에서 바로 활용할 수 있는 안정적인 에러 처리 기법을 배웁니다.

AWS Bedrock 인용과 출처 표시 완벽 가이드

AWS Bedrock의 Citation 기능을 활용하여 AI 응답의 신뢰도를 높이는 방법을 배웁니다. 출처 추출부터 UI 표시, 검증까지 실무에서 바로 사용할 수 있는 완전한 가이드입니다.