본문 바로가기
Library

TypeScript를 쓰는데 왜 Zod까지 사용할까?

by 들판에 날라다니는 솜 2026. 6. 18.

Zod를 사용하는 이유

TypeScript는 개발 단계에서 타입 오류를 알려주는 훌륭한 도구이다. 하지만 TypeScript의 타입 검사는 컴파일 시점에만 동작하며, 실제 서비스가 실행되는 런타임에서는 타입 정보가 사라진다.

즉, 개발자는 age가 숫자라고 정의했더라도 사용자가 실제로 어떤 데이터를 보낼지는 보장할 수 없다.

{ "age": "안녕하세요" }

위와 같은 잘못된 데이터도 서버로 전달될 수 있으며, TypeScript는 이를 막아주지 못한다.

이러한 문제를 해결하기 위해 사용하는 것이 Zod이다.

Zod는 런타임에서 실제 전달된 데이터를 검증하여 개발자가 정의한 스키마와 일치하는지 확인한다. 검증에 실패하면 애플리케이션 내부로 잘못된 데이터가 들어오는 것을 막을 수 있어 더욱 안전한 코드를 작성할 수 있다.


직접 검증하는 방법

물론 Zod를 사용하지 않고 직접 검증 코드를 작성할 수도 있다.

if (typeof body.email !== "string") {
  return Response.json(
    { error: "이메일은 문자열이어야 합니다." },
    { status: 400 }
  );
}

if (typeof body.age !== "number") {
  return Response.json(
    { error: "나이는 숫자여야 합니다." },
    { status: 400 }
  );
}

필드가 두세 개 정도라면 큰 문제가 되지 않는다.

하지만 실제 서비스에서는 회원가입이나 주문, 결제와 같은 기능의 경우 검증해야 하는 필드가 수십 개에 이를 수 있다. 필드가 늘어날수록 검증 코드도 계속 증가하게 되고, 코드가 길어질 뿐만 아니라 유지보수도 어려워진다.

Zod는 이러한 검증 로직을 하나의 스키마로 관리할 수 있어 코드의 가독성과 유지보수성을 크게 향상시켜 준다.


 

Zod 사용하기

먼저 검증할 데이터의 형태를 스키마로 정의한다.

스키마 정의하기

const LoginSchema = z.object({
  email: z
    .string()
    .min(1, "이메일을 입력해주세요.")
    .email("올바른 이메일 형식이 아닙니다."),

  password: z
    .string()
    .min(8, "비밀번호는 8자 이상 입력해주세요."),
});

여기서는

  • email은 문자열이어야 하며 이메일 형식을 만족해야 한다.
  • password는 문자열이며 최소 8자 이상이어야 한다.

라는 규칙을 정의하였다.

스키마 정의 옵션

string() 문자열
number() 숫자
boolean() 불리언
object() 객체
array() 배열
email() 이메일 형식
min() / max() 최소·최대 길이 또는 값
optional() 선택 입력
enum() 허용된 값만 입력
refine() 사용자 정의 검증
superRefine() 여러 필드를 함께 검증
transform() 검증 후 값 변환
safeParse() 검증 결과를 객체로 반환
parse() 검증 실패 시 예외 발생
 

TypeScript 타입 자동 생성

Zod 스키마를 정의했다면 z.infer를 이용하여 TypeScript 타입도 자동으로 생성할 수 있다.

type LoginFormData = z.infer<typeof LoginSchema>;

이렇게 하면 스키마를 수정하더라도 TypeScript 타입이 자동으로 함께 변경되므로, 스키마와 타입을 따로 관리할 필요가 없다.


safeParse로 데이터 검증

로그인 버튼을 클릭하면 먼저 입력값을 검증한다.

const result = LoginSchema.safeParse(form);

safeParse()는 검증 결과를 객체 형태로 반환한다.

 

검증 성공

{
  success: true,
  data: ...
}

검증이 성공하면 data에 검증을 통과한 안전한 데이터가 담긴다.

 

검증 실패

{
  success: false,
  error: ...
}

검증이 실패하면 어떤 필드에서 오류가 발생했는지 확인할 수 있다.

if (!result.success) {
  const fieldErrors =
    result.error.flatten().fieldErrors;

  setErrors({
    email: fieldErrors.email?.[0] ?? "",
    password: fieldErrors.password?.[0] ?? "",
  });

  return;
}

