TypeORM Custom Repository

Custom Repository란?

TypeORM Custom Repository는 기본 Repository가 제공하는 CRUD 메서드를 넘어, 도메인 특화 쿼리 로직을 캡슐화하는 패턴입니다. TypeORM 0.3.x에서 @EntityRepository 데코레이터가 제거되면서 구현 방식이 크게 바뀌었고, 많은 개발자가 혼란을 겪고 있습니다.

이 글에서는 TypeORM 0.3.x 기반 Custom Repository의 올바른 구현 방법, NestJS 통합, 테스트 전략, 그리고 실전 패턴까지 깊이 있게 다루겠습니다.

0.2.x vs 0.3.x 변화

항목 0.2.x (레거시) 0.3.x (현재)
선언 방식 @EntityRepository 데코레이터 DataSource.getRepository().extend()
DI 통합 getCustomRepository() 커스텀 Provider 등록
상속 기반 extends Repository<T> extend() 또는 클래스 래핑
트랜잭션 manager 자동 주입 DataSource/EntityManager 명시

패턴 1: Repository.extend() — 가장 간결

// repositories/user.repository.ts
import { DataSource, Repository } from 'typeorm';
import { User } from '../entities/user.entity';

// extend()로 기본 Repository에 커스텀 메서드 추가
export const UserRepository = (dataSource: DataSource) =>
  dataSource.getRepository(User).extend({

    async findByEmail(email: string): Promise<User | null> {
      return this.findOne({ where: { email } });
    },

    async findActiveUsers(): Promise<User[]> {
      return this.createQueryBuilder('user')
        .where('user.isActive = :isActive', { isActive: true })
        .andWhere('user.deletedAt IS NULL')
        .orderBy('user.createdAt', 'DESC')
        .getMany();
    },

    async findWithPosts(userId: number): Promise<User | null> {
      return this.createQueryBuilder('user')
        .leftJoinAndSelect('user.posts', 'post')
        .leftJoinAndSelect('post.category', 'category')
        .where('user.id = :userId', { userId })
        .andWhere('post.status = :status', { status: 'published' })
        .orderBy('post.createdAt', 'DESC')
        .getOne();
    },

    async searchByName(query: string, page: number, limit: number) {
      const [items, total] = await this.createQueryBuilder('user')
        .where('user.name ILIKE :query', { query: `%${query}%` })
        .skip((page - 1) * limit)
        .take(limit)
        .getManyAndCount();

      return { items, total, page, totalPages: Math.ceil(total / limit) };
    },
  });

// 타입 추출
export type UserRepositoryType = ReturnType<typeof UserRepository>;

NestJS Provider 등록

// user.module.ts
import { Module } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { UserRepository } from './repositories/user.repository';
import { UserService } from './user.service';

export const USER_REPOSITORY = Symbol('USER_REPOSITORY');

@Module({
  providers: [
    {
      provide: USER_REPOSITORY,
      useFactory: (dataSource: DataSource) => UserRepository(dataSource),
      inject: [DataSource],
    },
    UserService,
  ],
  exports: [USER_REPOSITORY, UserService],
})
export class UserModule {}

// user.service.ts
import { Inject, Injectable } from '@nestjs/common';
import { USER_REPOSITORY } from './user.module';
import { UserRepositoryType } from './repositories/user.repository';

@Injectable()
export class UserService {
  constructor(
    @Inject(USER_REPOSITORY)
    private readonly userRepo: UserRepositoryType,
  ) {}

  async getUserByEmail(email: string) {
    return this.userRepo.findByEmail(email);
  }

  async searchUsers(query: string, page = 1) {
    return this.userRepo.searchByName(query, page, 20);
  }
}

패턴 2: 클래스 기반 Repository — 가장 강력

// repositories/order.repository.ts
import { Injectable } from '@nestjs/common';
import { DataSource, Repository, SelectQueryBuilder } from 'typeorm';
import { Order, OrderStatus } from '../entities/order.entity';

