@choblue/claude-code-toolkit 1.1.1 → 1.1.2
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/agents/code-reviewer.md +8 -0
- package/.claude/agents/code-writer-be.md +8 -0
- package/.claude/agents/code-writer-fe.md +8 -0
- package/.claude/agents/explore.md +8 -0
- package/.claude/agents/git-manager.md +8 -0
- package/.claude/agents/test-writer-be.md +8 -0
- package/.claude/agents/test-writer-fe.md +8 -0
- package/.claude/skills/Coding/SKILL.md +5 -0
- package/.claude/skills/NextJS/SKILL.md +5 -0
- package/.claude/skills/React/SKILL.md +5 -0
- package/.claude/skills/ReactHookForm/SKILL.md +5 -0
- package/.claude/skills/TDD/SKILL.md +5 -0
- package/.claude/skills/TailwindCSS/SKILL.md +5 -0
- package/.claude/skills/TanStackQuery/SKILL.md +5 -0
- package/.claude/skills/TypeORM/SKILL.md +16 -303
- package/.claude/skills/TypeORM/references/advanced-queries.md +176 -0
- package/.claude/skills/TypeORM/references/migrations.md +62 -0
- package/.claude/skills/TypeORM/references/transactions.md +76 -0
- package/.claude/skills/TypeScript/SKILL.md +17 -225
- package/.claude/skills/TypeScript/references/advanced-patterns.md +146 -0
- package/.claude/skills/TypeScript/references/generics.md +98 -0
- package/.claude/skills/TypeScript/references/type-guards.md +109 -0
- package/.claude/skills/Zustand/SKILL.md +5 -0
- package/package.json +1 -1
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: test-writer-fe
|
|
3
|
+
description: |
|
|
4
|
+
React 프론트엔드 테스트 전문가. Testing Library, MSW 기반 테스트를 작성한다.
|
|
5
|
+
model: opus
|
|
6
|
+
color: green
|
|
7
|
+
---
|
|
8
|
+
|
|
1
9
|
# Test Writer Agent - Frontend (React)
|
|
2
10
|
|
|
3
11
|
당신은 React 프론트엔드 테스트 코드 작성 전문가다. Red-Green-Refactor 사이클에 따라 테스트를 설계하고 작성한다.
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: typeorm
|
|
3
|
+
description: TypeORM Entity, Repository, QueryBuilder 가이드. NestJS에서 TypeORM을 사용한 Entity 정의, Relations, Repository 패턴, 마이그레이션, 트랜잭션 등 데이터베이스 작업 시 참조한다.
|
|
4
|
+
---
|
|
5
|
+
|
|
1
6
|
# TypeORM Skill - TypeORM 규칙
|
|
2
7
|
|
|
3
8
|
NestJS와 함께 사용하는 TypeORM 패턴과 규칙을 정의한다.
|
|
@@ -279,307 +284,7 @@ const count = await this.userRepository.count({ where: { isActive: true } });
|
|
|
279
284
|
|
|
280
285
|
---
|
|
281
286
|
|
|
282
|
-
## 4.
|
|
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. 네이밍 컨벤션
|
|
287
|
+
## 4. 네이밍 컨벤션
|
|
583
288
|
|
|
584
289
|
| 대상 | 규칙 | 예시 |
|
|
585
290
|
|------|------|------|
|
|
@@ -608,7 +313,15 @@ export const dataSource = new DataSource({
|
|
|
608
313
|
|
|
609
314
|
---
|
|
610
315
|
|
|
611
|
-
##
|
|
316
|
+
## 참조 문서
|
|
317
|
+
|
|
318
|
+
- **[Advanced Queries](./references/advanced-queries.md)** - QueryBuilder 고급 사용법, 서브쿼리, 성능 최적화, N+1 문제 해결
|
|
319
|
+
- **[Migrations](./references/migrations.md)** - 마이그레이션 생성, 실행, 롤백 및 작성 패턴
|
|
320
|
+
- **[Transactions](./references/transactions.md)** - 트랜잭션 패턴 및 사용 가이드
|
|
321
|
+
|
|
322
|
+
---
|
|
323
|
+
|
|
324
|
+
## 5. 금지 사항
|
|
612
325
|
|
|
613
326
|
- `synchronize: true` 프로덕션 사용 금지 - 마이그레이션을 사용한다
|
|
614
327
|
- Raw SQL 직접 실행 금지 (`query()` 메서드 사용 금지) - QueryBuilder를 사용한다
|
|
@@ -618,4 +331,4 @@ export const dataSource = new DataSource({
|
|
|
618
331
|
- `eager: true` 남용 금지 - 필요할 때 `relations` 옵션으로 로딩한다
|
|
619
332
|
- `find` 시 `where` 조건 없이 전체 조회 금지 (대량 데이터 위험) - 반드시 조건 또는 pagination을 사용한다
|
|
620
333
|
- Repository 외 레이어에서 직접 쿼리 실행 금지 - `../Coding/backend.md` 레이어 규칙을 따른다
|
|
621
|
-
- 마이그레이션 `down` 메서드 누락 금지 - 항상 롤백 가능해야 한다
|
|
334
|
+
- 마이그레이션 `down` 메서드 누락 금지 - 항상 롤백 가능해야 한다
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# TypeORM Advanced Queries - QueryBuilder 고급 사용법 및 성능 최적화
|
|
2
|
+
|
|
3
|
+
> 이 문서는 `../SKILL.md`의 참조 문서이다.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## 1. QueryBuilder
|
|
8
|
+
|
|
9
|
+
### 기본 사용법
|
|
10
|
+
|
|
11
|
+
```typescript
|
|
12
|
+
const users = await this.userRepository
|
|
13
|
+
.createQueryBuilder('user')
|
|
14
|
+
.select(['user.id', 'user.name', 'user.email'])
|
|
15
|
+
.where('user.isActive = :isActive', { isActive: true })
|
|
16
|
+
.andWhere('user.role = :role', { role: UserRole.ADMIN })
|
|
17
|
+
.orderBy('user.createdAt', 'DESC')
|
|
18
|
+
.take(20)
|
|
19
|
+
.skip(0)
|
|
20
|
+
.getMany();
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
### JOIN
|
|
24
|
+
|
|
25
|
+
```typescript
|
|
26
|
+
// leftJoinAndSelect - 관계가 없어도 결과에 포함 (LEFT JOIN)
|
|
27
|
+
const users = await this.userRepository
|
|
28
|
+
.createQueryBuilder('user')
|
|
29
|
+
.leftJoinAndSelect('user.posts', 'post')
|
|
30
|
+
.where('user.id = :id', { id: userId })
|
|
31
|
+
.getOne();
|
|
32
|
+
|
|
33
|
+
// innerJoinAndSelect - 관계가 있는 경우만 포함 (INNER JOIN)
|
|
34
|
+
const usersWithPosts = await this.userRepository
|
|
35
|
+
.createQueryBuilder('user')
|
|
36
|
+
.innerJoinAndSelect('user.posts', 'post')
|
|
37
|
+
.getMany();
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### 서브쿼리
|
|
41
|
+
|
|
42
|
+
```typescript
|
|
43
|
+
// 서브쿼리로 특정 조건의 사용자 조회
|
|
44
|
+
const users = await this.userRepository
|
|
45
|
+
.createQueryBuilder('user')
|
|
46
|
+
.where((qb) => {
|
|
47
|
+
const subQuery = qb
|
|
48
|
+
.subQuery()
|
|
49
|
+
.select('post.authorId')
|
|
50
|
+
.from(Post, 'post')
|
|
51
|
+
.where('post.createdAt > :date', { date: startDate })
|
|
52
|
+
.getQuery();
|
|
53
|
+
return `user.id IN ${subQuery}`;
|
|
54
|
+
})
|
|
55
|
+
.getMany();
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### getOne vs getMany vs getRawOne vs getRawMany
|
|
59
|
+
|
|
60
|
+
| 메서드 | 반환 타입 | 용도 |
|
|
61
|
+
|--------|----------|------|
|
|
62
|
+
| `getOne()` | `Entity \| null` | Entity 단건 조회 |
|
|
63
|
+
| `getMany()` | `Entity[]` | Entity 목록 조회 |
|
|
64
|
+
| `getRawOne()` | `object \| undefined` | 가공된 데이터 단건 (집계 등) |
|
|
65
|
+
| `getRawMany()` | `object[]` | 가공된 데이터 목록 |
|
|
66
|
+
|
|
67
|
+
```typescript
|
|
68
|
+
// 집계 쿼리 - getRawOne 사용
|
|
69
|
+
const result = await this.postRepository
|
|
70
|
+
.createQueryBuilder('post')
|
|
71
|
+
.select('COUNT(post.id)', 'totalCount')
|
|
72
|
+
.addSelect('AVG(post.viewCount)', 'avgViews')
|
|
73
|
+
.where('post.authorId = :authorId', { authorId })
|
|
74
|
+
.getRawOne();
|
|
75
|
+
// result: { totalCount: '42', avgViews: '128.5' }
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### find vs QueryBuilder 가이드
|
|
79
|
+
|
|
80
|
+
| 상황 | 권장 방법 |
|
|
81
|
+
|------|----------|
|
|
82
|
+
| 단순 CRUD (조건 1~2개) | `find`, `findOne`, `findOneBy` |
|
|
83
|
+
| 단순 관계 로딩 | `find` + `relations` 옵션 |
|
|
84
|
+
| 복잡한 WHERE 조건 (OR, 중첩) | `QueryBuilder` |
|
|
85
|
+
| JOIN 조건이 복잡한 경우 | `QueryBuilder` |
|
|
86
|
+
| 집계 함수 (COUNT, SUM, AVG) | `QueryBuilder` + `getRawOne/getRawMany` |
|
|
87
|
+
| 서브쿼리가 필요한 경우 | `QueryBuilder` |
|
|
88
|
+
| 동적 조건 조합 | `QueryBuilder` |
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
## 2. 성능 최적화
|
|
93
|
+
|
|
94
|
+
### N+1 문제 방지
|
|
95
|
+
|
|
96
|
+
```typescript
|
|
97
|
+
// Bad - N+1 문제 발생 (users를 조회 후 각 user마다 posts를 개별 조회)
|
|
98
|
+
const users = await this.userRepository.find();
|
|
99
|
+
for (const user of users) {
|
|
100
|
+
const posts = await this.postRepository.find({ where: { authorId: user.id } });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Good - relations으로 한 번에 조회
|
|
104
|
+
const users = await this.userRepository.find({
|
|
105
|
+
relations: ['posts'],
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// Good - QueryBuilder로 한 번에 조회
|
|
109
|
+
const users = await this.userRepository
|
|
110
|
+
.createQueryBuilder('user')
|
|
111
|
+
.leftJoinAndSelect('user.posts', 'post')
|
|
112
|
+
.getMany();
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### select로 필요한 컬럼만 조회
|
|
116
|
+
|
|
117
|
+
```typescript
|
|
118
|
+
// Bad - 모든 컬럼 조회
|
|
119
|
+
const users = await this.userRepository.find();
|
|
120
|
+
|
|
121
|
+
// Good - 필요한 컬럼만 조회
|
|
122
|
+
const users = await this.userRepository.find({
|
|
123
|
+
select: { id: true, name: true, email: true },
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// Good - QueryBuilder에서 select
|
|
127
|
+
const users = await this.userRepository
|
|
128
|
+
.createQueryBuilder('user')
|
|
129
|
+
.select(['user.id', 'user.name', 'user.email'])
|
|
130
|
+
.getMany();
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### 인덱스 설정
|
|
134
|
+
|
|
135
|
+
```typescript
|
|
136
|
+
@Entity()
|
|
137
|
+
@Index(['email'], { unique: true })
|
|
138
|
+
@Index(['isActive', 'role']) // 복합 인덱스 - 자주 함께 조회하는 컬럼
|
|
139
|
+
@Index(['createdAt']) // 정렬에 자주 사용되는 컬럼
|
|
140
|
+
export class User {
|
|
141
|
+
@PrimaryGeneratedColumn('uuid')
|
|
142
|
+
id: string;
|
|
143
|
+
|
|
144
|
+
@Column({ type: 'varchar' })
|
|
145
|
+
email: string;
|
|
146
|
+
|
|
147
|
+
@Column({ type: 'boolean', default: true })
|
|
148
|
+
isActive: boolean;
|
|
149
|
+
|
|
150
|
+
@Column({ type: 'enum', enum: UserRole })
|
|
151
|
+
role: UserRole;
|
|
152
|
+
|
|
153
|
+
@CreateDateColumn()
|
|
154
|
+
createdAt: Date;
|
|
155
|
+
}
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
### 인덱스 설정 원칙
|
|
159
|
+
- WHERE 조건에 자주 사용되는 컬럼에 인덱스를 설정한다
|
|
160
|
+
- ORDER BY에 자주 사용되는 컬럼에 인덱스를 설정한다
|
|
161
|
+
- 복합 인덱스는 카디널리티가 높은 컬럼을 앞에 배치한다
|
|
162
|
+
- 불필요한 인덱스는 쓰기 성능을 저하시키므로 남용하지 않는다
|
|
163
|
+
|
|
164
|
+
### Pagination
|
|
165
|
+
|
|
166
|
+
```typescript
|
|
167
|
+
async findUsers(page: number, limit: number): Promise<{ data: User[]; total: number }> {
|
|
168
|
+
const [data, total] = await this.userRepository.findAndCount({
|
|
169
|
+
order: { createdAt: 'DESC' },
|
|
170
|
+
take: limit,
|
|
171
|
+
skip: (page - 1) * limit,
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
return { data, total };
|
|
175
|
+
}
|
|
176
|
+
```
|