@kood/claude-code 0.1.6 → 0.1.9
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/dist/index.js +109 -216
- package/package.json +8 -2
- package/templates/hono/CLAUDE.md +59 -328
- package/templates/hono/docs/architecture/architecture.md +93 -747
- package/templates/hono/docs/deployment/cloudflare.md +59 -513
- package/templates/hono/docs/deployment/docker.md +41 -356
- package/templates/hono/docs/deployment/index.md +54 -190
- package/templates/hono/docs/deployment/railway.md +36 -306
- package/templates/hono/docs/deployment/vercel.md +49 -434
- package/templates/hono/docs/library/ai-sdk/index.md +53 -290
- package/templates/hono/docs/library/ai-sdk/openrouter.md +19 -387
- package/templates/hono/docs/library/ai-sdk/providers.md +28 -394
- package/templates/hono/docs/library/ai-sdk/streaming.md +52 -353
- package/templates/hono/docs/library/ai-sdk/structured-output.md +63 -395
- package/templates/hono/docs/library/ai-sdk/tools.md +62 -431
- package/templates/hono/docs/library/hono/env-setup.md +24 -313
- package/templates/hono/docs/library/hono/error-handling.md +34 -295
- package/templates/hono/docs/library/hono/index.md +29 -121
- package/templates/hono/docs/library/hono/middleware.md +21 -188
- package/templates/hono/docs/library/hono/rpc.md +40 -341
- package/templates/hono/docs/library/hono/validation.md +35 -195
- package/templates/hono/docs/library/pino/index.md +42 -333
- package/templates/hono/docs/library/prisma/cloudflare-d1.md +64 -367
- package/templates/hono/docs/library/prisma/config.md +19 -260
- package/templates/hono/docs/library/prisma/index.md +67 -320
- package/templates/hono/docs/library/zod/index.md +53 -257
- package/templates/npx/CLAUDE.md +62 -274
- package/templates/npx/docs/references/patterns.md +160 -0
- package/templates/tanstack-start/CLAUDE.md +100 -256
- package/templates/tanstack-start/docs/architecture/architecture.md +44 -589
- package/templates/tanstack-start/docs/deployment/cloudflare.md +37 -424
- package/templates/tanstack-start/docs/deployment/index.md +57 -286
- package/templates/tanstack-start/docs/deployment/nitro.md +36 -318
- package/templates/tanstack-start/docs/deployment/railway.md +40 -409
- package/templates/tanstack-start/docs/deployment/vercel.md +43 -465
- package/templates/tanstack-start/docs/design/components.md +77 -311
- package/templates/tanstack-start/docs/design/index.md +113 -69
- package/templates/tanstack-start/docs/design/safe-area.md +51 -250
- package/templates/tanstack-start/docs/design/tailwind-setup.md +45 -359
- package/templates/tanstack-start/docs/guides/conventions.md +103 -0
- package/templates/tanstack-start/docs/guides/env-setup.md +34 -340
- package/templates/tanstack-start/docs/guides/getting-started.md +22 -209
- package/templates/tanstack-start/docs/guides/hooks.md +166 -0
- package/templates/tanstack-start/docs/guides/routes.md +166 -0
- package/templates/tanstack-start/docs/guides/services.md +143 -0
- package/templates/tanstack-start/docs/library/better-auth/2fa.md +27 -115
- package/templates/tanstack-start/docs/library/better-auth/advanced.md +22 -105
- package/templates/tanstack-start/docs/library/better-auth/index.md +17 -66
- package/templates/tanstack-start/docs/library/better-auth/plugins.md +11 -88
- package/templates/tanstack-start/docs/library/better-auth/session.md +12 -92
- package/templates/tanstack-start/docs/library/better-auth/setup.md +9 -91
- package/templates/tanstack-start/docs/library/prisma/cloudflare-d1.md +30 -358
- package/templates/tanstack-start/docs/library/prisma/config.md +27 -327
- package/templates/tanstack-start/docs/library/prisma/crud.md +46 -174
- package/templates/tanstack-start/docs/library/prisma/index.md +23 -113
- package/templates/tanstack-start/docs/library/prisma/relations.md +31 -153
- package/templates/tanstack-start/docs/library/prisma/schema.md +40 -217
- package/templates/tanstack-start/docs/library/prisma/setup.md +12 -112
- package/templates/tanstack-start/docs/library/prisma/transactions.md +20 -110
- package/templates/tanstack-start/docs/library/tanstack-query/index.md +26 -97
- package/templates/tanstack-start/docs/library/tanstack-query/invalidation.md +28 -107
- package/templates/tanstack-start/docs/library/tanstack-query/optimistic-updates.md +44 -146
- package/templates/tanstack-start/docs/library/tanstack-query/use-mutation.md +33 -127
- package/templates/tanstack-start/docs/library/tanstack-query/use-query.md +49 -149
- package/templates/tanstack-start/docs/library/tanstack-start/auth-patterns.md +19 -112
- package/templates/tanstack-start/docs/library/tanstack-start/index.md +33 -80
- package/templates/tanstack-start/docs/library/tanstack-start/middleware.md +28 -106
- package/templates/tanstack-start/docs/library/tanstack-start/routing.md +21 -118
- package/templates/tanstack-start/docs/library/tanstack-start/server-functions.md +34 -246
- package/templates/tanstack-start/docs/library/tanstack-start/setup.md +6 -39
- package/templates/tanstack-start/docs/library/zod/complex-types.md +32 -156
- package/templates/tanstack-start/docs/library/zod/index.md +31 -144
- package/templates/tanstack-start/docs/library/zod/transforms.md +20 -129
- package/templates/tanstack-start/docs/library/zod/validation.md +39 -155
- package/templates/hono/docs/commands/git.md +0 -145
- package/templates/hono/docs/mcp/context7.md +0 -106
- package/templates/hono/docs/mcp/index.md +0 -176
- package/templates/hono/docs/mcp/sequential-thinking.md +0 -101
- package/templates/hono/docs/mcp/serena.md +0 -269
- package/templates/hono/docs/mcp/sgrep.md +0 -105
- package/templates/hono/docs/skills/gemini-review/SKILL.md +0 -220
- package/templates/hono/docs/skills/gemini-review/references/checklists.md +0 -136
- package/templates/hono/docs/skills/gemini-review/references/prompt-templates.md +0 -303
- package/templates/npx/docs/commands/git.md +0 -145
- package/templates/npx/docs/mcp/index.md +0 -60
- package/templates/npx/docs/skills/gemini-review/SKILL.md +0 -220
- package/templates/npx/docs/skills/gemini-review/references/checklists.md +0 -134
- package/templates/npx/docs/skills/gemini-review/references/prompt-templates.md +0 -301
- package/templates/tanstack-start/docs/commands/git.md +0 -145
- package/templates/tanstack-start/docs/design/accessibility.md +0 -433
- package/templates/tanstack-start/docs/design/color.md +0 -235
- package/templates/tanstack-start/docs/design/spacing.md +0 -341
- package/templates/tanstack-start/docs/design/typography.md +0 -324
- package/templates/tanstack-start/docs/guides/best-practices.md +0 -950
- package/templates/tanstack-start/docs/guides/husky-lint-staged.md +0 -303
- package/templates/tanstack-start/docs/guides/prettier.md +0 -189
- package/templates/tanstack-start/docs/guides/project-templates.md +0 -710
- package/templates/tanstack-start/docs/library/tanstack-query/setup.md +0 -107
- package/templates/tanstack-start/docs/library/zod/basic-types.md +0 -186
- package/templates/tanstack-start/docs/mcp/context7.md +0 -204
- package/templates/tanstack-start/docs/mcp/index.md +0 -177
- package/templates/tanstack-start/docs/mcp/sequential-thinking.md +0 -180
- package/templates/tanstack-start/docs/mcp/serena.md +0 -269
- package/templates/tanstack-start/docs/mcp/sgrep.md +0 -174
- package/templates/tanstack-start/docs/skills/gemini-review/SKILL.md +0 -220
- package/templates/tanstack-start/docs/skills/gemini-review/references/checklists.md +0 -144
- package/templates/tanstack-start/docs/skills/gemini-review/references/prompt-templates.md +0 -292
|
@@ -1,950 +0,0 @@
|
|
|
1
|
-
# Best Practices
|
|
2
|
-
|
|
3
|
-
TanStack Start 애플리케이션 개발을 위한 모범 사례 가이드입니다.
|
|
4
|
-
|
|
5
|
-
## File Naming Convention
|
|
6
|
-
|
|
7
|
-
**모든 파일은 kebab-case**:
|
|
8
|
-
|
|
9
|
-
```
|
|
10
|
-
✅ user-profile.tsx
|
|
11
|
-
✅ auth-service.ts
|
|
12
|
-
✅ use-user-filter.ts
|
|
13
|
-
✅ user-list-section.tsx
|
|
14
|
-
|
|
15
|
-
❌ UserProfile.tsx
|
|
16
|
-
❌ authService.ts
|
|
17
|
-
❌ useUserFilter.ts
|
|
18
|
-
```
|
|
19
|
-
|
|
20
|
-
## Route Folder Structure
|
|
21
|
-
|
|
22
|
-
### 기본 구조
|
|
23
|
-
|
|
24
|
-
```
|
|
25
|
-
routes/<route-name>/
|
|
26
|
-
├── index.tsx # 페이지 컴포넌트
|
|
27
|
-
├── route.tsx # route 설정 (필요시)
|
|
28
|
-
├── -components/ # 페이지 전용 컴포넌트
|
|
29
|
-
│ ├── user-card.tsx
|
|
30
|
-
│ └── user-form.tsx
|
|
31
|
-
├── -sections/ # 섹션 분리 (복잡한 경우)
|
|
32
|
-
│ ├── user-list-section.tsx
|
|
33
|
-
│ └── user-filter-section.tsx
|
|
34
|
-
└── -hooks/ # 페이지 전용 훅
|
|
35
|
-
├── use-users.ts
|
|
36
|
-
└── use-user-filter.ts
|
|
37
|
-
```
|
|
38
|
-
|
|
39
|
-
### TanStack Start `-` 접두사
|
|
40
|
-
|
|
41
|
-
`-` 접두사가 있는 폴더는 라우트에서 제외됩니다:
|
|
42
|
-
|
|
43
|
-
```
|
|
44
|
-
routes/users/
|
|
45
|
-
├── index.tsx # /users ✅ 라우트
|
|
46
|
-
├── $id.tsx # /users/:id ✅ 라우트
|
|
47
|
-
├── -components/ # ❌ 라우트 아님
|
|
48
|
-
├── -sections/ # ❌ 라우트 아님
|
|
49
|
-
└── -hooks/ # ❌ 라우트 아님
|
|
50
|
-
```
|
|
51
|
-
|
|
52
|
-
## Project Organization
|
|
53
|
-
|
|
54
|
-
### 전체 폴더 구조
|
|
55
|
-
|
|
56
|
-
```
|
|
57
|
-
src/
|
|
58
|
-
├── routes/ # 파일 기반 라우팅
|
|
59
|
-
│ ├── __root.tsx
|
|
60
|
-
│ ├── index.tsx
|
|
61
|
-
│ └── users/
|
|
62
|
-
│ ├── index.tsx
|
|
63
|
-
│ ├── -components/
|
|
64
|
-
│ ├── -sections/
|
|
65
|
-
│ └── -hooks/
|
|
66
|
-
├── components/ # 공통 컴포넌트
|
|
67
|
-
│ └── ui/
|
|
68
|
-
│ ├── button.tsx
|
|
69
|
-
│ ├── input.tsx
|
|
70
|
-
│ └── modal.tsx
|
|
71
|
-
├── database/ # 데이터베이스 관련
|
|
72
|
-
│ ├── prisma.ts # Prisma Client 인스턴스
|
|
73
|
-
│ └── seed.ts # 시드 데이터 (필요시)
|
|
74
|
-
├── services/ # 도메인별 SDK/서비스 레이어
|
|
75
|
-
│ ├── user/
|
|
76
|
-
│ │ ├── index.ts # 진입점 (re-export)
|
|
77
|
-
│ │ ├── schemas.ts # Zod 스키마
|
|
78
|
-
│ │ ├── queries.ts # GET 요청 (읽기)
|
|
79
|
-
│ │ └── mutations.ts # POST 요청 (쓰기)
|
|
80
|
-
│ ├── auth/
|
|
81
|
-
│ │ ├── index.ts
|
|
82
|
-
│ │ ├── schemas.ts
|
|
83
|
-
│ │ ├── queries.ts
|
|
84
|
-
│ │ └── mutations.ts
|
|
85
|
-
│ └── post/
|
|
86
|
-
│ ├── index.ts
|
|
87
|
-
│ ├── schemas.ts
|
|
88
|
-
│ ├── queries.ts
|
|
89
|
-
│ └── mutations.ts
|
|
90
|
-
├── lib/ # 공통 유틸리티
|
|
91
|
-
│ ├── query-client.ts
|
|
92
|
-
│ ├── utils.ts
|
|
93
|
-
│ └── constants.ts
|
|
94
|
-
├── hooks/ # 공통 훅
|
|
95
|
-
│ ├── use-auth.ts
|
|
96
|
-
│ └── use-media-query.ts
|
|
97
|
-
└── types/ # 타입 정의
|
|
98
|
-
└── index.ts
|
|
99
|
-
```
|
|
100
|
-
|
|
101
|
-
### Database 폴더 구조
|
|
102
|
-
|
|
103
|
-
```
|
|
104
|
-
database/
|
|
105
|
-
├── prisma.ts # Prisma Client 싱글톤
|
|
106
|
-
└── seed.ts # 시드 스크립트 (선택)
|
|
107
|
-
```
|
|
108
|
-
|
|
109
|
-
```typescript
|
|
110
|
-
// database/prisma.ts
|
|
111
|
-
import { PrismaClient } from '../../generated/prisma'
|
|
112
|
-
|
|
113
|
-
const globalForPrisma = globalThis as unknown as {
|
|
114
|
-
prisma: PrismaClient | undefined
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
export const prisma =
|
|
118
|
-
globalForPrisma.prisma ??
|
|
119
|
-
new PrismaClient({
|
|
120
|
-
log: process.env.NODE_ENV === 'development' ? ['query'] : [],
|
|
121
|
-
})
|
|
122
|
-
|
|
123
|
-
if (process.env.NODE_ENV !== 'production') {
|
|
124
|
-
globalForPrisma.prisma = prisma
|
|
125
|
-
}
|
|
126
|
-
```
|
|
127
|
-
|
|
128
|
-
### Services 폴더 구조
|
|
129
|
-
|
|
130
|
-
```
|
|
131
|
-
services/
|
|
132
|
-
├── user/ # User 도메인
|
|
133
|
-
│ ├── index.ts # 진입점 (re-export)
|
|
134
|
-
│ ├── schemas.ts # Zod 스키마
|
|
135
|
-
│ ├── queries.ts # GET 요청 (읽기)
|
|
136
|
-
│ └── mutations.ts # POST 요청 (쓰기)
|
|
137
|
-
├── auth/ # Auth 도메인
|
|
138
|
-
│ ├── index.ts
|
|
139
|
-
│ ├── schemas.ts
|
|
140
|
-
│ ├── queries.ts
|
|
141
|
-
│ └── mutations.ts
|
|
142
|
-
└── post/ # Post 도메인
|
|
143
|
-
├── index.ts
|
|
144
|
-
├── schemas.ts
|
|
145
|
-
├── queries.ts
|
|
146
|
-
└── mutations.ts
|
|
147
|
-
```
|
|
148
|
-
|
|
149
|
-
## 코드 작성 규칙
|
|
150
|
-
|
|
151
|
-
### UTF-8 인코딩 유지
|
|
152
|
-
|
|
153
|
-
모든 한글 텍스트는 UTF-8 인코딩이 깨지지 않도록 작성합니다.
|
|
154
|
-
|
|
155
|
-
### 한글 주석 작성 규칙
|
|
156
|
-
|
|
157
|
-
**묶음 단위로 한글 주석을 작성합니다** (너무 세세하게 X)
|
|
158
|
-
|
|
159
|
-
```typescript
|
|
160
|
-
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
161
|
-
// 사용자 관련 상태
|
|
162
|
-
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
163
|
-
const [user, setUser] = useState<User | null>(null)
|
|
164
|
-
const [isLoading, setIsLoading] = useState(false)
|
|
165
|
-
|
|
166
|
-
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
167
|
-
// 데이터 조회
|
|
168
|
-
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
169
|
-
const { data: users } = useQuery({
|
|
170
|
-
queryKey: ['users'],
|
|
171
|
-
queryFn: () => getUsers(),
|
|
172
|
-
})
|
|
173
|
-
|
|
174
|
-
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
175
|
-
// 이벤트 핸들러
|
|
176
|
-
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
177
|
-
const handleSubmit = () => { /* ... */ }
|
|
178
|
-
const handleDelete = () => { /* ... */ }
|
|
179
|
-
```
|
|
180
|
-
|
|
181
|
-
### ❌ 너무 세세한 주석 (금지)
|
|
182
|
-
|
|
183
|
-
```typescript
|
|
184
|
-
// ❌ 이렇게 하지 마세요
|
|
185
|
-
const [user, setUser] = useState(null) // 사용자 상태
|
|
186
|
-
const [isLoading, setIsLoading] = useState(false) // 로딩 상태
|
|
187
|
-
const [error, setError] = useState(null) // 에러 상태
|
|
188
|
-
```
|
|
189
|
-
|
|
190
|
-
---
|
|
191
|
-
|
|
192
|
-
## TypeScript Standards
|
|
193
|
-
|
|
194
|
-
### Use `const` for Functions
|
|
195
|
-
|
|
196
|
-
```typescript
|
|
197
|
-
// ✅ Preferred
|
|
198
|
-
const getUserById = async (id: string): Promise<User> => {
|
|
199
|
-
return prisma.user.findUnique({ where: { id } })
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
// ❌ Avoid
|
|
203
|
-
function getUserById(id: string): Promise<User> {
|
|
204
|
-
return prisma.user.findUnique({ where: { id } })
|
|
205
|
-
}
|
|
206
|
-
```
|
|
207
|
-
|
|
208
|
-
### Explicit Return Types
|
|
209
|
-
|
|
210
|
-
```typescript
|
|
211
|
-
// ✅ Always specify return types
|
|
212
|
-
const formatDate = (date: Date): string => {
|
|
213
|
-
return date.toISOString()
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
// ✅ Component return types
|
|
217
|
-
const UserCard = ({ user }: UserCardProps): JSX.Element => {
|
|
218
|
-
return <div>{user.name}</div>
|
|
219
|
-
}
|
|
220
|
-
```
|
|
221
|
-
|
|
222
|
-
### No `any` Types
|
|
223
|
-
|
|
224
|
-
```typescript
|
|
225
|
-
// ✅ Use unknown
|
|
226
|
-
const parseJSON = (data: string): unknown => {
|
|
227
|
-
return JSON.parse(data)
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
// ❌ Never use any
|
|
231
|
-
const parseJSON = (data: string): any => {
|
|
232
|
-
return JSON.parse(data)
|
|
233
|
-
}
|
|
234
|
-
```
|
|
235
|
-
|
|
236
|
-
### Import Order
|
|
237
|
-
|
|
238
|
-
```typescript
|
|
239
|
-
// 1. External libraries
|
|
240
|
-
import { createFileRoute } from '@tanstack/react-router'
|
|
241
|
-
import { useQuery } from '@tanstack/react-query'
|
|
242
|
-
|
|
243
|
-
// 2. Internal packages
|
|
244
|
-
import { Button } from '@/components/ui/button'
|
|
245
|
-
import { prisma } from '@/lib/prisma'
|
|
246
|
-
|
|
247
|
-
// 3. Relative imports (route-specific)
|
|
248
|
-
import { UserCard } from './-components/user-card'
|
|
249
|
-
import { useUsers } from './-hooks/use-users'
|
|
250
|
-
|
|
251
|
-
// 4. Type imports
|
|
252
|
-
import type { User } from '@/types'
|
|
253
|
-
```
|
|
254
|
-
|
|
255
|
-
## Route Patterns
|
|
256
|
-
|
|
257
|
-
### Basic Route with Hook
|
|
258
|
-
|
|
259
|
-
```tsx
|
|
260
|
-
// routes/users/index.tsx
|
|
261
|
-
import { createFileRoute } from '@tanstack/react-router'
|
|
262
|
-
import { UserListSection } from './-sections/user-list-section'
|
|
263
|
-
import { UserFilterSection } from './-sections/user-filter-section'
|
|
264
|
-
|
|
265
|
-
export const Route = createFileRoute('/users/')({
|
|
266
|
-
component: UsersPage,
|
|
267
|
-
})
|
|
268
|
-
|
|
269
|
-
const UsersPage = (): JSX.Element => {
|
|
270
|
-
return (
|
|
271
|
-
<div className="container mx-auto p-4">
|
|
272
|
-
<h1 className="text-2xl font-bold mb-4">Users</h1>
|
|
273
|
-
<UserFilterSection />
|
|
274
|
-
<UserListSection />
|
|
275
|
-
</div>
|
|
276
|
-
)
|
|
277
|
-
}
|
|
278
|
-
```
|
|
279
|
-
|
|
280
|
-
### Custom Hook 작성 규칙
|
|
281
|
-
|
|
282
|
-
**Purpose**: 페이지 또는 섹션의 **모든 로직, 상태, 라이프사이클**을 중앙화합니다.
|
|
283
|
-
|
|
284
|
-
- 페이지 훅: 페이지 전체 로직 담당
|
|
285
|
-
- 섹션 훅: 해당 섹션의 로직만 담당 (섹션으로 분리한 경우)
|
|
286
|
-
|
|
287
|
-
### ⚠️ 필수: Custom Hook 내부 순서
|
|
288
|
-
|
|
289
|
-
훅 내부 코드는 **반드시 아래 순서**를 따릅니다:
|
|
290
|
-
|
|
291
|
-
```
|
|
292
|
-
1. State (useState, zustand store)
|
|
293
|
-
2. Global Hooks (useParams, useNavigate, useQueryClient 등)
|
|
294
|
-
3. React Query (useQuery → useMutation 순서)
|
|
295
|
-
4. Event Handlers & Functions
|
|
296
|
-
5. useMemo
|
|
297
|
-
6. useEffect
|
|
298
|
-
```
|
|
299
|
-
|
|
300
|
-
### ✅ 올바른 Custom Hook 예시
|
|
301
|
-
|
|
302
|
-
```typescript
|
|
303
|
-
// routes/users/-hooks/use-users.ts
|
|
304
|
-
import { useState, useMemo, useEffect, useCallback } from 'react'
|
|
305
|
-
import { useParams, useNavigate } from '@tanstack/react-router'
|
|
306
|
-
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
307
|
-
import { useAuthStore } from '@/stores/auth'
|
|
308
|
-
import { getUsers, createUser, deleteUser } from '@/services/user'
|
|
309
|
-
import type { User } from '@/types'
|
|
310
|
-
|
|
311
|
-
interface UseUsersReturn {
|
|
312
|
-
users: User[] | undefined
|
|
313
|
-
filteredUsers: User[]
|
|
314
|
-
isLoading: boolean
|
|
315
|
-
error: Error | null
|
|
316
|
-
search: string
|
|
317
|
-
setSearch: (value: string) => void
|
|
318
|
-
handleCreate: (data: { email: string; name: string }) => void
|
|
319
|
-
handleDelete: (id: string) => void
|
|
320
|
-
isCreating: boolean
|
|
321
|
-
isDeleting: boolean
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
export const useUsers = (): UseUsersReturn => {
|
|
325
|
-
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
326
|
-
// 1. State (useState, zustand store)
|
|
327
|
-
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
328
|
-
const [search, setSearch] = useState('')
|
|
329
|
-
const { user: currentUser } = useAuthStore()
|
|
330
|
-
|
|
331
|
-
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
332
|
-
// 2. Global Hooks
|
|
333
|
-
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
334
|
-
const params = useParams({ from: '/users/$id' })
|
|
335
|
-
const navigate = useNavigate()
|
|
336
|
-
const queryClient = useQueryClient()
|
|
337
|
-
|
|
338
|
-
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
339
|
-
// 3. React Query (useQuery → useMutation)
|
|
340
|
-
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
341
|
-
const { data: users, isLoading, error } = useQuery({
|
|
342
|
-
queryKey: ['users'],
|
|
343
|
-
queryFn: () => getUsers(),
|
|
344
|
-
})
|
|
345
|
-
|
|
346
|
-
const createMutation = useMutation({
|
|
347
|
-
mutationFn: createUser,
|
|
348
|
-
onSuccess: () => {
|
|
349
|
-
queryClient.invalidateQueries({ queryKey: ['users'] })
|
|
350
|
-
},
|
|
351
|
-
})
|
|
352
|
-
|
|
353
|
-
const deleteMutation = useMutation({
|
|
354
|
-
mutationFn: deleteUser,
|
|
355
|
-
onSuccess: () => {
|
|
356
|
-
queryClient.invalidateQueries({ queryKey: ['users'] })
|
|
357
|
-
},
|
|
358
|
-
})
|
|
359
|
-
|
|
360
|
-
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
361
|
-
// 4. Event Handlers & Functions
|
|
362
|
-
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
363
|
-
const handleCreate = useCallback(
|
|
364
|
-
(data: { email: string; name: string }) => {
|
|
365
|
-
createMutation.mutate({ data })
|
|
366
|
-
},
|
|
367
|
-
[createMutation]
|
|
368
|
-
)
|
|
369
|
-
|
|
370
|
-
const handleDelete = useCallback(
|
|
371
|
-
(id: string) => {
|
|
372
|
-
deleteMutation.mutate({ data: id })
|
|
373
|
-
},
|
|
374
|
-
[deleteMutation]
|
|
375
|
-
)
|
|
376
|
-
|
|
377
|
-
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
378
|
-
// 5. useMemo
|
|
379
|
-
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
380
|
-
const filteredUsers = useMemo(() => {
|
|
381
|
-
if (!users) return []
|
|
382
|
-
if (!search) return users
|
|
383
|
-
return users.filter((user) =>
|
|
384
|
-
user.name.toLowerCase().includes(search.toLowerCase())
|
|
385
|
-
)
|
|
386
|
-
}, [users, search])
|
|
387
|
-
|
|
388
|
-
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
389
|
-
// 6. useEffect
|
|
390
|
-
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
391
|
-
useEffect(() => {
|
|
392
|
-
if (!currentUser) {
|
|
393
|
-
navigate({ to: '/login' })
|
|
394
|
-
}
|
|
395
|
-
}, [currentUser, navigate])
|
|
396
|
-
|
|
397
|
-
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
398
|
-
// Return
|
|
399
|
-
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
400
|
-
return {
|
|
401
|
-
users,
|
|
402
|
-
filteredUsers,
|
|
403
|
-
isLoading,
|
|
404
|
-
error,
|
|
405
|
-
search,
|
|
406
|
-
setSearch,
|
|
407
|
-
handleCreate,
|
|
408
|
-
handleDelete,
|
|
409
|
-
isCreating: createMutation.isPending,
|
|
410
|
-
isDeleting: deleteMutation.isPending,
|
|
411
|
-
}
|
|
412
|
-
}
|
|
413
|
-
```
|
|
414
|
-
|
|
415
|
-
### ❌ 잘못된 순서 (금지)
|
|
416
|
-
|
|
417
|
-
```typescript
|
|
418
|
-
// ❌ 순서가 뒤섞인 잘못된 예시
|
|
419
|
-
export const useBadHook = () => {
|
|
420
|
-
const queryClient = useQueryClient() // ❌ Global Hook이 먼저
|
|
421
|
-
|
|
422
|
-
useEffect(() => { /* ... */ }, []) // ❌ useEffect가 중간에
|
|
423
|
-
|
|
424
|
-
const [state, setState] = useState() // ❌ State가 나중에
|
|
425
|
-
|
|
426
|
-
const { data } = useQuery({ /* ... */ }) // ❌ Query가 Effect 다음에
|
|
427
|
-
|
|
428
|
-
const computed = useMemo(() => {}, []) // ❌ useMemo 위치 잘못됨
|
|
429
|
-
}
|
|
430
|
-
```
|
|
431
|
-
|
|
432
|
-
### Page Hook (간단한 예시)
|
|
433
|
-
|
|
434
|
-
```typescript
|
|
435
|
-
// routes/users/-hooks/use-users.ts
|
|
436
|
-
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
437
|
-
import { getUsers, createUser, deleteUser } from '@/services/user'
|
|
438
|
-
import type { User } from '@/types'
|
|
439
|
-
|
|
440
|
-
interface UseUsersReturn {
|
|
441
|
-
users: User[] | undefined
|
|
442
|
-
isLoading: boolean
|
|
443
|
-
error: Error | null
|
|
444
|
-
createUser: (data: { email: string; name: string }) => void
|
|
445
|
-
deleteUser: (id: string) => void
|
|
446
|
-
isCreating: boolean
|
|
447
|
-
isDeleting: boolean
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
export const useUsers = (): UseUsersReturn => {
|
|
451
|
-
// 2. Global Hooks
|
|
452
|
-
const queryClient = useQueryClient()
|
|
453
|
-
|
|
454
|
-
// 3. React Query (useQuery → useMutation)
|
|
455
|
-
const { data: users, isLoading, error } = useQuery({
|
|
456
|
-
queryKey: ['users'],
|
|
457
|
-
queryFn: () => getUsers(),
|
|
458
|
-
})
|
|
459
|
-
|
|
460
|
-
const createMutation = useMutation({
|
|
461
|
-
mutationFn: createUser,
|
|
462
|
-
onSuccess: () => {
|
|
463
|
-
queryClient.invalidateQueries({ queryKey: ['users'] })
|
|
464
|
-
},
|
|
465
|
-
})
|
|
466
|
-
|
|
467
|
-
const deleteMutation = useMutation({
|
|
468
|
-
mutationFn: deleteUser,
|
|
469
|
-
onSuccess: () => {
|
|
470
|
-
queryClient.invalidateQueries({ queryKey: ['users'] })
|
|
471
|
-
},
|
|
472
|
-
})
|
|
473
|
-
|
|
474
|
-
return {
|
|
475
|
-
users,
|
|
476
|
-
isLoading,
|
|
477
|
-
error,
|
|
478
|
-
createUser: createMutation.mutate,
|
|
479
|
-
deleteUser: deleteMutation.mutate,
|
|
480
|
-
isCreating: createMutation.isPending,
|
|
481
|
-
isDeleting: deleteMutation.isPending,
|
|
482
|
-
}
|
|
483
|
-
}
|
|
484
|
-
```
|
|
485
|
-
|
|
486
|
-
### Section with Hook
|
|
487
|
-
|
|
488
|
-
```tsx
|
|
489
|
-
// routes/users/-sections/user-list-section.tsx
|
|
490
|
-
import { useUsers } from '../-hooks/use-users'
|
|
491
|
-
import { UserCard } from '../-components/user-card'
|
|
492
|
-
|
|
493
|
-
export const UserListSection = (): JSX.Element => {
|
|
494
|
-
const { users, isLoading, error, deleteUser, isDeleting } = useUsers()
|
|
495
|
-
|
|
496
|
-
if (isLoading) {
|
|
497
|
-
return <div className="text-center py-8">Loading...</div>
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
if (error) {
|
|
501
|
-
return <div className="text-red-600 py-8">Error: {error.message}</div>
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
if (!users?.length) {
|
|
505
|
-
return <div className="text-gray-500 py-8">No users found</div>
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
return (
|
|
509
|
-
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
|
510
|
-
{users.map((user) => (
|
|
511
|
-
<UserCard
|
|
512
|
-
key={user.id}
|
|
513
|
-
user={user}
|
|
514
|
-
onDelete={deleteUser}
|
|
515
|
-
isDeleting={isDeleting}
|
|
516
|
-
/>
|
|
517
|
-
))}
|
|
518
|
-
</div>
|
|
519
|
-
)
|
|
520
|
-
}
|
|
521
|
-
```
|
|
522
|
-
|
|
523
|
-
### Filter Section with Hook
|
|
524
|
-
|
|
525
|
-
```tsx
|
|
526
|
-
// routes/users/-sections/user-filter-section.tsx
|
|
527
|
-
import { useUserFilter } from '../-hooks/use-user-filter'
|
|
528
|
-
import { Input } from '@/components/ui/input'
|
|
529
|
-
import { Button } from '@/components/ui/button'
|
|
530
|
-
|
|
531
|
-
export const UserFilterSection = (): JSX.Element => {
|
|
532
|
-
const { search, setSearch, role, setRole, clearFilters } = useUserFilter()
|
|
533
|
-
|
|
534
|
-
return (
|
|
535
|
-
<div className="flex gap-4 mb-6">
|
|
536
|
-
<Input
|
|
537
|
-
placeholder="Search users..."
|
|
538
|
-
value={search}
|
|
539
|
-
onChange={(e) => setSearch(e.target.value)}
|
|
540
|
-
className="max-w-xs"
|
|
541
|
-
/>
|
|
542
|
-
<select
|
|
543
|
-
value={role}
|
|
544
|
-
onChange={(e) => setRole(e.target.value)}
|
|
545
|
-
className="border rounded px-3 py-2"
|
|
546
|
-
>
|
|
547
|
-
<option value="">All Roles</option>
|
|
548
|
-
<option value="USER">User</option>
|
|
549
|
-
<option value="ADMIN">Admin</option>
|
|
550
|
-
</select>
|
|
551
|
-
<Button variant="outline" onClick={clearFilters}>
|
|
552
|
-
Clear
|
|
553
|
-
</Button>
|
|
554
|
-
</div>
|
|
555
|
-
)
|
|
556
|
-
}
|
|
557
|
-
```
|
|
558
|
-
|
|
559
|
-
### Filter Hook
|
|
560
|
-
|
|
561
|
-
```typescript
|
|
562
|
-
// routes/users/-hooks/use-user-filter.ts
|
|
563
|
-
import { useState, useCallback } from 'react'
|
|
564
|
-
|
|
565
|
-
interface UseUserFilterReturn {
|
|
566
|
-
search: string
|
|
567
|
-
setSearch: (value: string) => void
|
|
568
|
-
role: string
|
|
569
|
-
setRole: (value: string) => void
|
|
570
|
-
clearFilters: () => void
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
export const useUserFilter = (): UseUserFilterReturn => {
|
|
574
|
-
const [search, setSearch] = useState('')
|
|
575
|
-
const [role, setRole] = useState('')
|
|
576
|
-
|
|
577
|
-
const clearFilters = useCallback(() => {
|
|
578
|
-
setSearch('')
|
|
579
|
-
setRole('')
|
|
580
|
-
}, [])
|
|
581
|
-
|
|
582
|
-
return {
|
|
583
|
-
search,
|
|
584
|
-
setSearch,
|
|
585
|
-
role,
|
|
586
|
-
setRole,
|
|
587
|
-
clearFilters,
|
|
588
|
-
}
|
|
589
|
-
}
|
|
590
|
-
```
|
|
591
|
-
|
|
592
|
-
### Page Component
|
|
593
|
-
|
|
594
|
-
```tsx
|
|
595
|
-
// routes/users/-components/user-card.tsx
|
|
596
|
-
import type { User } from '@/types'
|
|
597
|
-
import { Button } from '@/components/ui/button'
|
|
598
|
-
|
|
599
|
-
interface UserCardProps {
|
|
600
|
-
user: User
|
|
601
|
-
onDelete?: (id: string) => void
|
|
602
|
-
isDeleting?: boolean
|
|
603
|
-
}
|
|
604
|
-
|
|
605
|
-
export const UserCard = ({
|
|
606
|
-
user,
|
|
607
|
-
onDelete,
|
|
608
|
-
isDeleting,
|
|
609
|
-
}: UserCardProps): JSX.Element => {
|
|
610
|
-
return (
|
|
611
|
-
<div className="rounded-lg border p-4 shadow-sm">
|
|
612
|
-
<div className="flex items-center gap-4">
|
|
613
|
-
<div className="h-12 w-12 rounded-full bg-gray-200" />
|
|
614
|
-
<div>
|
|
615
|
-
<h3 className="font-semibold">{user.name}</h3>
|
|
616
|
-
<p className="text-sm text-gray-600">{user.email}</p>
|
|
617
|
-
</div>
|
|
618
|
-
</div>
|
|
619
|
-
|
|
620
|
-
{onDelete && (
|
|
621
|
-
<div className="mt-4">
|
|
622
|
-
<Button
|
|
623
|
-
variant="outline"
|
|
624
|
-
size="sm"
|
|
625
|
-
onClick={() => onDelete(user.id)}
|
|
626
|
-
disabled={isDeleting}
|
|
627
|
-
>
|
|
628
|
-
{isDeleting ? 'Deleting...' : 'Delete'}
|
|
629
|
-
</Button>
|
|
630
|
-
</div>
|
|
631
|
-
)}
|
|
632
|
-
</div>
|
|
633
|
-
)
|
|
634
|
-
}
|
|
635
|
-
```
|
|
636
|
-
|
|
637
|
-
## Service Layer
|
|
638
|
-
|
|
639
|
-
### Service 폴더 구조
|
|
640
|
-
|
|
641
|
-
도메인별로 폴더를 분리하고, 파일을 용도에 따라 구분합니다:
|
|
642
|
-
|
|
643
|
-
```
|
|
644
|
-
services/
|
|
645
|
-
├── user/
|
|
646
|
-
│ ├── index.ts # 진입점 (re-export)
|
|
647
|
-
│ ├── schemas.ts # Zod 스키마
|
|
648
|
-
│ ├── queries.ts # GET 요청 (읽기)
|
|
649
|
-
│ └── mutations.ts # POST 요청 (쓰기)
|
|
650
|
-
├── auth/
|
|
651
|
-
│ ├── index.ts
|
|
652
|
-
│ ├── schemas.ts
|
|
653
|
-
│ ├── queries.ts
|
|
654
|
-
│ └── mutations.ts
|
|
655
|
-
└── post/
|
|
656
|
-
├── index.ts
|
|
657
|
-
├── schemas.ts
|
|
658
|
-
├── queries.ts
|
|
659
|
-
└── mutations.ts
|
|
660
|
-
```
|
|
661
|
-
|
|
662
|
-
### Schemas 파일
|
|
663
|
-
|
|
664
|
-
```typescript
|
|
665
|
-
// services/user/schemas.ts
|
|
666
|
-
import { z } from 'zod'
|
|
667
|
-
|
|
668
|
-
export const createUserSchema = z.object({
|
|
669
|
-
email: z.email(),
|
|
670
|
-
name: z.string().min(1).max(100).trim(),
|
|
671
|
-
})
|
|
672
|
-
|
|
673
|
-
export const updateUserSchema = z.object({
|
|
674
|
-
id: z.string(),
|
|
675
|
-
email: z.email().optional(),
|
|
676
|
-
name: z.string().min(1).max(100).trim().optional(),
|
|
677
|
-
})
|
|
678
|
-
|
|
679
|
-
export type CreateUserInput = z.infer<typeof createUserSchema>
|
|
680
|
-
export type UpdateUserInput = z.infer<typeof updateUserSchema>
|
|
681
|
-
```
|
|
682
|
-
|
|
683
|
-
### Queries 파일
|
|
684
|
-
|
|
685
|
-
```typescript
|
|
686
|
-
// services/user/queries.ts
|
|
687
|
-
import { createServerFn } from '@tanstack/react-start'
|
|
688
|
-
import { prisma } from '@/database/prisma'
|
|
689
|
-
|
|
690
|
-
export const getUsers = createServerFn({ method: 'GET' })
|
|
691
|
-
.handler(async () => {
|
|
692
|
-
return prisma.user.findMany({
|
|
693
|
-
orderBy: { createdAt: 'desc' },
|
|
694
|
-
})
|
|
695
|
-
})
|
|
696
|
-
|
|
697
|
-
export const getUserById = createServerFn({ method: 'GET' })
|
|
698
|
-
.handler(async ({ data: id }: { data: string }) => {
|
|
699
|
-
const user = await prisma.user.findUnique({ where: { id } })
|
|
700
|
-
if (!user) throw new Error('User not found')
|
|
701
|
-
return user
|
|
702
|
-
})
|
|
703
|
-
|
|
704
|
-
export const getUserByEmail = createServerFn({ method: 'GET' })
|
|
705
|
-
.handler(async ({ data: email }: { data: string }) => {
|
|
706
|
-
return prisma.user.findUnique({ where: { email } })
|
|
707
|
-
})
|
|
708
|
-
```
|
|
709
|
-
|
|
710
|
-
### Mutations 파일
|
|
711
|
-
|
|
712
|
-
```typescript
|
|
713
|
-
// services/user/mutations.ts
|
|
714
|
-
import { createServerFn } from '@tanstack/react-start'
|
|
715
|
-
import { prisma } from '@/database/prisma'
|
|
716
|
-
import { createUserSchema, updateUserSchema } from './schemas'
|
|
717
|
-
|
|
718
|
-
export const createUser = createServerFn({ method: 'POST' })
|
|
719
|
-
.inputValidator(createUserSchema)
|
|
720
|
-
.handler(async ({ data }) => {
|
|
721
|
-
return prisma.user.create({ data })
|
|
722
|
-
})
|
|
723
|
-
|
|
724
|
-
export const updateUser = createServerFn({ method: 'POST' })
|
|
725
|
-
.inputValidator(updateUserSchema)
|
|
726
|
-
.handler(async ({ data }) => {
|
|
727
|
-
const { id, ...updateData } = data
|
|
728
|
-
return prisma.user.update({ where: { id }, data: updateData })
|
|
729
|
-
})
|
|
730
|
-
|
|
731
|
-
export const deleteUser = createServerFn({ method: 'POST' })
|
|
732
|
-
.handler(async ({ data: id }: { data: string }) => {
|
|
733
|
-
return prisma.user.delete({ where: { id } })
|
|
734
|
-
})
|
|
735
|
-
```
|
|
736
|
-
|
|
737
|
-
### Service 진입점 파일
|
|
738
|
-
|
|
739
|
-
```typescript
|
|
740
|
-
// services/user/index.ts
|
|
741
|
-
export * from './schemas'
|
|
742
|
-
export * from './queries'
|
|
743
|
-
export * from './mutations'
|
|
744
|
-
```
|
|
745
|
-
|
|
746
|
-
### 사용 예시
|
|
747
|
-
|
|
748
|
-
```typescript
|
|
749
|
-
// routes/users/-hooks/use-users.ts
|
|
750
|
-
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
751
|
-
import { getUsers, createUser, deleteUser } from '@/services/user'
|
|
752
|
-
```
|
|
753
|
-
|
|
754
|
-
## Common UI Components
|
|
755
|
-
|
|
756
|
-
### Button Component
|
|
757
|
-
|
|
758
|
-
```tsx
|
|
759
|
-
// components/ui/button.tsx
|
|
760
|
-
interface ButtonProps {
|
|
761
|
-
children: React.ReactNode
|
|
762
|
-
variant?: 'primary' | 'secondary' | 'outline'
|
|
763
|
-
size?: 'sm' | 'md' | 'lg'
|
|
764
|
-
onClick?: () => void
|
|
765
|
-
disabled?: boolean
|
|
766
|
-
type?: 'button' | 'submit' | 'reset'
|
|
767
|
-
}
|
|
768
|
-
|
|
769
|
-
export const Button = ({
|
|
770
|
-
children,
|
|
771
|
-
variant = 'primary',
|
|
772
|
-
size = 'md',
|
|
773
|
-
onClick,
|
|
774
|
-
disabled,
|
|
775
|
-
type = 'button',
|
|
776
|
-
}: ButtonProps): JSX.Element => {
|
|
777
|
-
const baseStyles = 'rounded font-medium transition-colors disabled:opacity-50'
|
|
778
|
-
|
|
779
|
-
const variants = {
|
|
780
|
-
primary: 'bg-blue-600 text-white hover:bg-blue-700',
|
|
781
|
-
secondary: 'bg-gray-200 text-gray-900 hover:bg-gray-300',
|
|
782
|
-
outline: 'border border-gray-300 hover:bg-gray-50',
|
|
783
|
-
}
|
|
784
|
-
|
|
785
|
-
const sizes = {
|
|
786
|
-
sm: 'px-3 py-1.5 text-sm',
|
|
787
|
-
md: 'px-4 py-2',
|
|
788
|
-
lg: 'px-6 py-3 text-lg',
|
|
789
|
-
}
|
|
790
|
-
|
|
791
|
-
return (
|
|
792
|
-
<button
|
|
793
|
-
type={type}
|
|
794
|
-
onClick={onClick}
|
|
795
|
-
disabled={disabled}
|
|
796
|
-
className={`${baseStyles} ${variants[variant]} ${sizes[size]}`}
|
|
797
|
-
>
|
|
798
|
-
{children}
|
|
799
|
-
</button>
|
|
800
|
-
)
|
|
801
|
-
}
|
|
802
|
-
```
|
|
803
|
-
|
|
804
|
-
### Input Component
|
|
805
|
-
|
|
806
|
-
```tsx
|
|
807
|
-
// components/ui/input.tsx
|
|
808
|
-
interface InputProps {
|
|
809
|
-
placeholder?: string
|
|
810
|
-
value: string
|
|
811
|
-
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void
|
|
812
|
-
type?: 'text' | 'email' | 'password'
|
|
813
|
-
className?: string
|
|
814
|
-
disabled?: boolean
|
|
815
|
-
}
|
|
816
|
-
|
|
817
|
-
export const Input = ({
|
|
818
|
-
placeholder,
|
|
819
|
-
value,
|
|
820
|
-
onChange,
|
|
821
|
-
type = 'text',
|
|
822
|
-
className = '',
|
|
823
|
-
disabled,
|
|
824
|
-
}: InputProps): JSX.Element => {
|
|
825
|
-
return (
|
|
826
|
-
<input
|
|
827
|
-
type={type}
|
|
828
|
-
placeholder={placeholder}
|
|
829
|
-
value={value}
|
|
830
|
-
onChange={onChange}
|
|
831
|
-
disabled={disabled}
|
|
832
|
-
className={`border rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 ${className}`}
|
|
833
|
-
/>
|
|
834
|
-
)
|
|
835
|
-
}
|
|
836
|
-
```
|
|
837
|
-
|
|
838
|
-
## Error Handling
|
|
839
|
-
|
|
840
|
-
### Custom Error Classes
|
|
841
|
-
|
|
842
|
-
```typescript
|
|
843
|
-
// lib/errors.ts
|
|
844
|
-
export class AppError extends Error {
|
|
845
|
-
constructor(
|
|
846
|
-
message: string,
|
|
847
|
-
public statusCode: number = 500,
|
|
848
|
-
public code: string = 'INTERNAL_ERROR'
|
|
849
|
-
) {
|
|
850
|
-
super(message)
|
|
851
|
-
this.name = 'AppError'
|
|
852
|
-
}
|
|
853
|
-
}
|
|
854
|
-
|
|
855
|
-
export class NotFoundError extends AppError {
|
|
856
|
-
constructor(resource: string) {
|
|
857
|
-
super(`${resource} not found`, 404, 'NOT_FOUND')
|
|
858
|
-
}
|
|
859
|
-
}
|
|
860
|
-
|
|
861
|
-
export class ValidationError extends AppError {
|
|
862
|
-
constructor(message: string) {
|
|
863
|
-
super(message, 400, 'VALIDATION_ERROR')
|
|
864
|
-
}
|
|
865
|
-
}
|
|
866
|
-
```
|
|
867
|
-
|
|
868
|
-
## Testing
|
|
869
|
-
|
|
870
|
-
### Test Structure
|
|
871
|
-
|
|
872
|
-
```typescript
|
|
873
|
-
// __tests__/services/user-service.test.ts
|
|
874
|
-
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
875
|
-
|
|
876
|
-
describe('UserService', () => {
|
|
877
|
-
beforeEach(() => {
|
|
878
|
-
vi.clearAllMocks()
|
|
879
|
-
})
|
|
880
|
-
|
|
881
|
-
describe('createUser', () => {
|
|
882
|
-
it('creates a user with valid input', async () => {
|
|
883
|
-
const input = { email: 'test@example.com', name: 'Test' }
|
|
884
|
-
const result = await createUser(input)
|
|
885
|
-
|
|
886
|
-
expect(result).toMatchObject({
|
|
887
|
-
email: input.email,
|
|
888
|
-
name: input.name,
|
|
889
|
-
})
|
|
890
|
-
})
|
|
891
|
-
|
|
892
|
-
it('throws on invalid email', async () => {
|
|
893
|
-
const input = { email: 'invalid', name: 'Test' }
|
|
894
|
-
|
|
895
|
-
await expect(createUser(input)).rejects.toThrow()
|
|
896
|
-
})
|
|
897
|
-
})
|
|
898
|
-
})
|
|
899
|
-
```
|
|
900
|
-
|
|
901
|
-
## Performance
|
|
902
|
-
|
|
903
|
-
### React Optimization
|
|
904
|
-
|
|
905
|
-
```tsx
|
|
906
|
-
import { useMemo, useCallback, memo } from 'react'
|
|
907
|
-
|
|
908
|
-
// Memoize expensive computations
|
|
909
|
-
const sortedUsers = useMemo(
|
|
910
|
-
() => users.sort((a, b) => a.name.localeCompare(b.name)),
|
|
911
|
-
[users]
|
|
912
|
-
)
|
|
913
|
-
|
|
914
|
-
// Memoize callbacks
|
|
915
|
-
const handleClick = useCallback(() => {
|
|
916
|
-
setIsOpen(true)
|
|
917
|
-
}, [])
|
|
918
|
-
|
|
919
|
-
// Memoize components
|
|
920
|
-
export const UserCard = memo(({ user }: { user: User }): JSX.Element => {
|
|
921
|
-
return <div>{user.name}</div>
|
|
922
|
-
})
|
|
923
|
-
```
|
|
924
|
-
|
|
925
|
-
## Security
|
|
926
|
-
|
|
927
|
-
### Environment Variables
|
|
928
|
-
|
|
929
|
-
- `.env` 파일은 절대 커밋하지 않음
|
|
930
|
-
- `.env.example` 제공
|
|
931
|
-
- 시작 시 환경변수 검증
|
|
932
|
-
|
|
933
|
-
### Input Validation
|
|
934
|
-
|
|
935
|
-
```typescript
|
|
936
|
-
import { z } from 'zod'
|
|
937
|
-
|
|
938
|
-
// Zod v4 API
|
|
939
|
-
const userInputSchema = z.object({
|
|
940
|
-
name: z.string().min(1).max(100).trim(),
|
|
941
|
-
email: z.email().toLowerCase(), // v4: z.email()
|
|
942
|
-
})
|
|
943
|
-
|
|
944
|
-
const envSchema = z.object({
|
|
945
|
-
NODE_ENV: z.enum(['development', 'production', 'test']),
|
|
946
|
-
DATABASE_URL: z.url(), // v4: z.url()
|
|
947
|
-
})
|
|
948
|
-
|
|
949
|
-
export const env = envSchema.parse(process.env)
|
|
950
|
-
```
|