Next.js에서 Zod로 검증 로직 중복을 없애기
Source: Dev.to
위에 제공된 소스 링크 외에 번역할 텍스트가 포함되어 있지 않습니다. 번역을 원하는 본문을 제공해 주시면 한국어로 번역해 드리겠습니다.
요즘 우리는 프론트엔드 개발자 그 이상입니다
Next.js를 사용하면 클라이언트와 서버 코드를 같은 프로젝트에서 함께 구축하여, 컨텍스트를 전환하지 않아도 풀스택 애플리케이션을 만들 수 있습니다. 이는 검증을 여러 곳에서 담당해야 함을 의미하기도 합니다.
- 사용자 경험을 향상시키기 위해 클라이언트에서 검증합니다.
- 보안을 보장하기 위해 서버에서도 다시 검증합니다.
문제는 보통 이 과정에서 로직이 중복된다는 점입니다. 같은 규칙을 두 번 작성하게 되는데, 한 번은 폼용으로, 또 한 번은 API용으로 작성합니다. 규칙이 바뀔 때마다 모든 곳을 기억해서 업데이트해야 하므로 불필요한 동기화 번거로움이 생깁니다.
단일 스키마 하나만으로 클라이언트와 서버 모두에서 검증을 처리한다면 어떨까요?
바로 Zod가 등장합니다. Zod‑first 접근 방식은 이를 가능하게 합니다: 하나의 진실된 소스가 모든 곳에서 검증됩니다. 어떻게 작동하는지 살펴보겠습니다.
Source: …
시나리오
일반적인 회원가입 폼을 생각해 보세요. 이름, 이메일, 웹사이트 세 개의 필드가 필요합니다. 첫 번째 단계는 아마 TypeScript 인터페이스를 만드는 것이겠죠:
// lib/types/user.ts
export interface SignupInput {
name: string;
email: string;
website: string;
}
편집기에서는 깔끔해 보이지만, 인터페이스는 런타임에서 아무것도 검증하지 못합니다—코드가 JavaScript로 컴파일되면 사라지기 때문이죠. 그래서 HTML5 검증(type="email" 및 required 속성)을 추가합니다. 이는 사용자에게 도움이 되지만 보안에는 충분하지 않습니다; 누구든 DevTools를 열어 입력 타입을 바꾸고 서버에 잘못된 데이터를 보낼 수 있습니다.
앱을 실제로 보호하려면 수동 검증을 작성합니다:
export function validateSignup(input: SignupInput) {
const errors: Partial> = {};
if (!input.name || input.name.length ;
무슨 일이 일어난 걸까요?
- Zod의 간단한 API를 사용해 검증 규칙을 정의했습니다—RegEx가 필요 없습니다.
z.infer는 스키마에서 TypeScript 타입을 생성하므로SignupInput은 언제나 검증 규칙과 일치합니다.
이제 데이터를 검증하고 TypeScript 타입을 생성하는 스키마가 생겼습니다. 다음으로 서버에서 사용해 보겠습니다.
4. 서버에서 검증하기
Next.js에서는 Server Actions(또는 API 라우트)를 사용해 폼 제출을 처리할 수 있습니다. 여기서 검증이 핵심인데, 클라이언트에서 오는 데이터를 신뢰할 수 없기 때문입니다.
app/actions/signup.ts 파일을 생성합니다:
"use server";
import { signupSchema, SignupInput } from "@/lib/schemas/signup";
export async function registerUser(data: unknown) {
// safeParse은 예외를 발생시키는 대신 객체를 반환합니다
const result = signupSchema.safeParse(data);
if (!result.success) {
return {
success: false,
errors: result.error.flatten().fieldErrors,
};
}
const validData: SignupInput = result.data;
// 여기서 데이터베이스에 저장하면 됩니다
return {
success: true,
user: validData,
};
}
왜 .safeParse()를 쓰고 .parse()를 쓰지 않을까요?
.safeParse()는 절대 예외를 던지지 않으며 { success: boolean, data?: T, error?: ZodError } 형태의 객체를 반환합니다. 이렇게 하면 UI 코드에서 오류 처리를 간단하게 할 수 있습니다.
이제 서버는 단일 진실의 원천(single source of truth)으로 보호됩니다.
(선택) react‑hook‑form과 연결하기
나중에 react-hook-form과 zodResolver를 사용해 스키마를 통합하면 클라이언트‑사이드 검증을 자동으로 얻을 수 있습니다. 동일한 스키마가 클라이언트와 서버 모두에서 공유되므로 중복을 완전히 없앨 수 있습니다.
요약
- 하나의 스키마 (
signupSchema)가 검증 규칙을 한 번에 정의합니다. - 이 스키마는 런타임(클라이언트 & 서버) 및 컴파일 타임(TypeScript 타입) 모두에서 작동합니다.
- 서버에서
.safeParse()를 사용하면 예외를 발생시키지 않고도 부드러운 오류 처리를 할 수 있습니다.
Zod를 사용하면 중복된 검증 로직을 없애고 버그를 줄이며 코드베이스를 깔끔하고 유지보수하기 쉽게 만들 수 있습니다. 즐거운 코딩 되세요!
잘못된 데이터는 명확한 오류 메시지와 함께 거부됩니다. 이제 프론트엔드 폼을 만들어 봅시다.
순수 React로 폼 만들기
Zod를 사용해 검증하는 폼을 만들되, 순수 React 상태 관리만 사용합니다. 이는 Zod가 독립적으로 어떻게 동작하는지 보여주며, 별도의 폼 라이브러리가 필요하지 않음을 의미합니다.
File: app/_auth/signup-form.tsx
"use client";
import { useState, FormEvent } from "react";
import { signupSchema, SignupInput } from "@/lib/schemas/signup";
import { registerUser } from "@/app/actions/signup";
export function SignupForm() {
const [formData, setFormData] = useState({
name: "",
email: "",
website: "",
});
const [errors, setErrors] = useState>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const handleChange = (e: React.ChangeEvent) => {
const { name, value } = e.target;
setFormData((prev) => ({
...prev,
[name]: value,
}));
// Clear error when user starts typing
if (errors[name]) {
setErrors((prev) => {
const newErrors = { ...prev };
delete newErrors[name];
return newErrors;
});
}
};
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
setErrors({});
// ✅ Validate with Zod (same schema as server!)
const result = signupSchema.safeParse(formData);
if (!result.success) {
// Convert Zod errors to a simple object
const fieldErrors: Record = {};
result.error.errors.forEach((error) => {
if (error.path[0]) {
fieldErrors[error.path[0].toString()] = error.message;
}
});
setErrors(fieldErrors);
setIsSubmitting(false);
return;
}
// Data is valid, send to server
const serverResult = await registerUser(result.data);
if (!serverResult.success) {
// Handle server‑side validation errors
setErrors(serverResult.errors || {});
setIsSubmitting(false);
return;
}
console.log("User registered successfully!", serverResult.user);
// Reset form
setFormData({
name: "",
email: "",
website: "",
});
setIsSubmitting(false);
};
return (
<form onSubmit={handleSubmit}>
<h2>Sign Up</h2>
{/* Name */}
<label>
Name
<input
name="name"
value={formData.name}
onChange={handleChange}
/>
</label>
{errors.name && <p>{errors.name}</p>}
{/* Email */}
<label>
Email
<input
name="email"
value={formData.email}
onChange={handleChange}
/>
</label>
{errors.email && <p>{errors.email}</p>}
{/* Website */}
<label>
Website
<input
name="website"
value={formData.website}
onChange={handleChange}
/>
</label>
{errors.website && <p>{errors.website}</p>}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Signing up..." : "Sign Up"}
</button>
</form>
);
}
이제 클라이언트와 서버 모두에서 동일한 Zod 스키마를 사용한 작동하는 폼이 준비되었습니다. 데이터가 올바르게 검증되고 명확한 오류 메시지를 표시합니다.
하지만 상태 관리와 오류 표시를 위한 이 모든 보일러플레이트 코드를 작성하는 것은 번거로울 수 있습니다. 이를 간소화해 봅시다.
React Hook Form 로 이동하기
React Hook Form은 폼의 “지루한” 부분을 처리해 주는 라이브러리입니다: 입력값 추적, 오류 표시, 제출 상태 관리 등을 담당합니다. 여러 개의 useState 훅을 직접 작성하는 대신, 라이브러리가 이 작업을 대신해 줍니다.
Zod와 연결하려면 resolver를 사용합니다—Zod 스키마를 React Hook Form에 전달해 데이터가 유효한지, 어떤 오류 메시지를 보여줄지 알게 해 주는 다리 역할을 합니다. 이를 통해 매 변경마다 수동으로 검증할 필요가 사라집니다.
React Hook Form에 대한 자세한 내용은 여기를 참고하세요.
의존성 설치
npm install react-hook-form @hookform/resolvers
React Hook Form과 Zod 사용하기
@hookform/resolvers/zod에서 resolver를 import합니다.- resolver를 통해 Zod 스키마를
useForm에 전달합니다. register로 입력을 등록하고, 라이브러리가 값 변화와 오류 추적을 담당하도록 합니다.
다음 섹션(여기서는 생략)에서는 React Hook Form을 이용한 간결한 구현 예시를 보여줄 예정입니다.
React Hook Form 통합
React Hook Form을 사용할 때는 스키마와 입력값만 연결하면 됩니다.
signupSchema를 resolver 옵션에 전달하면 라이브러리가 우리의 Zod 규칙을 사용합니다.
많은 useState 훅 대신 register 함수를 사용해 값과 이벤트를 자동으로 처리합니다. 이렇게 하면 코드가 훨씬 작고 깔끔해집니다.
errors객체가 이제 Zod 메시지를 자동으로 표시합니다.- 모든 데이터가 올바른 경우에만 폼이 제출됩니다.
app/_auth/signup-form.tsx 업데이트
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { signupSchema, SignupInput } from "@/lib/schemas/signup";
import { registerUser } from "@/app/actions/signup";
export function SignupForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
reset,
} = useForm({
resolver: zodResolver(signupSchema), // ✅ Same schema as server!
});
const onSubmit = async (data: SignupInput) => {
const result = await registerUser(data);
if (!result.success) {
console.error("Validation errors:", result.errors);
return;
}
console.log("User registered successfully!", result.user);
reset();
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<h2>Sign Up</h2>
{/* Name */}
<label>
Name
<input {...register("name")} />
</label>
{errors.name && <p>{errors.name.message}</p>}
{/* Email */}
<label>
Email
<input {...register("email")} />
</label>
{errors.email && <p>{errors.email.message}</p>}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Signing up..." : "Sign Up"}
</button>
</form>
);
}
Recap
- 우리는
lib/schemas/signup.ts에 단일 Zod 스키마를 만들고 프론트엔드와 백엔드 모두에서 사용했습니다. 이는 검증 규칙을 한 번만 작성한다는 의미이며, 규칙을 변경하면 모든 곳에 자동으로 적용됩니다. - React Hook Form은 폼 상태를 자동으로 관리해 코드를 더 깔끔하게 만들고,
zodResolver는 이를 스키마와 연결합니다. - Zod는 TypeScript 타입도 자동으로 생성해 주므로 직접 작성할 필요가 없습니다.
핵심 요점: 같은 작업을 두 번 하지 마세요. Zod를 사용하면 코드가 더 안전하고 관리하기 쉬우며 오류가 적어집니다.
검증 로직을 반복하지 마세요. Zod를 사용해 보세요! 🚀