Zod를 이용한 TypeScript 런타임 데이터 검증

1. 개요

TypeScript의 타입은 컴파일 후 제거됩니다. 따라서 API에서 받은 잘못된 JSON, 사용자가 제출한 폼, 또는 누락된 환경 변수로부터 보호하지 못합니다.

이 글에서는 Zod 4를 사용하여 런타임에 데이터를 검증하는 방법을 안내합니다. 한 번 schema를 선언하고 그로부터 타입을 추론할 수 있습니다. 또한 깔끔한 오류 처리, 테스트 작성, 그리고 흔한 함정을 피하는 방법도 배울 수 있습니다.

2. TypeScript 타입이 런타임에는 충분하지 않은 이유

다음과 같은 익숙한 코드를 살펴보세요:

interface Customer {
  id: number;
  email: string;
}

const raw = '{"id": "42", "email": null}';
const customer = JSON.parse(raw) as Customer;

console.log(customer.id + 1);
console.log(customer.email.toLowerCase());

JSON.parse는 any를 반환합니다. as Customer는 단지 컴파일러에 대한 약속일 뿐, 실제 검증 단계가 없습니다. 실행 결과:

421
TypeError: Cannot read properties of null (reading 'toLowerCase')

id는 문자열 "42"이므로 더하기 연산이 문자열 연결이 됩니다. email은 null이므로 프로그램이 충돌합니다. 컴파일러는 아무것도 경고하지 않습니다. 런타임에 실제 검증을 수행하는 레이어가 필요합니다. Zod가 정확히 이 일을 합니다.

3. 설치

Zod는 의존성이 없으며 Node.js와 브라우저 모두에서 실행됩니다:

npm install zod

Zod가 타입을 정확하게 추론하려면 tsconfig.json에서 strict 모드를 활성화해야 합니다:

{
  "compilerOptions": {
    "strict": true
  }
}

주의: "strict": true 없이는 optional field가 잘못 추론될 수 있습니다. 타입의 이점을 대부분 잃게 됩니다.

4. Schema 정의 및 타입 추론

Schema는 예상하는 데이터의 형태를 설명합니다:

import { z } from "zod";

const CustomerSchema = z.object({
  id: z.number().int().positive(),
  email: z.email(),
  fullName: z.string().min(2).max(100),
  phone: z.string().optional(),
  tier: z.enum(["FREE", "PRO", "ENTERPRISE"]).default("FREE"),
});

type Customer = z.infer<typeof CustomerSchema>;

각 부분의 역할:

  • z.number().int().positive() - 양의 정수를 요구합니다.
  • z.email() - 문자열이 올바른 이메일 형식인지 확인합니다. Zod 4에서는 email, URL, UUID 같은 문자열 형식이 최상위 레벨에서 선언됩니다.
  • .optional() - field가 없거나 undefined 값을 가질 수 있도록 합니다.
  • .default("FREE") - input에 tier field가 없을 때 자동으로 값을 채웁니다.
  • z.infer - schema에서 TypeScript 타입을 생성합니다.

Customer 위에 마우스를 올리면 다음과 같은 타입이 표시됩니다:

type Customer = {
  id: number;
  email: string;
  fullName: string;
  phone?: string | undefined;
  tier: "FREE" | "PRO" | "ENTERPRISE";
}

강점은 여기 있습니다: schema는 유일한 진실 공급원입니다. Schema에 field를 추가하면 타입이 자동으로 업데이트됩니다. 별도의 interface를 유지할 필요가 없습니다.

5. parse 및 safeParse

Zod는 검증을 실행하는 두 가지 주요 방법을 제공합니다.

5.1. parse - 오류 시 exception 발생

const customer = CustomerSchema.parse({
  id: 42,
  email: "an.nguyen@example.com",
  fullName: "Nguyễn Văn An",
});

console.log(customer);
{ id: 42, email: 'an.nguyen@example.com', fullName: 'Nguyễn Văn An', tier: 'FREE' }

반환된 값은 검증되었으며 Customer 타입을 가집니다. tier field는 기본값으로 채워집니다. 데이터가 잘못되면 parse는 ZodError를 발생시킵니다.

5.2. safeParse - 결과를 반환하며 오류를 발생시키지 않음

const result = CustomerSchema.safeParse({ id: "42", email: "not-an-email" });

if (!result.success) {
  console.log(result.error.issues.map((i) => `${i.path.join(".")}: ${i.code}`));
} else {
  console.log(result.data.email);
}
[ 'id: invalid_type', 'email: invalid_format', 'fullName: invalid_type' ]

result는 discriminated union입니다. result.success를 확인한 후 TypeScript는 어느 branch에 data가 있고 어느 branch에 error가 있는지 정확히 알게 됩니다. Zod는 첫 번째 오류에서 멈추지 않고 모든 오류를 수집합니다. 이는 폼에 오류를 표시할 때 매우 편합니다.