@Injectable()
export class OrderRepository extends Repository<Order> {

  constructor(private readonly dataSource: DataSource) {
    super(Order, dataSource.createEntityManager());
  }

  // 기본 쿼리 빌더 재사용
  private baseQuery(): SelectQueryBuilder<Order> {
    return this.createQueryBuilder('order')
      .leftJoinAndSelect('order.user', 'user')
      .leftJoinAndSelect('order.items', 'item')
      .leftJoinAndSelect('item.product', 'product');
  }

  async findByIdWithDetails(orderId: number): Promise<Order | null> {
    return this.baseQuery()
      .leftJoinAndSelect('order.payment', 'payment')
      .where('order.id = :orderId', { orderId })
      .getOne();
  }

  async findByUserPaginated(
    userId: number,
    options: { page: number; limit: number; status?: OrderStatus },
  ) {
    const qb = this.baseQuery()
      .where('order.userId = :userId', { userId });

    if (options.status) {
      qb.andWhere('order.status = :status', { status: options.status });
    }

    const [items, total] = await qb
      .orderBy('order.createdAt', 'DESC')
      .skip((options.page - 1) * options.limit)
      .take(options.limit)
      .getManyAndCount();

    return { items, total, page: options.page };
  }

  async getOrderStats(userId: number) {
    return this.createQueryBuilder('order')
      .select('order.status', 'status')
      .addSelect('COUNT(*)', 'count')
      .addSelect('SUM(order.totalAmount)', 'totalAmount')
      .where('order.userId = :userId', { userId })
      .groupBy('order.status')
      .getRawMany<{
        status: string;
        count: string;
        totalAmount: string;
      }>();
  }

  async softDeleteOrder(orderId: number): Promise<void> {
    await this.createQueryBuilder()
      .softDelete()
      .where('id = :orderId', { orderId })
      .execute();
  }
}

// Module 등록 — @Injectable이므로 간단
@Module({
  imports: [TypeOrmModule.forFeature([Order])],
  providers: [OrderRepository, OrderService],
  exports: [OrderRepository],
})
export class OrderModule {}

패턴 3: 제네릭 Base Repository

// repositories/base.repository.ts
import { Repository, DataSource, EntityTarget, DeepPartial,
         FindOptionsWhere, ObjectLiteral } from 'typeorm';

export abstract class BaseRepository<T extends ObjectLiteral> 
  extends Repository<T> {

  constructor(
    target: EntityTarget<T>,
    dataSource: DataSource,
  ) {
    super(target, dataSource.createEntityManager());
  }

  // 공통: 페이지네이션
  async paginate(
    where: FindOptionsWhere<T>,
    page: number,
    limit: number,
    order?: Record<string, 'ASC' | 'DESC'>,
  ) {
    const [items, total] = await this.findAndCount({
      where,
      skip: (page - 1) * limit,
      take: limit,
      order: order as any,
    });

    return {
      items,
      total,
      page,
      totalPages: Math.ceil(total / limit),
      hasNext: page * limit < total,
    };
  }

  // 공통: Upsert
  async upsertEntity(entity: DeepPartial<T>, conflictPaths: string[]) {
    return this.upsert(entity as any, conflictPaths);
  }

  // 공통: 존재 여부 확인
  async existsBy(where: FindOptionsWhere<T>): Promise<boolean> {
    return this.exists({ where });
  }
}

// 사용: Product Repository
@Injectable()
export class ProductRepository extends BaseRepository<Product> {
  constructor(dataSource: DataSource) {
    super(Product, dataSource);
  }

  // Product 전용 메서드
  async findByCategory(categoryId: number, page: number) {
    return this.paginate(
      { categoryId, isActive: true } as any,
      page,
      20,
      { createdAt: 'DESC' },
    );
  }

