@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,621 @@
|
|
|
1
|
+
# TypeORM Skill - TypeORM 규칙
|
|
2
|
+
|
|
3
|
+
NestJS와 함께 사용하는 TypeORM 패턴과 규칙을 정의한다.
|
|
4
|
+
NestJS 레이어 규칙은 `../Coding/backend.md`, 공통 코딩 원칙은 `../Coding/SKILL.md`를 함께 참고한다.
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## 1. Entity 정의
|
|
9
|
+
|
|
10
|
+
### 기본 패턴
|
|
11
|
+
|
|
12
|
+
```typescript
|
|
13
|
+
// Good
|
|
14
|
+
@Entity()
|
|
15
|
+
export class User {
|
|
16
|
+
@PrimaryGeneratedColumn('uuid')
|
|
17
|
+
id: string;
|
|
18
|
+
|
|
19
|
+
@Column({ type: 'varchar', length: 100 })
|
|
20
|
+
name: string;
|
|
21
|
+
|
|
22
|
+
@Column({ type: 'varchar', unique: true })
|
|
23
|
+
email: string;
|
|
24
|
+
|
|
25
|
+
@Column({ type: 'boolean', default: true })
|
|
26
|
+
isActive: boolean;
|
|
27
|
+
|
|
28
|
+
@Column({ type: 'enum', enum: UserRole, default: UserRole.USER })
|
|
29
|
+
role: UserRole;
|
|
30
|
+
|
|
31
|
+
@CreateDateColumn()
|
|
32
|
+
createdAt: Date;
|
|
33
|
+
|
|
34
|
+
@UpdateDateColumn()
|
|
35
|
+
updatedAt: Date;
|
|
36
|
+
|
|
37
|
+
@DeleteDateColumn()
|
|
38
|
+
deletedAt: Date | null;
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### 컬럼 타입 명시
|
|
43
|
+
- 모든 `@Column`에 `type`을 명시한다
|
|
44
|
+
- TypeORM이 자동 추론하는 타입에 의존하지 않는다
|
|
45
|
+
|
|
46
|
+
```typescript
|
|
47
|
+
// Bad - 타입 추론에 의존
|
|
48
|
+
@Column()
|
|
49
|
+
name: string;
|
|
50
|
+
|
|
51
|
+
@Column()
|
|
52
|
+
age: number;
|
|
53
|
+
|
|
54
|
+
// Good - 타입 명시
|
|
55
|
+
@Column({ type: 'varchar', length: 255 })
|
|
56
|
+
name: string;
|
|
57
|
+
|
|
58
|
+
@Column({ type: 'int' })
|
|
59
|
+
age: number;
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### nullable, default, unique 옵션
|
|
63
|
+
- `nullable`은 명시적으로 설정한다 (기본값 false)
|
|
64
|
+
- `default`를 사용하여 DB 레벨 기본값을 설정한다
|
|
65
|
+
- `unique` 제약은 `@Column`에 직접 설정하거나 `@Index`를 사용한다
|
|
66
|
+
|
|
67
|
+
```typescript
|
|
68
|
+
@Column({ type: 'varchar', nullable: true })
|
|
69
|
+
bio: string | null;
|
|
70
|
+
|
|
71
|
+
@Column({ type: 'int', default: 0 })
|
|
72
|
+
loginCount: number;
|
|
73
|
+
|
|
74
|
+
@Column({ type: 'varchar', unique: true })
|
|
75
|
+
email: string;
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### Soft Delete
|
|
79
|
+
- `@DeleteDateColumn`을 사용하여 soft delete를 구현한다
|
|
80
|
+
- `softRemove`/`softDelete` 메서드를 사용한다
|
|
81
|
+
- `find` 시 삭제된 데이터는 자동으로 제외된다 (`withDeleted` 옵션으로 포함 가능)
|
|
82
|
+
|
|
83
|
+
### Entity 파일 네이밍
|
|
84
|
+
- `PascalCase` 클래스명 + `kebab-case.entity.ts` 파일명
|
|
85
|
+
|
|
86
|
+
```
|
|
87
|
+
user.entity.ts -> User
|
|
88
|
+
user-profile.entity.ts -> UserProfile
|
|
89
|
+
order-item.entity.ts -> OrderItem
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
## 2. 관계 (Relations)
|
|
95
|
+
|
|
96
|
+
### 기본 관계 데코레이터
|
|
97
|
+
|
|
98
|
+
```typescript
|
|
99
|
+
// OneToOne - User <-> Profile
|
|
100
|
+
@Entity()
|
|
101
|
+
export class User {
|
|
102
|
+
@OneToOne(() => Profile, (profile) => profile.user, {
|
|
103
|
+
cascade: ['insert', 'update'],
|
|
104
|
+
})
|
|
105
|
+
@JoinColumn() // 소유하는 쪽(FK가 있는 쪽)에 @JoinColumn 설정
|
|
106
|
+
profile: Profile;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
@Entity()
|
|
110
|
+
export class Profile {
|
|
111
|
+
@OneToOne(() => User, (user) => user.profile)
|
|
112
|
+
user: User;
|
|
113
|
+
}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
```typescript
|
|
117
|
+
// OneToMany / ManyToOne - User <-> Post
|
|
118
|
+
@Entity()
|
|
119
|
+
export class User {
|
|
120
|
+
@OneToMany(() => Post, (post) => post.author)
|
|
121
|
+
posts: Post[];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
@Entity()
|
|
125
|
+
export class Post {
|
|
126
|
+
@ManyToOne(() => User, (user) => user.posts, { nullable: false })
|
|
127
|
+
author: User;
|
|
128
|
+
|
|
129
|
+
@Column({ type: 'uuid' })
|
|
130
|
+
authorId: string; // FK 컬럼을 명시적으로 선언하면 relation 로딩 없이 FK 값 접근 가능
|
|
131
|
+
}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
```typescript
|
|
135
|
+
// ManyToMany - Post <-> Tag
|
|
136
|
+
@Entity()
|
|
137
|
+
export class Post {
|
|
138
|
+
@ManyToMany(() => Tag, (tag) => tag.posts)
|
|
139
|
+
@JoinTable() // 소유하는 쪽에 @JoinTable 설정 (중간 테이블 생성)
|
|
140
|
+
tags: Tag[];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
@Entity()
|
|
144
|
+
export class Tag {
|
|
145
|
+
@ManyToMany(() => Post, (post) => post.tags)
|
|
146
|
+
posts: Post[];
|
|
147
|
+
}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### JoinColumn vs JoinTable
|
|
151
|
+
- `@JoinColumn`: OneToOne, ManyToOne 관계에서 FK를 소유하는 쪽에 설정한다
|
|
152
|
+
- `@JoinTable`: ManyToMany 관계에서 소유하는 쪽에 설정한다 (중간 테이블 자동 생성)
|
|
153
|
+
|
|
154
|
+
### eager vs lazy 로딩
|
|
155
|
+
- 기본은 **lazy** (관계를 자동 로딩하지 않음)
|
|
156
|
+
- `eager: true`는 남용하지 않는다
|
|
157
|
+
- 필요한 경우 `find`의 `relations` 옵션이나 `QueryBuilder`의 `leftJoinAndSelect`를 사용한다
|
|
158
|
+
|
|
159
|
+
```typescript
|
|
160
|
+
// Bad - eager 남용
|
|
161
|
+
@OneToMany(() => Post, (post) => post.author, { eager: true })
|
|
162
|
+
posts: Post[]; // User를 조회할 때마다 항상 posts를 로딩
|
|
163
|
+
|
|
164
|
+
// Good - 필요할 때만 관계 로딩
|
|
165
|
+
const user = await this.userRepository.findOne({
|
|
166
|
+
where: { id: userId },
|
|
167
|
+
relations: ['posts'],
|
|
168
|
+
});
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
### cascade 옵션
|
|
172
|
+
- `cascade: true`를 사용하지 않는다
|
|
173
|
+
- 필요한 동작만 개별적으로 설정한다
|
|
174
|
+
|
|
175
|
+
```typescript
|
|
176
|
+
// Bad - 무분별한 cascade
|
|
177
|
+
@OneToMany(() => Post, (post) => post.author, { cascade: true })
|
|
178
|
+
posts: Post[];
|
|
179
|
+
|
|
180
|
+
// Good - 필요한 동작만 설정
|
|
181
|
+
@OneToMany(() => Post, (post) => post.author, {
|
|
182
|
+
cascade: ['insert', 'update'], // remove는 제외 - 의도치 않은 삭제 방지
|
|
183
|
+
})
|
|
184
|
+
posts: Post[];
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
---
|
|
188
|
+
|
|
189
|
+
## 3. Repository 패턴
|
|
190
|
+
|
|
191
|
+
### NestJS에서 Repository 주입
|
|
192
|
+
|
|
193
|
+
```typescript
|
|
194
|
+
@Injectable()
|
|
195
|
+
export class UserService {
|
|
196
|
+
constructor(
|
|
197
|
+
@InjectRepository(User)
|
|
198
|
+
private readonly userRepository: Repository<User>,
|
|
199
|
+
) {}
|
|
200
|
+
}
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
### Custom Repository
|
|
204
|
+
|
|
205
|
+
비즈니스에 특화된 쿼리가 많을 때 Custom Repository를 사용한다.
|
|
206
|
+
|
|
207
|
+
```typescript
|
|
208
|
+
// user.repository.ts
|
|
209
|
+
@Injectable()
|
|
210
|
+
export class UserRepository extends Repository<User> {
|
|
211
|
+
constructor(private readonly dataSource: DataSource) {
|
|
212
|
+
super(User, dataSource.createEntityManager());
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async findActiveUsersByRole(role: UserRole): Promise<User[]> {
|
|
216
|
+
return this.find({
|
|
217
|
+
where: { isActive: true, role },
|
|
218
|
+
order: { createdAt: 'DESC' },
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async findWithPosts(userId: string): Promise<User | null> {
|
|
223
|
+
return this.findOne({
|
|
224
|
+
where: { id: userId },
|
|
225
|
+
relations: ['posts'],
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// user.module.ts
|
|
231
|
+
@Module({
|
|
232
|
+
imports: [TypeOrmModule.forFeature([User])],
|
|
233
|
+
providers: [UserService, UserRepository],
|
|
234
|
+
})
|
|
235
|
+
export class UserModule {}
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
### 기본 메서드 사용법
|
|
239
|
+
|
|
240
|
+
```typescript
|
|
241
|
+
// 단건 조회
|
|
242
|
+
const user = await this.userRepository.findOneBy({ id: userId });
|
|
243
|
+
const userWithRelation = await this.userRepository.findOne({
|
|
244
|
+
where: { id: userId },
|
|
245
|
+
relations: ['profile'],
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// 목록 조회
|
|
249
|
+
const users = await this.userRepository.find({
|
|
250
|
+
where: { isActive: true },
|
|
251
|
+
order: { createdAt: 'DESC' },
|
|
252
|
+
take: 20,
|
|
253
|
+
skip: 0,
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// 저장 (insert + update)
|
|
257
|
+
const user = this.userRepository.create({ name: '홍길동', email: 'hong@example.com' });
|
|
258
|
+
await this.userRepository.save(user);
|
|
259
|
+
|
|
260
|
+
// 삭제
|
|
261
|
+
await this.userRepository.remove(user); // 하드 삭제
|
|
262
|
+
await this.userRepository.softRemove(user); // 소프트 삭제
|
|
263
|
+
|
|
264
|
+
// 개수 조회
|
|
265
|
+
const count = await this.userRepository.count({ where: { isActive: true } });
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
### find 옵션
|
|
269
|
+
|
|
270
|
+
| 옵션 | 설명 | 예시 |
|
|
271
|
+
|------|------|------|
|
|
272
|
+
| `where` | 조건 필터링 | `{ isActive: true, role: UserRole.ADMIN }` |
|
|
273
|
+
| `relations` | 관계 로딩 | `['posts', 'profile']` |
|
|
274
|
+
| `order` | 정렬 | `{ createdAt: 'DESC' }` |
|
|
275
|
+
| `select` | 필요한 컬럼만 조회 | `{ id: true, name: true }` |
|
|
276
|
+
| `take` | 조회 개수 제한 | `20` |
|
|
277
|
+
| `skip` | 건너뛸 개수 | `0` |
|
|
278
|
+
| `withDeleted` | soft delete된 데이터 포함 | `true` |
|
|
279
|
+
|
|
280
|
+
---
|
|
281
|
+
|
|
282
|
+
## 4. QueryBuilder
|
|
283
|
+
|
|
284
|
+
### 기본 사용법
|
|
285
|
+
|
|
286
|
+
```typescript
|
|
287
|
+
const users = await this.userRepository
|
|
288
|
+
.createQueryBuilder('user')
|
|
289
|
+
.select(['user.id', 'user.name', 'user.email'])
|
|
290
|
+
.where('user.isActive = :isActive', { isActive: true })
|
|
291
|
+
.andWhere('user.role = :role', { role: UserRole.ADMIN })
|
|
292
|
+
.orderBy('user.createdAt', 'DESC')
|
|
293
|
+
.take(20)
|
|
294
|
+
.skip(0)
|
|
295
|
+
.getMany();
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
### JOIN
|
|
299
|
+
|
|
300
|
+
```typescript
|
|
301
|
+
// leftJoinAndSelect - 관계가 없어도 결과에 포함 (LEFT JOIN)
|
|
302
|
+
const users = await this.userRepository
|
|
303
|
+
.createQueryBuilder('user')
|
|
304
|
+
.leftJoinAndSelect('user.posts', 'post')
|
|
305
|
+
.where('user.id = :id', { id: userId })
|
|
306
|
+
.getOne();
|
|
307
|
+
|
|
308
|
+
// innerJoinAndSelect - 관계가 있는 경우만 포함 (INNER JOIN)
|
|
309
|
+
const usersWithPosts = await this.userRepository
|
|
310
|
+
.createQueryBuilder('user')
|
|
311
|
+
.innerJoinAndSelect('user.posts', 'post')
|
|
312
|
+
.getMany();
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
### 서브쿼리
|
|
316
|
+
|
|
317
|
+
```typescript
|
|
318
|
+
// 서브쿼리로 특정 조건의 사용자 조회
|
|
319
|
+
const users = await this.userRepository
|
|
320
|
+
.createQueryBuilder('user')
|
|
321
|
+
.where((qb) => {
|
|
322
|
+
const subQuery = qb
|
|
323
|
+
.subQuery()
|
|
324
|
+
.select('post.authorId')
|
|
325
|
+
.from(Post, 'post')
|
|
326
|
+
.where('post.createdAt > :date', { date: startDate })
|
|
327
|
+
.getQuery();
|
|
328
|
+
return `user.id IN ${subQuery}`;
|
|
329
|
+
})
|
|
330
|
+
.getMany();
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
### getOne vs getMany vs getRawOne vs getRawMany
|
|
334
|
+
|
|
335
|
+
| 메서드 | 반환 타입 | 용도 |
|
|
336
|
+
|--------|----------|------|
|
|
337
|
+
| `getOne()` | `Entity \| null` | Entity 단건 조회 |
|
|
338
|
+
| `getMany()` | `Entity[]` | Entity 목록 조회 |
|
|
339
|
+
| `getRawOne()` | `object \| undefined` | 가공된 데이터 단건 (집계 등) |
|
|
340
|
+
| `getRawMany()` | `object[]` | 가공된 데이터 목록 |
|
|
341
|
+
|
|
342
|
+
```typescript
|
|
343
|
+
// 집계 쿼리 - getRawOne 사용
|
|
344
|
+
const result = await this.postRepository
|
|
345
|
+
.createQueryBuilder('post')
|
|
346
|
+
.select('COUNT(post.id)', 'totalCount')
|
|
347
|
+
.addSelect('AVG(post.viewCount)', 'avgViews')
|
|
348
|
+
.where('post.authorId = :authorId', { authorId })
|
|
349
|
+
.getRawOne();
|
|
350
|
+
// result: { totalCount: '42', avgViews: '128.5' }
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
### find vs QueryBuilder 가이드
|
|
354
|
+
|
|
355
|
+
| 상황 | 권장 방법 |
|
|
356
|
+
|------|----------|
|
|
357
|
+
| 단순 CRUD (조건 1~2개) | `find`, `findOne`, `findOneBy` |
|
|
358
|
+
| 단순 관계 로딩 | `find` + `relations` 옵션 |
|
|
359
|
+
| 복잡한 WHERE 조건 (OR, 중첩) | `QueryBuilder` |
|
|
360
|
+
| JOIN 조건이 복잡한 경우 | `QueryBuilder` |
|
|
361
|
+
| 집계 함수 (COUNT, SUM, AVG) | `QueryBuilder` + `getRawOne/getRawMany` |
|
|
362
|
+
| 서브쿼리가 필요한 경우 | `QueryBuilder` |
|
|
363
|
+
| 동적 조건 조합 | `QueryBuilder` |
|
|
364
|
+
|
|
365
|
+
---
|
|
366
|
+
|
|
367
|
+
## 5. 마이그레이션
|
|
368
|
+
|
|
369
|
+
### CLI 명령어
|
|
370
|
+
|
|
371
|
+
```bash
|
|
372
|
+
# 마이그레이션 자동 생성 (Entity 변경 감지)
|
|
373
|
+
npx typeorm migration:generate src/migrations/AddUserRole -d src/data-source.ts
|
|
374
|
+
|
|
375
|
+
# 마이그레이션 수동 생성 (빈 파일)
|
|
376
|
+
npx typeorm migration:create src/migrations/SeedInitialData
|
|
377
|
+
|
|
378
|
+
# 마이그레이션 실행
|
|
379
|
+
npx typeorm migration:run -d src/data-source.ts
|
|
380
|
+
|
|
381
|
+
# 마이그레이션 되돌리기 (가장 최근 1개)
|
|
382
|
+
npx typeorm migration:revert -d src/data-source.ts
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
### 마이그레이션 파일 작성 패턴
|
|
386
|
+
|
|
387
|
+
```typescript
|
|
388
|
+
export class AddUserRole1700000000000 implements MigrationInterface {
|
|
389
|
+
public async up(queryRunner: QueryRunner): Promise<void> {
|
|
390
|
+
await queryRunner.addColumn(
|
|
391
|
+
'user',
|
|
392
|
+
new TableColumn({
|
|
393
|
+
name: 'role',
|
|
394
|
+
type: 'enum',
|
|
395
|
+
enum: ['user', 'admin', 'moderator'],
|
|
396
|
+
default: `'user'`,
|
|
397
|
+
}),
|
|
398
|
+
);
|
|
399
|
+
|
|
400
|
+
await queryRunner.createIndex(
|
|
401
|
+
'user',
|
|
402
|
+
new TableIndex({
|
|
403
|
+
name: 'IDX_USER_ROLE',
|
|
404
|
+
columnNames: ['role'],
|
|
405
|
+
}),
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
public async down(queryRunner: QueryRunner): Promise<void> {
|
|
410
|
+
await queryRunner.dropIndex('user', 'IDX_USER_ROLE');
|
|
411
|
+
await queryRunner.dropColumn('user', 'role');
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
### 마이그레이션 원칙
|
|
417
|
+
- `up`과 `down`을 반드시 쌍으로 작성한다 (되돌릴 수 있어야 한다)
|
|
418
|
+
- `down`은 `up`의 역순으로 실행한다
|
|
419
|
+
- `synchronize: true`는 개발 환경에서만 사용한다 - 프로덕션에서는 마이그레이션만 사용한다
|
|
420
|
+
|
|
421
|
+
---
|
|
422
|
+
|
|
423
|
+
## 6. 트랜잭션
|
|
424
|
+
|
|
425
|
+
### DataSource.transaction() 패턴
|
|
426
|
+
|
|
427
|
+
가장 간결한 트랜잭션 패턴이다. 콜백 내에서 제공되는 `EntityManager`를 사용한다.
|
|
428
|
+
|
|
429
|
+
```typescript
|
|
430
|
+
@Injectable()
|
|
431
|
+
export class OrderService {
|
|
432
|
+
constructor(private readonly dataSource: DataSource) {}
|
|
433
|
+
|
|
434
|
+
async createOrder(dto: CreateOrderDto): Promise<Order> {
|
|
435
|
+
return this.dataSource.transaction(async (manager) => {
|
|
436
|
+
const order = manager.create(Order, {
|
|
437
|
+
userId: dto.userId,
|
|
438
|
+
totalAmount: dto.totalAmount,
|
|
439
|
+
});
|
|
440
|
+
await manager.save(order);
|
|
441
|
+
|
|
442
|
+
const orderItems = dto.items.map((item) =>
|
|
443
|
+
manager.create(OrderItem, { ...item, orderId: order.id }),
|
|
444
|
+
);
|
|
445
|
+
await manager.save(orderItems);
|
|
446
|
+
|
|
447
|
+
await manager.decrement(
|
|
448
|
+
Product,
|
|
449
|
+
{ id: In(dto.items.map((i) => i.productId)) },
|
|
450
|
+
'stock',
|
|
451
|
+
1,
|
|
452
|
+
);
|
|
453
|
+
|
|
454
|
+
return order;
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
### QueryRunner를 사용한 수동 트랜잭션
|
|
461
|
+
|
|
462
|
+
세밀한 제어가 필요한 경우 사용한다.
|
|
463
|
+
|
|
464
|
+
```typescript
|
|
465
|
+
async transferPoints(fromId: string, toId: string, amount: number): Promise<void> {
|
|
466
|
+
const queryRunner = this.dataSource.createQueryRunner();
|
|
467
|
+
await queryRunner.connect();
|
|
468
|
+
await queryRunner.startTransaction();
|
|
469
|
+
|
|
470
|
+
try {
|
|
471
|
+
await queryRunner.manager.decrement(User, { id: fromId }, 'points', amount);
|
|
472
|
+
await queryRunner.manager.increment(User, { id: toId }, 'points', amount);
|
|
473
|
+
|
|
474
|
+
await queryRunner.commitTransaction();
|
|
475
|
+
} catch (error) {
|
|
476
|
+
await queryRunner.rollbackTransaction();
|
|
477
|
+
throw error;
|
|
478
|
+
} finally {
|
|
479
|
+
await queryRunner.release(); // 반드시 release
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
### 트랜잭션 선택 가이드
|
|
485
|
+
|
|
486
|
+
| 상황 | 권장 패턴 |
|
|
487
|
+
|------|----------|
|
|
488
|
+
| 단순 다중 저장 | `DataSource.transaction()` |
|
|
489
|
+
| 조건부 커밋/롤백 | `QueryRunner` 수동 트랜잭션 |
|
|
490
|
+
| 중간에 외부 API 호출 필요 | `QueryRunner` (커밋 시점 제어) |
|
|
491
|
+
|
|
492
|
+
---
|
|
493
|
+
|
|
494
|
+
## 7. 성능 최적화
|
|
495
|
+
|
|
496
|
+
### N+1 문제 방지
|
|
497
|
+
|
|
498
|
+
```typescript
|
|
499
|
+
// Bad - N+1 문제 발생 (users를 조회 후 각 user마다 posts를 개별 조회)
|
|
500
|
+
const users = await this.userRepository.find();
|
|
501
|
+
for (const user of users) {
|
|
502
|
+
const posts = await this.postRepository.find({ where: { authorId: user.id } });
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// Good - relations으로 한 번에 조회
|
|
506
|
+
const users = await this.userRepository.find({
|
|
507
|
+
relations: ['posts'],
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
// Good - QueryBuilder로 한 번에 조회
|
|
511
|
+
const users = await this.userRepository
|
|
512
|
+
.createQueryBuilder('user')
|
|
513
|
+
.leftJoinAndSelect('user.posts', 'post')
|
|
514
|
+
.getMany();
|
|
515
|
+
```
|
|
516
|
+
|
|
517
|
+
### select로 필요한 컬럼만 조회
|
|
518
|
+
|
|
519
|
+
```typescript
|
|
520
|
+
// Bad - 모든 컬럼 조회
|
|
521
|
+
const users = await this.userRepository.find();
|
|
522
|
+
|
|
523
|
+
// Good - 필요한 컬럼만 조회
|
|
524
|
+
const users = await this.userRepository.find({
|
|
525
|
+
select: { id: true, name: true, email: true },
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
// Good - QueryBuilder에서 select
|
|
529
|
+
const users = await this.userRepository
|
|
530
|
+
.createQueryBuilder('user')
|
|
531
|
+
.select(['user.id', 'user.name', 'user.email'])
|
|
532
|
+
.getMany();
|
|
533
|
+
```
|
|
534
|
+
|
|
535
|
+
### 인덱스 설정
|
|
536
|
+
|
|
537
|
+
```typescript
|
|
538
|
+
@Entity()
|
|
539
|
+
@Index(['email'], { unique: true })
|
|
540
|
+
@Index(['isActive', 'role']) // 복합 인덱스 - 자주 함께 조회하는 컬럼
|
|
541
|
+
@Index(['createdAt']) // 정렬에 자주 사용되는 컬럼
|
|
542
|
+
export class User {
|
|
543
|
+
@PrimaryGeneratedColumn('uuid')
|
|
544
|
+
id: string;
|
|
545
|
+
|
|
546
|
+
@Column({ type: 'varchar' })
|
|
547
|
+
email: string;
|
|
548
|
+
|
|
549
|
+
@Column({ type: 'boolean', default: true })
|
|
550
|
+
isActive: boolean;
|
|
551
|
+
|
|
552
|
+
@Column({ type: 'enum', enum: UserRole })
|
|
553
|
+
role: UserRole;
|
|
554
|
+
|
|
555
|
+
@CreateDateColumn()
|
|
556
|
+
createdAt: Date;
|
|
557
|
+
}
|
|
558
|
+
```
|
|
559
|
+
|
|
560
|
+
### 인덱스 설정 원칙
|
|
561
|
+
- WHERE 조건에 자주 사용되는 컬럼에 인덱스를 설정한다
|
|
562
|
+
- ORDER BY에 자주 사용되는 컬럼에 인덱스를 설정한다
|
|
563
|
+
- 복합 인덱스는 카디널리티가 높은 컬럼을 앞에 배치한다
|
|
564
|
+
- 불필요한 인덱스는 쓰기 성능을 저하시키므로 남용하지 않는다
|
|
565
|
+
|
|
566
|
+
### Pagination
|
|
567
|
+
|
|
568
|
+
```typescript
|
|
569
|
+
async findUsers(page: number, limit: number): Promise<{ data: User[]; total: number }> {
|
|
570
|
+
const [data, total] = await this.userRepository.findAndCount({
|
|
571
|
+
order: { createdAt: 'DESC' },
|
|
572
|
+
take: limit,
|
|
573
|
+
skip: (page - 1) * limit,
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
return { data, total };
|
|
577
|
+
}
|
|
578
|
+
```
|
|
579
|
+
|
|
580
|
+
---
|
|
581
|
+
|
|
582
|
+
## 8. 네이밍 컨벤션
|
|
583
|
+
|
|
584
|
+
| 대상 | 규칙 | 예시 |
|
|
585
|
+
|------|------|------|
|
|
586
|
+
| Entity 클래스 | `PascalCase` | `User`, `UserProfile` |
|
|
587
|
+
| Entity 파일 | `kebab-case.entity.ts` | `user.entity.ts`, `user-profile.entity.ts` |
|
|
588
|
+
| 컬럼 (코드) | `camelCase` | `firstName`, `isActive`, `createdAt` |
|
|
589
|
+
| 컬럼 (DB) | `snake_case` 자동 변환 | `first_name`, `is_active`, `created_at` |
|
|
590
|
+
| 관계 필드 | 관련 Entity 이름 (camelCase) | `user`, `posts`, `profile`, `orderItems` |
|
|
591
|
+
| FK 컬럼 | 관계명 + `Id` | `authorId`, `categoryId` |
|
|
592
|
+
| Repository 파일 | `kebab-case.repository.ts` | `user.repository.ts` |
|
|
593
|
+
| 인덱스 이름 | `IDX_테이블_컬럼` | `IDX_USER_EMAIL`, `IDX_POST_CREATED_AT` |
|
|
594
|
+
| 마이그레이션 파일 | `타임스탬프-설명` | `1700000000000-AddUserRole.ts` |
|
|
595
|
+
|
|
596
|
+
### snake_case 자동 변환 설정
|
|
597
|
+
|
|
598
|
+
`data-source.ts`에서 `NamingStrategy`를 설정하여 코드의 camelCase가 DB에서 snake_case로 자동 변환되도록 한다.
|
|
599
|
+
|
|
600
|
+
```typescript
|
|
601
|
+
import { SnakeNamingStrategy } from 'typeorm-naming-strategies';
|
|
602
|
+
|
|
603
|
+
export const dataSource = new DataSource({
|
|
604
|
+
// ...
|
|
605
|
+
namingStrategy: new SnakeNamingStrategy(),
|
|
606
|
+
});
|
|
607
|
+
```
|
|
608
|
+
|
|
609
|
+
---
|
|
610
|
+
|
|
611
|
+
## 9. 금지 사항
|
|
612
|
+
|
|
613
|
+
- `synchronize: true` 프로덕션 사용 금지 - 마이그레이션을 사용한다
|
|
614
|
+
- Raw SQL 직접 실행 금지 (`query()` 메서드 사용 금지) - QueryBuilder를 사용한다
|
|
615
|
+
- Entity에 비즈니스 로직 작성 금지 - Service 레이어에서 처리한다
|
|
616
|
+
- `any` 타입 사용 금지
|
|
617
|
+
- `cascade: true` 무분별 사용 금지 - 필요한 동작만 개별 설정한다 (`['insert', 'update']`)
|
|
618
|
+
- `eager: true` 남용 금지 - 필요할 때 `relations` 옵션으로 로딩한다
|
|
619
|
+
- `find` 시 `where` 조건 없이 전체 조회 금지 (대량 데이터 위험) - 반드시 조건 또는 pagination을 사용한다
|
|
620
|
+
- Repository 외 레이어에서 직접 쿼리 실행 금지 - `../Coding/backend.md` 레이어 규칙을 따른다
|
|
621
|
+
- 마이그레이션 `down` 메서드 누락 금지 - 항상 롤백 가능해야 한다
|