Ver3.0 ๐ฏ ํ์ ์คํฌ๋ฆฝํธ๋ก ํผ ์ฒ๋ฆฌ ์๋ฒฝ ์ ๋ณตํ๊ธฐ: Validation ๋ผ์ด๋ธ๋ฌ๋ฆฌ์ ํ์ ์์คํ ์ ํ์์ ์ธ ๋ง๋จ

๐ฏ ํ์ ์คํฌ๋ฆฝํธ๋ก ํผ ์ฒ๋ฆฌ ์๋ฒฝ ์ ๋ณตํ๊ธฐ: Validation ๋ผ์ด๋ธ๋ฌ๋ฆฌ์ ํ์ ์์คํ ์ ํ์์ ์ธ ๋ง๋จ
์น๊ตฌ์ฒ๋ผ ํธํ๊ฒ ์๋ ค์ฃผ๋ ์ค์ ํผ ์ฒ๋ฆฌ ๊ฐ์ด๋
์๋ ! ๐ ์ค๋์ ํ์ ์คํฌ๋ฆฝํธ๋ก ํผ์ ๋ค๋ฃจ๋ ๋ฐฉ๋ฒ์ ๋ํด ์ด์ผ๊ธฐํด๋ณผ ๊ฑฐ์ผ. ์์งํ ๋งํ๋ฉด, ํผ ์ฒ๋ฆฌ๋ ์น ๊ฐ๋ฐ์์ ๊ฐ์ฅ ๊ท์ฐฎ์ผ๋ฉด์๋ ์ค์ํ ๋ถ๋ถ์ด์์? ์ฌ์ฉ์๊ฐ ์ ๋ ฅํ ๋ฐ์ดํฐ๋ฅผ ๊ฒ์ฆํ๊ณ , ์๋ฌ๋ฅผ ์ฒ๋ฆฌํ๊ณ , ํ์ ์์ ์ฑ๊น์ง ์ฑ๊ฒจ์ผ ํ๋๊น ๋ง์ด์ผ.
๊ทผ๋ฐ ํ์ ์คํฌ๋ฆฝํธ์ ์ต์ validation ๋ผ์ด๋ธ๋ฌ๋ฆฌ๋ค์ ์ ๋๋ก ํ์ฉํ๋ฉด, ์ด ๋ชจ๋ ๊ฒ ์ ๋ง ์ฐ์ํ๊ฒ ํด๊ฒฐ๋ผ. ๋ง์น ํผ์ฆ ์กฐ๊ฐ๋ค์ด ๋ฑ๋ฑ ๋ง์๋จ์ด์ง๋ ๋๋์ด๋๊น? ๐งฉ
๐ก ์ด ๊ธ์์ ๋ค๋ฃฐ ๋ด์ฉ
ํ์ ์คํฌ๋ฆฝํธ์ ํ์ ์์คํ ๊ณผ validation ๋ผ์ด๋ธ๋ฌ๋ฆฌ๋ฅผ ํตํฉํ๋ ๋ฐฉ๋ฒ, ์ค์ ์์ ๋ฐ๋ก ์ธ ์ ์๋ ํจํด๋ค, ๊ทธ๋ฆฌ๊ณ ๊ฐ ๋ผ์ด๋ธ๋ฌ๋ฆฌ์ ์ฅ๋จ์ ๊น์ง ์น๊ตฌ์๊ฒ ์ค๋ช ํ๋ฏ ์ฝ๊ฒ ํ์ด๋ณผ๊ฒ!
๐ช ์ ํ์ ์คํฌ๋ฆฝํธ์ Validation ๋ผ์ด๋ธ๋ฌ๋ฆฌ๋ฅผ ํจ๊ป ์จ์ผ ํ ๊น?
์, ๋จผ์ ๊ทผ๋ณธ์ ์ธ ์ง๋ฌธ๋ถํฐ ์์ํด๋ณด์. ํ์ ์คํฌ๋ฆฝํธ๋ง ์์ผ๋ฉด ๋๋ ๊ฑฐ ์๋์ผ? ์ ๊ตณ์ด validation ๋ผ์ด๋ธ๋ฌ๋ฆฌ๊น์ง ์จ์ผ ํ๋ ๊ฑธ๊น? ๐ค
๋ต์ ๊ฐ๋จํด. ํ์ ์คํฌ๋ฆฝํธ๋ ์ปดํ์ผ ํ์์๋ง ์๋ํ๊ฑฐ๋ . ์ฆ, ์ฝ๋๋ฅผ ์์ฑํ๊ณ ๋น๋ํ ๋๋ ํ์ ์ฒดํฌ๋ฅผ ํด์ฃผ์ง๋ง, ์ค์ ๋ก ์ฌ์ฉ์๊ฐ ๋ธ๋ผ์ฐ์ ์์ ํผ์ ๋ฐ์ดํฐ๋ฅผ ์ ๋ ฅํ ๋๋ ์๋ฌด๋ฐ ๋ณดํธ๋ง์ด ์๋ค๋ ๊ฑฐ์ผ.
๐ ํต์ฌ ํฌ์ธํธ
์ปดํ์ผ ํ์ vs ๋ฐํ์
โข ํ์
์คํฌ๋ฆฝํธ: ์ปดํ์ผ ํ์์ ํ์
์ฒดํฌ (๊ฐ๋ฐ ์ค)
โข Validation ๋ผ์ด๋ธ๋ฌ๋ฆฌ: ๋ฐํ์์ ์ค์ ๋ฐ์ดํฐ ๊ฒ์ฆ (์คํ ์ค)
โข ๋ ๋ค ํ์ํ ์ด์ : ์๋ฒฝํ ํ์
์์ ์ฑ + ์ค์๊ฐ ๋ฐ์ดํฐ ๊ฒ์ฆ
์๋ฅผ ๋ค์ด๋ณผ๊ฒ. ์ด๋ฐ ํ์ ์ ์ ์ํ๋ค๊ณ ํด๋ณด์:
interface UserForm {
name: string;
email: string;
age: number;
}
function submitForm(data: UserForm) {
// ์ฌ๊ธฐ์ data๋ UserForm ํ์
์ด๋ผ๊ณ ๋ณด์ฅ๋์ง๋ง...
// ์ค์ ๋ก ๋ฐํ์์ ๋ค์ด์ค๋ ๊ฐ์ ๋ญ๊ฐ ๋ ์ง ๋ชฐ๋ผ!
}
์ฌ์ฉ์๊ฐ ๋์ด์ "์ค๋ฌผ๋ค์ฏ"์ด๋ผ๊ณ ์ ๋ ฅํ๋ฉด? ์ด๋ฉ์ผ์ "์ด๋ฉ์ผ์๋"์ด๋ผ๊ณ ์ฐ๋ฉด? ํ์ ์คํฌ๋ฆฝํธ๋ ์ด๊ฑธ ๋ง์์ฃผ์ง ๋ชปํด. ์๋ํ๋ฉด ์คํ ์์ ์๋ ํ์ ์ ๋ณด๊ฐ ์ฌ๋ผ์ง๊ฑฐ๋ . ๐ฑ
๊ทธ๋์ ์ฐ๋ฆฌ๋ validation ๋ผ์ด๋ธ๋ฌ๋ฆฌ๊ฐ ํ์ํ ๊ฑฐ์ผ. ๊ทธ๋ฆฌ๊ณ ๋ ๋์๊ฐ์, ์ด validation ์คํค๋ง์์ ํ์ ์ ์๋์ผ๋ก ์ถ๋ก ํ ์ ์๋ค๋ฉด? ์๋ฒฝํ์ง ์์? ๐ฏ
๐ ๏ธ ์ฃผ์ Validation ๋ผ์ด๋ธ๋ฌ๋ฆฌ ์๊ฐ
์, ์ด์ ๋ณธ๊ฒฉ์ ์ผ๋ก ์ด๋ค ๋ผ์ด๋ธ๋ฌ๋ฆฌ๋ค์ด ์๋์ง ์์๋ณด์. ์์ฆ ๊ฐ์ฅ ํซํ ์น๊ตฌ๋ค์ ์๊ฐํ ๊ฒ!
๐ท Zod
ํน์ง: ํ์ ์คํฌ๋ฆฝํธ ์ฐ์ ์ค๊ณ
์ฅ์ : ์ง๊ด์ ์ธ API, ๋ฐ์ด๋ ํ์ ์ถ๋ก , ์์ ๋ฒ๋ค ์ฌ์ด์ฆ
๋จ์ : ์๋์ ์ผ๋ก ์ต์ ๋ผ์ด๋ธ๋ฌ๋ฆฌ
์ธ๊ธฐ๋: โญโญโญโญโญ
๐ถ Yup
ํน์ง: ์ค๋๋ ๊ฒ์ฆ๋ ๋ผ์ด๋ธ๋ฌ๋ฆฌ
์ฅ์ : ํ๋ถํ ์ํ๊ณ, ๋ง์ ๋ ํผ๋ฐ์ค
๋จ์ : ํ์ ์ถ๋ก ์ด Zod๋ณด๋ค ์ฝํจ
์ธ๊ธฐ๋: โญโญโญโญ
๐น Joi
ํน์ง: Node.js ํ๊ฒฝ์ ์ต์ ํ
์ฅ์ : ๊ฐ๋ ฅํ ๊ฒ์ฆ ๊ธฐ๋ฅ
๋จ์ : ๋ธ๋ผ์ฐ์ ๋ฒ๋ค ์ฌ์ด์ฆ๊ฐ ํผ
์ธ๊ธฐ๋: โญโญโญโญ
๐ธ io-ts
ํน์ง: ํจ์ํ ํ๋ก๊ทธ๋๋ฐ ์คํ์ผ
์ฅ์ : ๊ฐ๋ ฅํ ํ์ ์์ ์ฑ
๋จ์ : ๋ฌ๋ ์ปค๋ธ๊ฐ ๋์
์ธ๊ธฐ๋: โญโญโญ
์ด ์ค์์ ์ค๋์ Zod์ Yup์ ์ค์ฌ์ผ๋ก ์ค๋ช ํ ๊ฒ. ์ด ๋์ด ํ์ฌ ๊ฐ์ฅ ๋ง์ด ์ฐ์ด๊ณ , ํ์ ์คํฌ๋ฆฝํธ์์ ํตํฉ๋ ํ๋ฅญํ๊ฑฐ๋ ! ๐ช
๐จ Zod๋ก ์์ํ๋ ํ์ ์์ ํ ํผ ์ฒ๋ฆฌ
Zod๋ ์ ๋ง ๋ฉ์ง ๋ผ์ด๋ธ๋ฌ๋ฆฌ์ผ. "Schema to Type"์ด ์๋๋ผ "Type from Schema"๋ผ๋ ์ฒ ํ์ ๊ฐ์ง๊ณ ์์ด. ๋ฌด์จ ๋ง์ด๋๋ฉด, ์คํค๋ง๋ฅผ ์ ์ํ๋ฉด ํ์ ์ด ์๋์ผ๋ก ๋ฐ๋ผ์จ๋ค๋ ๊ฑฐ์ง! ๐ช
๊ธฐ๋ณธ ์ฌ์ฉ๋ฒ
๋จผ์ ์ค์น๋ถํฐ ํด๋ณด์:
npm install zod
# ๋๋
yarn add zod
์ด์ ๊ฐ๋จํ ํผ ์คํค๋ง๋ฅผ ๋ง๋ค์ด๋ณผ๊ฒ:
import { z } from 'zod';
// ์คํค๋ง ์ ์
const userFormSchema = z.object({
name: z.string().min(2, '์ด๋ฆ์ ์ต์ 2๊ธ์ ์ด์์ด์ด์ผ ํด์'),
email: z.string().email('์ฌ๋ฐ๋ฅธ ์ด๋ฉ์ผ ํ์์ด ์๋์์'),
age: z.number().min(18, '18์ธ ์ด์๋ง ๊ฐ์
ํ ์ ์์ด์').max(100),
password: z.string()
.min(8, '๋น๋ฐ๋ฒํธ๋ ์ต์ 8์ ์ด์์ด์ด์ผ ํด์')
.regex(/[A-Z]/, '๋๋ฌธ์๋ฅผ ์ต์ 1๊ฐ ํฌํจํด์ผ ํด์')
.regex(/[0-9]/, '์ซ์๋ฅผ ์ต์ 1๊ฐ ํฌํจํด์ผ ํด์'),
agreeToTerms: z.boolean().refine(val => val === true, {
message: '์ฝ๊ด์ ๋์ํด์ผ ํด์'
})
});
// ํ์
์๋ ์ถ๋ก ! ๐
type UserForm = z.infer<typeof userFormSchema>;
// UserForm ํ์
์ ์ด๋ ๊ฒ ์ถ๋ก ๋ผ:
// {
// name: string;
// email: string;
// age: number;
// password: string;
// agreeToTerms: boolean;
// }
๐ฏ Pro Tip
z.infer๋ฅผ ์ฌ์ฉํ๋ฉด ์คํค๋ง์์ ํ์ ์ ์๋์ผ๋ก ์ถ๋ก ํ ์ ์์ด. ์ด๊ฒ Zod์ ๊ฐ์ฅ ํฐ ์ฅ์ ์ด์ผ! ์คํค๋ง๋ฅผ ์์ ํ๋ฉด ํ์ ๋ ์๋์ผ๋ก ์ ๋ฐ์ดํธ๋๋๊น ์ค๋ณต ์์ ์ด ์์ด์ ธ. ๐
์ค์ ํผ์์ ์ฌ์ฉํ๊ธฐ
์ด์ ์ค์ ๋ก ํผ ๋ฐ์ดํฐ๋ฅผ ๊ฒ์ฆํด๋ณด์:
function handleFormSubmit(formData: unknown) {
try {
// ๋ฐ์ดํฐ ๊ฒ์ฆ ๋ฐ ํ์ฑ
const validatedData = userFormSchema.parse(formData);
// ์ฌ๊ธฐ์ validatedData๋ UserForm ํ์
์ผ๋ก ๋ณด์ฅ๋ผ!
console.log('๊ฒ์ฆ ์ฑ๊ณต:', validatedData);
// API ํธ์ถ ๋ฑ์ ์์
์ํ
await submitToServer(validatedData);
} catch (error) {
if (error instanceof z.ZodError) {
// ๊ฒ์ฆ ์๋ฌ ์ฒ๋ฆฌ
console.error('๊ฒ์ฆ ์คํจ:', error.errors);
// ์๋ฌ ๋ฉ์์ง ํฌ๋งทํ
const formattedErrors = error.errors.map(err => ({
field: err.path.join('.'),
message: err.message
}));
displayErrors(formattedErrors);
}
}
}
๋ณด๋ค์ํผ ์ ๋ง ๊ฐ๋จํ์ง? parse ๋ฉ์๋ ํ๋๋ก ๊ฒ์ฆ๊ณผ ํ์ ๋ณํ์ด ๋์์ ์ด๋ฃจ์ด์ ธ. ๊ทธ๋ฆฌ๊ณ ์๋ฌ๊ฐ ๋ฐ์ํ๋ฉด ZodError ๊ฐ์ฒด์ ๋ชจ๋ ๊ฒ์ฆ ์คํจ ์ ๋ณด๊ฐ ๋ด๊ฒจ์์ด. ๐
๊ณ ๊ธ ๊ธฐ๋ฅ๋ค
Zod๋ ์ ๋ง ๋ค์ํ ๊ธฐ๋ฅ์ ์ ๊ณตํด. ๋ช ๊ฐ์ง ์ ์ฉํ ํจํด๋ค์ ์๊ฐํ ๊ฒ:
// 1. ์ ํ์ ํ๋์ ๊ธฐ๋ณธ๊ฐ
const profileSchema = z.object({
username: z.string(),
bio: z.string().optional(), // ์ ํ์ ํ๋
theme: z.enum(['light', 'dark']).default('light'), // ๊ธฐ๋ณธ๊ฐ
notifications: z.boolean().default(true)
});
// 2. ์กฐ๊ฑด๋ถ ๊ฒ์ฆ
const conditionalSchema = z.object({
accountType: z.enum(['personal', 'business']),
companyName: z.string().optional()
}).refine(data => {
// business ํ์
์ด๋ฉด companyName์ด ํ์
if (data.accountType === 'business') {
return data.companyName !== undefined && data.companyName.length > 0;
}
return true;
}, {
message: '์ฌ์
์ ๊ณ์ ์ ํ์ฌ๋ช
์ด ํ์์์',
path: ['companyName']
});
// 3. ๋ฐฐ์ด๊ณผ ์ค์ฒฉ ๊ฐ์ฒด
const orderSchema = z.object({
orderId: z.string().uuid(),
items: z.array(
z.object({
productId: z.string(),
quantity: z.number().positive(),
price: z.number().positive()
})
).min(1, '์ต์ 1๊ฐ ์ด์์ ์ํ์ด ํ์ํด์'),
shippingAddress: z.object({
street: z.string(),
city: z.string(),
zipCode: z.string().regex(/^\d{5}$/, '์ฐํธ๋ฒํธ๋ 5์๋ฆฌ ์ซ์์ฌ์ผ ํด์')
}),
totalAmount: z.number()
}).refine(data => {
// ์ด์ก์ด ์์ดํ
๊ฐ๊ฒฉ์ ํฉ๊ณผ ์ผ์นํ๋์ง ๊ฒ์ฆ
const calculatedTotal = data.items.reduce(
(sum, item) => sum + (item.price * item.quantity),
0
);
return Math.abs(calculatedTotal - data.totalAmount) < 0.01;
}, {
message: '์ด์ก์ด ์ผ์นํ์ง ์์์'
});
// 4. ๋ณํ(Transform)
const dateSchema = z.object({
birthDate: z.string().transform(str => new Date(str)),
registeredAt: z.string().datetime().transform(str => new Date(str))
});
// 5. ์ ๋์จ ํ์
const paymentSchema = z.discriminatedUnion('method', [
z.object({
method: z.literal('card'),
cardNumber: z.string().length(16),
cvv: z.string().length(3)
}),
z.object({
method: z.literal('bank'),
accountNumber: z.string(),
bankCode: z.string()
}),
z.object({
method: z.literal('paypal'),
email: z.string().email()
})
]);
๐ก ์ค์ ํ: safeParse ์ฌ์ฉํ๊ธฐ
parse ๋์ safeParse๋ฅผ ์ฌ์ฉํ๋ฉด ์์ธ๋ฅผ ๋์ง์ง ์๊ณ ๊ฒฐ๊ณผ ๊ฐ์ฒด๋ฅผ ๋ฐํํด. ์ด๊ฒ ๋ ํจ์ํ ํ๋ก๊ทธ๋๋ฐ ์คํ์ผ์ด๊ณ , ์๋ฌ ํธ๋ค๋ง๋ ๊น๋ํด์ ธ:
const result = userFormSchema.safeParse(formData);
if (result.success) {
// ํ์
์ด ์๋์ผ๋ก ์ขํ์ ธ!
const data = result.data; // UserForm ํ์
console.log('์ฑ๊ณต:', data);
} else {
// ์๋ฌ ์ฒ๋ฆฌ
const errors = result.error.errors;
console.log('์คํจ:', errors);
}
๐ญ Yup์ผ๋ก ํผ ๊ฒ์ฆํ๊ธฐ
Yup์ Zod๋ณด๋ค ๋จผ์ ๋์จ ๋ผ์ด๋ธ๋ฌ๋ฆฌ์ผ. Formik์ด๋ React Hook Form ๊ฐ์ ํผ ๋ผ์ด๋ธ๋ฌ๋ฆฌ๋ค๊ณผ ๊ถํฉ์ด ์ ๋ง ์ข์์ ์์ง๋ ๋ง์ด ์ฐ์ด๊ณ ์์ด. ๐ช
Yup ์์ํ๊ธฐ
npm install yup
# ํ์
์คํฌ๋ฆฝํธ ํ์
์ ์๋ ํจ๊ป ์ค์น๋ผ
Yup์ ๊ธฐ๋ณธ ์ฌ์ฉ๋ฒ์ ๋ณผ๊น?
import * as yup from 'yup';
// ์คํค๋ง ์ ์
const userFormSchema = yup.object({
name: yup.string()
.min(2, '์ด๋ฆ์ ์ต์ 2๊ธ์ ์ด์์ด์ด์ผ ํด์')
.required('์ด๋ฆ์ ํ์์์'),
email: yup.string()
.email('์ฌ๋ฐ๋ฅธ ์ด๋ฉ์ผ ํ์์ด ์๋์์')
.required('์ด๋ฉ์ผ์ ํ์์์'),
age: yup.number()
.min(18, '18์ธ ์ด์๋ง ๊ฐ์
ํ ์ ์์ด์')
.max(100)
.required('๋์ด๋ ํ์์์'),
password: yup.string()
.min(8, '๋น๋ฐ๋ฒํธ๋ ์ต์ 8์ ์ด์์ด์ด์ผ ํด์')
.matches(/[A-Z]/, '๋๋ฌธ์๋ฅผ ์ต์ 1๊ฐ ํฌํจํด์ผ ํด์')
.matches(/[0-9]/, '์ซ์๋ฅผ ์ต์ 1๊ฐ ํฌํจํด์ผ ํด์')
.required('๋น๋ฐ๋ฒํธ๋ ํ์์์'),
agreeToTerms: yup.boolean()
.oneOf([true], '์ฝ๊ด์ ๋์ํด์ผ ํด์')
.required()
});
// ํ์
์ถ๋ก
type UserForm = yup.InferType<typeof userFormSchema>;
// ๊ฒ์ฆ
async function validateForm(data: unknown) {
try {
const validatedData = await userFormSchema.validate(data, {
abortEarly: false // ๋ชจ๋ ์๋ฌ๋ฅผ ํ๋ฒ์ ์์ง
});
console.log('๊ฒ์ฆ ์ฑ๊ณต:', validatedData);
return validatedData;
} catch (error) {
if (error instanceof yup.ValidationError) {
console.error('๊ฒ์ฆ ์คํจ:', error.errors);
// error.inner์ ๋ชจ๋ ๊ฒ์ฆ ์๋ฌ๊ฐ ๋ด๊ฒจ์์ด
const formattedErrors = error.inner.map(err => ({
field: err.path,
message: err.message
}));
return formattedErrors;
}
}
}
Zod์ ๋น์ทํ์ง? ํ์ง๋ง ๋ช ๊ฐ์ง ์ฐจ์ด์ ์ด ์์ด:
๐ Zod vs Yup ์ฃผ์ ์ฐจ์ด์
1. ๋น๋๊ธฐ ์ฒ๋ฆฌ
โข Yup: validate ๋ฉ์๋๊ฐ Promise๋ฅผ ๋ฐํ (async/await ํ์)
โข Zod: ๋๊ธฐ์ ์ผ๋ก ์๋ (๋ ๋น ๋ฆ)
2. ํ์
์ถ๋ก
โข Yup: InferType ์ฌ์ฉ, ํ์
์ถ๋ก ์ด ์ฝ๊ฐ ๋ ์ ํํ ์ ์์
โข Zod: z.infer ์ฌ์ฉ, ๋ ์ ํํ ํ์
์ถ๋ก
3. ์๋ฌ ์ฒ๋ฆฌ
โข Yup: abortEarly ์ต์
์ผ๋ก ์ ์ด
โข Zod: ๊ธฐ๋ณธ์ ์ผ๋ก ๋ชจ๋ ์๋ฌ ์์ง
4. ๋ฒ๋ค ์ฌ์ด์ฆ
โข Yup: ์ฝ๊ฐ ๋ ํผ (~15KB)
โข Zod: ๋ ์์ (~8KB)
Yup์ ๊ฐ๋ ฅํ ๊ธฐ๋ฅ๋ค
// 1. ์กฐ๊ฑด๋ถ ๊ฒ์ฆ (when)
const conditionalSchema = yup.object({
accountType: yup.string().oneOf(['personal', 'business']).required(),
companyName: yup.string().when('accountType', {
is: 'business',
then: schema => schema.required('์ฌ์
์๋ ํ์ฌ๋ช
์ด ํ์์์'),
otherwise: schema => schema.notRequired()
})
});
// 2. ์ปค์คํ
๊ฒ์ฆ
const customSchema = yup.object({
username: yup.string()
.test('unique-username', '์ด๋ฏธ ์ฌ์ฉ์ค์ธ ์์ด๋์์', async (value) => {
if (!value) return false;
// API ํธ์ถ๋ก ์ค๋ณต ์ฒดํฌ
const isAvailable = await checkUsernameAvailability(value);
return isAvailable;
})
.required()
});
// 3. ์ฐธ์กฐ(Reference)๋ฅผ ์ด์ฉํ ๊ฒ์ฆ
const passwordSchema = yup.object({
password: yup.string()
.min(8)
.required('๋น๋ฐ๋ฒํธ๋ฅผ ์
๋ ฅํด์ฃผ์ธ์'),
confirmPassword: yup.string()
.oneOf([yup.ref('password')], '๋น๋ฐ๋ฒํธ๊ฐ ์ผ์นํ์ง ์์์')
.required('๋น๋ฐ๋ฒํธ ํ์ธ์ ์
๋ ฅํด์ฃผ์ธ์')
});
// 4. ๋ฐฐ์ด ๊ฒ์ฆ
const tagsSchema = yup.object({
tags: yup.array()
.of(yup.string().min(2).max(20))
.min(1, '์ต์ 1๊ฐ์ ํ๊ทธ๊ฐ ํ์ํด์')
.max(5, '์ต๋ 5๊ฐ๊น์ง๋ง ๊ฐ๋ฅํด์')
.required()
});
// 5. ์ค์ฒฉ ๊ฐ์ฒด์ ๋ฐฐ์ด
const complexSchema = yup.object({
user: yup.object({
profile: yup.object({
firstName: yup.string().required(),
lastName: yup.string().required()
})
}),
posts: yup.array().of(
yup.object({
title: yup.string().required(),
content: yup.string().required(),
tags: yup.array().of(yup.string())
})
)
});
๐ฏ React Hook Form๊ณผ์ ํตํฉ
์, ์ด์ ์ค์ ์ด์ผ! React์์ ํผ์ ๋ค๋ฃฐ ๋ ๊ฐ์ฅ ์ธ๊ธฐ์๋ ๋ผ์ด๋ธ๋ฌ๋ฆฌ์ธ React Hook Form๊ณผ ํจ๊ป ์ฌ์ฉํ๋ ๋ฐฉ๋ฒ์ ์์๋ณด์. ์ด ์กฐํฉ์ ์ ๋ง ํ์์ ์ด์ผ! ๐
Zod + React Hook Form
๋จผ์ ํ์ํ ํจํค์ง๋ค์ ์ค์นํ์:
npm install react-hook-form @hookform/resolvers zod
์ด์ ์ค์ ์ปดํฌ๋ํธ๋ฅผ ๋ง๋ค์ด๋ณผ๊ฒ:
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
// ์คํค๋ง ์ ์
const signupSchema = z.object({
username: z.string()
.min(3, '์์ด๋๋ 3๊ธ์ ์ด์์ด์ด์ผ ํด์')
.max(20, '์์ด๋๋ 20๊ธ์ ์ดํ์ฌ์ผ ํด์')
.regex(/^[a-zA-Z0-9_]+$/, '์๋ฌธ, ์ซ์, ์ธ๋์ค์ฝ์ด๋ง ์ฌ์ฉ ๊ฐ๋ฅํด์'),
email: z.string()
.email('์ฌ๋ฐ๋ฅธ ์ด๋ฉ์ผ ํ์์ด ์๋์์'),
password: z.string()
.min(8, '๋น๋ฐ๋ฒํธ๋ 8๊ธ์ ์ด์์ด์ด์ผ ํด์')
.regex(/[A-Z]/, '๋๋ฌธ์๋ฅผ ํฌํจํด์ผ ํด์')
.regex(/[a-z]/, '์๋ฌธ์๋ฅผ ํฌํจํด์ผ ํด์')
.regex(/[0-9]/, '์ซ์๋ฅผ ํฌํจํด์ผ ํด์'),
confirmPassword: z.string(),
age: z.number()
.min(18, '18์ธ ์ด์๋ง ๊ฐ์
๊ฐ๋ฅํด์')
.max(120, '์ฌ๋ฐ๋ฅธ ๋์ด๋ฅผ ์
๋ ฅํด์ฃผ์ธ์'),
terms: z.boolean()
.refine(val => val === true, '์ฝ๊ด์ ๋์ํด์ฃผ์ธ์')
}).refine(data => data.password === data.confirmPassword, {
message: '๋น๋ฐ๋ฒํธ๊ฐ ์ผ์นํ์ง ์์์',
path: ['confirmPassword']
});
// ํ์
์ถ๋ก
type SignupForm = z.infer<typeof signupSchema>;
function SignupForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
reset
} = useForm<SignupForm>({
resolver: zodResolver(signupSchema),
mode: 'onBlur' // ํฌ์ปค์ค๋ฅผ ๋ฒ์ด๋ ๋ ๊ฒ์ฆ
});
const onSubmit = async (data: SignupForm) => {
try {
console.log('ํผ ๋ฐ์ดํฐ:', data);
// API ํธ์ถ
await fetch('/api/signup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
alert('ํ์๊ฐ์
์ฑ๊ณต! ๐');
reset(); // ํผ ์ด๊ธฐํ
} catch (error) {
console.error('ํ์๊ฐ์
์คํจ:', error);
}
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<label htmlFor="username">์์ด๋</label>
<input
id="username"
type="text"
{...register('username')}
placeholder="์์ด๋๋ฅผ ์
๋ ฅํ์ธ์"
/>
{errors.username && (
<span className="error">{errors.username.message}</span>
)}
</div>
<div>
<label htmlFor="email">์ด๋ฉ์ผ</label>
<input
id="email"
type="email"
{...register('email')}
placeholder="email@example.com"
/>
{errors.email && (
<span className="error">{errors.email.message}</span>
)}
</div>
<div>
<label htmlFor="password">๋น๋ฐ๋ฒํธ</label>
<input
id="password"
type="password"
{...register('password')}
placeholder="๋น๋ฐ๋ฒํธ๋ฅผ ์
๋ ฅํ์ธ์"
/>
{errors.password && (
<span className="error">{errors.password.message}</span>
)}
</div>
<div>
<label htmlFor="confirmPassword">๋น๋ฐ๋ฒํธ ํ์ธ</label>
<input
id="confirmPassword"
type="password"
{...register('confirmPassword')}
placeholder="๋น๋ฐ๋ฒํธ๋ฅผ ๋ค์ ์
๋ ฅํ์ธ์"
/>
{errors.confirmPassword && (
<span className="error">{errors.confirmPassword.message}</span>
)}
</div>
<div>
<label htmlFor="age">๋์ด</label>
<input
id="age"
type="number"
{...register('age', { valueAsNumber: true })}
placeholder="๋์ด๋ฅผ ์
๋ ฅํ์ธ์"
/>
{errors.age && (
<span className="error">{errors.age.message}</span>
)}
</div>
<div>
<label>
<input
type="checkbox"
{...register('terms')}
/>
์ด์ฉ์ฝ๊ด์ ๋์ํฉ๋๋ค
</label>
{errors.terms && (
<span className="error">{errors.terms.message}</span>
)}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? '์ฒ๋ฆฌ์ค...' : 'ํ์๊ฐ์
'}
</button>
</form>
);
}
๐จ ์ด ์ฝ๋์ ๋ฉ์ง ์ ๋ค
1. ์๋ฒฝํ ํ์
์์ ์ฑ: register ํจ์์ ์๋ชป๋ ํ๋๋ช
์ ๋ฃ์ผ๋ฉด ํ์
์๋ฌ๊ฐ ๋ฐ์ํด
2. ์๋ ์์ฑ: errors ๊ฐ์ฒด์์ ํ๋๋ช
์ ์
๋ ฅํ๋ฉด ์๋์์ฑ์ด ๋ผ
3. ์ค์๊ฐ ๊ฒ์ฆ: mode ์ต์
์ผ๋ก ๊ฒ์ฆ ์์ ์ ์ ์ดํ ์ ์์ด
4. ์๋ฌ ๋ฉ์์ง: ์คํค๋ง์ ์ ์ํ ๋ฉ์์ง๊ฐ ๊ทธ๋๋ก ํ์๋ผ
Yup + React Hook Form
Yup๋ ๋๊ฐ์ด ์ฌ์ฉํ ์ ์์ด:
import { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import * as yup from 'yup';
const signupSchema = yup.object({
username: yup.string()
.min(3, '์์ด๋๋ 3๊ธ์ ์ด์์ด์ด์ผ ํด์')
.max(20, '์์ด๋๋ 20๊ธ์ ์ดํ์ฌ์ผ ํด์')
.matches(/^[a-zA-Z0-9_]+$/, '์๋ฌธ, ์ซ์, ์ธ๋์ค์ฝ์ด๋ง ์ฌ์ฉ ๊ฐ๋ฅํด์')
.required('์์ด๋๋ฅผ ์
๋ ฅํด์ฃผ์ธ์'),
email: yup.string()
.email('์ฌ๋ฐ๋ฅธ ์ด๋ฉ์ผ ํ์์ด ์๋์์')
.required('์ด๋ฉ์ผ์ ์
๋ ฅํด์ฃผ์ธ์'),
password: yup.string()
.min(8, '๋น๋ฐ๋ฒํธ๋ 8๊ธ์ ์ด์์ด์ด์ผ ํด์')
.matches(/[A-Z]/, '๋๋ฌธ์๋ฅผ ํฌํจํด์ผ ํด์')
.matches(/[a-z]/, '์๋ฌธ์๋ฅผ ํฌํจํด์ผ ํด์')
.matches(/[0-9]/, '์ซ์๋ฅผ ํฌํจํด์ผ ํด์')
.required('๋น๋ฐ๋ฒํธ๋ฅผ ์
๋ ฅํด์ฃผ์ธ์'),
confirmPassword: yup.string()
.oneOf([yup.ref('password')], '๋น๋ฐ๋ฒํธ๊ฐ ์ผ์นํ์ง ์์์')
.required('๋น๋ฐ๋ฒํธ ํ์ธ์ ์
๋ ฅํด์ฃผ์ธ์'),
age: yup.number()
.min(18, '18์ธ ์ด์๋ง ๊ฐ์
๊ฐ๋ฅํด์')
.max(120, '์ฌ๋ฐ๋ฅธ ๋์ด๋ฅผ ์
๋ ฅํด์ฃผ์ธ์')
.required('๋์ด๋ฅผ ์
๋ ฅํด์ฃผ์ธ์'),
terms: yup.boolean()
.oneOf([true], '์ฝ๊ด์ ๋์ํด์ฃผ์ธ์')
.required()
});
type SignupForm = yup.InferType<typeof signupSchema>;
function SignupForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting }
} = useForm<SignupForm>({
resolver: yupResolver(signupSchema)
});
// ๋๋จธ์ง๋ Zod์ ๋์ผ!
}
๋ณด๋ค์ํผ ์ฌ์ฉ๋ฒ์ด ๊ฑฐ์ ๋๊ฐ์. resolver๋ง ๋ฐ๊ฟ์ฃผ๋ฉด ๋ผ! ๐
๐ฅ ๊ณ ๊ธ ํจํด๊ณผ ์ค์ ํ
์ด์ ์ข ๋ ๊ณ ๊ธ ๊ธฐ์ ๋ค์ ์์๋ณด์. ์ค์ ํ๋ก์ ํธ์์ ์ ๋ง ์ ์ฉํ ํจํด๋ค์ด์ผ! ๐ช
1. ๋์ ํผ ์คํค๋ง
๋๋ก๋ ์ฌ์ฉ์์ ์ ํ์ ๋ฐ๋ผ ํผ ํ๋๊ฐ ๋ฌ๋ผ์ ธ์ผ ํ ๋๊ฐ ์์ด. ์ด๋ด ๋๋ ๋์ ์ผ๋ก ์คํค๋ง๋ฅผ ์์ฑํ๋ฉด ๋ผ:
import { z } from 'zod';
type AccountType = 'personal' | 'business';
function createAccountSchema(accountType: AccountType) {
const baseSchema = z.object({
accountType: z.enum(['personal', 'business']),
email: z.string().email(),
password: z.string().min(8)
});
if (accountType === 'business') {
return baseSchema.extend({
companyName: z.string().min(2, 'ํ์ฌ๋ช
์ ์
๋ ฅํด์ฃผ์ธ์'),
businessNumber: z.string()
.regex(/^\d{10}$/, '์ฌ์
์๋ฑ๋ก๋ฒํธ๋ 10์๋ฆฌ ์ซ์์์'),
companyAddress: z.string().min(5, 'ํ์ฌ ์ฃผ์๋ฅผ ์
๋ ฅํด์ฃผ์ธ์')
});
}
return baseSchema.extend({
firstName: z.string().min(1, '์ด๋ฆ์ ์
๋ ฅํด์ฃผ์ธ์'),
lastName: z.string().min(1, '์ฑ์ ์
๋ ฅํด์ฃผ์ธ์'),
birthDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'YYYY-MM-DD ํ์์ผ๋ก ์
๋ ฅํด์ฃผ์ธ์')
});
}
// ์ฌ์ฉ
function DynamicForm() {
const [accountType, setAccountType] = useState<AccountType>('personal');
const schema = useMemo(() => createAccountSchema(accountType), [accountType]);
const { register, handleSubmit } = useForm({
resolver: zodResolver(schema)
});
// ...
}
2. ๋น๋๊ธฐ ๊ฒ์ฆ (์๋ฒ ์ค๋ณต ์ฒดํฌ)
์์ด๋๋ ์ด๋ฉ์ผ ์ค๋ณต ์ฒดํฌ์ฒ๋ผ ์๋ฒ์ ํ์ธํด์ผ ํ๋ ๊ฒฝ์ฐ๊ฐ ์์ง? ์ด๋ ๊ฒ ์ฒ๋ฆฌํ ์ ์์ด:
// Zod ๋ฐฉ์
const usernameSchema = z.string()
.min(3)
.refine(async (username) => {
// ๋๋ฐ์ด์ฑ์ ์ํด ์ฝ๊ฐ์ ๋๋ ์ด
await new Promise(resolve => setTimeout(resolve, 300));
const response = await fetch(`/api/check-username?username=${username}`);
const { available } = await response.json();
return available;
}, {
message: '์ด๋ฏธ ์ฌ์ฉ์ค์ธ ์์ด๋์์'
});
// React Hook Form์์ ์ฌ์ฉ
function UsernameInput() {
const { register, formState: { errors } } = useForm({
resolver: zodResolver(z.object({ username: usernameSchema })),
mode: 'onBlur' // ํฌ์ปค์ค๋ฅผ ๋ฒ์ด๋ ๋๋ง ๊ฒ์ฆ
});
return (
<div>
<input {...register('username')} />
{errors.username && <span>{errors.username.message}</span>}
</div>
);
}
โ ๏ธ ์ฃผ์์ฌํญ
๋น๋๊ธฐ ๊ฒ์ฆ์ ์ฑ๋ฅ์ ์ํฅ์ ์ค ์ ์์ด. ๊ผญ ํ์ํ ๊ฒฝ์ฐ์๋ง ์ฌ์ฉํ๊ณ , ๋๋ฐ์ด์ฑ์ ์ ์ฉํด์ ๋ถํ์ํ API ํธ์ถ์ ์ค์ด์!
3. ๋ค๋จ๊ณ ํผ (Multi-step Form)
ํ์๊ฐ์ ์ด๋ ์ค๋ฌธ์กฐ์ฌ์ฒ๋ผ ์ฌ๋ฌ ๋จ๊ณ๋ก ๋๋ ํผ์ ๋ง๋ค ๋๋ ์ด๋ ๊ฒ ํ ์ ์์ด:
import { z } from 'zod';
// ๊ฐ ๋จ๊ณ๋ณ ์คํค๋ง
const step1Schema = z.object({
email: z.string().email(),
password: z.string().min(8)
});
const step2Schema = z.object({
firstName: z.string().min(1),
lastName: z.string().min(1),
birthDate: z.string()
});
const step3Schema = z.object({
address: z.string().min(5),
city: z.string().min(2),
zipCode: z.string().regex(/^\d{5}$/)
});
// ์ ์ฒด ์คํค๋ง
const fullSchema = step1Schema.merge(step2Schema).merge(step3Schema);
type FullForm = z.infer<typeof fullSchema>;
function MultiStepForm() {
const [step, setStep] = useState(1);
const [formData, setFormData] = useState<Partial<FullForm>>({});
const getCurrentSchema = () => {
switch(step) {
case 1: return step1Schema;
case 2: return step2Schema;
case 3: return step3Schema;
default: return step1Schema;
}
};
const { register, handleSubmit, formState: { errors } } = useForm({
resolver: zodResolver(getCurrentSchema()),
defaultValues: formData
});
const onSubmit = (data: any) => {
const updatedData = { ...formData, ...data };
setFormData(updatedData);
if (step < 3) {
setStep(step + 1);
} else {
// ์ต์ข
์ ์ถ
console.log('์ต์ข
๋ฐ์ดํฐ:', updatedData);
submitToServer(updatedData);
}
};
return (
<div>
<h2>๋จ๊ณ {step} / 3</h2>
<form onSubmit={handleSubmit(onSubmit)}>
{step === 1 && (
<>
<input {...register('email')} placeholder="์ด๋ฉ์ผ" />
{errors.email && <span>{errors.email.message}</span>}
<input {...register('password')} type="password" placeholder="๋น๋ฐ๋ฒํธ" />
{errors.password && <span>{errors.password.message}</span>}
</>
)}
{step === 2 && (
<>
<input {...register('firstName')} placeholder="์ด๋ฆ" />
{errors.firstName && <span>{errors.firstName.message}</span>}
<input {...register('lastName')} placeholder="์ฑ" />
{errors.lastName && <span>{errors.lastName.message}</span>}
<input {...register('birthDate')} type="date" />
{errors.birthDate && <span>{errors.birthDate.message}</span>}
</>
)}
{step === 3 && (
<>
<input {...register('address')} placeholder="์ฃผ์" />
{errors.address && <span>{errors.address.message}</span>}
<input {...register('city')} placeholder="๋์" />
{errors.city && <span>{errors.city.message}</span>}
<input {...register('zipCode')} placeholder="์ฐํธ๋ฒํธ" />
{errors.zipCode && <span>{errors.zipCode.message}</span>}
</>
)}
<div>
{step > 1 && (
<button type="button" onClick={() => setStep(step - 1)}>
์ด์
</button>
)}
<button type="submit">
{step === 3 ? '์๋ฃ' : '๋ค์'}
</button>
</div>
</form>
</div>
);
}
4. ํ์ผ ์ ๋ก๋ ๊ฒ์ฆ
ํ์ผ ์ ๋ก๋๋ ๊ฒ์ฆํ ์ ์์ด:
const fileSchema = z.object({
avatar: z.custom<FileList>()
.refine(files => files?.length === 1, 'ํ์ผ์ ์ ํํด์ฃผ์ธ์')
.refine(
files => files?.[0]?.size <= 5 * 1024 * 1024,
'ํ์ผ ํฌ๊ธฐ๋ 5MB ์ดํ์ฌ์ผ ํด์'
)
.refine(
files => ['image/jpeg', 'image/png', 'image/webp'].includes(files?.[0]?.type),
'JPG, PNG, WEBP ํ์ผ๋ง ์
๋ก๋ ๊ฐ๋ฅํด์'
)
.transform(files => files?.[0])
});
function FileUploadForm() {
const { register, handleSubmit, formState: { errors } } = useForm({
resolver: zodResolver(z.object({ avatar: fileSchema }))
});
const onSubmit = async (data: any) => {
const formData = new FormData();
formData.append('avatar', data.avatar);
await fetch('/api/upload', {
method: 'POST',
body: formData
});
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input
type="file"
accept="image/*"
{...register('avatar')}
/>
{errors.avatar && <span>{errors.avatar.message}</span>}
<button type="submit">์
๋ก๋</button>
</form>
);
}
๐จ ์ค์ ํ๋ก์ ํธ ๊ตฌ์กฐ
์ด์ ์ค์ ํ๋ก์ ํธ์์ ์ด๋ป๊ฒ ๊ตฌ์กฐํํ๋ฉด ์ข์์ง ์์๋ณด์. ๊น๋ํ ๊ตฌ์กฐ๋ ์ ์ง๋ณด์๋ฅผ ํจ์ฌ ์ฝ๊ฒ ๋ง๋ค์ด์ค! ๐๏ธ
ํด๋ ๊ตฌ์กฐ
src/
โโโ schemas/
โ โโโ auth.schema.ts # ์ธ์ฆ ๊ด๋ จ ์คํค๋ง
โ โโโ user.schema.ts # ์ฌ์ฉ์ ๊ด๋ จ ์คํค๋ง
โ โโโ product.schema.ts # ์ํ ๊ด๋ จ ์คํค๋ง
โ โโโ index.ts # ์คํค๋ง export
โโโ types/
โ โโโ auth.types.ts # ์ธ์ฆ ํ์
โ โโโ user.types.ts # ์ฌ์ฉ์ ํ์
โ โโโ index.ts
โโโ components/
โ โโโ forms/
โ โ โโโ LoginForm.tsx
โ โ โโโ SignupForm.tsx
โ โ โโโ ProfileForm.tsx
โ โโโ common/
โ โโโ FormInput.tsx # ์ฌ์ฌ์ฉ ๊ฐ๋ฅํ ์
๋ ฅ ์ปดํฌ๋ํธ
โ โโโ FormError.tsx # ์๋ฌ ํ์ ์ปดํฌ๋ํธ
โโโ utils/
โโโ validation.ts # ๊ฒ์ฆ ์ ํธ๋ฆฌํฐ
์คํค๋ง ํ์ผ ์์
// schemas/auth.schema.ts
import { z } from 'zod';
export const loginSchema = z.object({
email: z.string()
.email('์ฌ๋ฐ๋ฅธ ์ด๋ฉ์ผ ํ์์ด ์๋์์')
.min(1, '์ด๋ฉ์ผ์ ์
๋ ฅํด์ฃผ์ธ์'),
password: z.string()
.min(1, '๋น๋ฐ๋ฒํธ๋ฅผ ์
๋ ฅํด์ฃผ์ธ์'),
rememberMe: z.boolean().optional()
});
export const signupSchema = z.object({
email: z.string().email('์ฌ๋ฐ๋ฅธ ์ด๋ฉ์ผ ํ์์ด ์๋์์'),
password: z.string()
.min(8, '๋น๋ฐ๋ฒํธ๋ 8๊ธ์ ์ด์์ด์ด์ผ ํด์')
.regex(/[A-Z]/, '๋๋ฌธ์๋ฅผ ํฌํจํด์ผ ํด์')
.regex(/[0-9]/, '์ซ์๋ฅผ ํฌํจํด์ผ ํด์'),
confirmPassword: z.string(),
username: z.string()
.min(3, '์์ด๋๋ 3๊ธ์ ์ด์์ด์ด์ผ ํด์')
.regex(/^[a-zA-Z0-9_]+$/, '์๋ฌธ, ์ซ์, ์ธ๋์ค์ฝ์ด๋ง ๊ฐ๋ฅํด์'),
agreeToTerms: z.boolean()
.refine(val => val === true, '์ฝ๊ด์ ๋์ํด์ฃผ์ธ์')
}).refine(data => data.password === data.confirmPassword, {
message: '๋น๋ฐ๋ฒํธ๊ฐ ์ผ์นํ์ง ์์์',
path: ['confirmPassword']
});
export const resetPasswordSchema = z.object({
email: z.string().email('์ฌ๋ฐ๋ฅธ ์ด๋ฉ์ผ ํ์์ด ์๋์์')
});
// schemas/index.ts
export * from './auth.schema';
export * from './user.schema';
export * from './product.schema';
ํ์ ํ์ผ ์์
// types/auth.types.ts
import { z } from 'zod';
import { loginSchema, signupSchema, resetPasswordSchema } from '@/schemas';
export type LoginForm = z.infer<typeof loginSchema>;
export type SignupForm = z.infer<typeof signupSchema>;
export type ResetPasswordForm = z.infer<typeof resetPasswordSchema>;
// API ์๋ต ํ์
export interface AuthResponse {
token: string;
user: {
id: string;
email: string;
username: string;
};
}
export interface AuthError {
message: string;
field?: string;
}
์ฌ์ฌ์ฉ ๊ฐ๋ฅํ ํผ ์ปดํฌ๋ํธ
// components/common/FormInput.tsx
import { UseFormRegister, FieldError } from 'react-hook-form';
interface FormInputProps {
label: string;
name: string;
type?: string;
placeholder?: string;
register: UseFormRegister<any>;
error?: FieldError;
required?: boolean;
}
export function FormInput({
label,
name,
type = 'text',
placeholder,
register,
error,
required = false
}: FormInputProps) {
return (
<div className="form-group">
<label htmlFor={name}>
{label}
{required && <span className="required">*</span>}
</label>
<input
id={name}
type={type}
placeholder={placeholder}
{...register(name)}
className={error ? 'error' : ''}
/>
{error && (
<span className="error-message">{error.message}</span>
)}
</div>
);
}
// components/common/FormError.tsx
interface FormErrorProps {
message?: string;
}
export function FormError({ message }: FormErrorProps) {
if (!message) return null;
return (
<div className="form-error">
<span>โ ๏ธ {message}</span>
</div>
);
}
์ค์ ํผ ์ปดํฌ๋ํธ
// components/forms/LoginForm.tsx
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { loginSchema } from '@/schemas';
import { LoginForm as LoginFormType } from '@/types';
import { FormInput, FormError } from '@/components/common';
export function LoginForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
setError
} = useForm<LoginFormType>({
resolver: zodResolver(loginSchema)
});
const onSubmit = async (data: LoginFormType) => {
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!response.ok) {
const error = await response.json();
setError('root', {
message: error.message || '๋ก๊ทธ์ธ์ ์คํจํ์ด์'
});
return;
}
const result = await response.json();
// ๋ก๊ทธ์ธ ์ฑ๊ณต ์ฒ๋ฆฌ
console.log('๋ก๊ทธ์ธ ์ฑ๊ณต:', result);
} catch (error) {
setError('root', {
message: '๋คํธ์ํฌ ์ค๋ฅ๊ฐ ๋ฐ์ํ์ด์'
});
}
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="login-form">
<h2>๋ก๊ทธ์ธ</h2>
<FormError message={errors.root?.message} />
<FormInput
label="์ด๋ฉ์ผ"
name="email"
type="email"
placeholder="email@example.com"
register={register}
error={errors.email}
required
/>
<FormInput
label="๋น๋ฐ๋ฒํธ"
name="password"
type="password"
placeholder="๋น๋ฐ๋ฒํธ๋ฅผ ์
๋ ฅํ์ธ์"
register={register}
error={errors.password}
required
/>
<div className="form-group">
<label>
<input type="checkbox" {...register('rememberMe')} />
๋ก๊ทธ์ธ ์ํ ์ ์ง
</label>
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? '๋ก๊ทธ์ธ ์ค...' : '๋ก๊ทธ์ธ'}
</button>
</form>
);
}
๐ก ๊ตฌ์กฐํ์ ์ฅ์
โข ์ฌ์ฌ์ฉ์ฑ: ์คํค๋ง์ ํ์
์ ์ฌ๋ฌ ๊ณณ์์ ์ฌ์ฌ์ฉ
โข ์ ์ง๋ณด์: ๊ฒ์ฆ ๋ก์ง์ด ํ ๊ณณ์ ๋ชจ์ฌ์์ด ์์ ์ด ์ฌ์
โข ์ผ๊ด์ฑ: ํ๋ก์ ํธ ์ ์ฒด์์ ๋์ผํ ํจํด ์ฌ์ฉ
โข ํ
์คํธ: ์คํค๋ง๋ฅผ ๋
๋ฆฝ์ ์ผ๋ก ํ
์คํธ ๊ฐ๋ฅ
๐ ์ฑ๋ฅ ์ต์ ํ ํ
ํผ ๊ฒ์ฆ์ ์ฌ์ฉ์ ๊ฒฝํ์ ์ง์ ์ ์ธ ์ํฅ์ ๋ฏธ์ณ. ๊ทธ๋์ ์ฑ๋ฅ ์ต์ ํ๊ฐ ์ ๋ง ์ค์ํด! ๋ช ๊ฐ์ง ํ์ ๊ณต์ ํ ๊ฒ. ๐ฏ
1. ๊ฒ์ฆ ๋ชจ๋ ์ ํ
// ์ต์
1: onChange - ์
๋ ฅํ ๋๋ง๋ค ๊ฒ์ฆ (๊ฐ์ฅ ๋น ๋ฅธ ํผ๋๋ฐฑ, ์ฑ๋ฅ ๋ถ๋ด)
useForm({
mode: 'onChange'
});
// ์ต์
2: onBlur - ํฌ์ปค์ค๋ฅผ ๋ฒ์ด๋ ๋ ๊ฒ์ฆ (๊ท ํ์กํ ์ ํ)
useForm({
mode: 'onBlur'
});
// ์ต์
3: onSubmit - ์ ์ถํ ๋๋ง ๊ฒ์ฆ (๊ฐ์ฅ ๊ฐ๋ฒผ์)
useForm({
mode: 'onSubmit'
});
// ์ต์
4: ํผํฉ - ์ฒซ ์ ์ถ ํ์๋ onChange๋ก ์ ํ
useForm({
mode: 'onSubmit',
reValidateMode: 'onChange'
});
๐ ๊ฐ ๋ชจ๋์ ํน์ง
onChange: ์ค์๊ฐ ํผ๋๋ฐฑ, ์ฌ์ฉ์ ๊ฒฝํ ์ต๊ณ , ์ฑ๋ฅ ๋ถ๋ด ์์
onBlur: ์ ์ ํ ๊ท ํ, ๋๋ถ๋ถ์ ๊ฒฝ์ฐ ์ถ์ฒ
onSubmit: ๊ฐ์ฅ ๊ฐ๋ฒผ์, ๊ฐ๋จํ ํผ์ ์ ํฉ
ํผํฉ: ์ต์ด์๋ ๊ฐ๋ณ๊ฒ, ์๋ฌ ๋ฐ์ ํ ์ ๊ทน์ ์ผ๋ก
2. ๋๋ฐ์ด์ฑ ์ ์ฉ
ํนํ ๋น๋๊ธฐ ๊ฒ์ฆ์ด๋ ๋ณต์กํ ๊ฒ์ฆ์๋ ๋๋ฐ์ด์ฑ์ด ํ์์ผ:
import { useDebounce } from 'use-debounce';
function SearchForm() {
const [searchTerm, setSearchTerm] = useState('');
const [debouncedSearchTerm] = useDebounce(searchTerm, 500);
useEffect(() => {
if (debouncedSearchTerm) {
// ๋๋ฐ์ด์ฑ๋ ๊ฐ์ผ๋ก ๊ฒ์
performSearch(debouncedSearchTerm);
}
}, [debouncedSearchTerm]);
return (
<input
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="๊ฒ์์ด๋ฅผ ์
๋ ฅํ์ธ์"
/>
);
}
// ๋๋ React Hook Form์ watch์ ํจ๊ป
function DebouncedValidation() {
const { watch, setValue } = useForm();
const username = watch('username');
const [debouncedUsername] = useDebounce(username, 500);
useEffect(() => {
if (debouncedUsername) {
checkUsernameAvailability(debouncedUsername);
}
}, [debouncedUsername]);
}
3. ์คํค๋ง ๋ฉ๋ชจ์ด์ ์ด์
๋์ ์คํค๋ง๋ฅผ ์ฌ์ฉํ ๋๋ useMemo๋ก ์ต์ ํํ์:
function DynamicForm({ userType }: { userType: string }) {
// โ ๋์ ์: ๋งค ๋ ๋๋ง๋ง๋ค ์ ์คํค๋ง ์์ฑ
const schema = createSchema(userType);
// โ
์ข์ ์: userType์ด ๋ณ๊ฒฝ๋ ๋๋ง ์ ์คํค๋ง ์์ฑ
const schema = useMemo(() => createSchema(userType), [userType]);
const { register, handleSubmit } = useForm({
resolver: zodResolver(schema)
});
// ...
}
4. ์กฐ๊ฑด๋ถ ํ๋ ์ต์ ํ
// โ ๋นํจ์จ์ : ๋ชจ๋ ํ๋๋ฅผ ํญ์ ๋ ๋๋ง
function IneffcientForm() {
const { register, watch } = useForm();
const accountType = watch('accountType');
return (
<>
<select {...register('accountType')}>
<option value="personal">๊ฐ์ธ</option>
<option value="business">์ฌ์
์</option>
</select>
{/* ํญ์ ๋ ๋๋ง๋์ง๋ง ์จ๊ฒจ์ง */}
<div style={{ display: accountType === 'business' ? 'block' : 'none' }}>
<input {...register('companyName')} />
</div>
</>
);
}
// โ
ํจ์จ์ : ํ์ํ ๋๋ง ๋ ๋๋ง
function EfficientForm() {
const { register, watch } = useForm();
const accountType = watch('accountType');
return (
<>
<select {...register('accountType')}>
<option value="personal">๊ฐ์ธ</option>
<option value="business">์ฌ์
์</option>
</select>
{/* ์กฐ๊ฑด๋ถ ๋ ๋๋ง */}
{accountType === 'business' && (
<input {...register('companyName')} />
)}
</>
);
}
5. ๋ฒ๋ค ์ฌ์ด์ฆ ์ต์ ํ
// โ ์ ์ฒด ๋ผ์ด๋ธ๋ฌ๋ฆฌ import
import * as yup from 'yup';
// โ
ํ์ํ ๊ฒ๋ง import (tree-shaking ๊ฐ๋ฅ)
import { object, string, number } from 'yup';
// Zod๋ ๊ธฐ๋ณธ์ ์ผ๋ก tree-shaking์ด ์ ๋ผ
import { z } from 'zod'; // ์ด๋ฏธ ์ต์ ํ๋์ด ์์
๐ ์ค์ ์์ : ์์ ํ ํ์๊ฐ์ ํผ
์, ์ด์ ๋ฐฐ์ด ๊ฑธ ๋ชจ๋ ํฉ์ณ์ ์์ ํ ํ์๊ฐ์ ํผ์ ๋ง๋ค์ด๋ณด์! ์ค์ ์์ ๋ฐ๋ก ์ธ ์ ์๋ ์์ค์ผ๋ก ๋ง๋ค ๊ฑฐ์ผ. ๐ช
// schemas/signup.schema.ts
import { z } from 'zod';
export const signupSchema = z.object({
// ๊ธฐ๋ณธ ์ ๋ณด
email: z.string()
.min(1, '์ด๋ฉ์ผ์ ์
๋ ฅํด์ฃผ์ธ์')
.email('์ฌ๋ฐ๋ฅธ ์ด๋ฉ์ผ ํ์์ด ์๋์์')
.refine(async (email) => {
// ์ด๋ฉ์ผ ์ค๋ณต ์ฒดํฌ
const response = await fetch(`/api/check-email?email=${email}`);
const { available } = await response.json();
return available;
}, '์ด๋ฏธ ์ฌ์ฉ์ค์ธ ์ด๋ฉ์ผ์ด์์'),
username: z.string()
.min(3, '์์ด๋๋ 3๊ธ์ ์ด์์ด์ด์ผ ํด์')
.max(20, '์์ด๋๋ 20๊ธ์ ์ดํ์ฌ์ผ ํด์')
.regex(/^[a-zA-Z0-9_]+$/, '์๋ฌธ, ์ซ์, ์ธ๋์ค์ฝ์ด๋ง ์ฌ์ฉ ๊ฐ๋ฅํด์')
.refine(async (username) => {
const response = await fetch(`/api/check-username?username=${username}`);
const { available } = await response.json();
return available;
}, '์ด๋ฏธ ์ฌ์ฉ์ค์ธ ์์ด๋์์'),
password: z.string()
.min(8, '๋น๋ฐ๋ฒํธ๋ 8๊ธ์ ์ด์์ด์ด์ผ ํด์')
.max(100, '๋น๋ฐ๋ฒํธ๊ฐ ๋๋ฌด ๊ธธ์ด์')
.regex(/[A-Z]/, '๋๋ฌธ์๋ฅผ ์ต์ 1๊ฐ ํฌํจํด์ผ ํด์')
.regex(/[a-z]/, '์๋ฌธ์๋ฅผ ์ต์ 1๊ฐ ํฌํจํด์ผ ํด์')
.regex(/[0-9]/, '์ซ์๋ฅผ ์ต์ 1๊ฐ ํฌํจํด์ผ ํด์')
.regex(/[^A-Za-z0-9]/, 'ํน์๋ฌธ์๋ฅผ ์ต์ 1๊ฐ ํฌํจํด์ผ ํด์'),
confirmPassword: z.string()
.min(1, '๋น๋ฐ๋ฒํธ ํ์ธ์ ์
๋ ฅํด์ฃผ์ธ์'),
// ๊ฐ์ธ ์ ๋ณด
firstName: z.string()
.min(1, '์ด๋ฆ์ ์
๋ ฅํด์ฃผ์ธ์')
.max(50, '์ด๋ฆ์ด ๋๋ฌด ๊ธธ์ด์'),
lastName: z.string()
.min(1, '์ฑ์ ์
๋ ฅํด์ฃผ์ธ์')
.max(50, '์ฑ์ด ๋๋ฌด ๊ธธ์ด์'),
birthDate: z.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, 'YYYY-MM-DD ํ์์ผ๋ก ์
๋ ฅํด์ฃผ์ธ์')
.refine((date) => {
const birthDate = new Date(date);
const today = new Date();
const age = today.getFullYear() - birthDate.getFullYear();
return age >= 18;
}, '18์ธ ์ด์๋ง ๊ฐ์
ํ ์ ์์ด์'),
phone: z.string()
.regex(/^010-\d{4}-\d{4}$/, '010-0000-0000 ํ์์ผ๋ก ์
๋ ฅํด์ฃผ์ธ์')
.optional(),
// ์ฝ๊ด ๋์
agreeToTerms: z.boolean()
.refine(val => val === true, '์ด์ฉ์ฝ๊ด์ ๋์ํด์ฃผ์ธ์'),
agreeToPrivacy: z.boolean()
.refine(val => val === true, '๊ฐ์ธ์ ๋ณด ์ฒ๋ฆฌ๋ฐฉ์นจ์ ๋์ํด์ฃผ์ธ์'),
agreeToMarketing: z.boolean()
.optional()
}).refine(data => data.password === data.confirmPassword, {
message: '๋น๋ฐ๋ฒํธ๊ฐ ์ผ์นํ์ง ์์์',
path: ['confirmPassword']
});
export type SignupFormData = z.infer<typeof signupSchema>;
// components/SignupForm.tsx
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { signupSchema, SignupFormData } from '@/schemas/signup.schema';
export function SignupForm() {
const [step, setStep] = useState(1);
const [isSuccess, setIsSuccess] = useState(false);
const {
register,
handleSubmit,
watch,
formState: { errors, isSubmitting, isValid },
trigger
} = useForm<SignupFormData>({
resolver: zodResolver(signupSchema),
mode: 'onBlur'
});
const password = watch('password');
// ๋น๋ฐ๋ฒํธ ๊ฐ๋ ๊ณ์ฐ
const getPasswordStrength = (pwd: string): number => {
if (!pwd) return 0;
let strength = 0;
if (pwd.length >= 8) strength++;
if (/[A-Z]/.test(pwd)) strength++;
if (/[a-z]/.test(pwd)) strength++;
if (/[0-9]/.test(pwd)) strength++;
if (/[^A-Za-z0-9]/.test(pwd)) strength++;
return strength;
};
const passwordStrength = getPasswordStrength(password);
const onSubmit = async (data: SignupFormData) => {
try {
const response = await fetch('/api/auth/signup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!response.ok) {
throw new Error('ํ์๊ฐ์
์ ์คํจํ์ด์');
}
setIsSuccess(true);
} catch (error) {
console.error('ํ์๊ฐ์
์ค๋ฅ:', error);
alert('ํ์๊ฐ์
์ค ์ค๋ฅ๊ฐ ๋ฐ์ํ์ด์. ๋ค์ ์๋ํด์ฃผ์ธ์.');
}
};
const handleNextStep = async () => {
const fieldsToValidate = step === 1
? ['email', 'username', 'password', 'confirmPassword']
: ['firstName', 'lastName', 'birthDate', 'phone'];
const isStepValid = await trigger(fieldsToValidate as any);
if (isStepValid) {
setStep(step + 1);
}
};
if (isSuccess) {
return (
<div className="success-message">
<h2>๐ ํ์๊ฐ์
์๋ฃ!</h2>
<p>ํ์ํฉ๋๋ค! ์ด๋ฉ์ผ์ ํ์ธํด์ฃผ์ธ์.</p>
</div>
);
}
return (
<div className="signup-container">
<div className="progress-bar">
<div
className="progress"
style={{ width: `${(step / 3) * 100}%` }}
/>
</div>
<h2>ํ์๊ฐ์
- {step}/3</h2>
<form onSubmit={handleSubmit(onSubmit)}>
{/* Step 1: ๊ณ์ ์ ๋ณด */}
{step === 1 && (
<div className="form-step">
<h3>๊ณ์ ์ ๋ณด</h3>
<div className="form-group">
<label htmlFor="email">์ด๋ฉ์ผ *</label>
<input
id="email"
type="email"
{...register('email')}
placeholder="email@example.com"
/>
{errors.email && (
<span className="error">{errors.email.message}</span>
)}
</div>
<div className="form-group">
<label htmlFor="username">์์ด๋ *</label>
<input
id="username"
type="text"
{...register('username')}
placeholder="3-20์์ ์๋ฌธ, ์ซ์, ์ธ๋์ค์ฝ์ด"
/>
{errors.username && (
<span className="error">{errors.username.message}</span>
)}
</div>
<div className="form-group">
<label htmlFor="password">๋น๋ฐ๋ฒํธ *</label>
<input
id="password"
type="password"
{...register('password')}
placeholder="8์ ์ด์, ๋์๋ฌธ์, ์ซ์, ํน์๋ฌธ์ ํฌํจ"
/>
{password && (
<div className="password-strength">
<div className="strength-bar">
<div
className={`strength-fill strength-${passwordStrength}`}
style={{ width: `${(passwordStrength / 5) * 100}%` }}
/>
</div>
<span className="strength-text">
{passwordStrength <= 2 && '์ฝํจ'}
{passwordStrength === 3 && '๋ณดํต'}
{passwordStrength === 4 && '๊ฐํจ'}
{passwordStrength === 5 && '๋งค์ฐ ๊ฐํจ'}
</span>
</div>
)}
{errors.password && (
<span className="error">{errors.password.message}</span>
)}
</div>
<div className="form-group">
<label htmlFor="confirmPassword">๋น๋ฐ๋ฒํธ ํ์ธ *</label>
<input
id="confirmPassword"
type="password"
{...register('confirmPassword')}
placeholder="๋น๋ฐ๋ฒํธ๋ฅผ ๋ค์ ์
๋ ฅํ์ธ์"
/>
{errors.confirmPassword && (
<span className="error">{errors.confirmPassword.message}</span>
)}
</div>
</div>
)}
{/* Step 2: ๊ฐ์ธ ์ ๋ณด */}
{step === 2 && (
<div className="form-step">
<h3>๊ฐ์ธ ์ ๋ณด</h3>
<div className="form-row">
<div className="form-group">
<label htmlFor="lastName">์ฑ *</label>
<input
id="lastName"
type="text"
{...register('lastName')}
placeholder="ํ"
/>
{errors.lastName && (
<span className="error">{errors.lastName.message}</span>
)}
</div>
<div className="form-group">
<label htmlFor="firstName">์ด๋ฆ *</label>
<input
id="firstName"
type="text"
{...register('firstName')}
placeholder="๊ธธ๋"
/>
{errors.firstName && (
<span className="error">{errors.firstName.message}</span>
)}
</div>
</div>
<div className="form-group">
<label htmlFor="birthDate">์๋
์์ผ *</label>
<input
id="birthDate"
type="date"
{...register('birthDate')}
/>
{errors.birthDate && (
<span className="error">{errors.birthDate.message}</span>
)}
</div>
<div className="form-group">
<label htmlFor="phone">ํด๋ํฐ ๋ฒํธ</label>
<input
id="phone"
type="tel"
{...register('phone')}
placeholder="010-0000-0000"
/>
{errors.phone && (
<span className="error">{errors.phone.message}</span>
)}
</div>
</div>
)}
{/* Step 3: ์ฝ๊ด ๋์ */}
{step === 3 && (
<div className="form-step">
<h3>์ฝ๊ด ๋์</h3>
<div className="terms-group">
<label className="checkbox-label">
<input
type="checkbox"
{...register('agreeToTerms')}
/>
<span>[ํ์] ์ด์ฉ์ฝ๊ด์ ๋์ํฉ๋๋ค</span>
</label>
{errors.agreeToTerms && (
<span className="error">{errors.agreeToTerms.message}</span>
)}
</div>
<div className="terms-group">
<label className="checkbox-label">
<input
type="checkbox"
{...register('agreeToPrivacy')}
/>
<span>[ํ์] ๊ฐ์ธ์ ๋ณด ์ฒ๋ฆฌ๋ฐฉ์นจ์ ๋์ํฉ๋๋ค</span>
</label>
{errors.agreeToPrivacy && (
<span className="error">{errors.agreeToPrivacy.message}</span>
)}
</div>
<div className="terms-group">
<label className="checkbox-label">
<input
type="checkbox"
{...register('agreeToMarketing')}
/>
<span>[์ ํ] ๋ง์ผํ
์ ๋ณด ์์ ์ ๋์ํฉ๋๋ค</span>
</label>
</div>
</div>
)}
{/* ๋ฒํผ */}
<div className="form-actions">
{step > 1 && (
<button
type="button"
onClick={() => setStep(step - 1)}
className="btn-secondary"
>
์ด์
</button>
)}
{step < 3 ? (
<button
type="button"
onClick={handleNextStep}
className="btn-primary"
>
๋ค์
</button>
) : (
<button
type="submit"
disabled={isSubmitting}
className="btn-primary"
>
{isSubmitting ? '์ฒ๋ฆฌ์ค...' : '๊ฐ์
ํ๊ธฐ'}
</button>
)}
</div>
</form>
</div>
);
}
์ด ์์ ๋ ์ค์ ์์ ํ์ํ ๊ฑฐ์ ๋ชจ๋ ๊ธฐ๋ฅ์ ํฌํจํ๊ณ ์์ด:
โจ ํฌํจ๋ ๊ธฐ๋ฅ๋ค
โข ๋ค๋จ๊ณ ํผ (3๋จ๊ณ)
โข ์ค์๊ฐ ๊ฒ์ฆ (onBlur ๋ชจ๋)
โข ๋น๋๊ธฐ ์ค๋ณต ์ฒดํฌ (์ด๋ฉ์ผ, ์์ด๋)
โข ๋น๋ฐ๋ฒํธ ๊ฐ๋ ํ์
โข ๋น๋ฐ๋ฒํธ ์ผ์น ํ์ธ
โข ์งํ ์ํ ํ์
โข ๋จ๊ณ๋ณ ๊ฒ์ฆ
โข ์๋ฌ ๋ฉ์์ง ํ์
โข ๋ก๋ฉ ์ํ ๊ด๋ฆฌ
โข ์ฑ๊ณต ๋ฉ์์ง
๐ช ์ฌ๋ฅ๋ท์์ ๋ฐฐ์ฐ๋ ์ค์ ํ
์ฌ๊ธฐ์ ์ ๊น! ํน์ ํ์ ์คํฌ๋ฆฝํธ๋ ํผ ์ฒ๋ฆฌ์ ๋ํด ๋ ๊น์ด ๋ฐฐ์ฐ๊ณ ์ถ๋ค๋ฉด, ์ฌ๋ฅ๋ท์์ ์ ๋ฌธ๊ฐ๋ค์ ๊ฐ์๋ฅผ ๋ค์ด๋ณด๋ ๊ฒ๋ ์ข์ ๋ฐฉ๋ฒ์ด์ผ. ์ค์ ๊ฒฝํ์ด ํ๋ถํ ๊ฐ๋ฐ์๋ค์ด ์์ ๋ง์ ๋ ธํ์ฐ๋ฅผ ๊ณต์ ํ๊ฑฐ๋ ! ๐
ํนํ ๋ณต์กํ ํผ ์ฒ๋ฆฌ๋ ๋๊ท๋ชจ ํ๋ก์ ํธ์์์ ํ์ ์์คํ ์ค๊ณ ๊ฐ์ ๊ณ ๊ธ ์ฃผ์ ๋ค์ ๊ฒฝํ์์๊ฒ ์ง์ ๋ฐฐ์ฐ๋ ๊ฒ ํจ์ฌ ํจ์จ์ ์ด์ผ. ์ฌ๋ฅ๋ท์ '์ง์์ธ์ ์ฒ' ๊ฐ์ ๊ณณ์์ ๋ค์ํ ๊ฐ๋ฐ ์ง์์ ์ป์ ์ ์์ด!
๐ ํํ ์ค์์ ํด๊ฒฐ ๋ฐฉ๋ฒ
์ด์ ์ค์ ์์ ์์ฃผ ๋ง์ฃผ์น๋ ๋ฌธ์ ๋ค๊ณผ ํด๊ฒฐ ๋ฐฉ๋ฒ์ ์์๋ณด์. ๋๋ ์ด๋ฐ ์ค์๋ค์ ๋ง์ด ํ์์ด! ๐
1. ํ์ ๋ถ์ผ์น ๋ฌธ์
// โ ๋ฌธ์ : ํผ ๋ฐ์ดํฐ์ ์คํค๋ง ํ์
์ด ๋ถ์ผ์น
const schema = z.object({
age: z.number()
});
// HTML input์ ํญ์ string์ ๋ฐํ!
<input type="number" {...register('age')} />
// โ
ํด๊ฒฐ: valueAsNumber ์ต์
์ฌ์ฉ
<input
type="number"
{...register('age', { valueAsNumber: true })}
/>
// ๋๋ ์คํค๋ง์์ ๋ณํ
const schema = z.object({
age: z.string().transform(val => parseInt(val, 10))
});
2. ๋น๋๊ธฐ ๊ฒ์ฆ ํ์ด๋ฐ
// โ ๋ฌธ์ : ๋งค ์
๋ ฅ๋ง๋ค API ํธ์ถ
const schema = z.object({
username: z.string().refine(async (val) => {
const response = await fetch(`/api/check/${val}`);
return response.ok;
})
});
// โ
ํด๊ฒฐ: ๋๋ฐ์ด์ฑ + onBlur ๋ชจ๋
useForm({
resolver: zodResolver(schema),
mode: 'onBlur' // ํฌ์ปค์ค๋ฅผ ๋ฒ์ด๋ ๋๋ง ๊ฒ์ฆ
});
// ๋๋ ๋ณ๋์ ๊ฒ์ฆ ๋ก์ง์ผ๋ก ๋ถ๋ฆฌ
const { setError, clearErrors } = useForm();
const username = watch('username');
const [debouncedUsername] = useDebounce(username, 500);
useEffect(() => {
if (debouncedUsername) {
checkUsername(debouncedUsername).then(available => {
if (!available) {
setError('username', {
message: '์ด๋ฏธ ์ฌ์ฉ์ค์ธ ์์ด๋์์'
});
} else {
clearErrors('username');
}
});
}
}, [debouncedUsername]);
3. ์ค์ฒฉ ๊ฐ์ฒด ์ฒ๋ฆฌ
// โ ๋ฌธ์ : ์ค์ฒฉ ๊ฐ์ฒด์ ์๋ฌ ์ ๊ทผ
const schema = z.object({
address: z.object({
street: z.string(),
city: z.string()
})
});
// ์ด๋ ๊ฒ ํ๋ฉด ํ์
์๋ฌ!
{errors.address.street?.message}
// โ
ํด๊ฒฐ: ์ต์
๋ ์ฒด์ด๋ ์ ๋๋ก ์ฌ์ฉ
{errors.address?.street?.message}
// ๋๋ ๋ ์์ ํ๊ฒ
{errors.address && errors.address.street && (
<span>{errors.address.street.message}</span>
)}
4. ๋ฐฐ์ด ํ๋ ์ฒ๋ฆฌ
// โ ๋ฌธ์ : ๋ฐฐ์ด ํ๋๋ฅผ ์ผ๋ฐ ํ๋์ฒ๋ผ ์ฒ๋ฆฌ
const schema = z.object({
tags: z.array(z.string())
});
<input {...register('tags')} /> // ์๋ ์ ํจ!
// โ
ํด๊ฒฐ: useFieldArray ์ฌ์ฉ
import { useFieldArray } from 'react-hook-form';
function TagsInput() {
const { control, register } = useForm({
defaultValues: {
tags: ['']
}
});
const { fields, append, remove } = useFieldArray({
control,
name: 'tags'
});
return (
<div>
{fields.map((field, index) => (
<div key={field.id}>
<input {...register(`tags.${index}`)} />
<button type="button" onClick={() => remove(index)}>
์ญ์
</button>
</div>
))}
<button type="button" onClick={() => append('')}>
ํ๊ทธ ์ถ๊ฐ
</button>
</div>
);
}
5. ์กฐ๊ฑด๋ถ ํ๋ ๊ฒ์ฆ
// โ ๋ฌธ์ : ์กฐ๊ฑด๋ถ ํ๋๊ฐ ํญ์ ๊ฒ์ฆ๋จ
const schema = z.object({
hasCompany: z.boolean(),
companyName: z.string().min(1) // ํญ์ ํ์!
});
// โ
ํด๊ฒฐ: ์กฐ๊ฑด๋ถ ๊ฒ์ฆ ์ ์ฉ
const schema = z.object({
hasCompany: z.boolean(),
companyName: z.string().optional()
}).refine(data => {
if (data.hasCompany) {
return data.companyName && data.companyName.length > 0;
}
return true;
}, {
message: 'ํ์ฌ๋ช
์ ์
๋ ฅํด์ฃผ์ธ์',
path: ['companyName']
});
// ๋๋ Yup์ when ์ฌ์ฉ
const schema = yup.object({
hasCompany: yup.boolean(),
companyName: yup.string().when('hasCompany', {
is: true,
then: schema => schema.required('ํ์ฌ๋ช
์ ์
๋ ฅํด์ฃผ์ธ์'),
otherwise: schema => schema.notRequired()
})
});
โ ๏ธ ์ฃผ์ํ ์
โข ๋น๋๊ธฐ ๊ฒ์ฆ์ ์ฑ๋ฅ์ ์ํฅ์ ์ค ์ ์์ผ๋ ๊ผญ ํ์ํ ๊ฒฝ์ฐ์๋ง ์ฌ์ฉํ์
โข ๋ณต์กํ ์กฐ๊ฑด๋ถ ๊ฒ์ฆ์ ์ฝ๋๋ฅผ ์ฝ๊ธฐ ์ด๋ ต๊ฒ ๋ง๋ค ์ ์์ด. ์ฃผ์์ ์ ๋ฌ์๋์
โข ์๋ฌ ๋ฉ์์ง๋ ์ฌ์ฉ์ ์นํ์ ์ผ๋ก ์์ฑํ์. "Invalid input"๋ณด๋ค๋ "์ด๋ฉ์ผ ํ์์ด ์ฌ๋ฐ๋ฅด์ง ์์์"๊ฐ ํจ์ฌ ์ข์!
๐งช ํ ์คํธ ์์ฑํ๊ธฐ
ํผ ๊ฒ์ฆ ๋ก์ง๋ ํ ์คํธ๊ฐ ํ์ํด! ํนํ ๋ณต์กํ ๋น์ฆ๋์ค ๋ก์ง์ด ๋ค์ด๊ฐ ๊ฒฝ์ฐ์๋ ๋์ฑ ๊ทธ๋. ๊ฐ๋จํ ํ ์คํธ ์์ ๋ฅผ ๋ณด์. ๐งช
// schemas/__tests__/signup.schema.test.ts
import { describe, it, expect } from 'vitest';
import { signupSchema } from '../signup.schema';
describe('Signup Schema', () => {
it('์ ํจํ ๋ฐ์ดํฐ๋ฅผ ํต๊ณผ์์ผ์ผ ํจ', () => {
const validData = {
email: 'test@example.com',
username: 'testuser',
password: 'Test1234!',
confirmPassword: 'Test1234!',
firstName: '๊ธธ๋',
lastName: 'ํ',
birthDate: '1990-01-01',
agreeToTerms: true,
agreeToPrivacy: true
};
const result = signupSchema.safeParse(validData);
expect(result.success).toBe(true);
});
it('์๋ชป๋ ์ด๋ฉ์ผ์ ๊ฑฐ๋ถํด์ผ ํจ', () => {
const invalidData = {
email: 'invalid-email',
// ... ๋๋จธ์ง ํ๋
};
const result = signupSchema.safeParse(invalidData);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].path).toContain('email');
}
});
it('์งง์ ๋น๋ฐ๋ฒํธ๋ฅผ ๊ฑฐ๋ถํด์ผ ํจ', () => {
const invalidData = {
password: 'short',
// ... ๋๋จธ์ง ํ๋
};
const result = signupSchema.safeParse(invalidData);
expect(result.success).toBe(false);
});
it('๋น๋ฐ๋ฒํธ ๋ถ์ผ์น๋ฅผ ๊ฐ์งํด์ผ ํจ', () => {
const invalidData = {
password: 'Test1234!',
confirmPassword: 'Different1234!',
// ... ๋๋จธ์ง ํ๋
};
const result = signupSchema.safeParse(invalidData);
expect(result.success).toBe(false);
if (!result.success) {
const error = result.error.issues.find(
issue => issue.path.includes('confirmPassword')
);
expect(error).toBeDefined();
}
});
it('18์ธ ๋ฏธ๋ง์ ๊ฑฐ๋ถํด์ผ ํจ', () => {
const today = new Date();
const underageDate = new Date(
today.getFullYear() - 17,
today.getMonth(),
today.getDate()
);
const invalidData = {
birthDate: underageDate.toISOString().split('T')[0],
// ... ๋๋จธ์ง ํ๋
};
const result = signupSchema.safeParse(invalidData);
expect(result.success).toBe(false);
});
});
// ์ปดํฌ๋ํธ ํ
์คํธ
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { SignupForm } from '../SignupForm';
describe('SignupForm Component', () => {
it('ํผ์ ๋ ๋๋งํด์ผ ํจ', () => {
render(<SignupForm />);
expect(screen.getByLabelText(/์ด๋ฉ์ผ/i)).toBeInTheDocument();
expect(screen.getByLabelText(/์์ด๋/i)).toBeInTheDocument();
});
it('์ ํจํ์ง ์์ ์ด๋ฉ์ผ์ ์๋ฌ๋ฅผ ํ์ํด์ผ ํจ', async () => {
const user = userEvent.setup();
render(<SignupForm />);
const emailInput = screen.getByLabelText(/์ด๋ฉ์ผ/i);
await user.type(emailInput, 'invalid-email');
await user.tab(); // blur ์ด๋ฒคํธ ๋ฐ์
await waitFor(() => {
expect(screen.getByText(/์ฌ๋ฐ๋ฅธ ์ด๋ฉ์ผ ํ์์ด ์๋์์/i))
.toBeInTheDocument();
});
});
it('๋น๋ฐ๋ฒํธ ๊ฐ๋๋ฅผ ํ์ํด์ผ ํจ', async () => {
const user = userEvent.setup();
render(<SignupForm />);
const passwordInput = screen.getByLabelText(/^๋น๋ฐ๋ฒํธ/i);
await user.type(passwordInput, 'Test1234!');
await waitFor(() => {
expect(screen.getByText(/๊ฐํจ|๋งค์ฐ ๊ฐํจ/i)).toBeInTheDocument();
});
});
it('ํผ ์ ์ถ์ด ์๋ํด์ผ ํจ', async () => {
const user = userEvent.setup();
render(<SignupForm />);
// ๋ชจ๋ ํ๋ ์
๋ ฅ
await user.type(screen.getByLabelText(/์ด๋ฉ์ผ/i), 'test@example.com');
await user.type(screen.getByLabelText(/์์ด๋/i), 'testuser');
await user.type(screen.getByLabelText(/^๋น๋ฐ๋ฒํธ/i), 'Test1234!');
await user.type(screen.getByLabelText(/๋น๋ฐ๋ฒํธ ํ์ธ/i), 'Test1234!');
// ๋ค์ ๋จ๊ณ๋ก
await user.click(screen.getByText(/๋ค์/i));
// 2๋จ๊ณ ํ๋ ์
๋ ฅ
await user.type(screen.getByLabelText(/์ฑ/i), 'ํ');
await user.type(screen.getByLabelText(/์ด๋ฆ/i), '๊ธธ๋');
// ... ๋๋จธ์ง ํ๋
// ์ ์ถ
await user.click(screen.getByText(/๊ฐ์
ํ๊ธฐ/i));
await waitFor(() => {
expect(screen.getByText(/ํ์๊ฐ์
์๋ฃ/i)).toBeInTheDocument();
});
});
});
ํ ์คํธ๋ฅผ ์์ฑํ๋ฉด ๋ฆฌํฉํ ๋งํ ๋๋ ์์ฌํ๊ณ ์ฝ๋๋ฅผ ์์ ํ ์ ์์ด! ๐ช
๐ฏ ๋ง๋ฌด๋ฆฌํ๋ฉฐ
์, ์ฌ๊ธฐ๊น์ง ์ ๋ง ๊ธด ์ฌ์ ์ด์์ด! ๐ ํ์ ์คํฌ๋ฆฝํธ์ validation ๋ผ์ด๋ธ๋ฌ๋ฆฌ๋ฅผ ํจ๊ป ์ฌ์ฉํ๋ ๋ฐฉ๋ฒ์ ๋ํด ์ ๋ง ๋ง์ ๊ฑธ ๋ค๋ค์ง?
ํต์ฌ์ ์ ๋ฆฌํ์๋ฉด:
๐ ํต์ฌ ์์ฝ
1. ํ์
์์ ์ฑ
โข ์คํค๋ง์์ ํ์
์ ์๋์ผ๋ก ์ถ๋ก ํ์
โข z.infer๋ InferType์ ํ์ฉํ์
โข ์ปดํ์ผ ํ์๊ณผ ๋ฐํ์ ๊ฒ์ฆ์ ๋ชจ๋ ์ฑ๊ธฐ์
2. ๋ผ์ด๋ธ๋ฌ๋ฆฌ ์ ํ
โข Zod: ์ต์ ํ๋ก์ ํธ, ๋ฐ์ด๋ ํ์
์ถ๋ก
โข Yup: ์์ ์ฑ, ํ๋ถํ ์ํ๊ณ
โข ํ๋ก์ ํธ ์๊ตฌ์ฌํญ์ ๋ง๊ฒ ์ ํํ์
3. ์ฑ๋ฅ ์ต์ ํ
โข ์ ์ ํ ๊ฒ์ฆ ๋ชจ๋ ์ ํ
โข ๋๋ฐ์ด์ฑ ํ์ฉ
โข ์กฐ๊ฑด๋ถ ๋ ๋๋ง์ผ๋ก ๋ถํ์ํ ๋ ๋๋ง ๋ฐฉ์ง
4. ์ฌ์ฉ์ ๊ฒฝํ
โข ๋ช
ํํ ์๋ฌ ๋ฉ์์ง
โข ์ค์๊ฐ ํผ๋๋ฐฑ
โข ๋ก๋ฉ ์ํ ํ์
5. ์ฝ๋ ๊ตฌ์กฐํ
โข ์คํค๋ง์ ํ์
๋ถ๋ฆฌ
โข ์ฌ์ฌ์ฉ ๊ฐ๋ฅํ ์ปดํฌ๋ํธ
โข ํ
์คํธ ์์ฑ
ํผ ์ฒ๋ฆฌ๋ ์น ๊ฐ๋ฐ์ ๊ธฐ๋ณธ์ด๋ฉด์๋ ๊ฐ์ฅ ์ค์ํ ๋ถ๋ถ์ด์ผ. ์ฌ์ฉ์์ ์ง์ ์ํตํ๋ ์ฐฝ๊ตฌ๋๊น! ํ์ ์คํฌ๋ฆฝํธ์ validation ๋ผ์ด๋ธ๋ฌ๋ฆฌ๋ฅผ ์ ๋๋ก ํ์ฉํ๋ฉด, ์์ ํ๊ณ ์ฌ์ฉ์ ์นํ์ ์ธ ํผ์ ๋ง๋ค ์ ์์ด. ๐
์ฒ์์๋ ๋ณต์กํด ๋ณด์ผ ์ ์์ง๋ง, ํ ๋ฒ ์ต์ํด์ง๋ฉด ์ ๋ง ๊ฐ๋ ฅํ ๋๊ตฌ๊ฐ ๋ผ. ํนํ ๋๊ท๋ชจ ํ๋ก์ ํธ์์๋ ์ด๋ฐ ์ฒด๊ณ์ ์ธ ์ ๊ทผ์ด ํ์์ ์ด์ผ.
๐ก ๋ง์ง๋ง ์กฐ์ธ
โข ์์ ํ๋ก์ ํธ๋ถํฐ ์์ํด์ ์ ์ง์ ์ผ๋ก ๋ณต์กํ ํผ์ ๋ค๋ค๋ณด์
โข ๊ณต์ ๋ฌธ์๋ฅผ ์์ฃผ ์ฐธ๊ณ ํ์ (Zod, Yup, React Hook Form ๋ชจ๋ ๋ฌธ์๊ฐ ํ๋ฅญํด)
โข ์ปค๋ฎค๋ํฐ์์ ๋ค๋ฅธ ๊ฐ๋ฐ์๋ค์ ํจํด์ ๋ฐฐ์ฐ์
โข ์ค์๋ฅผ ๋๋ ค์ํ์ง ๋ง์. ์๋ฌ ๋ฉ์์ง๊ฐ ์น์ ํ๊ฒ ์๋ ค์ค ๊ฑฐ์ผ!
โข ํ์ํ๋ค๋ฉด ์ฌ๋ฅ๋ท ๊ฐ์ ํ๋ซํผ์์ ์ ๋ฌธ๊ฐ์ ๋์์ ๋ฐ๋ ๊ฒ๋ ์ข์ ๋ฐฉ๋ฒ์ด์ผ
์, ์ด์ ๋น์ ๋ ํ์ ์คํฌ๋ฆฝํธ๋ก ์๋ฒฝํ ํผ์ ๋ง๋ค ์ค๋น๊ฐ ๋์ด! ํ์ดํ ! ๐ชโจ
๊ด๋ จ ํค์๋
๋๊ธ ์์ฑ
์ด ๊ธ์ ๋ํ ์ฌ๋ฌ๋ถ์ ์๊ฐ์ ๋ค๋ ค์ฃผ์ธ์
๋ก๊ทธ์ธ์ด ํ์ํฉ๋๋ค
๋๊ธ์ ์์ฑํ๋ ค๋ฉด ๋จผ์ ๋ก๊ทธ์ธํด์ฃผ์ธ์.
๋๊ธ 0
์ง์์ธ์ ์ฒ - ์ง์ ์ฌ์ฐ๊ถ ๋ณดํธ ๊ณ ์ง
์ง์ ์ฌ์ฐ๊ถ ๋ณดํธ ๊ณ ์ง
- ์ ์๊ถ ๋ฐ ์์ ๊ถ: ๋ณธ ์ปจํ ์ธ ๋ ์ฌ๋ฅ๋ท์ ๋ ์ AI ๊ธฐ์ ๋ก ์์ฑ๋์์ผ๋ฉฐ, ๋ํ๋ฏผ๊ตญ ์ ์๊ถ๋ฒ ๋ฐ ๊ตญ์ ์ ์๊ถ ํ์ฝ์ ์ํด ๋ณดํธ๋ฉ๋๋ค.
- AI ์์ฑ ์ปจํ ์ธ ์ ๋ฒ์ ์ง์: ๋ณธ AI ์์ฑ ์ปจํ ์ธ ๋ ์ฌ๋ฅ๋ท์ ์ง์ ์ฐฝ์๋ฌผ๋ก ์ธ์ ๋๋ฉฐ, ๊ด๋ จ ๋ฒ๊ท์ ๋ฐ๋ผ ์ ์๊ถ ๋ณดํธ๋ฅผ ๋ฐ์ต๋๋ค.
- ์ฌ์ฉ ์ ํ: ์ฌ๋ฅ๋ท์ ๋ช ์์ ์๋ฉด ๋์ ์์ด ๋ณธ ์ปจํ ์ธ ๋ฅผ ๋ณต์ , ์์ , ๋ฐฐํฌ, ๋๋ ์์ ์ ์ผ๋ก ํ์ฉํ๋ ํ์๋ ์๊ฒฉํ ๊ธ์ง๋ฉ๋๋ค.
- ๋ฐ์ดํฐ ์์ง ๊ธ์ง: ๋ณธ ์ปจํ ์ธ ์ ๋ํ ๋ฌด๋จ ์คํฌ๋ํ, ํฌ๋กค๋ง, ๋ฐ ์๋ํ๋ ๋ฐ์ดํฐ ์์ง์ ๋ฒ์ ์ ์ฌ์ ๋์์ด ๋ฉ๋๋ค.
- AI ํ์ต ์ ํ: ์ฌ๋ฅ๋ท์ AI ์์ฑ ์ปจํ ์ธ ๋ฅผ ํ AI ๋ชจ๋ธ ํ์ต์ ๋ฌด๋จ ์ฌ์ฉํ๋ ํ์๋ ๊ธ์ง๋๋ฉฐ, ์ด๋ ์ง์ ์ฌ์ฐ๊ถ ์นจํด๋ก ๊ฐ์ฃผ๋ฉ๋๋ค.
์์ง ๋๊ธ์ด ์์ต๋๋ค.