"TypeScript를 쓰는데 왜 런타임 에러가 터지죠?", "API 응답 데이터 구조가 달라서 서비스가 멈췄어요!"
TypeScript는 컴파일 시점에 타입 오류를 잡아주어 코드의 안정성을 크게 높여줍니다. 하지만 TypeScript의 타입 시스템은 컴파일 시간에만 유효하다는 근본적인 한계가 있습니다. API 응답, 사용자 폼 입력, 웹훅, 데이터베이스 조회 결과 등 프로그램 경계 밖에서 들어오는 데이터는 TypeScript의 타입 체커가 관여할 수 없는 '검증의 사각지대'에 놓여있습니다.
이 공백을 메우지 않으면, "실행 시점의 객체 구조가 내가 기대했던 타입과 다르다"는 이유만으로 서비스는 속수무책으로 무너질 수 있습니다. 바로 이 문제를 해결하기 위해 등장한 라이브러리가 바로 Zod입니다.
이 글에서는 Zod가 왜 필요한지부터 시작하여, 핵심 철학과 설계 원칙, 다양한 스키마 정의 방법, 그리고 실제 프로젝트에 적용하며 겪었던 트러블슈팅 경험까지, Zod에 대한 모든 것을 깊이 있게 파헤쳐 보겠습니다.
TypeScript는 정적 타입 시스템을 통해 개발 단계에서 수많은 오류를 예방해 줍니다. 하지만 외부에서 들어오는 데이터는 그 타입을 신뢰할 수 없습니다.
// API 응답 데이터를 받는다고 가정
interface User {
id: number;
name: string;
email: string;
}
// fetch 응답은 any 또는 unknown 타입
const responseData: unknown = await fetch('/api/user/1').then(res => res.json());
// 우리는 이 데이터가 User 타입일 것이라고 '가정'하고 타입 단언을 사용합니다.
const user = responseData as User;
// 하지만 만약 실제 응답에 email 필드가 없다면?
// 컴파일 시점에는 오류가 없지만, 런타임에 user.email.toLowerCase() 같은 코드는 에러를 발생시킵니다.
console.log(user.email.toLowerCase()); // 런타임 에러 발생!
이러한 문제를 해결하기 위해 우리는 보통 if
문과 typeof
를 사용한 방어적인 코드를 작성하지만, 데이터 구조가 복잡해질수록 검증 로직은 지저분한 if
중첩 지옥이 되기 십상입니다.
Zod는 바로 이 지점에서 빛을 발합니다. Zod는 스키마(Schema)를 한 번만 선언하면,
parse
, safeParse
)z.infer
)이 두 가지를 동시에 해결해 줍니다. 즉, 타입 안전성(Type-level safety)과 런타임 안전성(Runtime-level safety)을 하나의 스키마로 통합하여 관리할 수 있게 되는 것이죠.
interface
나 type
을 유지보수할 필요가 없습니다. "Single Source of Truth(단일 진실 공급원)" 원칙을 자연스럽게 지킬 수 있습니다..min()
, .optional()
등 모든 유효성 검사 메서드는 새로운 스키마 인스턴스를 반환합니다. 이를 통해 메서드 체이닝 시 발생할 수 있는 부작용(Side effect)을 방지합니다.zod/v4-mini
와 같은 경량 버전을 통해 함수형 API만 노출하여, 트리 셰이킹(Tree Shaking) 효율을 극대화하고 번들 사이즈를 줄일 수 있습니다.1. 라이브러리 설치:
npm install zod
# 또는 yarn add zod, pnpm add zod
Zod를 사용할 때는 가급적 tsconfig.json
에서 strict
모드를 활성화하는 것이 좋습니다.
// tsconfig.json
{
"compilerOptions": {
"strict": true
}
}
2. 스키마 정의:
Zod를 사용하는 가장 첫 단계는 데이터의 형태와 구조를 정의하는 스키마(Schema)를 만드는 것입니다. z.object()
, z.string()
, z.number()
등 직관적인 API를 통해 쉽게 정의할 수 있습니다.
import { z } from "zod";
// 사용자 객체에 대한 스키마 정의
const UserSchema = z.object({
username: z.string().min(3, "사용자 이름은 3자 이상이어야 합니다."),
email: z.string().email("유효한 이메일 형식이 아닙니다."),
age: z.number().int().positive("나이는 양의 정수여야 합니다.").optional(),
});
3. 유효성 검증 (parse
vs. safeParse
):
스키마를 정의했다면, 이제 외부 데이터를 검증할 수 있습니다. Zod는 두 가지 주요 검증 메서드를 제공합니다.
parse()
:ZodError
예외를 발생(throw)시켜 프로그램 실행을 중단시킵니다. try-catch
구문과 함께 사용하기 적합합니다.safeParse()
:{ success: true, data: ... }
형태의 객체를 반환합니다.{ success: false, error: ... }
형태의 객체를 반환합니다. 개발자는 이 결과 객체를 바탕으로 조건 분기를 통해 오류 처리를 할 수 있습니다.const userData = {
username: "silverbell",
email: "silverbell@example.com",
// age 필드는 없음
};
// safeParse 사용 예시
const result = UserSchema.safeParse(userData);
if (result.success) {
// 검증 성공! 타입이 보장된 데이터 사용
console.log("Validation successful:", result.data);
// result.data의 타입은 { username: string; email: string; age?: number | undefined }
} else {
// 검증 실패! 오류 정보 확인
console.error("Validation failed:", result.error.flatten());
/* 출력 예시:
{
formErrors: [],
fieldErrors: {
// age 필드가 optional이므로 오류 없음
}
}
*/
}
flatten()
으로 오류 다루기: result.error.flatten()
메서드는 중첩된 오류 구조를 formErrors
(전역 오류)와 fieldErrors
(필드별 오류)로 평탄화하여 보여주므로, 특히 웹 폼의 오류 메시지를 바인딩할 때 매우 유용합니다.z.infer
)과의 시너지Zod가 "TypeScript-first" 라이브러리라고 불리는 가장 큰 이유는 바로 스키마로부터 TypeScript 타입을 자동으로 추론해내는 강력한 기능 때문입니다.
// 스키마는 이미 위에서 정의했습니다.
const UserSchema = z.object({
username: z.string().min(3),
email: z.string().email(),
age: z.number().int().positive().optional(),
});
// z.infer를 사용하여 스키마로부터 타입을 추론합니다.
// 이제 별도의 interface나 type을 선언할 필요가 없습니다!
type User = z.infer<typeof UserSchema>;
/*
추론된 User 타입:
type User = {
username: string;
email: string;
age?: number | undefined;
}
*/
// 이제 이 User 타입을 함수 시그니처 등에 자유롭게 사용할 수 있습니다.
function processUser(user: User) {
// 함수 내부에서는 user가 이미 타입을 만족한다고 가정하고 로직 작성
console.log(user.username);
}
// 외부 데이터를 받을 때는 스키마로 검증하고, 성공 시 타입이 보장된 데이터를 넘겨줍니다.
function handleApiRequest(data: unknown) {
const validationResult = UserSchema.safeParse(data);
if (validationResult.success) {
processUser(validationResult.data);
} else {
// 오류 처리
}
}
이처럼, 스키마 정의 하나만으로 런타임 유효성 검증과 정적 타입 선언이라는 두 마리 토끼를 모두 잡을 수 있습니다. 검증 로직과 타입 선언을 따로 관리하며 발생했던 불일치 문제(DRY 원칙 위배)가 근본적으로 해결되는 것입니다. 스키마를 수정하면 타입이 자동으로 변경되고, 타입이 변경되면 해당 타입을 사용하는 모든 코드에서 컴파일 에러가 발생하여 잠재적인 버그를 사전에 방지할 수 있습니다.
제가 진행했던 프로젝트에서는 사용자가 입력한 콘텐츠 데이터를 저장하기 전에, 콘텐츠 타입(IMAGE
, EDITOR
, URL
등)에 따라 각기 다른 유효성 검사를 수행해야 했습니다. 기존 코드는 switch-case
와 수많은 if
문으로 이루어져 있어 복잡하고 가독성이 떨어졌습니다.
기존 명령형 유효성 검사 코드 (Before):
// 수많은 if문으로 이루어진 복잡한 검증 로직...
switch (contentsType) {
case 'IMAGE':
if (!contentsImgPath) {
errors.push("이미지 경로가 필요합니다.");
} else {
if (contentsImgPath.includes('http://')) {
errors.push("http:// 링크는 사용할 수 없습니다.");
}
// ... 기타 이미지 경로 검증 ...
}
break;
case 'EDITOR':
if (!contentsEditor) {
errors.push("에디터 내용이 필요합니다.");
} else {
if (contentsEditor.includes('허용되지 않는 도메인')) {
errors.push("허용되지 않는 도메인이 포함되어 있습니다.");
}
// ... 기타 에디터 내용 검증 ...
}
break;
// ...
}
이 복잡한 로직을 Zod를 사용하여 선언적인 스키마로 분리하고 리팩토링했습니다.
Zod를 활용한 스키마 기반 유효성 검사 (After):
공통 스키마 및 유틸 함수 정의:
// 공통 에러 메시지 생성 유틸
const createValidationMessage = (prop: string) => ({
required_error: `${prop} 필드는 필수입니다.`,
invalid_type_error: `${prop} 필드의 타입이 올바르지 않습니다.`,
});
// 모든 콘텐츠 타입에 공통으로 적용될 기본 스키마
const baseContentsSchema = z.object({
lang: z.string(),
title: z.string().nullish(), // string | null | undefined
});
각 콘텐츠 타입별 스키마 정의: .extend
, .refine
, .superRefine
등을 활용하여 각 타입에 맞는 세부 검증 규칙을 추가합니다.
const createImageTypeSchema = () => baseContentsSchema.extend({
contentsType: z.literal("IMAGE"),
contentsImgPath: z
.string(createValidationMessage('이미지 경로'))
.trim() // 앞뒤 공백 제거
.min(1, "이미지 경로는 비워둘 수 없습니다.")
.max(255, "이미지 경로가 너무 깁니다.")
.refine(path => !path.includes('http://'), "https:// 링크만 사용 가능합니다."),
});
// .superRefine을 사용한 다중 커스텀 유효성 검사 예시
const createEditorTypeSchema = () => baseContentsSchema.extend({
contentsType: z.literal("EDITOR"),
contentsEditor: z.string().superRefine((val, ctx) => {
if (val.length === 0) {
ctx.addIssue({ code: z.ZodIssueCode.too_small, minimum: 1, type: "string", inclusive: true });
}
if (val.includes("금지된 단어")) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "금지된 단어가 포함되어 있습니다." });
}
}),
});
.extend
: 기존 스키마에 새로운 필드를 추가하여 확장합니다..refine
/ .superRefine
: 기본 검증 규칙으로 표현하기 어려운 복잡한 커스텀 유효성 검사 로직을 추가합니다. .superRefine
은 여러 개의 오류를 동시에 추가할 수 있는 등 더 강력한 기능을 제공합니다.discriminatedUnion
으로 최종 스키마 조합:
contentsType
이라는 식별자 키(discriminator key)를 사용하여, 해당 키의 값에 따라 적용할 스키마를 동적으로 선택하도록 z.discriminatedUnion
을 사용합니다. 이는 TypeScript의 구별된 유니온 타입(Discriminated Union Type)과 동일한 개념으로, 매우 강력한 타입 안정성을 제공합니다.const contentsSchema = z.discriminatedUnion("contentsType", [
createImageTypeSchema(),
createEditorTypeSchema(),
// ... 다른 타입 스키마 ...
]);
리팩토링 결과:
if
문 지옥에서 벗어나, 각 데이터의 유효성 규칙을 선언적으로 명확하게 파악할 수 있게 되었습니다.baseContentsSchema
와 같이 공통 스키마를 추출하고 재사용 및 조합하여 코드 중복을 줄일 수 있었습니다.Zod를 사용하면서 겪었던 흥미로운 트러블슈팅 사례를 하나 공유합니다.
문제 상황: 이미지 경로 필드를 필수 입력 항목으로 만들기 위해 다음과 같이 스키마를 정의했습니다.
const imageSchema = z.object({
imgPath: z.string({
required_error: "이미지 경로는 필수입니다.",
}),
});
required_error
를 설정했으니 imgPath
가 undefined
이거나 null
이면 오류가 발생할 것이라고 기대했습니다. 그런데, 사용자가 아무것도 입력하지 않아 빈 문자열(""
)이 전송되었을 때 유효성 검사를 통과하는 문제가 발생했습니다.
원인 분석:
z.string()
은 TypeScript의 string
타입을 의미합니다.""
는 유효한 string
타입입니다. 따라서 z.string()
검증을 정상적으로 통과했던 것입니다.required_error
는 imgPath
속성 자체가 존재하지 않는 경우, 즉 undefined
일 때 발생하는 오류 메시지입니다. null
이 입력되면 invalid_type_error
가 발생합니다.해결책:
* 빈 문자열을 허용하지 않으려면, .min(1)
또는 .nonempty()
(v3)와 같은 추가적인 제약 조건을 명시적으로 추가해야 합니다.
```typescript
const imageSchemaFixed = z.object({
imgPath: z
.string({
required_error: "이미지 경로는 필수입니다.",
})
.trim() // 앞뒤 공백을 먼저 제거하고
.min(1, "이미지 경로는 비워둘 수 없습니다."), // 최소 길이를 1로 지정
});
```
이 경험을 통해 Zod의 동작 원리를 더 깊이 이해하게 되었고, 단순히 "필수"라는 개념을 넘어, 각 제약 조건이 TypeScript 타입 시스템과 어떻게 연관되는지 명확히 파악하는 것이 중요하다는 것을 깨달았습니다.
🤔 꼬리 질문: Zod 스키마를 설계할 때, 성능에 영향을 미칠 수 있는 요소에는 어떤 것들이 있을까요? 예를 들어,
.refine
을 과도하게 사용하거나 복잡한 정규식을 포함시키는 것이 성능에 어떤 영향을 줄 수 있을지 생각해 볼 수 있을까요?
라이브러리 | 강점 | 한계 |
---|---|---|
AJV | JSON Schema 표준 호환, 컴파일 기반으로 최고의 런타임 성능 | 타입 추론 기능 미약, 스키마 문법이 상대적으로 장황함. |
Yup | React 폼 라이브러리(Formik 등) 생태계와 매우 친화적 | 복잡한 조건부 스키마에서 타입 안전성이 약해질 수 있음. |
Joi | 풍부한 내장 유효성 검사 규칙 및 커스텀 에러 메시지 지원 | 주로 Node.js 환경에서 사용, 번들 사이즈가 상대적으로 큼. |
Zod | TypeScript와의 완벽한 타입 연동성, 간결한 API, 타입 추론 | JSON Schema 표준과 100% 직접 호환되지는 않음. |
Zod 스키마는 한 번 정의하면 애플리케이션의 여러 계층에서 재사용할 수 있습니다.
body
, query
, params
를 진입점에서 즉시 safeParse
하여, 유효하지 않은 요청은 400 Bad Request로 빠르게 응답합니다.safeParse
를 수행합니다. 만약 검증에 실패하면, 캐시 데이터가 오염되었거나 스키마가 변경된 것으로 간주하고 캐시를 삭제한 후 원본 데이터 소스(DB 등)를 재조회합니다.react-hook-form
, Formik
등과 Zod를 연동(@hookform/resolvers/zod
등)하여, 백엔드에서 사용하는 것과 동일한 스키마로 프론트엔드에서 실시간 유효성 검사를 수행합니다. 이를 통해 사용자에게 즉각적인 피드백을 제공하고, 서버에 도달하기 전에 불필요한 API 호출을 줄이며, 최종적으로 서버에서 다시 한번 동일한 스키마로 검증하는 이중 안전망을 구축할 수 있습니다.Zod는 TypeScript 개발 환경에서 "타입"과 "데이터"라는 두 평행우주 사이에 존재했던 간극을 메워주는 매우 현실적이고 강력한 해법입니다.
- 스키마 = 단일 진실 공급원 (Single Source of Truth): 유지보수 비용 최소화
- 런타임 검증 + 정적 타입 추론 = 안정성 극대화
실무에서는 API의 입구부터 데이터베이스에 도달하기까지, 애플리케이션의 여러 계층에 동일한 Zod 스키마를 관통시켜 불필요한 변환 코드(Glue code) 없이 일관성 있고 안전한 데이터 처리 파이프라인을 구축할 수 있습니다.