MikroORM Repository 패턴
Repository는 데이터베이스 접근 로직을 캡슐화하는 계층이다. Service가 "매장 목록을 가져와라"라고 요청하면, Repository가 실제 쿼리를 실행해서 결과를 반환한다. Service는 쿼리가 어떻게 생겼는지 알 필요가 없다.
두 가지 Repository 구현 방식
MikroORM에서 Repository를 만드는 방식은 두 가지가 있다. EntityManager를 직접 사용하는 방식과, EntityRepository를 주입받는 방식이다.
EntityManager 직접 사용
EntityManager만 주입받아 모든 작업을 수행한다. 단순하고 직관적이다.
@Injectable()
export class StoresRepository {
constructor(private readonly em: EntityManager) {}
async create(store: Store): Promise<Store> {
await this.em.persistAndFlush(store);
return store;
}
async findAll(): Promise<Store[]> {
return this.em.find(
Store,
{ status: StoreStatus.ACTIVE },
{ populate: ['devices'] },
);
}
async findById(storeId: string): Promise<Store> {
return this.em.findOneOrFail(Store, { storeId });
}
async findByIdWithDevices(storeId: string): Promise<Store> {
return this.em.findOneOrFail(
Store,
{ storeId },
{ populate: ['devices'] },
);
}
async update(store: Store): Promise<Store> {
await this.em.flush();
return store;
}
}
em.find(Store, ...), em.findOneOrFail(Store, ...)처럼 매번 Entity 클래스를 첫 번째 인자로 넘겨야 한다.
EntityRepository 주입
@InjectRepository()로 특정 Entity에 바인딩된 Repository를 주입받는다. Entity 클래스를 매번 지정하지 않아도 된다.
@Injectable()
export class AIModelsRepository {
constructor(
@InjectRepository(AIModel)
private readonly repository: EntityRepository<AIModel>,
private readonly em: EntityManager,
) {}
async findAll(): Promise<AIModel[]> {
return this.repository.findAll({
orderBy: { name: 'ASC' },
});
}
async findAvailable(): Promise<AIModel[]> {
return this.repository.find(
{ status: AIModelStatus.AVAILABLE },
{ orderBy: { processingTime: 'ASC', additionalCost: 'ASC' } },
);
}
async create(data: Partial<AIModel>): Promise<AIModel> {
const aiModel = this.repository.create(data as any);
await this.em.persistAndFlush(aiModel);
return aiModel;
}
}
this.repository.find(...)처럼 Entity 클래스 없이 바로 쿼리한다. EntityRepository는 내부적으로 EntityManager를 감싸서 Entity 타입을 고정해놓은 것이다.
@InjectRepository(AIModel)를 사용하려면 모듈에서 해당 Entity를 등록해야 한다.
@Module({
imports: [MikroOrmModule.forFeature([AIModel])],
providers: [AIModelsRepository],
exports: [AIModelsRepository],
})
어느 방식을 쓸까
두 방식의 기능 차이는 거의 없다. EntityRepository는 Entity 클래스를 반복 지정하지 않아도 되므로 코드가 조금 더 간결하다. EntityManager를 직접 쓰면 여러 Entity를 하나의 Repository에서 다룰 때 자유롭다.
// EntityManager 방식: 여러 Entity를 다루기 편하다
async createSession(device: Device, concept: Concept, frame: Frame): Promise<Session> {
const session = new Session(device);
session.concept = concept;
session.frame = frame;
await this.em.persistAndFlush(session);
return session;
}
async countShots(sessionId: string): Promise<number> {
return this.em.count(Shot, { session: { sessionId } });
}
SessionsRepository는 Session뿐 아니라 Shot, Device, Concept 등 여러 Entity에 접근한다. EntityManager를 직접 사용하면 별도의 Repository를 주입받지 않아도 된다.
CRUD 패턴
Create
Entity 인스턴스를 만들고 persistAndFlush로 저장한다.
async create(sale: Sale): Promise<Sale> {
await this.em.persistAndFlush(sale);
return sale;
}
EntityRepository를 쓸 때는 repository.create()로 인스턴스를 만들 수도 있다.
const aiModel = this.repository.create(data as any);
await this.em.persistAndFlush(aiModel);
repository.create()는 plain object를 Entity 인스턴스로 변환한다. new Entity() 생성자와 달리 부분 데이터로 Entity를 만들 수 있다.
Read
조건 검색, 관계 로드, 정렬, 페이징을 조합한다.
async findByDevice(
deviceId: string,
options?: { limit?: number; offset?: number },
): Promise<Sale[]> {
return this.em.find(
Sale,
{ device: { deviceId } },
{
populate: ['concept'],
orderBy: { soldAt: 'DESC' },
limit: options?.limit,
offset: options?.offset,
},
);
}
관계를 통한 필터링({ device: { deviceId } })은 MikroORM이 자동으로 JOIN 쿼리를 생성한다.
Update
MikroORM은 Entity 속성 변경을 자동 추적하므로, 속성을 수정한 후 flush만 호출하면 된다.
async update(modelId: string, data: Partial<AIModel>): Promise<AIModel | null> {
const aiModel = await this.findById(modelId);
if (!aiModel) return null;
this.repository.assign(aiModel, data);
await this.em.flush();
return aiModel;
}
repository.assign()은 plain object의 속성을 Entity에 병합한다. Object.assign()과 비슷하지만, 관계 속성도 올바르게 처리한다.
대량 업데이트는 nativeUpdate를 사용한다.
async updateShotsAsSelected(shotIds: string[]): Promise<number> {
return this.em.nativeUpdate(
Shot,
{ shotId: { $in: shotIds } },
{ selected: true },
);
}
nativeUpdate는 Entity를 로드하지 않고 직접 UPDATE SQL을 실행한다. 수백 건을 업데이트할 때 각각 조회하고 수정하는 것보다 훨씬 빠르다.
Delete
Entity를 조회한 후 removeAndFlush로 삭제한다.
async delete(modelId: string): Promise<boolean> {
const aiModel = await this.findById(modelId);
if (!aiModel) return false;
await this.em.removeAndFlush(aiModel);
return true;
}
집계 쿼리
통계나 요약 데이터가 필요할 때는 Entity를 로드한 후 코드에서 계산하거나, count를 사용한다.
async getDailySummary(
deviceId: string,
date: string,
): Promise<{ totalRevenue: number; totalCount: number }> {
const targetDate = new Date(date);
const nextDate = new Date(date);
nextDate.setDate(nextDate.getDate() + 1);
const sales = await this.em.find(Sale, {
device: { deviceId },
soldAt: { $gte: targetDate, $lt: nextDate },
});
return {
totalRevenue: sales.reduce((sum, sale) => sum + Number(sale.amount), 0),
totalCount: sales.length,
};
}
날짜 범위 필터링에 $gte(이상)와 $lt(미만)를 조합한다. "2026-02-06 00:00:00 이상, 2026-02-07 00:00:00 미만"이면 2월 6일 하루 전체를 포함한다.
Number(sale.amount)로 타입 변환하는 이유는, decimal 타입 컬럼이 문자열로 반환될 수 있기 때문이다. PostgreSQL의 numeric/decimal 타입은 JavaScript의 number보다 정밀도가 높아서, ORM이 정밀도 손실을 방지하기 위해 문자열로 반환하는 경우가 있다.
검색 쿼리
텍스트 검색에는 $like 연산자를 사용한다.
async searchByName(searchTerm: string): Promise<AIModel[]> {
return this.repository.find(
{ name: { $like: `%${searchTerm}%` } },
{ orderBy: { name: 'ASC' } },
);
}
%searchTerm%는 SQL의 LIKE 패턴이다. 앞뒤에 %가 있으므로 문자열 어디든 포함되면 매칭된다.
Repository 모듈 구성
Repository가 많아지면 하나의 모듈에 묶어서 관리할 수 있다.
const repositories = [
ConceptsRepository,
FramesRepository,
FrameThemesRepository,
ServicesRepository,
AIModelsRepository,
// ...
];
@Module({
imports: [
MikroOrmModule.forFeature([
Service, Concept, Frame, FrameTheme, AIModel, // ...
]),
],
providers: [...repositories],
exports: [...repositories],
})
export class RepositoriesModule {}
배열 변수에 Repository를 모아두고 spread 연산자로 providers와 exports에 넣는 패턴이다. 새 Repository를 추가할 때 배열에만 추가하면 된다. 이 RepositoriesModule을 다른 모듈에서 import하면 모든 Repository를 한 번에 사용할 수 있다.
왜 Repository 패턴인가
Service에서 직접 EntityManager를 호출해도 동작은 한다. 하지만 쿼리 로직이 비즈니스 로직과 섞이면 테스트와 변경이 어려워진다.
| 비교 | Service에서 직접 em 사용 | Repository 분리 |
|---|---|---|
| 테스트 | em mock 필요, 쿼리 조건까지 검증해야 함 | Repository mock 하나로 충분 |
| 재사용 | 같은 쿼리를 여러 Service에서 중복 작성 | 메서드 호출로 재사용 |
| 변경 영향 | 쿼리 수정 시 Service 전체를 확인해야 함 | Repository 내부만 수정 |
| 복잡한 쿼리 | Service가 비대해짐 | 데이터 접근 로직이 한 곳에 집중 |
프로젝트 규모가 작고 쿼리가 단순하면 Service에서 직접 써도 문제없지만, Entity당 쿼리가 5개 이상이거나 여러 Service에서 같은 조회를 반복한다면 Repository 분리가 유지보수에 유리하다.
정리
- EntityManager 직접 사용은 여러 Entity를 한 Repository에서 다루기 자유롭고, EntityRepository 주입은 Entity 타입 반복 지정을 줄여준다.
- nativeUpdate/nativeDelete는 Entity 로드 없이 SQL을 직접 실행하므로, 대량 변경 시 성능이 훨씬 좋다.
- Repository를 RepositoriesModule에 모아두면 새 Repository 추가가 배열 한 줄이고, 다른 모듈에서 import 한 번으로 전부 접근 가능하다.
관련 문서
- Entity Manager - em의 핵심 API와 Unit of Work
- Entity 정의 - 데코레이터 기반 엔티티 매핑
- Raw SQL 집계 쿼리 - QueryBuilder/Knex로 집계