선택 규칙은 간단합니다:

  • request body나 폼 같은 시스템 경계에서 safeParse를 사용하세요. 여기서 오류는 일반적입니다.
  • 데이터 오류가 버그 또는 손상된 설정을 의미하며 애플리케이션이 즉시 중단해야 할 때는 parse를 사용하세요.

6. 커스텀 오류 메시지 및 오류 형식

Zod의 기본 메시지는 영어로 작성되고 꽤 기술적입니다. error 매개변수를 전달하여 이를 바꿀 수 있습니다:

const SignUpSchema = z.object({
  email: z.email({ error: "유효하지 않은 이메일입니다" }),
  password: z.string().min(8, { error: "비밀번호는 최소 8자 이상이어야 합니다" }),
});

const result = SignUpSchema.safeParse({ email: "abc", password: "123" });

if (!result.success) {
  console.log(z.flattenError(result.error));
}
{
  formErrors: [],
  fieldErrors: {
    email: [ '유효하지 않은 이메일입니다' ],
    password: [ '비밀번호는 최소 8자 이상이어야 합니다' ]
  }
}

z.flattenError는 오류를 field 이름으로 정렬합니다. 이 형식은 frontend로 반환하기에 적합합니다. 사람이 읽을 수 있는 형식으로 로깅할 때는 z.prettifyError(result.error)를 사용하여 여러 줄의 읽기 쉬운 문자열을 얻을 수 있습니다.

7. Coerce, Transform 및 Refine

실제 데이터는 거의 완벽하게 schema와 일치하지 않습니다. Zod는 이러한 상황을 처리하기 위한 세 가지 도구를 제공합니다.

7.1. z.coerce - 입력 강제 변환

Query string과 환경 변수는 항상 문자열입니다. z.coerce는 검증 전에 이들을 변환합니다:

const PaginationSchema = z.object({
  page: z.coerce.number().int().min(1).default(1),
  size: z.coerce.number().int().max(100).default(20),
});

console.log(PaginationSchema.parse({ page: "3", size: "50" }));
{ page: 3, size: 50 }

주의: z.coerce.number()는 Number(input)을 호출합니다. 따라서 빈 문자열 ""은 0이 되어 타입 검증을 통과합니다. 항상 .min(1)과 같은 제약을 추가하여 이 경우를 차단하세요.

7.2. transform - 검증 후 변환

const OrderCodeSchema = z
  .string()
  .trim()
  .regex(/^ord-\d{6}$/i, { error: "주문 코드 형식이 올바르지 않습니다" })
  .transform((code) => code.toUpperCase());

console.log(OrderCodeSchema.parse("  ord-000123 "));
ORD-000123

transform 후 입력 타입과 출력 타입이 다를 수 있습니다. z.input<typeof S>와 z.output<typeof S>를 사용하여 각각을 가져올 수 있습니다. z.infer는 z.output과 동일합니다.

7.3. refine - 임의의 비즈니스 로직

동시에 여러 field와 관련된 제약이 있을 수 있습니다. 객체 수준에서 refine을 사용하여 처리하세요:

const BookingSchema = z
  .object({
    checkIn: z.coerce.date(),
    checkOut: z.coerce.date(),
  })
  .refine((b) => b.checkOut > b.checkIn, {
    error: "체크아웃 날짜는 체크인 날짜보다 이후여야 합니다",
    path: ["checkOut"],
  });

const r = BookingSchema.safeParse({ checkIn: "2026-11-10", checkOut: "2026-11-08" });
console.log(r.success, r.error?.issues[0].path, r.error?.issues[0].message);
false [ 'checkOut' ] 체크아웃 날짜는 체크인 날짜보다 이후여야 합니다

path는 오류를 전체 객체가 아닌 checkOut field에 첨부합니다. 덕분에 frontend는 오류를 올바른 위치에 표시합니다.

8. 실제 예제

8.1. 주문 생성 요청 검증

다음 예제는 framework에 독립적인 요청 처리 함수입니다. Express, Fastify 또는 Next.js에서 사용할 수 있습니다:

import { z } from "zod";

const OrderItemSchema = z.object({
  productId: z.uuid(),
  quantity: z.number().int().min(1).max(99),
});

export const CreateOrderSchema = z.object({
  customerId: z.number().int().positive(),
  items: z.array(OrderItemSchema).nonempty({ error: "주문에는 최소 1개 이상의 상품이 있어야 합니다" }),
  note: z.string().max(500).optional(),
});

export type CreateOrderRequest = z.infer<typeof CreateOrderSchema>;

export function handleCreateOrder(body: unknown) {
  const result = CreateOrderSchema.safeParse(body);
  if (!result.success) {
    return { status: 400, body: z.flattenError(result.error).fieldErrors };
  }
  const order: CreateOrderRequest = result.data;
  return { status: 201, body: { itemCount: order.items.length } };
}

body 매개변수의 타입이 any가 아니라 unknown임을 주목하세요. 이덕분에 TypeScript는 사용하기 전에 검증을 강제합니다. safeParse 후 result.data는 완전한 타입을 가집니다. 기본적으로 z.object는 unknown field를 제거하므로 client는 object에 isAdmin: true를 “주입”할 수 없습니다.

