junyeokk
Blog
MikroORM·2025. 12. 11

MikroORM Repository 패턴

Repository는 데이터베이스 접근 로직을 캡슐화하는 계층이다. Service가 "매장 목록을 가져와라"라고 요청하면, Repository가 실제 쿼리를 실행해서 결과를 반환한다. Service는 쿼리가 어떻게 생겼는지 알 필요가 없다.

두 가지 Repository 구현 방식

MikroORM에서 Repository를 만드는 방식은 두 가지가 있다. EntityManager를 직접 사용하는 방식과, EntityRepository를 주입받는 방식이다.

EntityManager 직접 사용

EntityManager만 주입받아 모든 작업을 수행한다. 단순하고 직관적이다.

typescript
@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 클래스를 매번 지정하지 않아도 된다.

typescript
@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를 등록해야 한다.

typescript
@Module({
  imports: [MikroOrmModule.forFeature([AIModel])],
  providers: [AIModelsRepository],
  exports: [AIModelsRepository],
})

어느 방식을 쓸까

두 방식의 기능 차이는 거의 없다. EntityRepository는 Entity 클래스를 반복 지정하지 않아도 되므로 코드가 조금 더 간결하다. EntityManager를 직접 쓰면 여러 Entity를 하나의 Repository에서 다룰 때 자유롭다.

typescript
// 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로 저장한다.

typescript
async create(sale: Sale): Promise<Sale> {
  await this.em.persistAndFlush(sale);
  return sale;
}

EntityRepository를 쓸 때는 repository.create()로 인스턴스를 만들 수도 있다.

typescript
const aiModel = this.repository.create(data as any);
await this.em.persistAndFlush(aiModel);

repository.create()는 plain object를 Entity 인스턴스로 변환한다. new Entity() 생성자와 달리 부분 데이터로 Entity를 만들 수 있다.

Read

조건 검색, 관계 로드, 정렬, 페이징을 조합한다.

typescript
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만 호출하면 된다.

typescript
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를 사용한다.

typescript
async updateShotsAsSelected(shotIds: string[]): Promise<number> {
  return this.em.nativeUpdate(
    Shot,
    { shotId: { $in: shotIds } },
    { selected: true },
  );
}

nativeUpdate는 Entity를 로드하지 않고 직접 UPDATE SQL을 실행한다. 수백 건을 업데이트할 때 각각 조회하고 수정하는 것보다 훨씬 빠르다.

Delete

Entity를 조회한 후 removeAndFlush로 삭제한다.

typescript
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를 사용한다.

typescript
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 연산자를 사용한다.

typescript
async searchByName(searchTerm: string): Promise<AIModel[]> {
  return this.repository.find(
    { name: { $like: `%${searchTerm}%` } },
    { orderBy: { name: 'ASC' } },
  );
}

%searchTerm%는 SQL의 LIKE 패턴이다. 앞뒤에 %가 있으므로 문자열 어디든 포함되면 매칭된다.

Repository 모듈 구성

Repository가 많아지면 하나의 모듈에 묶어서 관리할 수 있다.

typescript
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 연산자로 providersexports에 넣는 패턴이다. 새 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 한 번으로 전부 접근 가능하다.

관련 문서