@choblue/claude-code-toolkit 1.0.0
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/.gitkeep +0 -0
- package/.claude/CLAUDE.md +100 -0
- package/.claude/agents/code-reviewer.md +87 -0
- package/.claude/agents/code-writer/backend.md +95 -0
- package/.claude/agents/code-writer/common.md +79 -0
- package/.claude/agents/code-writer/frontend.md +91 -0
- package/.claude/agents/explore.md +54 -0
- package/.claude/agents/git-manager.md +102 -0
- package/.claude/agents/tdd/backend.md +207 -0
- package/.claude/agents/tdd/common.md +137 -0
- package/.claude/agents/tdd/frontend.md +250 -0
- package/.claude/hooks/quality-gate.sh +17 -0
- package/.claude/settings.json +15 -0
- package/.claude/skills/Coding/SKILL.md +108 -0
- package/.claude/skills/Coding/backend.md +97 -0
- package/.claude/skills/Coding/frontend.md +11 -0
- package/.claude/skills/Git/SKILL.md +93 -0
- package/.claude/skills/NextJS/SKILL.md +424 -0
- package/.claude/skills/React/SKILL.md +261 -0
- package/.claude/skills/ReactHookForm/SKILL.md +317 -0
- package/.claude/skills/TDD/SKILL.md +161 -0
- package/.claude/skills/TDD/backend.md +356 -0
- package/.claude/skills/TDD/frontend.md +392 -0
- package/.claude/skills/TailwindCSS/SKILL.md +368 -0
- package/.claude/skills/TanStackQuery/SKILL.md +242 -0
- package/.claude/skills/TypeORM/SKILL.md +621 -0
- package/.claude/skills/TypeScript/SKILL.md +528 -0
- package/.claude/skills/Zustand/SKILL.md +285 -0
- package/README.md +157 -0
- package/bin/cli.js +18 -0
- package/install.sh +255 -0
- package/package.json +27 -0
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
# Next.js Skill - App Router 규칙
|
|
2
|
+
|
|
3
|
+
Next.js App Router 기반 프로젝트에 적용되는 규칙이다.
|
|
4
|
+
React 핵심 규칙은 `../React/SKILL.md`, 공통 원칙은 `../Coding/SKILL.md`를 함께 참고한다.
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## 1. App Router 기본 구조
|
|
9
|
+
|
|
10
|
+
### 예약 파일명
|
|
11
|
+
App Router는 다음 예약 파일명을 사용한다.
|
|
12
|
+
|
|
13
|
+
| 파일명 | 역할 | 설명 |
|
|
14
|
+
|--------|------|------|
|
|
15
|
+
| `page.tsx` | 페이지 | 해당 라우트의 UI |
|
|
16
|
+
| `layout.tsx` | 레이아웃 | 하위 라우트 공유 레이아웃 |
|
|
17
|
+
| `loading.tsx` | 로딩 UI | Suspense 기반 로딩 상태 |
|
|
18
|
+
| `error.tsx` | 에러 UI | Error Boundary 기반 에러 처리 |
|
|
19
|
+
| `not-found.tsx` | 404 UI | 리소스를 찾을 수 없을 때 |
|
|
20
|
+
| `template.tsx` | 템플릿 | 네비게이션마다 새 인스턴스 생성 |
|
|
21
|
+
| `default.tsx` | 기본 UI | Parallel Routes 기본 폴백 |
|
|
22
|
+
|
|
23
|
+
### 기본 구조 예시
|
|
24
|
+
```
|
|
25
|
+
app/
|
|
26
|
+
layout.tsx # 루트 레이아웃 (필수)
|
|
27
|
+
page.tsx # 홈 페이지
|
|
28
|
+
loading.tsx # 글로벌 로딩
|
|
29
|
+
error.tsx # 글로벌 에러
|
|
30
|
+
not-found.tsx # 404 페이지
|
|
31
|
+
users/
|
|
32
|
+
page.tsx # /users
|
|
33
|
+
[id]/
|
|
34
|
+
page.tsx # /users/:id
|
|
35
|
+
loading.tsx
|
|
36
|
+
error.tsx
|
|
37
|
+
api/
|
|
38
|
+
users/
|
|
39
|
+
route.ts # API: /api/users
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## 2. Server Component vs Client Component
|
|
45
|
+
|
|
46
|
+
### 기본 원칙
|
|
47
|
+
- **모든 컴포넌트는 기본적으로 Server Component이다**
|
|
48
|
+
- `'use client'`는 실제로 클라이언트 기능이 필요할 때만 선언한다
|
|
49
|
+
- 서버에서 할 수 있는 일은 서버에서 처리한다
|
|
50
|
+
|
|
51
|
+
### Client Component가 필요한 경우
|
|
52
|
+
- `useState`, `useEffect` 등 React 훅 사용 시
|
|
53
|
+
- 브라우저 API 접근 시 (`window`, `document`, `localStorage`)
|
|
54
|
+
- 이벤트 핸들러 사용 시 (`onClick`, `onChange`)
|
|
55
|
+
- 클라이언트 전용 라이브러리 사용 시
|
|
56
|
+
|
|
57
|
+
### 분리 전략
|
|
58
|
+
- `'use client'` 경계를 최대한 하위로 밀어내린다
|
|
59
|
+
- 페이지 전체를 Client Component로 만들지 않는다
|
|
60
|
+
|
|
61
|
+
```typescript
|
|
62
|
+
// Bad - 페이지 전체를 Client Component로 선언
|
|
63
|
+
'use client';
|
|
64
|
+
|
|
65
|
+
export default function UserPage() {
|
|
66
|
+
const [tab, setTab] = useState('profile');
|
|
67
|
+
const users = await fetchUsers(); // Server에서 가능한 작업인데 Client로 선언
|
|
68
|
+
|
|
69
|
+
return <div>...</div>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Good - 클라이언트 기능만 분리
|
|
73
|
+
// app/users/page.tsx (Server Component)
|
|
74
|
+
export default async function UserPage() {
|
|
75
|
+
const users = await fetchUsers();
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<div>
|
|
79
|
+
<UserList users={users} />
|
|
80
|
+
<UserTabs /> {/* 이것만 Client Component */}
|
|
81
|
+
</div>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// components/UserTabs.tsx (Client Component)
|
|
86
|
+
'use client';
|
|
87
|
+
|
|
88
|
+
export function UserTabs() {
|
|
89
|
+
const [tab, setTab] = useState('profile');
|
|
90
|
+
return <Tabs value={tab} onChange={setTab} />;
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
## 3. 데이터 페칭
|
|
97
|
+
|
|
98
|
+
### Server Component에서 직접 fetch
|
|
99
|
+
- Server Component에서 `async/await`로 직접 데이터를 가져온다
|
|
100
|
+
- `useEffect`로 데이터를 가져오지 않는다
|
|
101
|
+
|
|
102
|
+
```typescript
|
|
103
|
+
// Good - Server Component에서 직접 페칭
|
|
104
|
+
export default async function UserPage({ params }: { params: { id: string } }) {
|
|
105
|
+
const user = await getUser(params.id);
|
|
106
|
+
return <UserProfile user={user} />;
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### Revalidation 전략
|
|
111
|
+
- **Time-based**: `next: { revalidate: 60 }` - 일정 시간마다 갱신
|
|
112
|
+
- **On-demand**: `revalidateTag()`, `revalidatePath()` - 특정 이벤트 시 갱신
|
|
113
|
+
|
|
114
|
+
```typescript
|
|
115
|
+
// Time-based revalidation
|
|
116
|
+
const data = await fetch('https://api.example.com/users', {
|
|
117
|
+
next: { revalidate: 3600 }, // 1시간마다 갱신
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// Tag-based revalidation
|
|
121
|
+
const data = await fetch('https://api.example.com/users', {
|
|
122
|
+
next: { tags: ['users'] },
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// Server Action에서 revalidation 트리거
|
|
126
|
+
'use server';
|
|
127
|
+
import { revalidateTag } from 'next/cache';
|
|
128
|
+
|
|
129
|
+
export async function createUser(formData: FormData) {
|
|
130
|
+
await saveUser(formData);
|
|
131
|
+
revalidateTag('users');
|
|
132
|
+
}
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
---
|
|
136
|
+
|
|
137
|
+
## 4. Route Handlers
|
|
138
|
+
|
|
139
|
+
### 위치 및 구조
|
|
140
|
+
- `app/api/` 디렉토리 하위에 `route.ts` 파일로 정의한다
|
|
141
|
+
- HTTP 메서드를 named export로 정의한다
|
|
142
|
+
|
|
143
|
+
```typescript
|
|
144
|
+
// app/api/users/route.ts
|
|
145
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
146
|
+
|
|
147
|
+
export async function GET(request: NextRequest) {
|
|
148
|
+
const users = await getUsers();
|
|
149
|
+
return NextResponse.json(users);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export async function POST(request: NextRequest) {
|
|
153
|
+
const body = await request.json();
|
|
154
|
+
const user = await createUser(body);
|
|
155
|
+
return NextResponse.json(user, { status: 201 });
|
|
156
|
+
}
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### 동적 라우트
|
|
160
|
+
```typescript
|
|
161
|
+
// app/api/users/[id]/route.ts
|
|
162
|
+
export async function GET(
|
|
163
|
+
request: NextRequest,
|
|
164
|
+
{ params }: { params: { id: string } },
|
|
165
|
+
) {
|
|
166
|
+
const user = await getUser(params.id);
|
|
167
|
+
if (!user) {
|
|
168
|
+
return NextResponse.json({ error: 'Not Found' }, { status: 404 });
|
|
169
|
+
}
|
|
170
|
+
return NextResponse.json(user);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export async function PUT(
|
|
174
|
+
request: NextRequest,
|
|
175
|
+
{ params }: { params: { id: string } },
|
|
176
|
+
) {
|
|
177
|
+
const body = await request.json();
|
|
178
|
+
const user = await updateUser(params.id, body);
|
|
179
|
+
return NextResponse.json(user);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export async function DELETE(
|
|
183
|
+
request: NextRequest,
|
|
184
|
+
{ params }: { params: { id: string } },
|
|
185
|
+
) {
|
|
186
|
+
await deleteUser(params.id);
|
|
187
|
+
return new NextResponse(null, { status: 204 });
|
|
188
|
+
}
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
---
|
|
192
|
+
|
|
193
|
+
## 5. Middleware
|
|
194
|
+
|
|
195
|
+
### 용도
|
|
196
|
+
- 인증/인가 체크
|
|
197
|
+
- 리다이렉트 처리
|
|
198
|
+
- 요청/응답 헤더 수정
|
|
199
|
+
- 국제화(i18n) 라우팅
|
|
200
|
+
|
|
201
|
+
### 작성 패턴
|
|
202
|
+
```typescript
|
|
203
|
+
// middleware.ts (프로젝트 루트)
|
|
204
|
+
import { NextResponse } from 'next/server';
|
|
205
|
+
import type { NextRequest } from 'next/server';
|
|
206
|
+
|
|
207
|
+
export function middleware(request: NextRequest) {
|
|
208
|
+
const token = request.cookies.get('token')?.value;
|
|
209
|
+
|
|
210
|
+
if (!token && request.nextUrl.pathname.startsWith('/dashboard')) {
|
|
211
|
+
return NextResponse.redirect(new URL('/login', request.url));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return NextResponse.next();
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export const config = {
|
|
218
|
+
matcher: ['/dashboard/:path*', '/admin/:path*'],
|
|
219
|
+
};
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
---
|
|
223
|
+
|
|
224
|
+
## 6. Metadata
|
|
225
|
+
|
|
226
|
+
### 정적 Metadata
|
|
227
|
+
```typescript
|
|
228
|
+
// app/about/page.tsx
|
|
229
|
+
import type { Metadata } from 'next';
|
|
230
|
+
|
|
231
|
+
export const metadata: Metadata = {
|
|
232
|
+
title: '소개 - 서비스명',
|
|
233
|
+
description: '서비스 소개 페이지입니다.',
|
|
234
|
+
};
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
### 동적 Metadata
|
|
238
|
+
```typescript
|
|
239
|
+
// app/users/[id]/page.tsx
|
|
240
|
+
import type { Metadata } from 'next';
|
|
241
|
+
|
|
242
|
+
export async function generateMetadata({
|
|
243
|
+
params,
|
|
244
|
+
}: {
|
|
245
|
+
params: { id: string };
|
|
246
|
+
}): Promise<Metadata> {
|
|
247
|
+
const user = await getUser(params.id);
|
|
248
|
+
|
|
249
|
+
return {
|
|
250
|
+
title: `${user.name} - 프로필`,
|
|
251
|
+
description: `${user.name}의 프로필 페이지입니다.`,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
---
|
|
257
|
+
|
|
258
|
+
## 7. Server Actions
|
|
259
|
+
|
|
260
|
+
### 정의 및 사용
|
|
261
|
+
- `'use server'` 지시문으로 서버 액션을 정의한다
|
|
262
|
+
- form의 `action` 속성이나 이벤트 핸들러에서 호출한다
|
|
263
|
+
|
|
264
|
+
```typescript
|
|
265
|
+
// actions/user.ts
|
|
266
|
+
'use server';
|
|
267
|
+
|
|
268
|
+
import { revalidatePath } from 'next/cache';
|
|
269
|
+
|
|
270
|
+
export async function createUser(formData: FormData) {
|
|
271
|
+
const name = formData.get('name') as string;
|
|
272
|
+
const email = formData.get('email') as string;
|
|
273
|
+
|
|
274
|
+
await db.user.create({ data: { name, email } });
|
|
275
|
+
revalidatePath('/users');
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export async function deleteUser(userId: string) {
|
|
279
|
+
await db.user.delete({ where: { id: userId } });
|
|
280
|
+
revalidatePath('/users');
|
|
281
|
+
}
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
### Form에서 사용
|
|
285
|
+
```typescript
|
|
286
|
+
// components/CreateUserForm.tsx
|
|
287
|
+
import { createUser } from '@/actions/user';
|
|
288
|
+
|
|
289
|
+
export function CreateUserForm() {
|
|
290
|
+
return (
|
|
291
|
+
<form action={createUser}>
|
|
292
|
+
<input name="name" type="text" required />
|
|
293
|
+
<input name="email" type="email" required />
|
|
294
|
+
<button type="submit">생성</button>
|
|
295
|
+
</form>
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
---
|
|
301
|
+
|
|
302
|
+
## 8. 디렉토리 구조 패턴
|
|
303
|
+
|
|
304
|
+
### Route Groups - `(group)`
|
|
305
|
+
- URL에 영향을 주지 않고 라우트를 그룹화한다
|
|
306
|
+
|
|
307
|
+
```
|
|
308
|
+
app/
|
|
309
|
+
(marketing)/
|
|
310
|
+
about/page.tsx # /about
|
|
311
|
+
blog/page.tsx # /blog
|
|
312
|
+
(dashboard)/
|
|
313
|
+
layout.tsx # dashboard 전용 레이아웃
|
|
314
|
+
settings/page.tsx # /settings
|
|
315
|
+
profile/page.tsx # /profile
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
### Private Folders - `_folder`
|
|
319
|
+
- 라우팅에서 제외되는 내부 폴더
|
|
320
|
+
|
|
321
|
+
```
|
|
322
|
+
app/
|
|
323
|
+
_components/ # 라우팅에 포함되지 않음
|
|
324
|
+
Header.tsx
|
|
325
|
+
Footer.tsx
|
|
326
|
+
_lib/ # 내부 유틸리티
|
|
327
|
+
utils.ts
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
### Parallel Routes - `@slot`
|
|
331
|
+
- 동일 레이아웃에서 여러 페이지를 동시에 렌더링한다
|
|
332
|
+
|
|
333
|
+
```
|
|
334
|
+
app/
|
|
335
|
+
layout.tsx # children + @analytics + @team 동시 렌더링
|
|
336
|
+
@analytics/
|
|
337
|
+
page.tsx
|
|
338
|
+
@team/
|
|
339
|
+
page.tsx
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
### Intercepting Routes - `(.)`, `(..)`, `(...)`
|
|
343
|
+
- 현재 레이아웃 내에서 다른 라우트를 가로채서 표시한다
|
|
344
|
+
|
|
345
|
+
```
|
|
346
|
+
app/
|
|
347
|
+
feed/
|
|
348
|
+
page.tsx
|
|
349
|
+
(..)photo/[id]/ # /photo/:id를 모달로 가로챔
|
|
350
|
+
page.tsx
|
|
351
|
+
photo/[id]/
|
|
352
|
+
page.tsx # 직접 접근 시 전체 페이지
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
---
|
|
356
|
+
|
|
357
|
+
## 9. Image/Font 최적화
|
|
358
|
+
|
|
359
|
+
### next/image
|
|
360
|
+
- 모든 이미지는 `next/image`를 사용한다
|
|
361
|
+
- `width`, `height`를 명시하거나 `fill` 속성을 사용한다
|
|
362
|
+
|
|
363
|
+
```typescript
|
|
364
|
+
import Image from 'next/image';
|
|
365
|
+
|
|
366
|
+
// 크기 지정
|
|
367
|
+
<Image src="/hero.png" alt="히어로 이미지" width={800} height={400} />
|
|
368
|
+
|
|
369
|
+
// fill 모드 (부모 기준 채움)
|
|
370
|
+
<div className="relative h-64 w-full">
|
|
371
|
+
<Image src="/banner.png" alt="배너" fill className="object-cover" />
|
|
372
|
+
</div>
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
### next/font
|
|
376
|
+
- Google Fonts는 `next/font/google`을 사용한다
|
|
377
|
+
- 커스텀 폰트는 `next/font/local`을 사용한다
|
|
378
|
+
|
|
379
|
+
```typescript
|
|
380
|
+
// app/layout.tsx
|
|
381
|
+
import { Inter } from 'next/font/google';
|
|
382
|
+
|
|
383
|
+
const inter = Inter({ subsets: ['latin'] });
|
|
384
|
+
|
|
385
|
+
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
386
|
+
return (
|
|
387
|
+
<html lang="ko" className={inter.className}>
|
|
388
|
+
<body>{children}</body>
|
|
389
|
+
</html>
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
---
|
|
395
|
+
|
|
396
|
+
## 10. 네이밍 컨벤션
|
|
397
|
+
|
|
398
|
+
| 대상 | 규칙 | 예시 |
|
|
399
|
+
|------|------|------|
|
|
400
|
+
| 페이지 파일 | `page.tsx` (예약) | `app/users/page.tsx` |
|
|
401
|
+
| 레이아웃 파일 | `layout.tsx` (예약) | `app/layout.tsx` |
|
|
402
|
+
| 로딩 파일 | `loading.tsx` (예약) | `app/users/loading.tsx` |
|
|
403
|
+
| 에러 파일 | `error.tsx` (예약) | `app/users/error.tsx` |
|
|
404
|
+
| API 라우트 | `route.ts` (예약) | `app/api/users/route.ts` |
|
|
405
|
+
| Server Action 파일 | `camelCase.ts` | `actions/createUser.ts` |
|
|
406
|
+
| Server Action 함수 | `camelCase` | `createUser`, `deletePost` |
|
|
407
|
+
| Route Group | `(groupName)` | `(marketing)`, `(dashboard)` |
|
|
408
|
+
| Private Folder | `_folderName` | `_components`, `_lib` |
|
|
409
|
+
| Dynamic Segment | `[param]` | `[id]`, `[slug]` |
|
|
410
|
+
| Catch-all Segment | `[...param]` | `[...slug]` |
|
|
411
|
+
|
|
412
|
+
---
|
|
413
|
+
|
|
414
|
+
## 11. 금지 사항
|
|
415
|
+
|
|
416
|
+
- Client Component에서 무거운 데이터 페칭 로직 작성 금지
|
|
417
|
+
- 불필요한 `'use client'` 선언 금지 - 서버에서 가능하면 서버에서 처리
|
|
418
|
+
- Pages Router 패턴 사용 금지 (`getServerSideProps`, `getStaticProps`, `_app.tsx`, `_document.tsx`)
|
|
419
|
+
- `page.tsx`에 `'use client'` 직접 선언 금지 - 클라이언트 로직은 하위 컴포넌트로 분리
|
|
420
|
+
- Server Component에서 `useState`, `useEffect` 등 클라이언트 훅 사용 금지
|
|
421
|
+
- `<img>` 태그 직접 사용 금지 - `next/image` 사용
|
|
422
|
+
- 외부 폰트를 `<link>` 태그로 로드 금지 - `next/font` 사용
|
|
423
|
+
- `router.push()`를 Server Component에서 사용 금지 - `redirect()` 사용
|
|
424
|
+
- API Route에서 비즈니스 로직 직접 구현 금지 - 별도 서비스 레이어로 분리
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
# React Skill - React 핵심 규칙
|
|
2
|
+
|
|
3
|
+
React 컴포넌트 설계 및 개발에 적용되는 핵심 규칙이다.
|
|
4
|
+
공통 코딩 원칙은 `../Coding/SKILL.md`를 함께 참고한다.
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## 1. 컴포넌트 설계 원칙
|
|
9
|
+
|
|
10
|
+
### 함수형 컴포넌트만 사용한다
|
|
11
|
+
- 클래스 컴포넌트를 사용하지 않는다
|
|
12
|
+
- `React.FC`를 사용하지 않는다 - Props를 직접 타이핑한다
|
|
13
|
+
|
|
14
|
+
### Props interface 명시
|
|
15
|
+
- 모든 컴포넌트는 Props 타입을 `interface`로 선언한다
|
|
16
|
+
- Props 네이밍은 `컴포넌트명 + Props`로 한다
|
|
17
|
+
|
|
18
|
+
### SRP (Single Responsibility Principle)
|
|
19
|
+
- 하나의 컴포넌트는 하나의 역할만 수행한다
|
|
20
|
+
- "이 컴포넌트가 하는 일"을 한 문장으로 설명할 수 없으면 분리한다
|
|
21
|
+
|
|
22
|
+
### 크기 제한
|
|
23
|
+
- 컴포넌트는 최대 200줄을 넘기지 않는다
|
|
24
|
+
- 200줄을 초과하면 하위 컴포넌트로 분리한다
|
|
25
|
+
|
|
26
|
+
```typescript
|
|
27
|
+
// Bad
|
|
28
|
+
const UserPage = () => {
|
|
29
|
+
// 200줄 이상의 거대한 컴포넌트
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// Good
|
|
33
|
+
interface UserPageProps {
|
|
34
|
+
userId: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function UserPage({ userId }: UserPageProps) {
|
|
38
|
+
const { user, isLoading } = useUser(userId);
|
|
39
|
+
|
|
40
|
+
if (isLoading) return <UserSkeleton />;
|
|
41
|
+
if (!user) return <UserNotFound />;
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<div>
|
|
45
|
+
<UserHeader user={user} />
|
|
46
|
+
<UserContent user={user} />
|
|
47
|
+
<UserActions userId={user.id} />
|
|
48
|
+
</div>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## 2. Props 설계
|
|
56
|
+
|
|
57
|
+
### 최소한의 Props
|
|
58
|
+
- 컴포넌트가 실제로 사용하는 값만 전달한다
|
|
59
|
+
- Props가 5개를 초과하면 설계를 재검토한다
|
|
60
|
+
|
|
61
|
+
### 콜백 네이밍
|
|
62
|
+
- Props로 전달하는 콜백은 `on` + 동사로 네이밍한다
|
|
63
|
+
|
|
64
|
+
```typescript
|
|
65
|
+
// Bad
|
|
66
|
+
interface ButtonProps {
|
|
67
|
+
clickHandler: () => void;
|
|
68
|
+
deleteCallback: () => void;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Good
|
|
72
|
+
interface ButtonProps {
|
|
73
|
+
onClick: () => void;
|
|
74
|
+
onDelete: () => void;
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### 객체 통째 전달 지양
|
|
79
|
+
- 필요한 프로퍼티만 개별적으로 전달한다
|
|
80
|
+
- 단, Props가 너무 많아지면 객체 전달을 허용한다
|
|
81
|
+
|
|
82
|
+
```typescript
|
|
83
|
+
// Bad - 불필요한 데이터까지 전달
|
|
84
|
+
interface UserAvatarProps {
|
|
85
|
+
user: User; // user.name, user.avatar만 사용하는데 전체 객체 전달
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Good - 필요한 값만 전달
|
|
89
|
+
interface UserAvatarProps {
|
|
90
|
+
name: string;
|
|
91
|
+
avatarUrl: string;
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
## 3. 상태 관리 원칙
|
|
98
|
+
|
|
99
|
+
### 가까운 곳에 배치
|
|
100
|
+
- 상태는 그것을 사용하는 가장 가까운 컴포넌트에 배치한다
|
|
101
|
+
- 상위 컴포넌트로의 lifting은 실제로 필요할 때만 수행한다
|
|
102
|
+
|
|
103
|
+
### 파생값은 상태가 아니다
|
|
104
|
+
- 기존 상태에서 계산할 수 있는 값은 별도 상태로 만들지 않는다
|
|
105
|
+
|
|
106
|
+
```typescript
|
|
107
|
+
// Bad - 파생값을 상태로 관리
|
|
108
|
+
const [items, setItems] = useState<Item[]>([]);
|
|
109
|
+
const [itemCount, setItemCount] = useState(0);
|
|
110
|
+
// items가 변경될 때마다 setItemCount를 호출해야 함
|
|
111
|
+
|
|
112
|
+
// Good - 파생값은 계산
|
|
113
|
+
const [items, setItems] = useState<Item[]>([]);
|
|
114
|
+
const itemCount = items.length;
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### 서버 상태와 클라이언트 상태 분리
|
|
118
|
+
- **서버 상태**: API에서 가져온 데이터 -> React Query / SWR 사용
|
|
119
|
+
- **클라이언트 상태**: UI 상태 (모달 열림, 탭 선택 등) -> useState / useReducer 사용
|
|
120
|
+
- 서버 데이터를 `useState`로 복사하지 않는다
|
|
121
|
+
|
|
122
|
+
---
|
|
123
|
+
|
|
124
|
+
## 4. 커스텀 훅
|
|
125
|
+
|
|
126
|
+
### use 접두사
|
|
127
|
+
- 모든 커스텀 훅은 `use`로 시작한다
|
|
128
|
+
|
|
129
|
+
### 하나의 관심사
|
|
130
|
+
- 하나의 훅은 하나의 관심사만 다룬다
|
|
131
|
+
|
|
132
|
+
### 데이터 페칭/상태 로직 분리
|
|
133
|
+
- 컴포넌트에서 데이터 페칭과 상태 로직을 커스텀 훅으로 분리한다
|
|
134
|
+
|
|
135
|
+
```typescript
|
|
136
|
+
// Bad - 컴포넌트에 로직이 섞여 있음
|
|
137
|
+
function UserList() {
|
|
138
|
+
const [users, setUsers] = useState<User[]>([]);
|
|
139
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
140
|
+
const [error, setError] = useState<Error | null>(null);
|
|
141
|
+
|
|
142
|
+
useEffect(() => {
|
|
143
|
+
setIsLoading(true);
|
|
144
|
+
fetchUsers()
|
|
145
|
+
.then(setUsers)
|
|
146
|
+
.catch(setError)
|
|
147
|
+
.finally(() => setIsLoading(false));
|
|
148
|
+
}, []);
|
|
149
|
+
|
|
150
|
+
// ... 렌더링
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Good - 커스텀 훅으로 분리
|
|
154
|
+
function useUserList() {
|
|
155
|
+
const { data: users = [], isLoading, error } = useQuery({
|
|
156
|
+
queryKey: ['users'],
|
|
157
|
+
queryFn: fetchUsers,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
return { users, isLoading, error };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function UserList() {
|
|
164
|
+
const { users, isLoading, error } = useUserList();
|
|
165
|
+
// ... 렌더링만 담당
|
|
166
|
+
}
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
---
|
|
170
|
+
|
|
171
|
+
## 5. 렌더링 최적화
|
|
172
|
+
|
|
173
|
+
### 불필요한 useMemo/useCallback 금지
|
|
174
|
+
- 성능 문제가 실제로 측정된 경우에만 사용한다
|
|
175
|
+
- 참조 동일성이 필요한 경우(의존성 배열, memo된 자식 컴포넌트)에만 사용한다
|
|
176
|
+
|
|
177
|
+
```typescript
|
|
178
|
+
// Bad - 불필요한 메모이제이션
|
|
179
|
+
const userName = useMemo(() => `${first} ${last}`, [first, last]);
|
|
180
|
+
|
|
181
|
+
// Good - 단순 계산은 그냥 수행
|
|
182
|
+
const userName = `${first} ${last}`;
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
### key 올바르게 사용
|
|
186
|
+
- 리스트 렌더링 시 고유한 식별자를 `key`로 사용한다
|
|
187
|
+
- 배열 인덱스를 `key`로 사용하지 않는다 (정적 리스트 제외)
|
|
188
|
+
|
|
189
|
+
```typescript
|
|
190
|
+
// Bad
|
|
191
|
+
{items.map((item, index) => (
|
|
192
|
+
<Item key={index} data={item} />
|
|
193
|
+
))}
|
|
194
|
+
|
|
195
|
+
// Good
|
|
196
|
+
{items.map((item) => (
|
|
197
|
+
<Item key={item.id} data={item} />
|
|
198
|
+
))}
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
---
|
|
202
|
+
|
|
203
|
+
## 6. 조건부 렌더링 패턴
|
|
204
|
+
|
|
205
|
+
### 단순 조건
|
|
206
|
+
- 2개 이하의 조건은 삼항 연산자 또는 `&&`를 사용한다
|
|
207
|
+
|
|
208
|
+
```typescript
|
|
209
|
+
// 단순 표시/숨김
|
|
210
|
+
{isVisible && <Modal />}
|
|
211
|
+
|
|
212
|
+
// 이분기
|
|
213
|
+
{isLoading ? <Skeleton /> : <Content />}
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
### 복잡한 조건
|
|
217
|
+
- 3개 이상의 분기는 early return 또는 별도 컴포넌트로 분리한다
|
|
218
|
+
|
|
219
|
+
```typescript
|
|
220
|
+
// Bad - 중첩된 삼항
|
|
221
|
+
{isLoading ? <Skeleton /> : error ? <Error /> : data ? <Content /> : <Empty />}
|
|
222
|
+
|
|
223
|
+
// Good - early return
|
|
224
|
+
function UserContent({ isLoading, error, data }: UserContentProps) {
|
|
225
|
+
if (isLoading) return <Skeleton />;
|
|
226
|
+
if (error) return <ErrorDisplay error={error} />;
|
|
227
|
+
if (!data) return <EmptyState />;
|
|
228
|
+
return <Content data={data} />;
|
|
229
|
+
}
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
---
|
|
233
|
+
|
|
234
|
+
## 7. 네이밍 컨벤션
|
|
235
|
+
|
|
236
|
+
| 대상 | 규칙 | 예시 |
|
|
237
|
+
|------|------|------|
|
|
238
|
+
| 컴포넌트 파일 | `PascalCase.tsx` | `UserCard.tsx` |
|
|
239
|
+
| 훅 파일 | `useCamelCase.ts` | `useAuth.ts` |
|
|
240
|
+
| 유틸 파일 | `camelCase.ts` | `formatDate.ts` |
|
|
241
|
+
| 컴포넌트 | `PascalCase` | `UserCard` |
|
|
242
|
+
| 커스텀 훅 | `useCamelCase` | `useUserList` |
|
|
243
|
+
| 이벤트 핸들러 | `handle` + 대상 + 동작 | `handleUserDelete` |
|
|
244
|
+
| Props 콜백 | `on` + 동작 | `onDelete`, `onChange` |
|
|
245
|
+
| Props 타입 | `컴포넌트명 + Props` | `UserCardProps` |
|
|
246
|
+
| Context | `PascalCase + Context` | `AuthContext` |
|
|
247
|
+
| Provider | `PascalCase + Provider` | `AuthProvider` |
|
|
248
|
+
|
|
249
|
+
---
|
|
250
|
+
|
|
251
|
+
## 8. 금지 사항
|
|
252
|
+
|
|
253
|
+
- `any` 타입 사용 금지
|
|
254
|
+
- 인라인 스타일(`style={{}}`) 사용 금지 - 프로젝트 스타일링 방식을 따른다
|
|
255
|
+
- `useEffect` 의존성 배열 누락 금지
|
|
256
|
+
- `index.tsx`에 컴포넌트 로직 직접 작성 금지 (re-export만 허용)
|
|
257
|
+
- 클래스 컴포넌트 사용 금지
|
|
258
|
+
- `React.FC` 사용 금지
|
|
259
|
+
- 배열 인덱스를 `key`로 사용 금지 (정적 리스트 제외)
|
|
260
|
+
- `useEffect` 내에서 상태 동기화 로직 작성 금지 (파생값으로 처리)
|
|
261
|
+
- Props drilling이 3단계 이상일 때 Context 또는 상태 관리 라이브러리 미사용 금지
|