@choblue/claude-code-toolkit 1.2.6 → 1.2.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/.project-map-cache +1 -1
- package/.claude/CLAUDE.md +58 -18
- package/.claude/hooks/prompt-hook.sh +4 -4
- package/.claude/skills/Coding/SKILL.md +5 -4
- package/.claude/skills/{Coding/backend.md → NestJS/SKILL.md} +7 -2
- package/.claude/skills/NextJS/SKILL.md +13 -303
- package/.claude/skills/NextJS/references/data-fetching.md +96 -0
- package/.claude/skills/NextJS/references/middleware-actions.md +74 -0
- package/.claude/skills/NextJS/references/optimization.md +127 -0
- package/.claude/skills/Planning/SKILL.md +30 -7
- package/.claude/skills/React/SKILL.md +25 -287
- package/.claude/skills/React/references/a11y-ux.md +134 -0
- package/.claude/skills/React/references/rendering-patterns.md +62 -0
- package/.claude/skills/React/references/state-hooks.md +73 -0
- package/.claude/skills/ReactHookForm/SKILL.md +8 -196
- package/.claude/skills/ReactHookForm/references/advanced-patterns.md +193 -0
- package/.claude/skills/TDD/SKILL.md +2 -2
- package/.claude/skills/TailwindCSS/SKILL.md +14 -240
- package/.claude/skills/TailwindCSS/references/patterns-components.md +93 -0
- package/.claude/skills/TailwindCSS/references/responsive-dark.md +102 -0
- package/.claude/skills/TailwindCSS/references/transitions.md +33 -0
- package/README.md +15 -10
- package/package.json +1 -1
- package/.claude/skills/Coding/frontend.md +0 -11
- /package/.claude/skills/TDD/{backend.md → references/backend.md} +0 -0
- /package/.claude/skills/TDD/{frontend.md → references/frontend.md} +0 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# 렌더링 최적화 및 조건부 렌더링 패턴
|
|
2
|
+
|
|
3
|
+
## 1. 렌더링 최적화
|
|
4
|
+
|
|
5
|
+
### 불필요한 useMemo/useCallback 금지
|
|
6
|
+
- 성능 문제가 실제로 측정된 경우에만 사용한다
|
|
7
|
+
- 참조 동일성이 필요한 경우(의존성 배열, memo된 자식 컴포넌트)에만 사용한다
|
|
8
|
+
|
|
9
|
+
```typescript
|
|
10
|
+
// Bad - 불필요한 메모이제이션
|
|
11
|
+
const userName = useMemo(() => `${first} ${last}`, [first, last]);
|
|
12
|
+
|
|
13
|
+
// Good - 단순 계산은 그냥 수행
|
|
14
|
+
const userName = `${first} ${last}`;
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
### key 올바르게 사용
|
|
18
|
+
- 리스트 렌더링 시 고유한 식별자를 `key`로 사용한다
|
|
19
|
+
- 배열 인덱스를 `key`로 사용하지 않는다 (정적 리스트 제외)
|
|
20
|
+
|
|
21
|
+
```typescript
|
|
22
|
+
// Bad
|
|
23
|
+
{items.map((item, index) => (
|
|
24
|
+
<Item key={index} data={item} />
|
|
25
|
+
))}
|
|
26
|
+
|
|
27
|
+
// Good
|
|
28
|
+
{items.map((item) => (
|
|
29
|
+
<Item key={item.id} data={item} />
|
|
30
|
+
))}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## 2. 조건부 렌더링 패턴
|
|
36
|
+
|
|
37
|
+
### 단순 조건
|
|
38
|
+
- 2개 이하의 조건은 삼항 연산자 또는 `&&`를 사용한다
|
|
39
|
+
|
|
40
|
+
```typescript
|
|
41
|
+
// 단순 표시/숨김
|
|
42
|
+
{isVisible && <Modal />}
|
|
43
|
+
|
|
44
|
+
// 이분기
|
|
45
|
+
{isLoading ? <Skeleton /> : <Content />}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### 복잡한 조건
|
|
49
|
+
- 3개 이상의 분기는 early return 또는 별도 컴포넌트로 분리한다
|
|
50
|
+
|
|
51
|
+
```typescript
|
|
52
|
+
// Bad - 중첩된 삼항
|
|
53
|
+
{isLoading ? <Skeleton /> : error ? <Error /> : data ? <Content /> : <Empty />}
|
|
54
|
+
|
|
55
|
+
// Good - early return
|
|
56
|
+
function UserContent({ isLoading, error, data }: UserContentProps) {
|
|
57
|
+
if (isLoading) return <Skeleton />;
|
|
58
|
+
if (error) return <ErrorDisplay error={error} />;
|
|
59
|
+
if (!data) return <EmptyState />;
|
|
60
|
+
return <Content data={data} />;
|
|
61
|
+
}
|
|
62
|
+
```
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# 상태 관리 및 커스텀 훅
|
|
2
|
+
|
|
3
|
+
## 1. 상태 관리 원칙
|
|
4
|
+
|
|
5
|
+
### 가까운 곳에 배치
|
|
6
|
+
- 상태는 그것을 사용하는 가장 가까운 컴포넌트에 배치한다
|
|
7
|
+
- 상위 컴포넌트로의 lifting은 실제로 필요할 때만 수행한다
|
|
8
|
+
|
|
9
|
+
### 파생값은 상태가 아니다
|
|
10
|
+
- 기존 상태에서 계산할 수 있는 값은 별도 상태로 만들지 않는다
|
|
11
|
+
|
|
12
|
+
```typescript
|
|
13
|
+
// Bad - 파생값을 상태로 관리
|
|
14
|
+
const [items, setItems] = useState<Item[]>([]);
|
|
15
|
+
const [itemCount, setItemCount] = useState(0);
|
|
16
|
+
// items가 변경될 때마다 setItemCount를 호출해야 함
|
|
17
|
+
|
|
18
|
+
// Good - 파생값은 계산
|
|
19
|
+
const [items, setItems] = useState<Item[]>([]);
|
|
20
|
+
const itemCount = items.length;
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
### 서버 상태와 클라이언트 상태 분리
|
|
24
|
+
- **서버 상태**: API에서 가져온 데이터 -> React Query / SWR 사용
|
|
25
|
+
- **클라이언트 상태**: UI 상태 (모달 열림, 탭 선택 등) -> useState / useReducer 사용
|
|
26
|
+
- 서버 데이터를 `useState`로 복사하지 않는다
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## 2. 커스텀 훅
|
|
31
|
+
|
|
32
|
+
### use 접두사
|
|
33
|
+
- 모든 커스텀 훅은 `use`로 시작한다
|
|
34
|
+
|
|
35
|
+
### 하나의 관심사
|
|
36
|
+
- 하나의 훅은 하나의 관심사만 다룬다
|
|
37
|
+
|
|
38
|
+
### 데이터 페칭/상태 로직 분리
|
|
39
|
+
- 컴포넌트에서 데이터 페칭과 상태 로직을 커스텀 훅으로 분리한다
|
|
40
|
+
|
|
41
|
+
```typescript
|
|
42
|
+
// Bad - 컴포넌트에 로직이 섞여 있음
|
|
43
|
+
function UserList() {
|
|
44
|
+
const [users, setUsers] = useState<User[]>([]);
|
|
45
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
46
|
+
const [error, setError] = useState<Error | null>(null);
|
|
47
|
+
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
setIsLoading(true);
|
|
50
|
+
fetchUsers()
|
|
51
|
+
.then(setUsers)
|
|
52
|
+
.catch(setError)
|
|
53
|
+
.finally(() => setIsLoading(false));
|
|
54
|
+
}, []);
|
|
55
|
+
|
|
56
|
+
// ... 렌더링
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Good - 커스텀 훅으로 분리
|
|
60
|
+
function useUserList() {
|
|
61
|
+
const { data: users = [], isLoading, error } = useQuery({
|
|
62
|
+
queryKey: ['users'],
|
|
63
|
+
queryFn: fetchUsers,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
return { users, isLoading, error };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function UserList() {
|
|
70
|
+
const { users, isLoading, error } = useUserList();
|
|
71
|
+
// ... 렌더링만 담당
|
|
72
|
+
}
|
|
73
|
+
```
|
|
@@ -107,201 +107,7 @@ function CreateUserForm() {
|
|
|
107
107
|
|
|
108
108
|
---
|
|
109
109
|
|
|
110
|
-
## 4.
|
|
111
|
-
|
|
112
|
-
`register`를 사용할 수 없는 제어 컴포넌트 (Select, DatePicker, 커스텀 UI 라이브러리 등)에 사용한다.
|
|
113
|
-
|
|
114
|
-
```typescript
|
|
115
|
-
import { Controller, useForm } from 'react-hook-form';
|
|
116
|
-
|
|
117
|
-
function UserForm() {
|
|
118
|
-
const { control, handleSubmit } = useForm<UserFormValues>({
|
|
119
|
-
resolver: zodResolver(userSchema),
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
return (
|
|
123
|
-
<form onSubmit={handleSubmit(onSubmit)}>
|
|
124
|
-
<Controller
|
|
125
|
-
name="role"
|
|
126
|
-
control={control}
|
|
127
|
-
render={({ field, fieldState: { error } }) => (
|
|
128
|
-
<>
|
|
129
|
-
<Select
|
|
130
|
-
value={field.value}
|
|
131
|
-
onChange={field.onChange}
|
|
132
|
-
options={roleOptions}
|
|
133
|
-
/>
|
|
134
|
-
{error && <span>{error.message}</span>}
|
|
135
|
-
</>
|
|
136
|
-
)}
|
|
137
|
-
/>
|
|
138
|
-
|
|
139
|
-
<Controller
|
|
140
|
-
name="birthDate"
|
|
141
|
-
control={control}
|
|
142
|
-
render={({ field, fieldState: { error } }) => (
|
|
143
|
-
<>
|
|
144
|
-
<DatePicker
|
|
145
|
-
selected={field.value}
|
|
146
|
-
onChange={field.onChange}
|
|
147
|
-
/>
|
|
148
|
-
{error && <span>{error.message}</span>}
|
|
149
|
-
</>
|
|
150
|
-
)}
|
|
151
|
-
/>
|
|
152
|
-
</form>
|
|
153
|
-
);
|
|
154
|
-
}
|
|
155
|
-
```
|
|
156
|
-
|
|
157
|
-
---
|
|
158
|
-
|
|
159
|
-
## 5. 에러 핸들링
|
|
160
|
-
|
|
161
|
-
### errors 객체로 직접 표시
|
|
162
|
-
|
|
163
|
-
```typescript
|
|
164
|
-
// 간단한 에러 표시
|
|
165
|
-
{errors.email && <span className="error">{errors.email.message}</span>}
|
|
166
|
-
```
|
|
167
|
-
|
|
168
|
-
### 재사용 가능한 에러 컴포넌트
|
|
169
|
-
|
|
170
|
-
```typescript
|
|
171
|
-
interface FieldErrorProps {
|
|
172
|
-
error?: FieldError;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
function FieldError({ error }: FieldErrorProps) {
|
|
176
|
-
if (!error) return null;
|
|
177
|
-
return <span className="field-error">{error.message}</span>;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
// 사용
|
|
181
|
-
<input {...register('email')} />
|
|
182
|
-
<FieldError error={errors.email} />
|
|
183
|
-
```
|
|
184
|
-
|
|
185
|
-
---
|
|
186
|
-
|
|
187
|
-
## 6. 폼 검증 모드
|
|
188
|
-
|
|
189
|
-
### mode 옵션
|
|
190
|
-
|
|
191
|
-
| mode | 동작 | 용도 |
|
|
192
|
-
|------|------|------|
|
|
193
|
-
| `onSubmit` (기본) | 제출 시에만 검증 | 대부분의 폼 |
|
|
194
|
-
| `onBlur` | 포커스를 벗어날 때 검증 | 긴 폼, 단계별 입력 |
|
|
195
|
-
| `onChange` | 입력할 때마다 검증 | 실시간 피드백이 필요한 필드 |
|
|
196
|
-
| `onTouched` | 첫 blur 이후부터 onChange로 검증 | 사용자 친화적 검증 |
|
|
197
|
-
|
|
198
|
-
```typescript
|
|
199
|
-
const { register, handleSubmit } = useForm<FormValues>({
|
|
200
|
-
resolver: zodResolver(schema),
|
|
201
|
-
mode: 'onBlur', // 포커스를 벗어날 때 검증
|
|
202
|
-
});
|
|
203
|
-
```
|
|
204
|
-
|
|
205
|
-
---
|
|
206
|
-
|
|
207
|
-
## 7. 동적 필드 (useFieldArray)
|
|
208
|
-
|
|
209
|
-
반복되는 필드 그룹을 동적으로 추가/삭제한다.
|
|
210
|
-
|
|
211
|
-
```typescript
|
|
212
|
-
const orderSchema = z.object({
|
|
213
|
-
items: z.array(
|
|
214
|
-
z.object({
|
|
215
|
-
productId: z.string().min(1, '상품을 선택해주세요'),
|
|
216
|
-
quantity: z.number().min(1, '수량은 1개 이상이어야 합니다'),
|
|
217
|
-
})
|
|
218
|
-
).min(1, '최소 1개 이상의 상품을 추가해주세요'),
|
|
219
|
-
});
|
|
220
|
-
|
|
221
|
-
type OrderForm = z.infer<typeof orderSchema>;
|
|
222
|
-
|
|
223
|
-
function OrderForm() {
|
|
224
|
-
const { control, register, handleSubmit } = useForm<OrderForm>({
|
|
225
|
-
resolver: zodResolver(orderSchema),
|
|
226
|
-
defaultValues: { items: [{ productId: '', quantity: 1 }] },
|
|
227
|
-
});
|
|
228
|
-
|
|
229
|
-
const { fields, append, remove } = useFieldArray({
|
|
230
|
-
control,
|
|
231
|
-
name: 'items',
|
|
232
|
-
});
|
|
233
|
-
|
|
234
|
-
return (
|
|
235
|
-
<form onSubmit={handleSubmit(onSubmit)}>
|
|
236
|
-
{fields.map((field, index) => (
|
|
237
|
-
<div key={field.id}>
|
|
238
|
-
<input {...register(`items.${index}.productId`)} />
|
|
239
|
-
<input
|
|
240
|
-
type="number"
|
|
241
|
-
{...register(`items.${index}.quantity`, { valueAsNumber: true })}
|
|
242
|
-
/>
|
|
243
|
-
<button type="button" onClick={() => remove(index)}>
|
|
244
|
-
삭제
|
|
245
|
-
</button>
|
|
246
|
-
</div>
|
|
247
|
-
))}
|
|
248
|
-
<button type="button" onClick={() => append({ productId: '', quantity: 1 })}>
|
|
249
|
-
상품 추가
|
|
250
|
-
</button>
|
|
251
|
-
</form>
|
|
252
|
-
);
|
|
253
|
-
}
|
|
254
|
-
```
|
|
255
|
-
|
|
256
|
-
---
|
|
257
|
-
|
|
258
|
-
## 8. 중첩 객체 / 배열 스키마
|
|
259
|
-
|
|
260
|
-
### 중첩 객체
|
|
261
|
-
|
|
262
|
-
```typescript
|
|
263
|
-
const addressSchema = z.object({
|
|
264
|
-
zipCode: z.string().length(5, '우편번호는 5자리입니다'),
|
|
265
|
-
city: z.string().min(1, '도시를 입력해주세요'),
|
|
266
|
-
detail: z.string().min(1, '상세 주소를 입력해주세요'),
|
|
267
|
-
});
|
|
268
|
-
|
|
269
|
-
const userSchema = z.object({
|
|
270
|
-
name: z.string().min(1),
|
|
271
|
-
address: addressSchema, // 중첩 객체
|
|
272
|
-
tags: z.array(z.string()), // 문자열 배열
|
|
273
|
-
});
|
|
274
|
-
|
|
275
|
-
type UserForm = z.infer<typeof userSchema>;
|
|
276
|
-
|
|
277
|
-
// register로 점 표기법 사용
|
|
278
|
-
<input {...register('address.zipCode')} />
|
|
279
|
-
<input {...register('address.city')} />
|
|
280
|
-
<input {...register('address.detail')} />
|
|
281
|
-
|
|
282
|
-
// 에러 접근도 점 표기법
|
|
283
|
-
{errors.address?.zipCode && <span>{errors.address.zipCode.message}</span>}
|
|
284
|
-
```
|
|
285
|
-
|
|
286
|
-
### 스키마 재사용
|
|
287
|
-
|
|
288
|
-
```typescript
|
|
289
|
-
// 공통 스키마를 조합하여 재사용한다
|
|
290
|
-
const baseUserSchema = z.object({
|
|
291
|
-
name: z.string().min(1),
|
|
292
|
-
email: z.string().email(),
|
|
293
|
-
});
|
|
294
|
-
|
|
295
|
-
const createUserSchema = baseUserSchema.extend({
|
|
296
|
-
password: z.string().min(8),
|
|
297
|
-
});
|
|
298
|
-
|
|
299
|
-
const updateUserSchema = baseUserSchema.partial(); // 모든 필드 optional
|
|
300
|
-
```
|
|
301
|
-
|
|
302
|
-
---
|
|
303
|
-
|
|
304
|
-
## 9. 네이밍 컨벤션
|
|
110
|
+
## 4. 네이밍 컨벤션
|
|
305
111
|
|
|
306
112
|
| 대상 | 규칙 | 예시 |
|
|
307
113
|
|------|------|------|
|
|
@@ -312,7 +118,7 @@ const updateUserSchema = baseUserSchema.partial(); // 모든 필드 optional
|
|
|
312
118
|
|
|
313
119
|
---
|
|
314
120
|
|
|
315
|
-
##
|
|
121
|
+
## 5. 금지 사항
|
|
316
122
|
|
|
317
123
|
- 스키마 없이 수동 검증 금지 - 반드시 Zod 스키마를 정의하고 `zodResolver`를 사용한다
|
|
318
124
|
- `any` 타입 사용 금지 - `z.infer<typeof schema>`로 타입을 추론한다
|
|
@@ -320,3 +126,9 @@ const updateUserSchema = baseUserSchema.partial(); // 모든 필드 optional
|
|
|
320
126
|
- `handleSubmit` 없이 `onSubmit` 직접 처리 금지 - `handleSubmit`이 검증을 수행한다
|
|
321
127
|
- 폼 타입과 스키마를 별도로 정의 금지 - 타입 불일치 위험이 있으므로 `z.infer`를 사용한다
|
|
322
128
|
- 에러 메시지 하드코딩 금지 - Zod 스키마에 메시지를 정의한다
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
## 심화 참조
|
|
133
|
+
|
|
134
|
+
- `references/advanced-patterns.md` - Controller 패턴, 에러 핸들링, 폼 검증 모드, 동적 필드(useFieldArray), 중첩 객체/배열 스키마
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# React Hook Form 심화 패턴
|
|
2
|
+
|
|
3
|
+
## 1. Controller 패턴
|
|
4
|
+
|
|
5
|
+
`register`를 사용할 수 없는 제어 컴포넌트 (Select, DatePicker, 커스텀 UI 라이브러리 등)에 사용한다.
|
|
6
|
+
|
|
7
|
+
```typescript
|
|
8
|
+
import { Controller, useForm } from 'react-hook-form';
|
|
9
|
+
|
|
10
|
+
function UserForm() {
|
|
11
|
+
const { control, handleSubmit } = useForm<UserFormValues>({
|
|
12
|
+
resolver: zodResolver(userSchema),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<form onSubmit={handleSubmit(onSubmit)}>
|
|
17
|
+
<Controller
|
|
18
|
+
name="role"
|
|
19
|
+
control={control}
|
|
20
|
+
render={({ field, fieldState: { error } }) => (
|
|
21
|
+
<>
|
|
22
|
+
<Select
|
|
23
|
+
value={field.value}
|
|
24
|
+
onChange={field.onChange}
|
|
25
|
+
options={roleOptions}
|
|
26
|
+
/>
|
|
27
|
+
{error && <span>{error.message}</span>}
|
|
28
|
+
</>
|
|
29
|
+
)}
|
|
30
|
+
/>
|
|
31
|
+
|
|
32
|
+
<Controller
|
|
33
|
+
name="birthDate"
|
|
34
|
+
control={control}
|
|
35
|
+
render={({ field, fieldState: { error } }) => (
|
|
36
|
+
<>
|
|
37
|
+
<DatePicker
|
|
38
|
+
selected={field.value}
|
|
39
|
+
onChange={field.onChange}
|
|
40
|
+
/>
|
|
41
|
+
{error && <span>{error.message}</span>}
|
|
42
|
+
</>
|
|
43
|
+
)}
|
|
44
|
+
/>
|
|
45
|
+
</form>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## 2. 에러 핸들링
|
|
53
|
+
|
|
54
|
+
### errors 객체로 직접 표시
|
|
55
|
+
|
|
56
|
+
```typescript
|
|
57
|
+
// 간단한 에러 표시
|
|
58
|
+
{errors.email && <span className="error">{errors.email.message}</span>}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### 재사용 가능한 에러 컴포넌트
|
|
62
|
+
|
|
63
|
+
```typescript
|
|
64
|
+
type FieldErrorProps = {
|
|
65
|
+
error?: FieldError;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
function FieldError({ error }: FieldErrorProps) {
|
|
69
|
+
if (!error) return null;
|
|
70
|
+
return <span className="field-error">{error.message}</span>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// 사용
|
|
74
|
+
<input {...register('email')} />
|
|
75
|
+
<FieldError error={errors.email} />
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## 3. 폼 검증 모드
|
|
81
|
+
|
|
82
|
+
### mode 옵션
|
|
83
|
+
|
|
84
|
+
| mode | 동작 | 용도 |
|
|
85
|
+
|------|------|------|
|
|
86
|
+
| `onSubmit` (기본) | 제출 시에만 검증 | 대부분의 폼 |
|
|
87
|
+
| `onBlur` | 포커스를 벗어날 때 검증 | 긴 폼, 단계별 입력 |
|
|
88
|
+
| `onChange` | 입력할 때마다 검증 | 실시간 피드백이 필요한 필드 |
|
|
89
|
+
| `onTouched` | 첫 blur 이후부터 onChange로 검증 | 사용자 친화적 검증 |
|
|
90
|
+
|
|
91
|
+
```typescript
|
|
92
|
+
const { register, handleSubmit } = useForm<FormValues>({
|
|
93
|
+
resolver: zodResolver(schema),
|
|
94
|
+
mode: 'onBlur', // 포커스를 벗어날 때 검증
|
|
95
|
+
});
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## 4. 동적 필드 (useFieldArray)
|
|
101
|
+
|
|
102
|
+
반복되는 필드 그룹을 동적으로 추가/삭제한다.
|
|
103
|
+
|
|
104
|
+
```typescript
|
|
105
|
+
const orderSchema = z.object({
|
|
106
|
+
items: z.array(
|
|
107
|
+
z.object({
|
|
108
|
+
productId: z.string().min(1, '상품을 선택해주세요'),
|
|
109
|
+
quantity: z.number().min(1, '수량은 1개 이상이어야 합니다'),
|
|
110
|
+
})
|
|
111
|
+
).min(1, '최소 1개 이상의 상품을 추가해주세요'),
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
type OrderForm = z.infer<typeof orderSchema>;
|
|
115
|
+
|
|
116
|
+
function OrderForm() {
|
|
117
|
+
const { control, register, handleSubmit } = useForm<OrderForm>({
|
|
118
|
+
resolver: zodResolver(orderSchema),
|
|
119
|
+
defaultValues: { items: [{ productId: '', quantity: 1 }] },
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const { fields, append, remove } = useFieldArray({
|
|
123
|
+
control,
|
|
124
|
+
name: 'items',
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<form onSubmit={handleSubmit(onSubmit)}>
|
|
129
|
+
{fields.map((field, index) => (
|
|
130
|
+
<div key={field.id}>
|
|
131
|
+
<input {...register(`items.${index}.productId`)} />
|
|
132
|
+
<input
|
|
133
|
+
type="number"
|
|
134
|
+
{...register(`items.${index}.quantity`, { valueAsNumber: true })}
|
|
135
|
+
/>
|
|
136
|
+
<button type="button" onClick={() => remove(index)}>
|
|
137
|
+
삭제
|
|
138
|
+
</button>
|
|
139
|
+
</div>
|
|
140
|
+
))}
|
|
141
|
+
<button type="button" onClick={() => append({ productId: '', quantity: 1 })}>
|
|
142
|
+
상품 추가
|
|
143
|
+
</button>
|
|
144
|
+
</form>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
---
|
|
150
|
+
|
|
151
|
+
## 5. 중첩 객체 / 배열 스키마
|
|
152
|
+
|
|
153
|
+
### 중첩 객체
|
|
154
|
+
|
|
155
|
+
```typescript
|
|
156
|
+
const addressSchema = z.object({
|
|
157
|
+
zipCode: z.string().length(5, '우편번호는 5자리입니다'),
|
|
158
|
+
city: z.string().min(1, '도시를 입력해주세요'),
|
|
159
|
+
detail: z.string().min(1, '상세 주소를 입력해주세요'),
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const userSchema = z.object({
|
|
163
|
+
name: z.string().min(1),
|
|
164
|
+
address: addressSchema, // 중첩 객체
|
|
165
|
+
tags: z.array(z.string()), // 문자열 배열
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
type UserForm = z.infer<typeof userSchema>;
|
|
169
|
+
|
|
170
|
+
// register로 점 표기법 사용
|
|
171
|
+
<input {...register('address.zipCode')} />
|
|
172
|
+
<input {...register('address.city')} />
|
|
173
|
+
<input {...register('address.detail')} />
|
|
174
|
+
|
|
175
|
+
// 에러 접근도 점 표기법
|
|
176
|
+
{errors.address?.zipCode && <span>{errors.address.zipCode.message}</span>}
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
### 스키마 재사용
|
|
180
|
+
|
|
181
|
+
```typescript
|
|
182
|
+
// 공통 스키마를 조합하여 재사용한다
|
|
183
|
+
const baseUserSchema = z.object({
|
|
184
|
+
name: z.string().min(1),
|
|
185
|
+
email: z.string().email(),
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
const createUserSchema = baseUserSchema.extend({
|
|
189
|
+
password: z.string().min(8),
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
const updateUserSchema = baseUserSchema.partial(); // 모든 필드 optional
|
|
193
|
+
```
|
|
@@ -7,8 +7,8 @@ description: TDD(테스트 주도 개발) 공통 원칙. 테스트 작성 시 Re
|
|
|
7
7
|
|
|
8
8
|
이 문서는 모든 테스트 코드 작성 시 적용되는 공통 원칙을 정의한다.
|
|
9
9
|
FE/BE별 상세 규칙은 각각의 파일을 참고한다.
|
|
10
|
-
- `frontend.md` - React Testing Library 기반 프론트엔드 테스트 규칙
|
|
11
|
-
- `backend.md` - NestJS 백엔드 테스트 규칙
|
|
10
|
+
- `references/frontend.md` - React Testing Library 기반 프론트엔드 테스트 규칙
|
|
11
|
+
- `references/backend.md` - NestJS 백엔드 테스트 규칙
|
|
12
12
|
|
|
13
13
|
---
|
|
14
14
|
|