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와 조합하여 복잡한 도메인 쿼리를 캡슐화하는 것이 핵심입니다.