  async findLowStock(threshold: number): Promise<Product[]> {
    return this.createQueryBuilder('product')
      .where('product.stock <= :threshold', { threshold })
      .andWhere('product.isActive = true')
      .orderBy('product.stock', 'ASC')
      .getMany();
  }
}

트랜잭션과 Custom Repository

// 트랜잭션 내에서 Custom Repository 사용 시 주의점
@Injectable()
export class OrderService {
  constructor(
    private readonly dataSource: DataSource,
    private readonly orderRepo: OrderRepository,
    private readonly productRepo: ProductRepository,
  ) {}

  async createOrder(dto: CreateOrderDto) {
    // ❌ 트랜잭션 밖 Repository → 트랜잭션 미적용!
    // this.orderRepo.save(order);

    // ✅ queryRunner로 트랜잭션 관리
    const queryRunner = this.dataSource.createQueryRunner();
    await queryRunner.connect();
    await queryRunner.startTransaction();

    try {
      const orderRepo = queryRunner.manager
        .getRepository(Order);
      const productRepo = queryRunner.manager
        .getRepository(Product);

      // 재고 차감
      for (const item of dto.items) {
        const product = await productRepo.findOneBy({ 
          id: item.productId 
        });
        if (!product || product.stock < item.quantity) {
          throw new Error(`Insufficient stock: ${item.productId}`);
        }
        product.stock -= item.quantity;
        await productRepo.save(product);
      }

      // 주문 생성
      const order = orderRepo.create({
        userId: dto.userId,
        items: dto.items,
        totalAmount: dto.totalAmount,
      });
      const saved = await orderRepo.save(order);

      await queryRunner.commitTransaction();
      return saved;

    } catch (error) {
      await queryRunner.rollbackTransaction();
      throw error;
    } finally {
      await queryRunner.release();
    }
  }
}

테스트 전략

// Custom Repository 단위 테스트
describe('OrderRepository', () => {
  let orderRepo: OrderRepository;
  let dataSource: DataSource;

  beforeAll(async () => {
    const module = await Test.createTestingModule({
      imports: [
        TypeOrmModule.forRoot({
          type: 'sqlite',          // 테스트용 인메모리 DB
          database: ':memory:',
          entities: [Order, User, Product, OrderItem],
          synchronize: true,
        }),
        TypeOrmModule.forFeature([Order]),
      ],
      providers: [OrderRepository],
    }).compile();

    orderRepo = module.get(OrderRepository);
    dataSource = module.get(DataSource);
  });

  afterAll(() => dataSource.destroy());

  it('should paginate orders by user', async () => {
    // Given: 테스트 데이터 생성
    const user = await dataSource.getRepository(User)
      .save({ name: 'Test', email: 'test@test.com' });

    for (let i = 0; i < 25; i++) {
      await orderRepo.save({ 
        userId: user.id, 
        totalAmount: 1000 * (i + 1),
        status: i % 3 === 0 ? 'cancelled' : 'completed',
      });
    }

    // When
    const result = await orderRepo.findByUserPaginated(
      user.id, { page: 1, limit: 10, status: 'completed' }
    );

    // Then
    expect(result.items).toHaveLength(10);
    expect(result.total).toBe(17);  // 25 - 8 cancelled
    expect(result.items[0].createdAt)
      .toBeInstanceOf(Date);
  });
});

마무리

TypeORM 0.3.x의 Custom Repository는 extend() 팩토리와 클래스 상속 두 가지 방식으로 구현할 수 있습니다. NestJS에서는 클래스 기반 패턴이 @Injectable()과 자연스럽게 통합되어 가장 권장됩니다. 제네릭 Base Repository로 공통 로직(페이지네이션, upsert)을 추출하면 코드 중복을 크게 줄일 수 있습니다. 트랜잭션 사용 시에는 반드시 queryRunner.manager를 통해 Repository를 가져와야 한다는 점, 그리고 QueryBuilder와 조합하여 복잡한 도메인 쿼리를 캡슐화하는 것이 핵심입니다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux