@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.
@@ -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` 메서드 누락 금지 - 항상 롤백 가능해야 한다