8.2. 시작 시 환경 변수 검증

애플리케이션은 구성이 잘못되면 즉시 중단해야 하며, 첫 번째 요청을 처리할 때까지 기다려서는 안 됩니다:

import { z } from "zod";

const EnvSchema = z.object({
  NODE_ENV: z.enum(["development", "test", "production"]),
  PORT: z.coerce.number().int().default(3000),
  DATABASE_URL: z.url(),
});

export const env = EnvSchema.parse(process.env);

DATABASE_URL이 누락되었다고 가정하면, 애플리케이션은 모듈을 import할 때 즉시 ZodError를 발생시키며, DATABASE_URL이 누락되었음을 지적하는 메시지를 표시합니다. 다른 모든 곳에서 env.PORT는 string | undefined가 아닌 number 타입을 가집니다.

9. Vitest로 테스트 작성

Schema는 비즈니스 로직을 포함하므로 테스트도 필요합니다. 먼저 Vitest를 설치하세요:

npm install -D vitest

그런 다음 CreateOrderSchema에 대한 테스트를 작성하세요:

import { describe, it, expect } from "vitest";
import { z } from "zod";
import { CreateOrderSchema } from "./order";

describe("CreateOrderSchema", () => {
  const validItem = { productId: "3f1c2a9e-8b4d-4c1a-9f2e-1a2b3c4d5e6f", quantity: 2 };

  it("유효한 주문을 수락하고 unknown field를 제거한다", () => {
    const result = CreateOrderSchema.safeParse({
      customerId: 7,
      items: [validItem],
      isAdmin: true,
    });

    expect(result.success).toBe(true);
    expect(result.data).toEqual({ customerId: 7, items: [validItem] });
  });

  it("상품이 없는 주문을 거부한다", () => {
    const result = CreateOrderSchema.safeParse({ customerId: 7, items: [] });

    expect(result.success).toBe(false);
    expect(z.flattenError(result.error!).fieldErrors.items).toContain(
      "주문에는 최소 1개 이상의 상품이 있어야 합니다",
    );
  });

  it("수량이 제한을 초과하면 거부한다", () => {
    const result = CreateOrderSchema.safeParse({
      customerId: 7,
      items: [{ ...validItem, quantity: 100 }],
    });

    expect(result.error?.issues[0].path).toEqual(["items", 0, "quantity"]);
  });
});

npx vitest run을 실행하면 세 테스트 모두 통과합니다. 세 번째 테스트는 오류의 path가 배열 내부의 요소까지 간다는 것을 보여줍니다. 이것이 정확히 frontend가 올바른 input field에 빨간색으로 표시하기 위해 필요한 정보입니다.

10. 흔한 실수와 Zod를 사용하지 말아야 할 때

이미 schema가 있는데도 as로 강제 변환하기. body as CreateOrderRequest를 작성하면 전체 보호 메커니즘이 무너집니다. 항상 result.data에서 데이터를 가져오세요.

async refine과 함께 parse 사용하기. 예를 들어 database에 이메일이 이미 있는지 확인하는 경우입니다. async refine이 있으면 parseAsync 또는 safeParseAsync를 호출해야 합니다. 그렇지 않으면 Zod가 오류를 발생시킵니다.

unknown field가 거부될 것으로 예상하기. z.object는 기본적으로 unknown field를 조용히 제거합니다. 예를 들어 설정 파일의 오타를 감지하기 위해 unknown field가 있을 때 오류를 발생시키려면 z.strictObject(...)을 사용하세요.

애플리케이션 내부에서 반복적으로 검증하기. API, 외부 API로부터의 응답, message queue, 설정 파일 등 시스템 경계에서만 검증해야 합니다. 이미 검증된 데이터는 그 타입을 믿으세요. 모든 함수에서 다시 parse하는 것은 CPU만 낭비합니다.

주의: 초당 수백만 개 객체를 처리해야 하는 hot loop에서는 Zod를 사용하지 마세요. 검증에는 실제 비용이 있습니다. 그런 경우 입력 시 한 번만 검증하거나 schema를 코드로 컴파일하는 라이브러리를 선택하세요.

함수 내부에서 schema 선언하기. 함수가 호출될 때마다 schema를 생성하면 애플리케이션이 초기화 비용이 추가됩니다. schema를 모듈 수준에서 선언하고 재사용하세요.

11. 결론

Zod는 compile time의 타입과 런타임의 실제 데이터 사이의 간격을 채웁니다. schema를 한 번 선언하고 z.infer로 타입을 추론한 다음, 시스템 경계에서 safeParse를 사용하여 깨끗한 데이터 또는 구조화된 오류 목록을 얻습니다.

coerce, transform, refine으로 대부분의 실제 상황을 처리할 수 있습니다: query string, 데이터 정규화, 다중 field 비즈니스 로직. 일찍 검증하고, 경계에서 검증하고, 나머지는 TypeScript에 맡기세요.

typescriptzodvalidationnodejs