Reese-log
  • About
  • Blog

© 2025 Reese. All rights reserved.

2024년 8월 27일

Zod로 유효성 검사 구현에 생산성을 더해보아요

#Etc

TypeScript를 사용하는데 왜 별도의 데이터 유효성 검사 라이브러리가 필요할까요?

TypeScript의 한계: 정적 타입 검사만 제공

TypeScript는 정적 타입 시스템을 제공합니다. 즉, 코드를 작성하고 컴파일하는 시점에 타입 오류를 잡아낼 수는 있지만, 실제 코드가 브라우저나 서버에서 실행되는 런타임에서는 TypeScript의 타입 정보가 완전히 사라집니다.

컴파일 후 dist/ 폴더에 생성되는 파일은 전부 .js 확장자이며, 타입 정보는 포함되어 있지 않습니다.

💥 문제는 런타임에 발생합니다

API 응답, 사용자 입력 등 외부에서 유입되는 데이터는 런타임에 들어오며, 이에 대해 TypeScript는 아무런 보장도 하지 않습니다.

  • API가 예기치 않게 스펙을 바꾸었을 때
  • 사용자가 예상치 못한 입력을 했을 때
  • 타 시스템에서 전송된 데이터가 손상되었을 때
  • 이러한 경우 런타임 오류가 발생하고, 이는 운영상의 장애로 이어질 수 있습니다.

    그래서 필요한 것이 런타임 유효성 검사

    런타임 데이터를 검사하려면 아래 중 하나가 필요합니다.

  • 직접 검증 함수 구현
  • 유효성 검사 라이브러리 사용
  • 런타임 데이터에 대한 유효성 검사를 구현하는데 생산성을 높여주는 라이브러리들

    ⇒ Yup/ Zod/ joi / Avj

    이러한 라이브러리를 쓰면 어떠한 장점이 있나요?

    ⇒ 유효성 검사 기능 간결하게 구현 + 에러 처리 용이 + 스키마 정의 후 별도의 인터페이스 생성 없이 재활용 가능

    joi와 Avj는 우선 건너뜁시다

    joi - 정적 타입 추론을 지원하지 않아 제외 (Node.js에서 잘 쓰임.)

    Avj - 가장 오래되긴 했습니다. (2015 / 올해 릴리즈 버전은 8까지 있음.) 다만 이 친구는 ts 지원 안되고, 특히 호환성과 의존성 문제들이 있을 수 있다고 합니다. 해당 라이브러리를 사용한 오래된 서비스들이 있다보니 계속 유지되고 있는것으로 보입니다.

    라이브러리 비교: Yup vs Zod

    Yup

  • 장점
  • 단점
  • typescript
    1import * as yup from 'yup';
    2
    3const schema = yup.string();
    4
    5schema.isValid(123).then(console.log); // ✅ true (숫자도 통과)
    6

    Zod

  • 장점
  • 단점
  • Zod를 선택한 이유

    Yup은 아래 예시처럼 기본적으로 타입에 관대한 편입니다.

    javascript
    yup.string().isValid(333).then(console.log); // true

    strict() 옵션을 사용하면 해결되긴 하지만, 이걸 매번 명시해야 한다는 점에서 실수의 여지가 큽니다.

    반면 Zod는 기본적으로 엄격한 타입 검사를 적용합니다. 실수로 숫자를 문자열로 받는 일을 미연에 방지할 수 있죠.

    직접 검증 함수 구현하기 vs Zod

    저도 라이브러리 적용 전엔 아래 같은 함수들을 하나하나 구현했습니다.

    typescript
    const emailValidation = (email: string) => {
      const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
      return regex.test(email);
    };

    → 이 방식은 검증 로직 재사용성 부족, 오류 메시지 일관성 문제, 테스트 및 유지보수 비용 증가 등의 문제가 있습니다.

    비밀번호의 경우라면 비밀번호 정책에 따른 validation에 비밀번호 입력 / 비밀번호 확인 Input에 따라 별도의 함수를 만들고… x100

    Zod로 구현한 예

    typescript
    1import { z } from 'zod';
    2
    3const emailSchema = z.string().email({ message: "이메일 형식이 아닙니다." });
    4
    5const result = emailSchema.safeParse(userInput);
    6
    7if (!result.success) {
    8  console.error(result.error.format());
    9}
  • emailSchema를 타입으로도 바로 사용할 수 있어 스키마와 타입 정의 일치
  • safeParse를 사용해 런타임에서도 안전하게 검사 및 에러 핸들링 가능
  • Zod의 parse vs safeParse 언제, 왜 사용해야 할까?

    Zod에서 제공하는 parse와 safeParse는 모두 스키마 기반의 유효성 검사를 수행하지만, 오류를 처리하는 방식이 다릅니다. 이를 통해 예외 기반 처리와 조건 분기 기반 처리를 명확히 구분할 수 있습니다.

    API예외 발생반환 타입사용 시점
    parse()❗ 예외 발생값 or throw데이터가 반드시 올바르다고 가정할 때
    safeParse()❌ 예외 없음{ success: boolean, data?, error? }사용자 입력 등 오류 발생 가능성이 있는 경우

    팁

  • parse()는 테스트 코드, seed 데이터, 내부 시스템 간 통신에서 주로 사용
  • safeParse()는 사용자 입력 폼, 외부 API 응답, 클라이언트-서버 통신의 입력 처리 등 안전성이 필요한 영역에서 사용
  • 코드 예시 비교

    typescript
    1
    2// 예외 발생 → try/catch 필요
    3try {
    4  const value = z.string().email().parse("not-an-email");
    5} catch (e) {
    6  console.error("유효하지 않은 이메일:", e);
    7}
    8
    9
    typescript
    1
    2// 조건 분기 → 흐름 제어가 깔끔함
    3const result = z.string().email().safeParse("not-an-email");
    4if (!result.success) {
    5  console.error("입력 오류:", result.error.format());
    6

    타입 따로 선언할 필요 없음! Zod의 타입 추론 활용

    Zod의 강력한 장점은 타입 정의와 검증 스키마가 동일 소스로부터 유도된다는 것입니다. 이로 인해 타입 불일치로 인한 런타임 오류가 줄어듭니다.

    typescript
    1const userSchema = z.object({
    2  id: z.number(),
    3  name: z.string(),
    4  email: z.string().email(),
    5});
    6
    7// 타입 자동 추론
    8type User = z.infer<typeof userSchema>;

    이렇게 하면 API 응답 타입을 별도 선언할 필요 없이 userSchema만 잘 작성하면 됩니다.

    Zod 에러 메시지 커스터마이징과 format() 활용

    Zod는 유효성 실패 시 다양한 형태의 에러 메시지 커스터마이징이 가능하며, safeParse 결과의 error.format()은 폼과 UI에 에러 메시지를 표시할 때 매우 유용합니다.

    typescript
    1const userSchema = z.object({
    2  email: z.string().email({ message: '이메일 형식이 아닙니다.' }),
    3  password: z.string().min(8, { message: '비밀번호는 최소 8자 이상이어야 합니다.' }),
    4});
    5
    6const result = userSchema.safeParse(formData);
    7
    8// 결과가 실패했을 경우, 각 필드별 메시지를 구조화된 형태로 제공
    9if (!result.success) {
    10  const errors = result.error.format();
    11  console.log(errors.email?._errors[0]); // "이메일 형식이 아닙니다."
    12}
    이 구조는 react-hook-form과 연동할 때도 그대로 쓸 수 있어 UX 개선에 도움이 됩니다.

    입력값 가공(Transform)도 가능

    Zod는 단순히 검증만 하는 것이 아니라, 데이터를 자동으로 가공(transform) 할 수도 있습니다. 예를 들어:

    typescript
    1const schema = z.string().transform((val) => val.trim().toLowerCase());
    2
    3const result = schema.safeParse("  ExAmPlE@Email.Com  ");
    4console.log(result.data); // "example@email.com"
    5
    6
  • 이를 통해 입력값 정규화(standardization) 를 쉽게 처리할 수 있어 API 요청 전 데이터를 항상 일관된 상태로 유지 가능
  • merge, extend, partial, pick, omit 같은 스키마 조작 API

    Zod는 객체 스키마를 조작할 수 있는 다양한 메서드를 제공합니다. 이를 통해 공통 필드 추출, 일부 필드 제외, 부분 입력 허용 등이 가능합니다.

    typescript
    1const baseUser = z.object({
    2  name: z.string(),
    3  email: z.string().email(),
    4});
    5
    6const updateUser = baseUser.partial(); // 모든 필드를 선택적으로 바꿈
    7const createUser = baseUser.extend({
    8  password: z.string().min(8),
    9});

    → 유지보수성과 재사용성을 대폭 향상시킬 수 있습니다.

    실무에서 어떻게 활용했을까?

    제가 참여한 프로젝트에서는 자원 등록 폼의 입력 오류로 인해 여러 문제가 반복적으로 발생했습니다.

    대표적인 사례는 다음과 같습니다.

  • 특정 필드에 대한 검증 정책이 변경되었지만, 일부 입력값이 여전히 잘못된 형식으로 서버에 전송됨
  • 필수 항목 누락 상태로 등록 시도가 이루어짐
  • 그 결과, 운영팀이 직접 데이터를 수정해야 하는 수작업 업무가 지속적으로 발생
  • 초기에는 각 입력 필드마다 정규식과 조건문으로 직접 검증 로직을 작성했지만, 점점 케이스가 많아지면서 다음과 같은 어려움이 생겼습니다.

  • 검증 로직이 여러 곳에 중복되어 변경사항을 반영하기 어려움
  • 에러 메시지가 일관되지 않아 사용자 피드백이 불명확
  • 테스트와 유지보수에 시간이 점점 더 많이 소요됨
  • 🛠 그래서 어떻게 개선했나?

    이런 반복적인 문제를 해결하기 위해, Zod와 react-hook-form을 기반으로 입력 검증 시스템을 리빌딩했습니다.

  • Zod로 각 입력값에 대한 스키마를 명확하게 정의
  • safeParse()를 사용해 검증 결과를 안전하게 분기 처리
  • 실패한 경우, error.format()을 활용해 필드별 에러 메시지를 구조화하여 UI에 표시
  • typescript
    1// 실제 코드는 아닙니다.
    2const userSchema = z.object({
    3  email: z.string().email({ message: '이메일 형식이 아닙니다.' }),
    4  password: z.string().min(8, { message: '비밀번호는 최소 8자 이상이어야 합니다.' }),
    5});
    6
    7const result = userSchema.safeParse(formData);
    8
    9if (!result.success) {
    10  const errors = result.error.format();
    11  // errors.email._errors[0] 이런 식으로 UI에 표시 가능
    12}

    이렇게 하니 다음과 같은 개선이 즉각적으로 체감되었습니다.

  • 모든 입력 검증 로직을 일원화 → 변경 시에도 한 곳만 수정하면 됨
  • 검증 실패 메시지를 UI에 정확하고 일관되게 출력
  • 클라이언트에서 잘못된 데이터가 아예 서버에 도달하지 않음 → 운영 리소스 절감
  • 🔚 결론

    TypeScript는 정적 타입 안정성을 제공하지만, 실제 사용자의 입력이나 외부 API 응답과 같은 런타임 데이터까지 안전하게 다루려면 그 이상의 대응이 필요합니다.

    Zod는 단순한 유효성 검증 도구를 넘어, 다음과 같은 프론트엔드 개발 전반의 신뢰성을 높이는 역할을 합니다.

  • 타입스크립트와 완전히 통합된 스키마 정의
  • safeParse, format() 등을 통한 UI와 자연스럽게 연동되는 에러 처리
  • .transform(), .merge(), .partial() 등으로 복잡한 폼 스키마도 선언적으로 구성
  • API 요청 전 단계에서 데이터 정합성을 강제할 수 있는 구조
  • React Hook Form과의 결합으로 실제 폼 입력부터 전송 직전까지 일관된 검증 로직을 유지할 수 있었고,

    이전에는 수작업 수정이 필요했던 입력 오류들을 사용자 수준에서 예방할 수 있게 되었습니다.

    이번 개선을 통해 단순히 오류를 줄이는 데 그치지 않고,

    "검증 로직은 선언형으로 구성되고 UI 로직과 명확히 분리돼야 유지보수가 쉬워진다"는 점을 실감할 수 있었습니다.

    Zod는 지금 이 순간에도 업데이트가 활발하고, 타입 안정성 기반의 프론트엔드 유효성 검증을 고민하는 팀이라면 충분히 도입할 만한 가치가 있는 도구입니다.