@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,368 @@
|
|
|
1
|
+
# TailwindCSS Skill - Tailwind CSS 규칙
|
|
2
|
+
|
|
3
|
+
Tailwind CSS를 사용하는 프로젝트에 적용되는 스타일링 규칙이다.
|
|
4
|
+
React 규칙은 `../React/SKILL.md`, 공통 원칙은 `../Coding/SKILL.md`를 함께 참고한다.
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## 1. 유틸리티 클래스 사용 원칙
|
|
9
|
+
|
|
10
|
+
### 인라인 스타일 대신 유틸리티 클래스
|
|
11
|
+
- `style={{}}` 대신 Tailwind 유틸리티 클래스를 사용한다
|
|
12
|
+
- CSS 파일을 별도로 작성하지 않는다 (특수한 경우 제외)
|
|
13
|
+
|
|
14
|
+
```typescript
|
|
15
|
+
// Bad - 인라인 스타일
|
|
16
|
+
<div style={{ display: 'flex', gap: '8px', padding: '16px' }}>
|
|
17
|
+
|
|
18
|
+
// Good - 유틸리티 클래스
|
|
19
|
+
<div className="flex gap-2 p-4">
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
### 클래스 조합
|
|
23
|
+
- 관련 있는 클래스를 논리적 그룹으로 정렬한다
|
|
24
|
+
- 권장 순서: 레이아웃 -> 크기 -> 간격 -> 타이포그래피 -> 색상 -> 효과
|
|
25
|
+
|
|
26
|
+
```typescript
|
|
27
|
+
// Good - 논리적 순서로 정렬
|
|
28
|
+
<button className="flex items-center justify-center w-full h-10 px-4 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700">
|
|
29
|
+
버튼
|
|
30
|
+
</button>
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## 2. 반응형 디자인
|
|
36
|
+
|
|
37
|
+
### 브레이크포인트
|
|
38
|
+
| 접두사 | 최소 너비 | 용도 |
|
|
39
|
+
|--------|-----------|------|
|
|
40
|
+
| (없음) | 0px | 모바일 기본 |
|
|
41
|
+
| `sm:` | 640px | 소형 태블릿 |
|
|
42
|
+
| `md:` | 768px | 태블릿 |
|
|
43
|
+
| `lg:` | 1024px | 데스크톱 |
|
|
44
|
+
| `xl:` | 1280px | 대형 데스크톱 |
|
|
45
|
+
| `2xl:` | 1536px | 초대형 데스크톱 |
|
|
46
|
+
|
|
47
|
+
### Mobile-First 원칙
|
|
48
|
+
- 모바일 스타일을 기본으로 작성하고, 큰 화면에 대해 재정의한다
|
|
49
|
+
|
|
50
|
+
```typescript
|
|
51
|
+
// Good - Mobile-First
|
|
52
|
+
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
|
53
|
+
{items.map((item) => (
|
|
54
|
+
<Card key={item.id} data={item} />
|
|
55
|
+
))}
|
|
56
|
+
</div>
|
|
57
|
+
|
|
58
|
+
// Good - 반응형 타이포그래피
|
|
59
|
+
<h1 className="text-2xl font-bold md:text-3xl lg:text-4xl">
|
|
60
|
+
제목
|
|
61
|
+
</h1>
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## 3. 다크 모드
|
|
67
|
+
|
|
68
|
+
### dark: prefix 사용
|
|
69
|
+
- `dark:` 접두사로 다크 모드 스타일을 정의한다
|
|
70
|
+
- v4에서는 기본적으로 `prefers-color-scheme` 미디어 쿼리로 동작한다
|
|
71
|
+
- 수동 토글(class 전략)이 필요하면 CSS에서 `@custom-variant`를 설정한다
|
|
72
|
+
|
|
73
|
+
```css
|
|
74
|
+
/* app.css - 수동 다크 모드 토글 시 */
|
|
75
|
+
@import "tailwindcss";
|
|
76
|
+
@custom-variant dark (&:where(.dark, .dark *));
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
```typescript
|
|
80
|
+
// 컴포넌트에서 사용
|
|
81
|
+
<div className="bg-white text-gray-900 dark:bg-gray-900 dark:text-white">
|
|
82
|
+
<p className="text-gray-600 dark:text-gray-300">
|
|
83
|
+
라이트/다크 모드를 지원하는 텍스트
|
|
84
|
+
</p>
|
|
85
|
+
</div>
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### 시맨틱 색상 활용
|
|
89
|
+
- 반복되는 라이트/다크 색상 조합은 `@theme`과 CSS 변수로 추출한다
|
|
90
|
+
|
|
91
|
+
```css
|
|
92
|
+
/* app.css */
|
|
93
|
+
@import "tailwindcss";
|
|
94
|
+
|
|
95
|
+
@theme {
|
|
96
|
+
--color-background: #ffffff;
|
|
97
|
+
--color-foreground: #0a0a0a;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
.dark {
|
|
101
|
+
--color-background: #0a0a0a;
|
|
102
|
+
--color-foreground: #fafafa;
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
## 4. 커스텀 설정
|
|
109
|
+
|
|
110
|
+
### @theme 디렉티브 사용
|
|
111
|
+
- CSS 파일에서 `@theme` 디렉티브로 디자인 토큰을 정의한다
|
|
112
|
+
- v4에서는 `tailwind.config.ts`가 불필요하다. 모든 커스텀 설정은 CSS의 `@theme`에서 정의한다
|
|
113
|
+
- v4는 콘텐츠 파일을 자동 감지하므로 `content` 배열 설정이 불필요하다
|
|
114
|
+
|
|
115
|
+
```css
|
|
116
|
+
/* app.css */
|
|
117
|
+
@import "tailwindcss";
|
|
118
|
+
|
|
119
|
+
@theme {
|
|
120
|
+
--color-primary-50: #eff6ff;
|
|
121
|
+
--color-primary-100: #dbeafe;
|
|
122
|
+
--color-primary-500: #3b82f6;
|
|
123
|
+
--color-primary-600: #2563eb;
|
|
124
|
+
--color-primary-700: #1d4ed8;
|
|
125
|
+
--color-success: #10b981;
|
|
126
|
+
--color-warning: #f59e0b;
|
|
127
|
+
--color-danger: #ef4444;
|
|
128
|
+
|
|
129
|
+
--spacing-18: 4.5rem;
|
|
130
|
+
--spacing-88: 22rem;
|
|
131
|
+
|
|
132
|
+
--font-sans: 'Inter', sans-serif;
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
---
|
|
137
|
+
|
|
138
|
+
## 5. 자주 쓰는 패턴
|
|
139
|
+
|
|
140
|
+
### Flexbox 레이아웃
|
|
141
|
+
```typescript
|
|
142
|
+
// 가로 정렬 (중앙)
|
|
143
|
+
<div className="flex items-center justify-center">
|
|
144
|
+
|
|
145
|
+
// 가로 정렬 (양쪽 분산)
|
|
146
|
+
<div className="flex items-center justify-between">
|
|
147
|
+
|
|
148
|
+
// 세로 정렬
|
|
149
|
+
<div className="flex flex-col gap-4">
|
|
150
|
+
|
|
151
|
+
// 자식 요소 간격
|
|
152
|
+
<div className="flex gap-2">
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
### Grid 레이아웃
|
|
156
|
+
```typescript
|
|
157
|
+
// 반응형 그리드
|
|
158
|
+
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
|
159
|
+
|
|
160
|
+
// 고정 비율 그리드
|
|
161
|
+
<div className="grid grid-cols-[1fr_2fr] gap-4">
|
|
162
|
+
|
|
163
|
+
// 사이드바 레이아웃
|
|
164
|
+
<div className="grid grid-cols-[240px_1fr] gap-8">
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
### Spacing 패턴
|
|
168
|
+
```typescript
|
|
169
|
+
// 섹션 간격
|
|
170
|
+
<section className="py-12 md:py-16 lg:py-20">
|
|
171
|
+
|
|
172
|
+
// 컨테이너
|
|
173
|
+
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
|
174
|
+
|
|
175
|
+
// 카드
|
|
176
|
+
<div className="rounded-lg border p-6 shadow-sm">
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
### Typography 패턴
|
|
180
|
+
```typescript
|
|
181
|
+
// 텍스트 말줄임
|
|
182
|
+
<p className="truncate">긴 텍스트...</p>
|
|
183
|
+
|
|
184
|
+
// 여러 줄 말줄임
|
|
185
|
+
<p className="line-clamp-3">긴 텍스트...</p>
|
|
186
|
+
|
|
187
|
+
// 반응형 텍스트
|
|
188
|
+
<h1 className="text-2xl font-bold tracking-tight md:text-4xl">
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
---
|
|
192
|
+
|
|
193
|
+
## 6. cn() 유틸리티
|
|
194
|
+
|
|
195
|
+
### clsx + tailwind-merge 조합
|
|
196
|
+
- 조건부 클래스 결합에는 `cn()` 유틸리티를 사용한다
|
|
197
|
+
- `clsx`로 조건부 결합하고, `tailwind-merge`로 충돌을 해결한다
|
|
198
|
+
|
|
199
|
+
```typescript
|
|
200
|
+
// lib/utils.ts
|
|
201
|
+
import { type ClassValue, clsx } from 'clsx';
|
|
202
|
+
import { twMerge } from 'tailwind-merge';
|
|
203
|
+
|
|
204
|
+
export function cn(...inputs: ClassValue[]) {
|
|
205
|
+
return twMerge(clsx(inputs));
|
|
206
|
+
}
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
### 사용 예시
|
|
210
|
+
```typescript
|
|
211
|
+
import { cn } from '@/lib/utils';
|
|
212
|
+
|
|
213
|
+
interface ButtonProps {
|
|
214
|
+
variant?: 'primary' | 'secondary' | 'danger';
|
|
215
|
+
size?: 'sm' | 'md' | 'lg';
|
|
216
|
+
className?: string;
|
|
217
|
+
children: React.ReactNode;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function Button({ variant = 'primary', size = 'md', className, children }: ButtonProps) {
|
|
221
|
+
return (
|
|
222
|
+
<button
|
|
223
|
+
className={cn(
|
|
224
|
+
// 기본 스타일
|
|
225
|
+
'inline-flex items-center justify-center rounded-lg font-medium transition-colors',
|
|
226
|
+
// variant
|
|
227
|
+
{
|
|
228
|
+
'bg-blue-600 text-white hover:bg-blue-700': variant === 'primary',
|
|
229
|
+
'bg-gray-100 text-gray-900 hover:bg-gray-200': variant === 'secondary',
|
|
230
|
+
'bg-red-600 text-white hover:bg-red-700': variant === 'danger',
|
|
231
|
+
},
|
|
232
|
+
// size
|
|
233
|
+
{
|
|
234
|
+
'h-8 px-3 text-sm': size === 'sm',
|
|
235
|
+
'h-10 px-4 text-sm': size === 'md',
|
|
236
|
+
'h-12 px-6 text-base': size === 'lg',
|
|
237
|
+
},
|
|
238
|
+
// 외부에서 전달된 클래스 (오버라이드 가능)
|
|
239
|
+
className,
|
|
240
|
+
)}
|
|
241
|
+
>
|
|
242
|
+
{children}
|
|
243
|
+
</button>
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
### CVA (Class Variance Authority)
|
|
249
|
+
- variant가 있는 컴포넌트는 `cva`로 스타일을 정의한다
|
|
250
|
+
- `cn()` + `cva` 조합으로 variant 관리와 클래스 오버라이드를 동시에 처리한다
|
|
251
|
+
|
|
252
|
+
```typescript
|
|
253
|
+
// components/Button.tsx
|
|
254
|
+
import { cva, type VariantProps } from 'class-variance-authority';
|
|
255
|
+
import { cn } from '@/lib/utils';
|
|
256
|
+
|
|
257
|
+
const buttonVariants = cva(
|
|
258
|
+
// 기본 스타일
|
|
259
|
+
'inline-flex items-center justify-center rounded-lg font-medium transition-colors disabled:pointer-events-none disabled:opacity-50',
|
|
260
|
+
{
|
|
261
|
+
variants: {
|
|
262
|
+
variant: {
|
|
263
|
+
primary: 'bg-blue-600 text-white hover:bg-blue-700',
|
|
264
|
+
secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200',
|
|
265
|
+
danger: 'bg-red-600 text-white hover:bg-red-700',
|
|
266
|
+
ghost: 'hover:bg-gray-100',
|
|
267
|
+
link: 'text-blue-600 underline-offset-4 hover:underline',
|
|
268
|
+
},
|
|
269
|
+
size: {
|
|
270
|
+
sm: 'h-8 px-3 text-sm',
|
|
271
|
+
md: 'h-10 px-4 text-sm',
|
|
272
|
+
lg: 'h-12 px-6 text-base',
|
|
273
|
+
icon: 'h-10 w-10',
|
|
274
|
+
},
|
|
275
|
+
},
|
|
276
|
+
defaultVariants: {
|
|
277
|
+
variant: 'primary',
|
|
278
|
+
size: 'md',
|
|
279
|
+
},
|
|
280
|
+
}
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
interface ButtonProps
|
|
284
|
+
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
|
285
|
+
VariantProps<typeof buttonVariants> {}
|
|
286
|
+
|
|
287
|
+
export function Button({ variant, size, className, ...props }: ButtonProps) {
|
|
288
|
+
return (
|
|
289
|
+
<button className={cn(buttonVariants({ variant, size }), className)} {...props} />
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
### CVA 사용 원칙
|
|
295
|
+
- variant가 2개 이상이면 `cva`를 사용한다 (1개면 `cn()`으로 충분)
|
|
296
|
+
- `VariantProps<typeof xxxVariants>`로 Props 타입을 자동 추론한다
|
|
297
|
+
- `defaultVariants`로 기본값을 선언한다
|
|
298
|
+
- 컴포넌트 외부에서 `className`으로 오버라이드할 수 있게 `cn()`을 함께 사용한다
|
|
299
|
+
|
|
300
|
+
---
|
|
301
|
+
|
|
302
|
+
## 7. 컴포넌트 추출
|
|
303
|
+
|
|
304
|
+
### 반복 클래스 처리
|
|
305
|
+
- 동일한 클래스 조합이 3회 이상 반복되면 컴포넌트로 추출한다
|
|
306
|
+
- `@apply`보다 컴포넌트 추출을 우선한다
|
|
307
|
+
|
|
308
|
+
```typescript
|
|
309
|
+
// Bad - 동일한 클래스 반복
|
|
310
|
+
<button className="rounded-lg bg-blue-600 px-4 py-2 text-white hover:bg-blue-700">저장</button>
|
|
311
|
+
<button className="rounded-lg bg-blue-600 px-4 py-2 text-white hover:bg-blue-700">확인</button>
|
|
312
|
+
<button className="rounded-lg bg-blue-600 px-4 py-2 text-white hover:bg-blue-700">전송</button>
|
|
313
|
+
|
|
314
|
+
// Good - 컴포넌트로 추출
|
|
315
|
+
<Button>저장</Button>
|
|
316
|
+
<Button>확인</Button>
|
|
317
|
+
<Button>전송</Button>
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
### @apply 사용 (제한적 허용)
|
|
321
|
+
- 컴포넌트 추출이 어려운 경우에만 `@apply`를 사용한다
|
|
322
|
+
- 주로 base layer의 글로벌 스타일에서 사용한다
|
|
323
|
+
- v4에서도 `@apply`는 지원되지만, 여전히 컴포넌트 추출을 우선한다
|
|
324
|
+
|
|
325
|
+
```css
|
|
326
|
+
/* 허용 - 글로벌 기본 스타일 */
|
|
327
|
+
@layer base {
|
|
328
|
+
body {
|
|
329
|
+
@apply bg-background text-foreground;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/* 지양 - 컴포넌트 스타일을 @apply로 작성 */
|
|
334
|
+
.btn-primary {
|
|
335
|
+
@apply rounded-lg bg-blue-600 px-4 py-2 text-white hover:bg-blue-700;
|
|
336
|
+
}
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
---
|
|
340
|
+
|
|
341
|
+
## 8. 금지 사항
|
|
342
|
+
|
|
343
|
+
- 인라인 `style={{}}` 사용 금지 - Tailwind 유틸리티 클래스를 사용한다
|
|
344
|
+
- 임의값(arbitrary values) 남용 금지 - `w-[137px]` 같은 임의값은 최소화한다
|
|
345
|
+
- 디자인 시스템의 토큰에 맞는 값을 사용한다
|
|
346
|
+
- 불가피한 경우에만 임의값을 사용한다
|
|
347
|
+
- `@apply` 남용 금지 - 컴포넌트 추출을 우선한다
|
|
348
|
+
- `!important` 사용 금지 - Tailwind의 `!` 접두사(`!p-4`)도 최소화한다
|
|
349
|
+
- 커스텀 CSS 파일 남발 금지 - `globals.css` 외에 CSS 파일을 추가하지 않는다
|
|
350
|
+
- Tailwind 기본 테마 토큰 삭제 금지 - `@theme`에서 필요한 토큰만 추가한다
|
|
351
|
+
- 사용하지 않는 커스텀 색상/간격 정의 금지 - 실제 사용하는 값만 정의한다
|
|
352
|
+
- `tailwind.config.ts` 사용 금지 - v4에서는 CSS `@theme`으로 설정한다
|
|
353
|
+
- 클래스 문자열 동적 생성 금지 - Tailwind의 JIT가 감지하지 못한다
|
|
354
|
+
|
|
355
|
+
```typescript
|
|
356
|
+
// Bad - 동적 클래스 생성 (JIT가 감지 불가)
|
|
357
|
+
const color = 'blue';
|
|
358
|
+
<div className={`bg-${color}-500`}>
|
|
359
|
+
|
|
360
|
+
// Good - 완전한 클래스명 사용
|
|
361
|
+
const colorClasses = {
|
|
362
|
+
blue: 'bg-blue-500',
|
|
363
|
+
red: 'bg-red-500',
|
|
364
|
+
green: 'bg-green-500',
|
|
365
|
+
} as const;
|
|
366
|
+
|
|
367
|
+
<div className={colorClasses[color]}>
|
|
368
|
+
```
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
# TanStack Query Skill - 서버 상태 관리 규칙
|
|
2
|
+
|
|
3
|
+
TanStack Query (React Query)를 사용한 서버 상태 관리 규칙을 정의한다.
|
|
4
|
+
클라이언트 상태 관리는 `../Zustand/SKILL.md`를 참고한다.
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## 1. 기본 개념
|
|
9
|
+
|
|
10
|
+
### 서버 상태 vs 클라이언트 상태
|
|
11
|
+
- **서버 상태**: API에서 가져오는 데이터 (사용자 목록, 게시글 등)
|
|
12
|
+
- **클라이언트 상태**: UI에서만 존재하는 데이터 (모달 열림/닫힘, 폼 입력값 등)
|
|
13
|
+
- TanStack Query는 서버 상태만 관리한다
|
|
14
|
+
|
|
15
|
+
### Stale-While-Revalidate
|
|
16
|
+
- 캐시된 데이터(stale)를 먼저 보여주고, 백그라운드에서 최신 데이터를 가져온다
|
|
17
|
+
- 사용자 경험과 데이터 최신성을 동시에 확보한다
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## 2. useQuery 패턴
|
|
22
|
+
|
|
23
|
+
### 기본 사용법
|
|
24
|
+
|
|
25
|
+
```typescript
|
|
26
|
+
// Good
|
|
27
|
+
const { data, isLoading, error } = useQuery({
|
|
28
|
+
queryKey: ['users', { status: 'active' }],
|
|
29
|
+
queryFn: () => userApi.getUsers({ status: 'active' }),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// Good - enabled로 조건부 실행
|
|
33
|
+
const { data: user } = useQuery({
|
|
34
|
+
queryKey: ['users', userId],
|
|
35
|
+
queryFn: () => userApi.getUserById(userId),
|
|
36
|
+
enabled: !!userId,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// Good - select로 데이터 변환
|
|
40
|
+
const { data: userNames } = useQuery({
|
|
41
|
+
queryKey: ['users'],
|
|
42
|
+
queryFn: () => userApi.getUsers(),
|
|
43
|
+
select: (users) => users.map((user) => user.name),
|
|
44
|
+
});
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### queryKey 컨벤션
|
|
48
|
+
- 배열 형태로 작성한다
|
|
49
|
+
- 첫 번째 요소는 엔티티명 (복수형)
|
|
50
|
+
- 이후 요소는 필터/파라미터
|
|
51
|
+
|
|
52
|
+
```typescript
|
|
53
|
+
// 목록
|
|
54
|
+
['users']
|
|
55
|
+
['users', { status: 'active', page: 1 }]
|
|
56
|
+
|
|
57
|
+
// 단건
|
|
58
|
+
['users', userId]
|
|
59
|
+
|
|
60
|
+
// 관계 데이터
|
|
61
|
+
['users', userId, 'posts']
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## 3. queryKey 팩토리 패턴
|
|
67
|
+
|
|
68
|
+
queryKey를 객체로 중앙 관리하여 일관성과 재사용성을 확보한다.
|
|
69
|
+
|
|
70
|
+
```typescript
|
|
71
|
+
export const userKeys = {
|
|
72
|
+
all: ['users'] as const,
|
|
73
|
+
lists: () => [...userKeys.all, 'list'] as const,
|
|
74
|
+
list: (filters: UserFilters) => [...userKeys.lists(), filters] as const,
|
|
75
|
+
details: () => [...userKeys.all, 'detail'] as const,
|
|
76
|
+
detail: (id: string) => [...userKeys.details(), id] as const,
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// 사용
|
|
80
|
+
useQuery({
|
|
81
|
+
queryKey: userKeys.detail(userId),
|
|
82
|
+
queryFn: () => userApi.getUserById(userId),
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// Invalidation - 모든 user 목록 캐시 무효화
|
|
86
|
+
queryClient.invalidateQueries({ queryKey: userKeys.lists() });
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## 4. useMutation 패턴
|
|
92
|
+
|
|
93
|
+
### 기본 사용법
|
|
94
|
+
|
|
95
|
+
```typescript
|
|
96
|
+
const createUser = useMutation({
|
|
97
|
+
mutationFn: (data: CreateUserDto) => userApi.createUser(data),
|
|
98
|
+
onSuccess: () => {
|
|
99
|
+
queryClient.invalidateQueries({ queryKey: userKeys.lists() });
|
|
100
|
+
},
|
|
101
|
+
onError: (error) => {
|
|
102
|
+
toast.error(`사용자 생성 실패: ${error.message}`);
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// 호출
|
|
107
|
+
createUser.mutate({ name: '홍길동', email: 'hong@example.com' });
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### onSuccess에서 반드시 캐시를 무효화한다
|
|
111
|
+
|
|
112
|
+
| 작업 | invalidateQueries 대상 |
|
|
113
|
+
|------|------------------------|
|
|
114
|
+
| 생성 | 목록 쿼리 (`lists()`) |
|
|
115
|
+
| 수정 | 목록 + 해당 상세 (`all`) 또는 정밀 지정 |
|
|
116
|
+
| 삭제 | 목록 쿼리 (`lists()`) |
|
|
117
|
+
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
## 5. Optimistic Update 패턴
|
|
121
|
+
|
|
122
|
+
사용자 경험을 위해 서버 응답 전에 UI를 먼저 업데이트한다.
|
|
123
|
+
|
|
124
|
+
```typescript
|
|
125
|
+
const updateUser = useMutation({
|
|
126
|
+
mutationFn: (data: UpdateUserDto) => userApi.updateUser(data),
|
|
127
|
+
onMutate: async (newData) => {
|
|
128
|
+
// 1. 진행 중인 쿼리 취소 (덮어쓰기 방지)
|
|
129
|
+
await queryClient.cancelQueries({ queryKey: userKeys.detail(newData.id) });
|
|
130
|
+
|
|
131
|
+
// 2. 이전 데이터 스냅샷 저장
|
|
132
|
+
const previousUser = queryClient.getQueryData(userKeys.detail(newData.id));
|
|
133
|
+
|
|
134
|
+
// 3. 캐시를 낙관적으로 업데이트
|
|
135
|
+
queryClient.setQueryData(userKeys.detail(newData.id), (old: User) => ({
|
|
136
|
+
...old,
|
|
137
|
+
...newData,
|
|
138
|
+
}));
|
|
139
|
+
|
|
140
|
+
// 4. 롤백용 컨텍스트 반환
|
|
141
|
+
return { previousUser };
|
|
142
|
+
},
|
|
143
|
+
onError: (_error, newData, context) => {
|
|
144
|
+
// 에러 시 이전 데이터로 롤백
|
|
145
|
+
queryClient.setQueryData(userKeys.detail(newData.id), context?.previousUser);
|
|
146
|
+
},
|
|
147
|
+
onSettled: (_data, _error, variables) => {
|
|
148
|
+
// 성공/실패 무관하게 캐시 재검증
|
|
149
|
+
queryClient.invalidateQueries({ queryKey: userKeys.detail(variables.id) });
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
---
|
|
155
|
+
|
|
156
|
+
## 6. Prefetching
|
|
157
|
+
|
|
158
|
+
### 라우트 전환 시 Prefetching
|
|
159
|
+
|
|
160
|
+
```typescript
|
|
161
|
+
// 마우스 호버 시 미리 데이터를 가져온다
|
|
162
|
+
function UserListItem({ userId }: { userId: string }) {
|
|
163
|
+
const queryClient = useQueryClient();
|
|
164
|
+
|
|
165
|
+
const handleMouseEnter = () => {
|
|
166
|
+
queryClient.prefetchQuery({
|
|
167
|
+
queryKey: userKeys.detail(userId),
|
|
168
|
+
queryFn: () => userApi.getUserById(userId),
|
|
169
|
+
staleTime: 1000 * 60 * 5, // 5분 동안 재요청 방지
|
|
170
|
+
});
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
return (
|
|
174
|
+
<Link to={`/users/${userId}`} onMouseEnter={handleMouseEnter}>
|
|
175
|
+
사용자 상세
|
|
176
|
+
</Link>
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
---
|
|
182
|
+
|
|
183
|
+
## 7. Suspense 모드
|
|
184
|
+
|
|
185
|
+
React Suspense와 함께 사용하여 로딩 처리를 선언적으로 한다.
|
|
186
|
+
|
|
187
|
+
```typescript
|
|
188
|
+
// useSuspenseQuery는 data가 항상 존재함을 보장한다
|
|
189
|
+
function UserProfile({ userId }: { userId: string }) {
|
|
190
|
+
const { data: user } = useSuspenseQuery({
|
|
191
|
+
queryKey: userKeys.detail(userId),
|
|
192
|
+
queryFn: () => userApi.getUserById(userId),
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// user는 undefined가 아님 - 타입 안전
|
|
196
|
+
return <div>{user.name}</div>;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// 부모에서 Suspense로 감싼다
|
|
200
|
+
function UserPage({ userId }: { userId: string }) {
|
|
201
|
+
return (
|
|
202
|
+
<Suspense fallback={<UserProfileSkeleton />}>
|
|
203
|
+
<UserProfile userId={userId} />
|
|
204
|
+
</Suspense>
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
---
|
|
210
|
+
|
|
211
|
+
## 8. QueryClient 설정
|
|
212
|
+
|
|
213
|
+
```typescript
|
|
214
|
+
const queryClient = new QueryClient({
|
|
215
|
+
defaultOptions: {
|
|
216
|
+
queries: {
|
|
217
|
+
staleTime: 1000 * 60 * 5, // 5분 - 데이터가 stale로 간주되기까지의 시간
|
|
218
|
+
gcTime: 1000 * 60 * 30, // 30분 - 미사용 캐시 제거까지의 시간
|
|
219
|
+
retry: 1, // 실패 시 1회 재시도
|
|
220
|
+
refetchOnWindowFocus: false, // 윈도우 포커스 시 자동 refetch 비활성화
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
});
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
| 옵션 | 권장값 | 설명 |
|
|
227
|
+
|------|--------|------|
|
|
228
|
+
| `staleTime` | `5분` | 짧으면 요청 과다, 길면 데이터 지연 |
|
|
229
|
+
| `gcTime` | `30분` | staleTime보다 길어야 한다 |
|
|
230
|
+
| `retry` | `1` | 네트워크 오류 대비 최소 재시도 |
|
|
231
|
+
| `refetchOnWindowFocus` | `false` | 프로젝트 요구사항에 따라 조정 |
|
|
232
|
+
|
|
233
|
+
---
|
|
234
|
+
|
|
235
|
+
## 9. 금지 사항
|
|
236
|
+
|
|
237
|
+
- `useEffect`로 데이터 페칭 금지 - TanStack Query를 사용한다
|
|
238
|
+
- queryKey 하드코딩 금지 - queryKey 팩토리 패턴을 사용한다
|
|
239
|
+
- 불필요한 `refetchOnWindowFocus: true` 설정 금지 - 기본값 대신 명시적으로 관리한다
|
|
240
|
+
- `onSuccess` 콜백 안에서 상태 동기화 금지 (`useState`에 서버 데이터 복사 등)
|
|
241
|
+
- `queryFn` 안에서 에러를 삼키는 try-catch 금지 - TanStack Query가 에러를 관리하게 한다
|
|
242
|
+
- `cacheTime` 사용 금지 - v5부터 `gcTime`으로 변경되었다
|