
개발을 하다 보면 사용자가 예상한 방식으로만 서비스를 이용할 것이라고 생각하기 쉽지만,
실제로는 그렇지 않다.
이에 관련한 경험으로, 초급 프로젝트를 진행할 당시,
상호 팀 간 테스트를 진행한 적이 있었다.
우리 팀은 개발 과정에서 다양한 예외 상황을 직접 테스트해 보기로 계획하고 작업을 진행했기 때문에,
글자수 제한이라던지 모든 예외 상황을 팀 내부에서 먼저 진행해보고,
다른 팀의 프로젝트를 테스트할 때도 같은 방식으로 접근했다.
당시 테스트했던 프로젝트는 기업 투자와 관련된 서비스였다.
나는 입력창마다 허용 범위를 벗어난 값을 넣어보거나,
비정상적으로 긴 값을 입력하는 등 여러 예외 상황을 시도해 보았다.
그 과정에서 투자 금액 입력에 대한 검증이 충분하지 않았고,
결과적으로 서비스가 의도하지 않은 매우 큰 금액을 투자할 수 있는 상황이 발생했다.
물론 실제 서비스에서는 더 많은 방어 로직이 필요하겠지만,
이 경험을 통해 사용자의 입력을 그대로 신뢰하면 안 된다는 사실을 다시 한번 느낄 수 있었다.
사용자는 언제든 예상하지 못한 값을 입력할 수 있고,
때로는 의도적으로 잘못된 데이터를 보내기도 한다.
따라서 서버는 데이터를 사용하기 전에 반드시 검증 과정을 거쳐야 한다.
이러한 과정을 유효성 검사(Validation) 라고 하며,
이번 글에서는 JavaScript와 TypeScript 환경에서 많이 사용되는 데이터 검증 라이브러리인
Zod를 통해 유효성 검사를 보다 쉽고 체계적으로 관리하는 방법을 알아보려고 한다.
앞서 이야기했듯이 사용자는 항상 개발자가 의도한 방식대로 입력하지 않는다.
회원가입을 예로 들어보자.
개발자는 다음과 같은 데이터를 기대한다.
{
"email": "user@example.com",
"password": "12345678"
}
하지만 실제로는 전혀 다른 형태의 데이터가 들어올 수 있다.
{
"email": 1234,
"password": null
}
혹은 필수 값 자체가 누락될 수도 있다.
{}
개발자가 의도한 형태의 데이터만 들어온다는 보장은 어디에도 없다.
유효성 검사는 반드시 Zod가 있어야만 가능한 것은 아니다.
프론트엔드에서도 유효성 검사를 진행할 수 있으며,
JavaScript만으로도 충분히 구현할 수 있다.
하지만 프론트엔드 검증은 사용자의 편의를 위한 검증일 뿐이다.
사용자는 브라우저 화면을 거치지 않고도 서버에 직접 요청을 보낼 수 있다.
대표적으로 Postman과 같은 API 테스트 도구를 사용하면 프론트엔드의 검증 로직을 모두 우회할 수 있다.
유효성 검사는 JavaScript만으로도 당연히 구현 가능하다.
if (!email) {
throw new Error("이메일은 필수입니다.");
}
if (password.length < 8) {
throw new Error("비밀번호는 8자 이상이어야 합니다.");
}
처음에는 간단해 보이지만,
검증해야 할 필드가 많아질수록 조건문도 함께 늘어난다.
검증 규칙이 늘어날수록 코드가 복잡해지고 유지보수도 어려워진다.
그래서 검증 규칙을 한 곳에 모아 체계적으로 관리할 수 있는 도구가 필요하게 되었고,
그중 하나가 바로 오늘 소개할 Zod다.
앞서 살펴본 것처럼 유효성 검사는 JavaScript만으로도 충분히 구현할 수 있다.
하지만 서비스가 커질수록 검증해야 할 데이터도 함께 늘어난다.
회원가입 기능만 보더라도 이메일, 비밀번호, 닉네임 등 검증해야 할 값이 여러 개 존재한다.
여기에 로그인, 프로필 수정, 게시글 작성 기능까지 추가된다면 검증 로직은 더욱 많아질 수밖에 없다.
이러한 검증 로직이 여러 곳에 흩어져 있으면 코드의 가독성이 떨어지고 유지보수도 어려워진다.
Zod는 이런 문제를 해결하기 위해 검증 규칙을 하나의 스키마(Schema)로 관리할 수 있도록 도와준다.
스키마는 쉽게 말해 "데이터의 설계도" 라고 생각하면 된다.
회원가입 요청을 예로 들면 우리는 다음과 같은 데이터를 기대하고 있다.
{
"email": "user@example.com",
"password": "12345678"
}
그렇다면 Zod에서는 이 데이터의 구조를 미리 정의할 수 있다.
const signupSchema = z.object({
email: z.string(),
password: z.string(),
});
위 코드를 해석해보면 다음과 같다.
즉, 회원가입 요청이 들어왔을 때 반드시 이 구조를 따라야 한다는 규칙을 정의한 것이다.
Zod에서 가장 많이 사용하는 기능은 object()다.
const userSchema = z.object({
email: z.string(),
password: z.string(),
});
객체 안에 어떤 속성이 존재해야 하는지 정의할 수 있다.
만약 객체 구조가 다르다면 검증에 실패하게 된다.
{
email: 1234,
password: null
}
위 데이터는 email과 password가 문자열이 아니기 때문에 검증에 통과하지 못한다.
객체 안에서는 다양한 타입을 검증할 수 있다.
문자열 검증
z.string();
숫자 검증
z.number();
배열 검증
z.array(z.string());
예를 들어 사용자의 관심 태그 목록을 검증한다고 가정해보자.
const tagSchema = z.object({
tags: z.array(z.string()),
});
그러면 다음과 같은 데이터만 허용된다.
{
tags: ["JavaScript", "React", "Node.js"];
}
배열 안에 숫자나 객체가 들어온다면 검증에 실패한다.
JavaScript만 사용할 경우 검증 로직은 보통 이런 형태가 된다.
if (typeof email !== "string") {
throw new Error("이메일은 문자열이어야 합니다.");
}
if (typeof password !== "string") {
throw new Error("비밀번호는 문자열이어야 합니다.");
}
필드가 늘어날수록 조건문도 함께 늘어난다.
반면 Zod는 데이터의 구조를 한 번 정의해두면 된다.
const signupSchema = z.object({
email: z.string(),
password: z.string(),
});
즉, 조건문으로 데이터를 검사하는 것이 아니라,
데이터가 어떤 형태여야 하는지를 먼저 정의하는 방식이다.
이것이 Zod의 가장 큰 특징이다.
앞선 예제에서는 데이터의 형태만 검증했다.
하지만 데이터 형태가 옳다고 해서 모두 올바른 값은 아니다.
예를 들어 비밀번호가 한 글자여도 문자열이고,
이메일이 아닌 값도 문자열이다.
실제 서비스에서는 단순히 타입만 확인하는 것이 아니라, 개발자가 정한 조건까지 만족해야 한다.
Zod는 이러한 조건을 메서드 형태로 추가할 수 있다.
회원가입을 할 때 닉네임이나 비밀번호 길이를 제한하는 경우가 많다.
z.string().min(3).max(20);
위 코드는 다음 규칙을 의미한다.
예를 들어 닉네임 검증은 다음과 같이 작성할 수 있다.
const schema = z.object({
nickname: z.string().min(2).max(10),
});
이렇게 하면 너무 짧거나 긴 닉네임을 방지할 수 있다.
이메일은 문자열이라고 해서 모두 허용할 수 없다.
"user@example.com"처럼 이메일 형식을 따라야 한다.
Zod는 이를 위한 메서드를 제공한다.
z.string().email();
예를 들어
const schema = z.object({
email: z.string().email(),
});
이라면
{
email: "test@test.com";
}
은 통과하지만,
{
email: "hello";
}
는 검증에 실패한다.
프로필 이미지나 웹사이트 주소를 입력받는 경우도 있다.
이럴 때는 URL 형식을 검증할 수 있다.
z.string().url();
예를 들어
{
imageUrl: "https://example.com/profile.png";
}
는 통과하지만
,
{
imageUrl: "내 프로필 사진";
}
은 URL 형식이 아니므로 실패한다.
때로는 정해진 값만 입력받아야 하는 경우가 있다.
예를 들어 등급을 가정해보자.
COMMON;
RARE;
SUPER_RARE;
이 외의 값은 허용하면 안 된다.
이럴 때 사용하는 것이 enum() 이다.
z.enum(["COMMON", "RARE", "SUPER_RARE"]);
이렇게 하면 지정한 값만 통과할 수 있다.
{
grade: "RARE";
}
이 경우엔 성공,
{
grade: "LEGEND";
}
enum()과 벗어나는 이 경우엔 실패다.
Zod의 장점 중 하나는 여러 조건을 자연스럽게 연결할 수 있다는 점이다.
z.string().min(8).max(20);
또는
z.string().email();
처럼 필요한 검증 규칙을 체인 형태로 추가할 수 있다.
덕분에 코드만 보더라도 어떤 조건을 검사하는지 쉽게 파악할 수 있다.
지금까지 살펴본 검증은 모든 값이 반드시 존재한다고 가정했다.
하지만 이것 또한 실제 서비스에서는 항상 그렇지 않다.
예를 들어 회원가입 시 닉네임은 선택 입력일 수도 있고,
페이지 번호는 사용자가 보내지 않았을 때 기본값을 사용하고 싶을 수도 있다.
또한 URL의 Query Parameter처럼 숫자여야 하는 값이 문자열로 전달되는 경우도 자주 발생한다.
Zod는 이런 상황을 위해 다양한 기능을 제공한다.
optional()회원가입 시 닉네임을 선택 입력으로 받는다고 가정해보자.
const schema = z.object({
nickname: z.string().optional(),
});
optional()을 사용하면 해당 값이 없어도 검증에 실패하지 않는다.
예를 들어 다음 두 데이터 모두 통과한다.
{
nickname: "hogu__giriboy";
}
{}
반면 optional()이 없다면 값이 존재하지 않는 순간 검증은 실패하게 된다.
default()페이지네이션 기능을 만든다고 가정해보자.
보통 페이지 번호는 사용자가 직접 입력하지 않는 경우가 많다.
이럴 때는 기본값을 설정할 수 있다.
const schema = z.object({
page: z.number().default(1),
});
만약 사용자가 값을 보내지 않았다면
Zod가 자동으로
{
page: 1;
}
로 처리해준다.
별도의 조건문을 작성하지 않아도 되기 때문에 코드가 훨씬 간결해진다.
실무에서 생각보다 자주 만나는 문제 중 하나가 타입 변환이다.
예를 들어 URL의 Query Parameter는 모두 문자열로 전달된다.
?page=1
개발자는 숫자를 기대하지만 실제로 서버가 받는 값은 다음과 같다.
{
page: "1";
}
따라서 아래 검증은 실패한다.
z.number();
왜냐하면 "1"은 문자열이기 때문이다.
이럴 때 사용하는 것이 coerce()다.
z.coerce.number();
그러면 "1"을 1로 자동 변환한 뒤 검증을 수행한다.
실제로 Express에서 페이지네이션을 구현할 때 매우 자주 사용되는 기능이다.
Zod의 메서드는 조합해서 사용할 수 있다.
const schema = z.object({
page: z.coerce.number().default(1),
});
이렇게 작성하면
이라는 두 가지 규칙을 동시에 적용할 수 있다.
검증은 단순히 막는 것이 아니다
처음에는 유효성 검사가 잘못된 데이터를 거부하는 기능이라고 생각할 수 있지만 실제로는
처럼 데이터를 사용하기 좋은 형태로 정리하는 역할도 수행한다.
이 덕분에 이후 비즈니스 로직에서는 데이터 형태를 걱정하지 않고 기능 구현에 집중할 수 있다.
앞서 살펴본 min(), max(), email() 같은 기능은 대부분의 상황에서 유용하게 사용할 수 있다.
하지만 실제 프로젝트를 진행하다 보면 기본적으로 제공되는 검증만으로는 부족한 경우가 있다.
예를 들어 다음과 같은 조건을 검증해야 할 수도 있다.
이처럼 개발자가 직접 규칙을 정의해야 하는 경우 사용할 수 있는 기능이 refine()이다.
refine() 사용하기가장 간단한 형태는 다음과 같다.
z.string().refine((value) => value.includes("@"), {
message: "이메일 형식이 아닙니다.",
});
refine()은 두 가지 값을 받는다.
즉,
value.includes("@");
가 true를 반환하면 검증에 성공하고,
false를 반환하면 검증에 실패한다.
예를 들어 비밀번호에 특수문자가 반드시 포함되어야 한다고 가정해보자.
const schema = z.object({
password: z.string().refine((value) => /[!@#$%^&*]/.test(value), {
message: "비밀번호에는 특수문자가 포함되어야 합니다.",
}),
});
이 경우
{
password: "password123!";
}
는 통과하지만
{
password: "password123";
}
는 검증에 실패한다.
특정 단어를 닉네임으로 사용할 수 없도록 제한하는 것도 가능하다.
const forbiddenWords = ["admin", "manager"];
const schema = z.object({
nickname: z
.string()
.refine((value) => !forbiddenWords.some((word) => value.includes(word)), {
message: "사용할 수 없는 닉네임입니다.",
}),
});
예를 들어
{
nickname: "admin123";
}
은 실패하고,
{
nickname: "myNickname";
}
은 통과하게 된다.
refine()은 단독으로 사용하지 않아도 된다.
기존 검증 규칙 뒤에 추가로 연결할 수 있다.
const schema = z.object({
password: z
.string()
.min(8)
.refine((value) => /[!@#$%^&*]/.test(value), {
message: "비밀번호에는 특수문자가 포함되어야 합니다.",
}),
});
이렇게 하면
두 가지 검증을 모두 수행할 수 있다.
refine()을 사용하기에 앞서
min(), max(), email()처럼 이미 제공되는 기능이 있다면 그것을 사용하는 것이 가장 좋다.
하지만 "우리 서비스만의 규칙"이 필요한 순간에는
refine()이 필요해진다.
이라고 생각하면 된다.
지금까지 다양한 검증 규칙을 정의하는 방법을 살펴봤다.
하지만 스키마를 정의했다고 해서 자동으로 검증이 이루어지는 것은 아니다.
실제로 데이터를 검증하려면 정의한 스키마를 실행해야 한다.
Zod에서는 대표적으로 safeParse()와 parse()를 사용한다.
safeParse()가장 많이 사용되는 방식은 safeParse()다.
const result = schema.safeParse(data);
검증에 성공하면 다음과 같은 객체를 반환한다.
{
success: true,
data: ...
}
반대로 검증에 실패하면
{
success: false,
error: ...
}
를 반환한다.
즉, 검증 결과를 직접 확인할 수 있기 때문에 에러를 안전하게 처리할 수 있다.
const result = schema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
message: "잘못된 요청입니다.",
});
}
실무에서 가장 많이 사용하는 방식이며, API 요청 검증에서도 자주 사용된다.
parse()parse()는 조금 다르게 동작한다.
const data = schema.parse(req.body);
검증에 성공하면 검증된 데이터를 반환한다.
하지만 검증에 실패하면 에러를 반환하는 것이 아니라 ZodError를 발생시킨다.
ZodError는 어떤 필드가 왜 검증에 실패했는지에 대한 정보를 담고 있는 Zod 전용 에러 객체다.
따라서 parse()를 사용할 경우 에러를 처리할 수 있도록 try...catch 문이 필요하다.
try {
const data = schema.parse(req.body);
} catch (error) {
console.error(error);
}
또는 Express의 전역 에러 핸들러와 함께 사용할 수도 있다.