154 lines
4.1 KiB
Markdown
154 lines
4.1 KiB
Markdown
# 12. 폼 검증 (Zod)
|
|
|
|
> 대상: 프론트엔드 개발자
|
|
> 최종 업데이트: 2026-03-20
|
|
|
|
---
|
|
|
|
## 목차
|
|
|
|
| 번호 | 항목 |
|
|
|------|------|
|
|
| 12.1 | [적용 범위](#121-적용-범위) |
|
|
| 12.2 | [기본 패턴](#122-기본-패턴) |
|
|
| 12.3 | [트러블슈팅](#123-트러블슈팅) |
|
|
| 12.4 | [체크리스트](#124-체크리스트) |
|
|
|
|
---
|
|
|
|
## 12.1 적용 범위
|
|
|
|
| 대상 | Zod 적용 |
|
|
|------|---------|
|
|
| 신규 폼 | 필수 |
|
|
| 기존 폼 (정상 작동 중) | 건드리지 않음 |
|
|
| 단순 필드 1-2개 인라인 폼 | 불필요 (오버엔지니어링) |
|
|
| 신규 서버 액션 API 응답 | 선택적 |
|
|
|
|
---
|
|
|
|
## 12.2 기본 패턴
|
|
|
|
### 스키마 정의 + 타입 추출
|
|
|
|
```typescript
|
|
import { z } from 'zod';
|
|
import { useForm } from 'react-hook-form';
|
|
import { zodResolver } from '@hookform/resolvers/zod';
|
|
|
|
// 1. 스키마 정의 (타입 + 검증 한 번에)
|
|
const formSchema = z.object({
|
|
itemName: z.string().min(1, '품목명을 입력하세요'),
|
|
quantity: z.number().min(1, '1 이상 입력하세요'),
|
|
status: z.enum(['active', 'inactive']),
|
|
memo: z.string().optional(),
|
|
});
|
|
|
|
// 2. 스키마에서 타입 추출 (별도 interface 정의 불필요)
|
|
type FormData = z.infer<typeof formSchema>;
|
|
|
|
// 3. useForm에 zodResolver 연결
|
|
const form = useForm<FormData>({
|
|
resolver: zodResolver(formSchema),
|
|
defaultValues: { itemName: '', quantity: 1, status: 'active' },
|
|
});
|
|
```
|
|
|
|
### 규칙
|
|
|
|
- **스키마 위치**: 컴포넌트 파일 상단 또는 같은 디렉토리의 `schema.ts`
|
|
- **타입 추출**: `z.infer<typeof schema>` 사용, 별도 `interface` 중복 정의 금지
|
|
- **에러 메시지**: 한글로 작성 (사용자에게 직접 표시됨)
|
|
- **`as` 캐스트 지양**: Zod 스키마로 타입이 보장되므로 불필요
|
|
|
|
---
|
|
|
|
## 12.3 트러블슈팅
|
|
|
|
### 문제 1: 에러 메시지가 영어로 나옴
|
|
|
|
```typescript
|
|
// ❌ 기본 에러 메시지 (영어)
|
|
const schema = z.object({
|
|
name: z.string().min(1),
|
|
});
|
|
// -> "String must contain at least 1 character(s)"
|
|
|
|
// ✅ 한글 메시지 명시
|
|
const schema = z.object({
|
|
name: z.string().min(1, '이름을 입력하세요'),
|
|
});
|
|
```
|
|
|
|
### 문제 2: 불필요한 필드까지 검증됨
|
|
|
|
수정 폼에서 등록 전용 필드가 검증되는 경우:
|
|
|
|
```typescript
|
|
// ✅ .omit()으로 불필요 필드 제외
|
|
const editSchema = createSchema.omit({ password: true });
|
|
```
|
|
|
|
### 문제 3: 조건부 필수 필드
|
|
|
|
특정 값 선택 시에만 다른 필드가 필수인 경우:
|
|
|
|
```typescript
|
|
// ✅ .superRefine() 사용
|
|
const schema = z.object({
|
|
type: z.enum(['individual', 'company']),
|
|
companyName: z.string().optional(),
|
|
}).superRefine((data, ctx) => {
|
|
if (data.type === 'company' && !data.companyName) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: '법인명을 입력하세요',
|
|
path: ['companyName'],
|
|
});
|
|
}
|
|
});
|
|
```
|
|
|
|
### 문제 4: .omit() 후 refinement 소실
|
|
|
|
`.omit()`은 `.refine()`, `.superRefine()` 등 체이닝된 검증을 제거합니다.
|
|
|
|
```typescript
|
|
// ❌ refinement가 사라짐
|
|
const base = z.object({ a: z.string(), b: z.string() })
|
|
.refine((d) => d.a !== d.b, { message: 'a와 b는 달라야 합니다' });
|
|
const partial = base.omit({ b: true }); // refine 소실!
|
|
|
|
// ✅ .omit() 후 refinement 재적용
|
|
const partial = base.omit({ b: true });
|
|
const withRefine = partial.refine(...); // 필요 시 다시 추가
|
|
```
|
|
|
|
### 문제 5: 폼 필드명과 스키마 키 불일치
|
|
|
|
```typescript
|
|
// ❌ 폼에서는 camelCase, 스키마에서는 snake_case
|
|
const schema = z.object({ item_name: z.string() });
|
|
// useForm에서 register('itemName') -> 검증 안 됨
|
|
|
|
// ✅ 동일한 키 사용
|
|
const schema = z.object({ itemName: z.string() });
|
|
```
|
|
|
|
---
|
|
|
|
## 12.4 체크리스트
|
|
|
|
신규 폼 작성 시 확인:
|
|
|
|
| # | 항목 |
|
|
|---|------|
|
|
| 1 | 스키마 정의 완료 (모든 필드 포함) |
|
|
| 2 | `z.infer`로 타입 추출 (별도 interface 없음) |
|
|
| 3 | `zodResolver` 연결 |
|
|
| 4 | 에러 메시지 한글 작성 |
|
|
| 5 | 조건부 필수 -> `.superRefine()` 사용 |
|
|
| 6 | 수정 폼 -> `.omit()` 적용 시 refinement 재확인 |
|
|
| 7 | 스키마 키와 폼 필드명 일치 확인 |
|
|
| 8 | `as` 캐스트 제거 |
|