본문 바로가기
C.W.K.
Stream
Lesson 06 of 06 · published

Zod로 Action 입력 검증하기

~12 min · zod, validation, schemas

Level 0React 입문자
0 XP0/54 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
FormData는 필드 모양을 보장하지 않아. Zod로 입력을 검증하고 타입이 붙은 성공 값과 오류를 나누는 경계를 만들어 보자.

Schema에 입력 계약을 적어

Zod schema는 이메일 형식, 숫자의 최솟값, 문자열 길이처럼 유효한 데이터의 모양을 한곳에 선언해. parse는 실패하면 오류를 던지고, safeParse는 성공과 실패를 구분한 결과를 돌려줘. 사용자가 고칠 수 있는 form 오류에는 safeParse가 잘 맞아.

FormData를 평범한 객체로 바꿔

Zod는 FormData를 직접 해석하지 않으므로 먼저 Object.fromEntries(formData)로 객체를 만들어. 그 값을 검사하면 성공했을 때는 타입이 붙은 data를, 실패했을 때는 구조화된 error를 얻어.

필드별 오류를 화면에 돌려줘

error.flatten().fieldErrors를 사용하면 이메일과 이름처럼 필드별 메시지를 얻을 수 있어. Action이 이 객체를 상태로 반환하면 form은 문제가 있는 입력 바로 아래에 오류를 표시할 수 있어.

검사와 실제 작업의 순서를 분리해

Action은 먼저 입력을 검사하고, 실패하면 외부 작업을 시작하지 않은 채 오류 상태를 반환해. 성공한 data만 저장 요청에 넘기고 그 결과를 다음 상태로 돌려줘.

Schema를 입력 계약의 단일 출처로 삼아. 서버와 클라이언트가 같은 규칙을 공유하면 TypeScript도 검사된 값의 타입을 자동으로 추론해.

브라우저 문자열을 필요한 타입으로 변환해

FormData의 숫자 입력도 문자열로 들어오므로 z.coerce.number().min(18)처럼 변환과 검사를 함께 선언할 수 있어. 빈 문자열과 checkbox, File은 제품 계약에 맞는 전처리나 별도 schema가 필요해.

SafeParse 결과 자체가 discriminated union이야

result.success가 true인 분기에서는 data가 schema가 보장한 타입으로 좁혀지고, false 분기에서는 error를 사용할 수 있어. FieldErrors와 form 전체 오류를 나눠 반환하면 각 입력과 상단 오류 영역에 맞게 표시할 수 있어.

클라이언트 검사는 빠른 피드백을 주지만 보안 경계는 아니야. 실제 저장을 소유한 서버에서도 같은 schema나 동등한 검사를 실행해야 해.

Code

Zod로 검증하는 구독 Action·tsx
import { z } from "zod";
import { useActionState } from "react";

const SubscribeSchema = z.object({
  email: z.string().email("Enter a valid email"),
  name: z.string().min(1, "Name is required"),
});

type SubscribeState = {
  email: string;
  name: string;
  errors: {
    email?: string[];
    name?: string[];
    _form?: string[];
  };
  submitted: boolean;
};

const initial: SubscribeState = { email: "", name: "", errors: {}, submitted: false };

async function subscribe(_prev: SubscribeState, fd: FormData): Promise<SubscribeState> {
  const raw = Object.fromEntries(fd) as { email?: string; name?: string };
  const result = SubscribeSchema.safeParse(raw);
  if (!result.success) {
    return {
      email: raw.email ?? "",
      name: raw.name ?? "",
      errors: result.error.flatten().fieldErrors,
      submitted: false,
    };
  }
  // result.data가 타입 잡힘: { email: string; name: string }
  try {
    await fetch("/api/subscribe", { method: "POST", body: JSON.stringify(result.data) });
    return { ...result.data, errors: {}, submitted: true };
  } catch (e) {
    return {
      ...result.data,
      errors: { _form: [(e as Error).message] },
      submitted: false,
    };
  }
}

export function SubscribeForm() {
  const [state, formAction] = useActionState(subscribe, initial);
  return (
    <form action={formAction} className="space-y-3 max-w-md">
      <Field name="name" label="Name" defaultValue={state.name} errors={state.errors.name} />
      <Field name="email" label="Email" defaultValue={state.email} errors={state.errors.email} />
      {state.errors._form && <p className="text-danger text-sm">{state.errors._form[0]}</p>}
      {state.submitted && <p className="text-success text-sm">Subscribed!</p>}
      <SubmitButton>Subscribe</SubmitButton>
    </form>
  );
}

function Field({ name, label, defaultValue, errors }: {
  name: string;
  label: string;
  defaultValue: string;
  errors?: string[];
}) {
  return (
    <label className="block">
      <span className="text-sm text-muted">{label}</span>
      <input name={name} defaultValue={defaultValue} className="w-full mt-1 px-3 py-2 border" />
      {errors && <p className="text-danger text-xs mt-1">{errors[0]}</p>}
    </label>
  );
}

External links

Exercise

이메일과 이름을 받는 form에 Zod schema와 useActionState를 연결해. 잘못된 값은 필드별 오류로 보여 주고 성공한 data만 가짜 저장 함수에 전달해.
Hint
safeParse 결과의 success를 확인한 뒤 error.flatten().fieldErrors를 상태에 넣어.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.