Zod 기본 개념 (TypeScript에서 스키마 기반 데이터 검증)
Intro | Zod
zod.dev
Zod란?
Zod는 TypeScript에서 스키마 기반 데이터 검증을 수행하는 라이브러리이다.
TypeScript의 타입 시스템은 컴파일 단계에서만 타입을 체크하지만, Zod는 런타임에서도 데이터를 안전하게 검증할 수 있도록 도와준다.
TypeScript 타입만 사용한 경우 (런타임에서 오류 검출 불가)
type User = {
name: string;
age: number;
};
const data = JSON.parse('{"name": "Alice"}'); // `age` 없음
const user: User = data; // ❌ 컴파일 시 문제 없음 (하지만 실행 시 오류 가능)
console.log(user.age.toFixed(2)); // ❌ 런타임 오류 발생!
TypeScript는 data가 User 타입을 따르는지 확인하지 않는다.
런타임에서 user.age가 undefined여서 toFixed() 호출 시 오류 발생!
Zod를 사용하여 런타임에서 검증하는 경우
import { z } from "zod";
const UserSchema = z.object({
name: z.string(),
age: z.number(),
});
const data = JSON.parse('{"name": "Alice"}');
const result = UserSchema.safeParse(data);
if (!result.success) {
console.error("❌ 유효하지 않은 데이터:", result.error.format());
} else {
console.log("✅ 유효한 데이터:", result.data);
}
safeParse()를 사용하면 유효한지 확인 후 실행 가능, 런타임에서 오류를 미리 방지 가능
1단계. Zod 기초 스키마 작성과 검증 이해
1. 스키마 선언
- Zod는 z.string(), z.number() 등으로 데이터의 타입과 유효성 검사를 선언적(Declarative)으로 작성
Zod의 기본 타입 스키마
| z.string() | 문자열 | z.string().min(3).max(10) |
| z.number() | 숫자 | z.number().min(1).max(100) |
| z.boolean() | 불리언 | z.boolean() |
| z.null() | null 값 허용 | z.null() |
| z.undefined() | undefined 값 허용 | z.undefined() |
| z.date() | 날짜 객체 | z.date() |
| z.bigint() | bigint 타입 | z.bigint() |
| z.symbol() | symbol 타입 | z.symbol() |
1️⃣ 문자열 (z.string())
문자열을 검증할 수 있으며, 다양한 유효성 검사 메서드를 사용할 수 있다.
import { z } from "zod";
const nameSchema = z.string().min(3).max(20);
console.log(nameSchema.parse("Alice")); // ✅ 정상
console.log(nameSchema.safeParse("Al")); // ❌ 오류: 최소 3글자 이상 필요
문자열 유효성 검사
const passwordSchema = z.string().min(8, "비밀번호는 최소 8자 이상이어야 합니다.");
console.log(passwordSchema.safeParse("1234")); // ❌ 오류 발생
console.log(passwordSchema.safeParse("securePassword123")); // ✅ 정상
이메일 검증 (email())
const emailSchema = z.string().email();
console.log(emailSchema.safeParse("hello@example.com")); // ✅ 정상
console.log(emailSchema.safeParse("not-an-email")); // ❌ 오류
URL 검증 (url())
const urlSchema = z.string().url();
console.log(urlSchema.safeParse("https://example.com")); // ✅ 정상
console.log(urlSchema.safeParse("not-a-url")); // ❌ 오류
2️⃣숫자 (z.number())
숫자 검증을 수행할 수 있으며, 최소값/최대값 제한이 가능하다.
const ageSchema = z.number().min(18).max(100);
console.log(ageSchema.safeParse(25)); // ✅ 정상
console.log(ageSchema.safeParse(10)); // ❌ 오류: 최소 18세 이상
console.log(ageSchema.safeParse(120)); // ❌ 오류: 최대 100세 이하
정수(int()) 및 양수(positive()) 검사
const positiveIntSchema = z.number().int().positive();
console.log(positiveIntSchema.safeParse(10)); // ✅ 정상
console.log(positiveIntSchema.safeParse(-5)); // ❌ 오류: 양수만 가능
console.log(positiveIntSchema.safeParse(3.5)); // ❌ 오류: 정수만 가능
3️⃣ 불리언 (z.boolean())
불리언 값을 검증할 수 있다.
const isActiveSchema = z.boolean();
console.log(isActiveSchema.safeParse(true)); // ✅ 정상
console.log(isActiveSchema.safeParse("true")); // ❌ 오류: 문자열이 아님
4️⃣ null과 undefined 허용 (z.null(), z.undefined())
특정 필드에서 null 또는 undefined 값을 허용할 수도 있다.
const nullableSchema = z.string().nullable();
const optionalSchema = z.string().optional();
console.log(nullableSchema.safeParse(null)); // ✅ 정상
console.log(optionalSchema.safeParse(undefined)); // ✅ 정상
console.log(nullableSchema.safeParse(undefined)); // ❌ 오류: `null`만 허용
nullable() → null 허용
optional() → undefined 허용
5️⃣ 날짜 (z.date())
날짜(Date 객체)를 검증할 수 있다.
const dateSchema = z.date();
console.log(dateSchema.safeParse(new Date())); // ✅ 정상
console.log(dateSchema.safeParse("2024-01-01")); // ❌ 오류: 문자열이 아니라 `Date` 객체여야 함
날짜 문자열을 Date로 변환하려면 transform()을 사용!
const dateFromString = z.string().transform((str) => new Date(str));
console.log(dateFromString.parse("2024-01-01")); // ✅ 정상 (Date 객체로 변환됨)
6️⃣ bigint와 symbol
const bigIntSchema = z.bigint();
console.log(bigIntSchema.safeParse(BigInt(1234567890123456789))); // ✅ 정상
console.log(bigIntSchema.safeParse(1000)); // ❌ 오류: `bigint` 타입이 아님
const symbolSchema = z.symbol();
console.log(symbolSchema.safeParse(Symbol("mySymbol"))); // ✅ 정상
console.log(symbolSchema.safeParse("mySymbol")); // ❌ 오류: `symbol` 타입이 아님
2. 검증 메서드
1️⃣ 기본 데이터 검증 (parse() & safeParse())
parse() 사용
- parse()는 데이터가 스키마와 일치하면 정상 반환하고,
- 일치하지 않으면 예외(Error)를 발생시킨다.
import { z } from "zod";
const UserSchema = z.object({
name: z.string(),
age: z.number().min(18),
});
const validUser = { name: "Alice", age: 25 };
console.log(UserSchema.parse(validUser)); // ✅ 정상
const invalidUser = { name: "Bob", age: 15 };
console.log(UserSchema.parse(invalidUser)); // ❌ 오류 발생 (min(18) 조건 위반)
유효하지 않은 데이터는 예외를 발생시키므로 try-catch로 감싸야 한다.
try {
UserSchema.parse(invalidUser);
} catch (error) {
console.error("❌ 유효하지 않은 데이터:", error);
}
safeParse() 사용 (안전한 검증)
- safeParse()는 예외를 발생시키지 않고,
- success: false 또는 success: true 값을 반환한다.
const result = UserSchema.safeParse(invalidUser);
if (!result.success) {
console.error("❌ 오류 발생:", result.error.format());
} else {
console.log("✅ 유효한 데이터:", result.data);
}
safeParse()를 사용하면 try-catch 없이도 안전하게 처리 가능
2️⃣ 선택적(optional()) 및 기본값(default())
Zod를 사용하면 선택적 필드와 기본값을 쉽게 설정할 수 있다.
const UserSchema = z.object({
name: z.string(),
age: z.number().optional(), // 선택적 필드
country: z.string().default("USA"), // 기본값 설정
});
console.log(UserSchema.parse({ name: "Alice" }));
// ✅ 출력: { name: "Alice", country: "USA" } (age는 없음)
optional()을 사용하면 필드를 생략할 수 있음
default()를 사용하면 값이 없을 경우 기본값을 자동으로 설정
3️⃣ 객체 데이터 검증
기본 객체 스키마
const UserSchema = z.object({
name: z.string(),
age: z.number().min(18),
address: z.object({
street: z.string(),
city: z.string(),
}),
});
const validUser = {
name: "Alice",
age: 30,
address: { street: "Main St", city: "New York" },
};
console.log(UserSchema.parse(validUser)); // ✅ 정상
중첩된 객체도 z.object()를 사용하여 검증 가능
배열(z.array())을 포함한 스키마
const UsersSchema = z.array(
z.object({
name: z.string(),
age: z.number(),
})
);
const validUsers = [
{ name: "Alice", age: 30 },
{ name: "Bob", age: 25 },
];
console.log(UsersSchema.parse(validUsers)); // ✅ 정상
배열을 z.array()로 감싸면 여러 개의 객체를 검증 가능
4️⃣ union(), enum()을 사용한 데이터 검증
여러 타입 허용 (union())
const StringOrNumber = z.union([z.string(), z.number()]);
console.log(StringOrNumber.parse("hello")); // ✅ 정상
console.log(StringOrNumber.parse(123)); // ✅ 정상
console.log(StringOrNumber.parse(true)); // ❌ 오류 발생 (boolean 허용 안 됨)
union()을 사용하면 여러 타입을 허용할 수 있음
열거형 (enum())
const RoleSchema = z.enum(["admin", "user", "guest"]);
console.log(RoleSchema.parse("admin")); // ✅ 정상
console.log(RoleSchema.safeParse("moderator")); // ❌ 오류 발생
enum()을 사용하면 특정 값만 허용 가능
5️⃣ transform()을 사용하여 값 변환
입력 값을 원하는 형태로 변환할 수 있음
기본적인 transform() 사용법
transform()을 사용하면 입력 값을 변환하여 새로운 값으로 반환할 수 있다.
import { z } from "zod";
const TrimmedString = z.string().transform((val) => val.trim());
console.log(TrimmedString.parse(" hello ")); // ✅ "hello"
console.log(TrimmedString.parse(" Zod ")); // ✅ "Zod"
입력 문자열의 앞뒤 공백을 제거하여 변환 가능!
1️⃣ 문자열 변환 (string → number, date)
문자열을 숫자로 변환
const NumberFromString = z.string().transform((val) => Number(val));
console.log(NumberFromString.parse("42")); // ✅ 42 (문자열 → 숫자 변환)
console.log(NumberFromString.parse("3.14")); // ✅ 3.14
console.log(NumberFromString.safeParse("hello")); // ❌ NaN 발생
입력된 문자열을 숫자로 변환 가능!
문자열을 Date 객체로 변환
const DateFromString = z.string().transform((val) => new Date(val));
console.log(DateFromString.parse("2024-03-19")); // ✅ Date 객체
console.log(DateFromString.safeParse("invalid date")); // ❌ 오류 발생
문자열을 Date 객체로 변환 가능!
2️⃣ 숫자 변환 (number → string, boolean)
숫자를 문자열로 변환
const StringFromNumber = z.number().transform((val) => val.toString());
console.log(StringFromNumber.parse(100)); // ✅ "100"
console.log(StringFromNumber.parse(3.14)); // ✅ "3.14"
숫자를 문자열로 변환 가능!
숫자를 boolean으로 변환
const BooleanFromNumber = z.number().transform((val) => val > 0);
console.log(BooleanFromNumber.parse(10)); // ✅ true
console.log(BooleanFromNumber.parse(0)); // ✅ false
console.log(BooleanFromNumber.parse(-5)); // ✅ false
0보다 크면 true, 아니면 false로 변환 가능!
3️⃣ 배열 변환 (array())
배열을 문자열로 변환
const StringFromArray = z.array(z.string()).transform((val) => val.join(", "));
console.log(StringFromArray.parse(["Apple", "Banana", "Cherry"])); // ✅ "Apple, Banana, Cherry"
배열을 join(", ")을 사용하여 문자열로 변환 가능!
문자열을 배열로 변환
const ArrayFromString = z.string().transform((val) => val.split(", "));
console.log(ArrayFromString.parse("Apple, Banana, Cherry"));
// ✅ ["Apple", "Banana", "Cherry"]
문자열을 split(", ")을 사용하여 배열로 변환 가능!
4️⃣ 객체 변환 (object())
객체 속성 변환
const UserSchema = z.object({
name: z.string(),
age: z.string().transform((val) => Number(val)), // 문자열 → 숫자 변환
});
console.log(UserSchema.parse({ name: "Alice", age: "30" }));
// ✅ { name: "Alice", age: 30 }
객체 내 특정 필드를 변환 가능!
5️⃣ 여러 개의 변환 적용 (transform().transform())
문자열 → 숫자 → boolean
const ComplexTransform = z.string()
.transform((val) => Number(val))
.transform((num) => num > 10);
console.log(ComplexTransform.parse("15")); // ✅ true
console.log(ComplexTransform.parse("5")); // ✅ false
여러 개의 변환을 연속적으로 적용 가능!
3. Zod는 기본적으로 동기 검증
- 서버 호출 없는 모든 검증을 동기 처리 가능
- 비동기 검증이 필요하다면 .refine(async)를 사용하거나 별도 로직 분리
하지만 refine, superRefine 내부에서 async 검증 로직을 사용할 경우, parse 대신 parseAsync를 사용해야 정상 동작합니다. parseAsync는 항상 Promise를 반환하며, await으로 결과를 기다려야 합니다.
사용 시점
| 단순 입력 검증 (형식, 길이, 패턴) | ❌ parse 가능 |
| 서버 호출이 필요한 검증 (닉네임 중복 체크, DB 검증, 외부 API 호출) | ✅ parseAsync 필수 |
1. 잘못된 예 (동기 parse 사용 시 오류)
const schema = z.string().refine(async (value) => {
const result = await checkUsernameAvailable(value); // 비동기 호출
return result;
}, { message: '이미 사용 중인 닉네임입니다.' });
schema.parse('admin'); // ❗ 실행 시 무조건 에러 (Zod는 동기 parse에서는 async 검증 불가능)
2. ✔ 올바른 예 (parseAsync 사용)
const schema = z.string().refine(async (value) => {
const result = await checkUsernameAvailable(value);
return result;
}, { message: '이미 사용 중인 닉네임입니다.' });
(async () => {
try {
const result = await schema.parseAsync('admin');
console.log('검증 성공', result);
} catch (err) {
console.log('검증 실패:', err.errors[0].message);
}
})();
✔ refine + parseAsync 흐름 refine 안에서 async 함수 사용 → parseAsync 호출 필수→ Promise 기반 검증 흐름 사용 가능
서버 중복 확인 API 호출 검증 (비동기)
const userSchema = z.object({
username: z.string().refine(async (username) => {
const response = await axios.post('/api/check-username', { username });
return response.data.available;
}, { message: '이미 사용 중인 닉네임입니다.' }),
});
const validate = async () => {
try {
await userSchema.parseAsync({ username: 'admin' });
console.log('사용 가능');
} catch (error) {
console.error(error.errors[0].message);
}
};
실무 요령
- refine 내부에서 async 사용 시 반드시 parseAsync
- parseAsync는 항상 Promise 반환 → await 필요
- Form에서 사용할 때 → RHF resolver가 알아서 parseAsync 처리하므로 직접 parseAsync 호출 안 해도 됨
→ BUT, 테스트나 서버 사이드에서 직접 검증할 때는 parseAsync 필수
실습 코드
✔ 1. 문자열, 숫자, 객체 스키마 선언 및 검증
import { z } from 'zod';
// 문자열 검증
const nameSchema = z.string().min(2, '이름은 2글자 이상이어야 합니다.');
console.log(nameSchema.parse('Hee')); // 'Hee'
// 숫자 검증
const ageSchema = z.number().min(18, '18세 이상이어야 합니다.');
console.log(ageSchema.parse(20)); // 20
// 객체 검증
const userSchema = z.object({
name: z.string(),
age: z.number(),
});
console.log(userSchema.parse({ name: 'Hee', age: 25 }));
✔ 2. safeParse로 안전 검증 (예외 없이 결과 관리)
const result = nameSchema.safeParse('A');
if (!result.success) {
console.log('에러 메시지:', result.error.errors[0].message);
} else {
console.log('성공:', result.data);
}
✔ 3. 비동기 검증 (이메일 중복 체크 같은 경우)
const asyncSchema = z.string().refine(async (value) => {
// 예시: 서버 중복 확인 API 호출 가정
await new Promise((res) => setTimeout(res, 1000));
return value !== 'admin';
}, {
message: '이미 사용 중인 이름입니다.',
});
// 비동기 검증은 수동 호출 필요
async function checkUsername(name: string) {
try {
await asyncSchema.parseAsync(name);
console.log('사용 가능');
} catch (err) {
console.log('에러:', err.errors[0].message);
}
}
checkUsername('admin'); // → '이미 사용 중인 이름입니다.'
2단계. Zod 고급 스키마 및 유효성 검사 로직 작성
핵심 개념
1. refine
- 단일 필드 검증
- true/false 반환 → false일 경우 커스텀 에러 메시지
- 간단한 조건, 필드 하나에 적용
2. superRefine
- 전체 객체 레벨 검증 가능 (모든 필드 접근 가능)
- 여러 필드를 동시에 비교하거나 복합 검증 가능
- ctx.addIssue()를 사용해서 원하는 위치, 메시지 지정 가능
3. 조건부 필드 (z.optional, z.nullable), 배열, z.union, z.intersection
- 실무에서 복잡한 폼, API 데이터 검증에 필수
실습 코드
✔ 1. refine 사용 (비밀번호 복잡도 검증)
const passwordSchema = z.string().refine((value) => value.length >= 8, {
message: '비밀번호는 8자리 이상이어야 합니다.',
});
console.log(passwordSchema.safeParse('1234')); // 실패
console.log(passwordSchema.safeParse('12345678')); // 성공
✔ 2. superRefine 사용 (비밀번호 확인 일치 검증)
const registerSchema = z.object({
password: z.string(),
confirmPassword: z.string(),
}).superRefine((data, ctx) => {
if (data.password !== data.confirmPassword) {
ctx.addIssue({
path: ['confirmPassword'], // confirmPassword 필드에 에러 표시
message: '비밀번호가 일치하지 않습니다.',
});
}
});
✔ 3. 조건부 필드 (optional, nullable)
const profileSchema = z.object({
nickname: z.string().optional(),
description: z.string().nullable(),
});
console.log(profileSchema.parse({})); // OK
console.log(profileSchema.parse({ description: null })); // OK
✔ 4. 배열 검증 + 각 아이템 검증
const tagsSchema = z.array(z.string().min(2, '태그는 2글자 이상'));
console.log(tagsSchema.parse(['JS', 'React'])); // OK
console.log(tagsSchema.parse(['J'])); // 에러
✔ 5. union (or 조건)
const ageOrNameSchema = z.union([z.string(), z.number()]);
console.log(ageOrNameSchema.parse('Heeseong')); // OK
console.log(ageOrNameSchema.parse(20)); // OK
✔ 6. intersection (and 조건)
const schema1 = z.object({ a: z.string() });
const schema2 = z.object({ b: z.number() });
const combinedSchema = schema1.and(schema2);
console.log(combinedSchema.parse({ a: 'abc', b: 123 })); // OK
실습 목표
- registerSchema 생성
- email, password, confirmPassword 필드 작성
- refine → password 길이 검증 (8자 이상)
- superRefine → confirmPassword 일치 검증
- z.union → phone 또는 email 둘 중 하나 필수
- z.array → skills 필드는 3글자 이상 스킬만 허용
코드 예제
import { z } from 'zod';
// 1. 기본 필드 스키마 선언
const emailSchema = z.string().email('유효한 이메일 형식');
const phoneSchema = z.string().regex(/^010-\d{4}-\d{4}$/, '올바른 전화번호 형식 (예: 010-1234-5678)');
// 2. 메인 스키마
export const registerSchema = z.object({
email: emailSchema,
phone: phoneSchema,
password: z.string(),
confirmPassword: z.string(),
skills: z.array(z.string().min(3, '스킬은 3글자 이상')),
})
.superRefine((data, ctx) => {
// 3. password 길이 검증 (refine 사용 안 하고 superRefine 안에서 처리)
if (data.password.length < 8) {
ctx.addIssue({
path: ['password'],
message: '비밀번호는 8자 이상 입력하세요.',
});
}
// 4. confirmPassword 일치 검증
if (data.password !== data.confirmPassword) {
ctx.addIssue({
path: ['confirmPassword'],
message: '비밀번호가 일치하지 않습니다.',
});
}
// 5. phone 또는 email 중 하나 필수 조건 (email이 빈 값이거나 phone이 빈 값인 경우)
if (!data.email && !data.phone) {
ctx.addIssue({
path: ['email'],
message: '이메일 또는 전화번호 중 하나는 반드시 입력해야 합니다.',
});
ctx.addIssue({
path: ['phone'],
message: '이메일 또는 전화번호 중 하나는 반드시 입력해야 합니다.',
});
}
});
// 6. 타입 추론
export type RegisterRequest = z.infer<typeof registerSchema>;
검증 흐름
| email, phone | z.object 필드 기본 검증 |
| password 길이 | superRefine 안에서 커스텀 검증 (또는 refine 별도 가능) |
| confirmPassword 일치 | superRefine에서 cross-field 검증 |
| phone 또는 email 하나 필수 | superRefine에서 복합 조건 검증 |
| skills 배열 | z.array(z.string().min(3)) |
실무 UX 포인트
- 모든 검증 로직 → superRefine 하나에서 처리 가능 (복잡한 cross-field 검증에 유리)
→ 권장: superRefine을 메인으로 두고, 단순 필드는 min, email, regex 같이 사용 - z.union보다 superRefine을 사용하면 좀 더 유연하게 사용자 맞춤 메시지 분리 가능
→ 예를 들어 둘 다 입력 안 했을 때 각각 필드에 에러 표시 가능 - 배열 검증 → z.array(z.string().min(3))로 개별 스킬 검증
→ UI에서는 map으로 errors를 index 기준으로 표시
3단계. React Hook Form 기본 validator와 Zod의 차이
핵심 개념
RHF 기본 validator
- register 할 때 직접 required, minLength, pattern 등 옵션으로 필드별 validation 설정
- HTML 표준 validation 규칙 사용 (명령형)
- 간단하고 빠르지만, 검증 로직이 컴포넌트 안에 흩어질 수 있고 복잡한 검증에 부적합
RHF + Zod (zodResolver)
- validation 로직을 외부 스키마(Zod)로 분리 → 선언적
- Form 정의와 검증을 분리해서 더 일관성 있고 재사용 가능
- 복잡한 검증(조건부, 복합 필드)에도 강력
기본 validator 예제 (RHF 방식)
import { useForm } from 'react-hook-form';
const BasicForm = () => {
const { register, handleSubmit, formState: { errors } } = useForm();
const onSubmit = (data) => {
console.log('제출 데이터:', data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('username', { required: '이름 필수', minLength: { value: 2, message: '2글자 이상' } })} />
{errors.username && <p>{errors.username.message}</p>}
<input type="email" {...register('email', { required: '이메일 필수' })} />
{errors.email && <p>{errors.email.message}</p>}
<button type="submit">제출</button>
</form>
);
};
Zod + zodResolver 예제 (추천 실무 패턴)
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
const schema = z.object({
username: z.string().min(2, '2글자 이상'),
email: z.string().email('유효한 이메일 입력'),
});
const ZodForm = () => {
const { register, handleSubmit, formState: { errors } } = useForm({
resolver: zodResolver(schema),
});
const onSubmit = (data) => {
console.log('제출 데이터:', data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('username')} />
{errors.username && <p>{errors.username.message}</p>}
<input type="email" {...register('email')} />
{errors.email && <p>{errors.email.message}</p>}
<button type="submit">제출</button>
</form>
);
};
4단계. zodResolver 사용법 이해 (React Hook Form + Zod 연동)
핵심 개념
1. zodResolver 역할 (React Hook Form의 검증 플로우를 Zod 스키마 기반으로 변경해주는 역할)
- React Hook Form과 Zod 스키마를 연결해주는 중간 adapter
- useForm의 resolver 옵션에 전달
- Form 제출 시 → Zod 스키마로 데이터 검증 → 오류를 React Hook Form의 errors 객체로 자동 매핑
2. zodResolver 사용 위치
const { register, handleSubmit, formState: { errors } } = useForm({
resolver: zodResolver(schema),
});
3. 검증 흐름
사용자 입력 → handleSubmit → zodResolver → Zod 스키마 검증 → errors로 반환
실습 예제: RHF + Zod 통합 Form
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
// 1. Zod 스키마 선언
const schema = z.object({
username: z.string().min(2, '이름은 2글자 이상'),
email: z.string().email('유효한 이메일 형식'),
password: z.string().min(6, '비밀번호는 6자리 이상'),
});
// 2. RHF에서 resolver로 연결
const ZodIntegratedForm = () => {
const { register, handleSubmit, formState: { errors } } = useForm({
resolver: zodResolver(schema),
});
const onSubmit = (data) => {
console.log('검증 완료 데이터:', data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input placeholder="이름" {...register('username')} />
{errors.username && <p>{errors.username.message}</p>}
<input placeholder="이메일" {...register('email')} />
{errors.email && <p>{errors.email.message}</p>}
<input type="password" placeholder="비밀번호" {...register('password')} />
{errors.password && <p>{errors.password.message}</p>}
<button type="submit">제출</button>
</form>
);
};
export default ZodIntegratedForm;
실무 기준 요령
- z.object({ ... })로 스키마 선언
- useForm({ resolver: zodResolver(schema) }) 연결
- 필드는 그대로 register('필드명')
- 에러는 errors.필드명.message 사용
- 복잡한 검증 로직(비밀번호 확인, 조건부 필드, custom refine)은 스키마 안에서 처리 → Form은 깔끔해짐
기억하기 쉬운 흐름
// 선언적 검증 → UI와 검증 완벽 분리
const schema = z.object({
...
});
useForm({
resolver: zodResolver(schema),
});
5단계. 실무형 Form 구성 → 스키마 중심 설계
핵심 개념
✔ 스키마 중심 Form 개발 패턴
- 스키마 선언 → Form 설계와 분리 (최우선)
- Form은 스키마만 연결 → RHF는 UI/상태 관리 전담
- 검증 로직, 커스텀 검증, 조건부 검증은 모두 Zod에 집중
- RHF는 검증 결과를 errors에서 보여주고 제출(handleSubmit)만 처리
✔ 장점
- Form 필드와 검증이 분리 → 유지보수 쉬움
- API 요청 DTO, validation을 통일 (타입 안전성 확보)
- Form이 복잡해져도 검증 로직 혼재 없음 → 가독성/안정성 높음
실습 예제: 실무형 회원가입 Form
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
// 1. 스키마 먼저 선언 (최우선)
const signUpSchema = z.object({
email: z.string().email('유효한 이메일을 입력하세요.'),
password: z.string().min(8, '비밀번호는 8자리 이상'),
confirmPassword: z.string(),
}).superRefine((data, ctx) => {
if (data.password !== data.confirmPassword) {
ctx.addIssue({
path: ['confirmPassword'],
message: '비밀번호가 일치하지 않습니다.',
});
}
});
// 2. Form은 스키마만 연결 → UI만 관리
const SignUpForm = () => {
const { register, handleSubmit, formState: { errors } } = useForm({
resolver: zodResolver(signUpSchema),
});
const onSubmit = (data) => {
console.log('제출 데이터 (검증 완료):', data);
// 여기서 API 호출
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input placeholder="이메일" {...register('email')} />
{errors.email && <p>{errors.email.message}</p>}
<input type="password" placeholder="비밀번호" {...register('password')} />
{errors.password && <p>{errors.password.message}</p>}
<input type="password" placeholder="비밀번호 확인" {...register('confirmPassword')} />
{errors.confirmPassword && <p>{errors.confirmPassword.message}</p>}
<button type="submit">회원가입</button>
</form>
);
};
export default SignUpForm;
실무에서의 핵심 체크리스트
- Form마다 스키마 → forms/schemas/ 폴더로 관리 추천
- 스키마 → API 요청 body 타입으로 재사용 (z.infer<typeof schema>)
- 검증 실패 시 handleSubmit 안 실행 → submit 안전 보장
- 모든 복잡한 검증(confirmPassword, 조건부 필드) → 스키마에 집중 → Form 컴포넌트는 가볍게
6단계. 스키마 재사용 + API 타입 안전성 연동
핵심 개념
✔ z.infer<typeof schema>
- Zod 스키마에서 타입을 추론해서 TypeScript 타입으로 사용 가능
- z.infer을 통해 Form 데이터 타입 → API 요청 body 타입 → 서비스 내부 DTO 통일
- 코드 중복 제거 → 검증과 타입의 일관성 확보
실습 코드 예제
1. 스키마 정의
import { z } from 'zod';
export const signUpSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
confirmPassword: z.string(),
}).superRefine((data, ctx) => {
if (data.password !== data.confirmPassword) {
ctx.addIssue({
path: ['confirmPassword'],
message: '비밀번호가 일치하지 않습니다.',
});
}
});
2. 타입 재사용 (z.infer)
export type SignUpRequest = z.infer<typeof signUpSchema>;
3. React Hook Form에서 사용
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { signUpSchema, SignUpRequest } from '@/schemas/signUp';
const SignUpForm = () => {
const { register, handleSubmit, formState: { errors } } = useForm<SignUpRequest>({
resolver: zodResolver(signUpSchema),
});
const onSubmit = (data: SignUpRequest) => {
console.log('제출 데이터 (타입 안전):', data);
// ✅ 타입 안전하게 API 호출 가능
submitToApi(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email')} />
{errors.email && <p>{errors.email.message}</p>}
<input type="password" {...register('password')} />
{errors.password && <p>{errors.password.message}</p>}
<input type="password" {...register('confirmPassword')} />
{errors.confirmPassword && <p>{errors.confirmPassword.message}</p>}
<button type="submit">회원가입</button>
</form>
);
};
4. API 호출 시 재사용된 타입 적용
const submitToApi = (data: SignUpRequest) => {
// 타입 안전하게 API 호출 가능 (서버 DTO와 일치)
return axios.post('/api/signup', data);
};
실무 요령
- 스키마 선언 → 타입 추론 → Form + API 호출에 그대로 사용
- z.infer 타입을 API 레이어에도 그대로 전달 (Axios, React Query, RTK Query 등)
- 유지보수 시 스키마만 변경 → Form, API, 백엔드 타입 자동 일관성 확보
- schemas/ 폴더에 스키마, 타입을 통합 관리 추천
실무 체크리스트
| 스키마 선언 | schemas/signUp.ts에 선언 |
| 타입 추론 | export type SignUpRequest = z.infer<typeof signUpSchema> |
| Form 사용 | RHF → <useForm<SignUpRequest> ...> |
| API 호출 | axios.post('/api/signup', data: SignUpRequest) |
7단계. 커스텀 에러 처리 + UX 최적화
핵심 개념
1. Zod 에러 메시지 커스터마이징 방법
- .refine((val) => ..., { message })
→ 필드별 직접 메시지 지정 - superRefine → 객체 레벨 검증 + ctx.addIssue() → 위치, 메시지 지정 가능
- 전역 커스터마이징
→ z.setErrorMap() 사용해서 Zod 전역 에러 메시지 스타일 지정 가능
2. Form UX 최적화
- useForm의 mode: 'onBlur' → blur 시 검증 실행
- reValidateMode: 'onChange' → 한번 검증 후 input 수정 시 실시간 검증 (추천)
- 에러 발생 시 UI에서 명확하게 표시 (Input 하단, 색상, 아이콘 등)
실습 예제
✔ 1. 필드별 커스터마이징 (.refine(message))
const schema = z.object({
username: z.string().min(2, { message: '이름은 2글자 이상 입력하세요.' }),
email: z.string().email({ message: '올바른 이메일 형식으로 입력하세요.' }),
});
✔ 2. 복합 필드 에러 (superRefine + ctx.addIssue)
const schema = z.object({
password: z.string(),
confirmPassword: z.string(),
}).superRefine((data, ctx) => {
if (data.password !== data.confirmPassword) {
ctx.addIssue({
path: ['confirmPassword'],
message: '비밀번호가 일치하지 않습니다.',
});
}
});
✔ 3. 전역 에러 메시지 스타일 (z.setErrorMap)
z.setErrorMap((issue, ctx) => {
return { message: `❗ ${ctx.defaultError}` };
});
✔ 4. Form에서 UX 최적화 (onBlur 검증, 실시간 검증)
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
const Form = () => {
const { register, handleSubmit, formState: { errors } } = useForm({
resolver: zodResolver(schema),
mode: 'onBlur', // focus out 시 검증
reValidateMode: 'onChange', // 수정 시 실시간 재검증
});
return (
<form onSubmit={handleSubmit((data) => console.log(data))}>
<input {...register('username')} placeholder="이름" />
{errors.username && <p>{errors.username.message}</p>}
<input {...register('email')} placeholder="이메일" />
{errors.email && <p>{errors.email.message}</p>}
<button type="submit">제출</button>
</form>
);
};
실무 요령
| 필드 메시지 커스터마이징 | .refine, min(x, { message }) |
| 객체 레벨 복합 검증 | superRefine + ctx.addIssue |
| 글로벌 에러 스타일 | z.setErrorMap |
| UX 최적화 | mode: 'onBlur' + reValidateMode: 'onChange' |
| 에러 표시 UI | 인풋 하단 명확히 표시, 색상, 아이콘 활용 |
실무에서 자주 쓰는 UX 패턴
- 입력 필드 focus out 시 onBlur → 기본 검증 실행
- 입력 수정하면 즉시 onChange → 재검증 (UX 부드럽게)
- 에러 메시지는 errors.xxx.message
→ 사용자 친화적 메시지 (직접 입력, 전역 설정 둘 다 사용)
실전 통합 예제
회원가입 Form → Zod validation → RHF → React Query mutation → 에러 UX 개선
- Zod로 Form validation 관리 (schema)
- React Hook Form에서 zodResolver로 스키마 연결
- React Query mutation으로 API 호출 (useMutation)
- onMutate → 낙관적 UI, onError → 사용자에게 toast로 에러 알림, onSuccess → 알림/리다이렉트
전체 흐름
[사용자 입력]
↓
[React Hook Form (onSubmit)]
↓
[zodResolver → Zod 스키마 검증]
↓
[검증 성공 → useMutation으로 API 호출]
↓
[onMutate → UI 반응 (버튼 비활성화 등)]
↓
[onSuccess → 성공 알림, 페이지 이동]
↓
[onError → 사용자 에러 안내, rollback]
실전 코드 예제
1. Zod 스키마 및 타입 정의
// schemas/signUp.ts
import { z } from 'zod';
export const signUpSchema = z.object({
email: z.string().email('올바른 이메일을 입력하세요.'),
password: z.string().min(8, '비밀번호는 8자 이상 입력하세요.'),
confirmPassword: z.string(),
}).superRefine((data, ctx) => {
if (data.password !== data.confirmPassword) {
ctx.addIssue({
path: ['confirmPassword'],
message: '비밀번호가 일치하지 않습니다.',
});
}
});
export type SignUpRequest = z.infer<typeof signUpSchema>;
2. API 요청 함수
// api/auth.ts
import axios from 'axios';
import { SignUpRequest } from '@/schemas/signUp';
export const signUpApi = (data: SignUpRequest) => {
return axios.post('/api/signup', data);
};
3. React Query mutation + Form 통합
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { signUpSchema, SignUpRequest } from '@/schemas/signUp';
import { useMutation } from '@tanstack/react-query';
import { signUpApi } from '@/api/auth';
const SignUpForm = () => {
const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm<SignUpRequest>({
resolver: zodResolver(signUpSchema),
mode: 'onBlur',
reValidateMode: 'onChange',
});
const mutation = useMutation({
mutationFn: signUpApi,
onMutate: () => {
console.log('요청 전 UI 상태 변경');
},
onSuccess: () => {
alert('회원가입 성공');
// router.push('/login'); // 페이지 이동
},
onError: (error) => {
alert('회원가입 실패: ' + error.response?.data?.message ?? '알 수 없는 에러');
},
});
const onSubmit = (data: SignUpRequest) => {
mutation.mutate(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input placeholder="이메일" {...register('email')} disabled={isSubmitting} />
{errors.email && <p>{errors.email.message}</p>}
<input type="password" placeholder="비밀번호" {...register('password')} disabled={isSubmitting} />
{errors.password && <p>{errors.password.message}</p>}
<input type="password" placeholder="비밀번호 확인" {...register('confirmPassword')} disabled={isSubmitting} />
{errors.confirmPassword && <p>{errors.confirmPassword.message}</p>}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? '처리 중...' : '회원가입'}
</button>
</form>
);
};
export default SignUpForm;
실무 UX 최적화 포인트
- isSubmitting → 버튼 비활성화, 중복 요청 방지 (React Hook Form)
- onMutate → API 요청 전 UI 준비 (로딩 표시, 상태 변경)
- onError → 서버 에러 메시지 표시 (Toast, alert)
- onSuccess → 성공 알림 후 페이지 이동, 상태 초기화
- 검증 실패 시 → handleSubmit 실행 X → 검증 안정성 확보
흐름
- 스키마 → 타입 추론 → RHF Form → API 요청까지 재사용
- 검증, API 호출, UX, 에러 관리 → 역할별 깔끔하게 분리
- React Query + RHF + Zod → 현대 프론트엔드 표준 Form 패턴
프로젝트 구조 예시 (Zod + RHF + React Query 패턴)
src/
├── api/ # API 호출 함수 모음 (axios wrapper 사용 추천)
│ └── auth.ts # 인증 관련 API (signup, login 등)
│ └── user.ts
│
├── hooks/ # React Query custom hook (useUserQuery, useSignUpMutation)
│ └── useSignUpMutation.ts
│
├── schemas/ # Zod 스키마 & 타입 관리 (Form과 API DTO 통합)
│ └── auth/ # auth 관련 스키마 모음
│ └── signUp.ts
│ └── login.ts
│
├── pages/ # 페이지 컴포넌트 (Next.js 라우트 또는 react-router 기준)
│ └── SignUpPage.tsx
│ └── LoginPage.tsx
│
├── components/ # UI 컴포넌트 (Form, Input, Button)
│ └── forms/SignUpForm.tsx
│
└── lib/ # axios, react-query, error handling 공통 설정
└── axios.ts
└── react-query.ts
역할 기준 명확한 구조 요약
| schemas/ | 모든 스키마, DTO, 타입 중앙 관리 | Form 검증, API DTO 일원화 |
| api/ | 순수 API 호출 함수 (axios wrapper) | 비즈니스 X, 순수 요청만 |
| hooks/ | React Query custom hook 관리 | Query, Mutation 상태 관리 |
| components/forms/ | Form 컴포넌트 (UI + RHF + zodResolver) | 검증 X → 스키마만 연결 |
| lib/ | axios, react-query 전역 설정 | 에러 처리, interceptors, queryClient |
흐름 (회원가입 페이지 기준)
- schemas/auth/signUp.ts
→ Form 검증 + API DTO 타입 중앙 집중 - api/auth.ts
→ signUpApi(data: SignUpRequest) → 타입 안전 API 호출 함수 - hooks/useSignUpMutation.ts
→ React Query mutation + onError, onSuccess 처리 포함 - components/forms/SignUpForm.tsx
→ useForm({ resolver: zodResolver(signUpSchema) })
→ UI만 관리 - pages/SignUpPage.tsx
→ Form 컴포넌트 렌더링
체크리스트
| 스키마/타입 관리 | schemas/에서 Form + API 타입 통합 관리 |
| API 호출 | api/에서는 SignUpRequest 타입 재사용 |
| mutation 관리 | hooks/useSignUpMutation.ts에서 React Query mutation 핸들러 관리 |
| Form 관리 | components/forms/SignUpForm.tsx → UI + RHF만 책임 |
| 페이지 구성 | pages/SignUpPage.tsx → Form 렌더링, 라우팅 책임 |
보일러플레이트
1. schemas/auth/signUp.ts
import { z } from 'zod';
export const signUpSchema = z.object({
email: z.string().email('올바른 이메일 형식을 입력하세요.'),
password: z.string().min(8, '비밀번호는 8자 이상 입력하세요.'),
confirmPassword: z.string(),
}).superRefine((data, ctx) => {
if (data.password !== data.confirmPassword) {
ctx.addIssue({
path: ['confirmPassword'],
message: '비밀번호가 일치하지 않습니다.',
});
}
});
export type SignUpRequest = z.infer<typeof signUpSchema>;
2. api/auth.ts
import axios from '@/lib/axios';
import { SignUpRequest } from '@/schemas/auth/signUp';
export const signUpApi = (data: SignUpRequest) => {
return axios.post('/api/signup', data);
};
3. hooks/useSignUpMutation.ts
import { useMutation } from '@tanstack/react-query';
import { signUpApi } from '@/api/auth';
import { SignUpRequest } from '@/schemas/auth/signUp';
export const useSignUpMutation = () => {
return useMutation({
mutationFn: signUpApi,
onSuccess: () => {
alert('회원가입 성공');
// 페이지 이동 등 후처리
},
onError: (error) => {
alert('회원가입 실패: ' + (error.response?.data?.message ?? '알 수 없는 오류'));
},
});
};
4. components/forms/SignUpForm.tsx
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { signUpSchema, SignUpRequest } from '@/schemas/auth/signUp';
import { useSignUpMutation } from '@/hooks/useSignUpMutation';
export const SignUpForm = () => {
const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm<SignUpRequest>({
resolver: zodResolver(signUpSchema),
mode: 'onBlur',
reValidateMode: 'onChange',
});
const mutation = useSignUpMutation();
const onSubmit = (data: SignUpRequest) => {
mutation.mutate(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email')} placeholder="이메일" disabled={isSubmitting} />
{errors.email && <p>{errors.email.message}</p>}
<input type="password" {...register('password')} placeholder="비밀번호" disabled={isSubmitting} />
{errors.password && <p>{errors.password.message}</p>}
<input type="password" {...register('confirmPassword')} placeholder="비밀번호 확인" disabled={isSubmitting} />
{errors.confirmPassword && <p>{errors.confirmPassword.message}</p>}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? '처리 중...' : '회원가입'}
</button>
</form>
);
};
5. pages/SignUpPage.tsx
import { SignUpForm } from '@/components/forms/SignUpForm';
const SignUpPage = () => {
return (
<div>
<h1>회원가입</h1>
<SignUpForm />
</div>
);
};
export default SignUpPage;
6. lib/axios.ts (공통 axios wrapper)
import axios from 'axios';
const instance = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3000',
withCredentials: true,
});
export default instance;
7. lib/react-query.ts (QueryClient 설정)
import { QueryClient } from '@tanstack/react-query';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
retry: 1,
},
},
});
실무형 Best Practice
| schemas/ | Form 검증 + API DTO 타입 통합 |
| api/ | Axios 요청 전담, SignUpRequest 타입 사용 |
| hooks/ | React Query 상태 관리 (mutation, query) |
| components/forms/ | UI 컴포넌트 (RHF + zodResolver 연결) |
| pages/ | 페이지 레벨 (Form 렌더링, 라우팅) |
| lib/ | Axios, QueryClient, 공통 에러 처리 |
'JavaScript > TypeScript' 카테고리의 다른 글
| 제네릭(Generic) (1) | 2025.06.21 |
|---|---|
| Jsdoc, ts, Typedoc (6) | 2025.06.21 |
| TypeScript에서 타입을 정의하는 주요 방식 (1) | 2025.05.02 |
| Zod vs zod-validator (1) | 2025.03.19 |
| Zod (0) | 2025.03.19 |