flatten().fieldErrors를 사용하면 필드별 에러 메시지를 쉽게 가져올 수 있으며, 이를 화면에 표시하여 사용자에게 입력 오류를 안내할 수 있다.


검증이 성공한 경우

검증에 성공했다면 에러 메시지를 초기화한 뒤 서버에 로그인 요청을 보낸다.

setErrors({
  email: "",
  password: "",
});

const response = await axios.post(
  "/api/login",
  result.data
);

여기서 사용하는 result.data는 이미 Zod의 검증을 통과한 데이터이므로, 타입과 값이 모두 스키마를 만족하는 안전한 데이터라고 볼 수 있다.


로그인 예시

app/login/page.tsx

"use client";

import { useState } from "react";
import { z } from "zod";

/* ===========================
   Zod 스키마
=========================== */
const LoginSchema = z.object({
  email: z
    .string()
    .min(1, "이메일을 입력해주세요.")
    .email("올바른 이메일 형식이 아닙니다."),

  password: z
    .string()
    .min(8, "비밀번호는 8자 이상 입력해주세요."),
});

/* TypeScript 타입 자동 생성 */
type LoginFormData = z.infer<typeof LoginSchema>;

export default function LoginPage() {
  const [form, setForm] =
    useState<LoginFormData>({
      email: "",
      password: "",
    });

  const [errors, setErrors] = useState({
    email: "",
    password: "",
  });

  /* 입력값 변경 */
  const handleChange = (
    e: React.ChangeEvent<HTMLInputElement>
  ) => {
    setForm({
      ...form,
      [e.target.name]: e.target.value,
    });
  };

  /* 로그인 */
  const handleSubmit = async (
    e: React.FormEvent<HTMLFormElement>
  ) => {
    e.preventDefault();

    /**
     * safeParse()
     *
     * 현재 form 데이터를
     * LoginSchema 규칙에 맞는지 검사한다.
     */
    const result = LoginSchema.safeParse(form);

    /* 검증 실패 */
    if (!result.success) {
      /**
       * fieldErrors
       *
       * {
       *   email:["올바른 이메일..."],
       *   password:["8자 이상..."]
       * }
       */
      const fieldErrors = result.error.flatten().fieldErrors;

      setErrors({
        email: fieldErrors.email?.[0] ?? "",
        password: fieldErrors.password?.[0] ?? "",
      });

      return;
    }

    /* 검증 성공 */
    setErrors({ email: "", password: "",});

    /**
     * result.data
     *
     * safeParse를 통과한 "안전한 데이터"
     */
    const response = await fetch(
      "/api/login",
      {
        method: "POST",
        headers: { "Content-Type": "application/json", },
        body: JSON.stringify(
          result.data
        ),
      }
    );

    const data = await response.json();

    alert(data.message);
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        name="email"
        value={form.email}
        onChange={handleChange}
        placeholder="이메일"
      />

      {errors.email && (
        <p>{errors.email}</p>
      )}

      <input
        type="password"
        name="password"
        value={form.password}
        onChange={handleChange}
        placeholder="비밀번호"
      />

      {errors.password && (
        <p>{errors.password}</p>
      )}

      <button type="submit">
        로그인
      </button>
    </form>
  );
}

 

app/api/login/route.ts

import { z } from "zod";

const LoginSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
});

export async function POST(
  request: Request
) {
  const body = await request.json();

  /**
   * 서버도 절대 믿으면 안 된다.
   *
   * 프론트에서 검증했다고 해도
   * Postman이나 다른 프로그램으로
   * 아무 데이터나 보낼 수 있기 때문에
   * 서버에서도 다시 검증한다.
   */
  const result =
    LoginSchema.safeParse(body);

  if (!result.success) {
    return Response.json(
      {
        message:
          "잘못된 요청입니다.",
      },
      {
        status: 400,
      }
    );
  }

  /**
   * result.data는
   * 검증을 통과한 안전한 데이터
   */

  console.log(result.data);

  return Response.json({
    message: "로그인 성공!",
  });
}

결론

프론트 검증 사용자 경험(UX) 을 위한 것이고,

서버 검증 보안과 데이터 무결성을 위한 것이다.

그래서 실무에서는 "프론트에서도*검증하고, 서버에서도 동일한 스키마로 다시 검증하는 것"이 가장 일반적인 방식이다.

'Library' 카테고리의 다른 글

Zustand  (0) 2026.06.22
React Hook Form  (0) 2026.06.20
TanStack Query란? (서버상태 관리도구)  (0) 2026.06.17