junyeokk
Blog
MikroORM·2026. 01. 04

MikroORM EntityManager

EntityManager(EM)는 MikroORM의 핵심이다. Entity의 생성, 조회, 수정, 삭제를 모두 이 객체를 통해 수행한다. 직접 SQL을 작성하지 않아도 EntityManager가 적절한 쿼리를 생성하고 실행한다.

EntityManager의 역할

EntityManager는 두 가지 핵심 역할을 수행한다.

첫째, 데이터베이스와 상호작용하는 API를 제공한다. find, findOne, persistAndFlush, flush 같은 메서드로 CRUD를 수행한다.

둘째, Entity의 상태를 추적한다. 어떤 Entity가 새로 만들어졌는지, 어떤 속성이 변경되었는지를 기억하고, flush할 때 변경된 것만 DB에 반영한다. 이 메커니즘을 Unit of Work라 부른다.

조회 메서드

find

조건에 맞는 Entity 목록을 반환한다.

typescript
// 활성 상태인 모든 매장 조회
const stores = await this.em.find(
  Store,
  { status: StoreStatus.ACTIVE },
  { populate: ['devices'] },
);

첫 번째 인자는 Entity 클래스, 두 번째는 검색 조건, 세 번째는 옵션이다.

populate: ['devices']는 관계를 함께 로드한다. MikroORM은 기본적으로 관계를 lazy loading한다. 즉, store.devices에 접근해도 실제 데이터가 없다. populate로 명시해야 DB에서 관련 데이터를 함께 가져온다.

조건에 비교 연산자를 쓸 수 있다.

typescript
// 만료된 세션 조회
const expired = await this.em.find(Session, {
  status: { $nin: [SessionStatus.COMPLETED, SessionStatus.CANCELLED, SessionStatus.EXPIRED] },
  expiresAt: { $lt: new Date() },
});
연산자의미
$eq같다 (기본값, 생략 가능)
$ne같지 않다
$gt / $gte크다 / 크거나 같다
$lt / $lte작다 / 작거나 같다
$in / $nin포함 / 미포함

정렬, 페이징, 관계 로드를 옵션으로 지정한다.

typescript
const sales = await this.em.find(
  Sale,
  { device: { deviceId } },
  {
    populate: ['concept'],
    orderBy: { soldAt: 'DESC' },
    limit: 10,
    offset: 0,
  },
);

orderBy로 정렬 기준을, limitoffset으로 페이징을 처리한다. 관계를 통한 필터링도 가능하다. { device: { deviceId } }는 Device Entity의 deviceId가 일치하는 Sale을 찾는다.

findOne

조건에 맞는 Entity 하나를 반환한다. 없으면 null을 반환한다.

typescript
const session = await this.em.findOne(
  Session,
  { sessionId },
  { populate: ['device', 'concept', 'frameTheme', 'frame', 'shots', 'aiModel'] },
);

findOneOrFail

findOne과 같지만, Entity를 찾지 못하면 예외를 던진다.

typescript
const store = await this.em.findOneOrFail(Store, { storeId });

Entity가 반드시 존재해야 하는 상황에서 사용한다. findOne 후 null 체크를 하는 것보다 간결하다. NestJS의 전역 예외 필터와 결합하면, 자동으로 404 응답으로 변환된다.

count

조건에 맞는 Entity 개수를 반환한다. 데이터 자체를 로드하지 않으므로 단순 집계에 효율적이다.

typescript
const selectedCount = await this.em.count(Shot, {
  session: { sessionId },
  selected: true,
});

Unit of Work 패턴

Unit of Work는 MikroORM이 Entity 변경을 추적하고, 한 번에 DB에 반영하는 메커니즘이다. 이 개념을 이해하면 persist, flush, 그리고 "왜 수정할 때 save가 아니라 flush인가"가 명확해진다.

변경 추적

EntityManager가 조회한 Entity는 "관리 대상(managed)"이 된다. 이후 해당 Entity의 속성을 변경하면 EM이 이를 감지한다.

typescript
// 1. 조회 - EM이 이 Entity를 관리 대상으로 등록
const store = await this.em.findOneOrFail(Store, { storeId });

// 2. 수정 - 코드에서 속성만 변경 (아직 DB에 반영 안 됨)
store.name = '강남점';
store.status = StoreStatus.INACTIVE;

// 3. flush - 변경된 속성만 DB에 반영
await this.em.flush();

flush를 호출하면 EM이 관리 중인 모든 Entity를 검사해서, 변경된 것만 골라 UPDATE 쿼리를 생성한다. namestatus만 바뀌었으므로 다른 컬럼은 건드리지 않는다.

이것이 update 메서드가 flush만 호출하는 이유다.

typescript
async update(store: Store): Promise<Store> {
  await this.em.flush();  // 이미 변경된 속성을 DB에 반영
  return store;
}

Service에서 store.name = '강남점'으로 속성을 바꾸고 update(store)를 호출하면, Repository의 flush가 변경 사항을 DB에 쓴다. 별도의 UPDATE 쿼리를 작성할 필요가 없다.

persist와 flush

새 Entity를 만들 때는 persist로 EM에 등록하고, flush로 DB에 반영한다.

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

persistAndFlushpersist + flush를 한 번에 수행하는 편의 메서드다. 분리해서 쓸 수도 있다.

typescript
// 여러 Entity를 한 번에 저장
const shot1 = new Shot(session, 1);
const shot2 = new Shot(session, 2);

this.em.persist(shot1);  // EM에 등록만
this.em.persist(shot2);  // EM에 등록만
await this.em.flush();   // 한 번에 DB 반영 (INSERT 2건)

persist를 따로 호출하고 마지막에 flush를 한 번 호출하면, 여러 INSERT를 하나의 트랜잭션으로 묶을 수 있다. 중간에 하나가 실패하면 전체가 롤백된다.

삭제

typescript
await this.em.removeAndFlush(aiModel);

remove로 EM에 삭제 표시를 하고, flush로 DELETE 쿼리를 실행한다.

nativeUpdate

EntityManager의 변경 추적을 거치지 않고 직접 UPDATE 쿼리를 실행한다.

typescript
const updatedCount = await this.em.nativeUpdate(
  Shot,
  { shotId: { $in: shotIds } },
  { selected: true },
);

대량의 레코드를 한 번에 수정할 때 효율적이다. 각 Entity를 하나씩 조회해서 수정하고 flush하는 것보다 훨씬 빠르다. 다만 변경 추적을 우회하므로 EM에 캐시된 Entity와 DB 상태가 불일치할 수 있다는 점에 주의해야 한다.

Identity Map

EntityManager는 같은 Entity를 두 번 조회해도 하나의 인스턴스만 유지한다. 이를 Identity Map이라 부른다.

typescript
const store1 = await this.em.findOneOrFail(Store, { storeId: 'abc' });
const store2 = await this.em.findOneOrFail(Store, { storeId: 'abc' });

console.log(store1 === store2); // true

두 번째 조회는 DB에 쿼리를 보내지 않고 EM의 캐시에서 반환한다. 같은 Entity에 대한 수정이 여러 곳에서 일어나도, 하나의 인스턴스를 공유하므로 불일치가 발생하지 않는다.

NestJS에서는 요청 스코프로 EntityManager가 관리된다. 각 HTTP 요청마다 새로운 EM 인스턴스가 생성되므로, 요청 간에 캐시가 공유되지 않는다. MikroORM의 NestJS 통합 모듈이 이를 자동으로 처리한다.

populate: 관계 로드 전략

MikroORM은 기본적으로 관계를 로드하지 않는다. Store를 조회해도 devices는 빈 Collection이다.

typescript
// devices가 로드되지 않음
const store = await this.em.findOneOrFail(Store, { storeId });
console.log(store.devices.isInitialized()); // false

// devices가 로드됨
const storeWithDevices = await this.em.findOneOrFail(
  Store,
  { storeId },
  { populate: ['devices'] },
);
console.log(storeWithDevices.devices.isInitialized()); // true

isInitialized()로 관계가 로드되었는지 확인할 수 있다. 로드되지 않은 관계에 접근하면 실제 데이터가 없는 빈 상태이므로, 반드시 populate으로 로드한 후 사용해야 한다.

중첩 관계도 로드할 수 있다.

typescript
const session = await this.em.findOne(
  Session,
  { sessionId },
  { populate: ['frame', 'frame.previewImage', 'frame.frameType', 'device'] },
);

frame.previewImage는 Session → Frame → PreviewImage까지 중첩으로 로드한다. 필요한 관계만 명시적으로 로드하는 것이 성능에 유리하다. 모든 관계를 무조건 로드하면 불필요한 JOIN이 발생한다.

왜 MikroORM EntityManager인가

TypeORM의 EntityManager도 비슷한 역할을 하지만, Unit of Work 구현이 다르다. TypeORM은 save() 호출마다 즉시 DB에 반영하고 변경 추적이 제한적이다. MikroORM은 flush() 시점까지 변경을 모아서 한 번에 반영하므로, 여러 Entity를 수정해도 트랜잭션 하나로 묶인다. Prisma는 EntityManager 개념 없이 각 모델에서 직접 쿼리를 실행하는 방식이라, 변경 추적이나 Identity Map이 없다. 복잡한 도메인 로직에서 여러 Entity를 동시에 다뤄야 한다면 MikroORM의 Unit of Work가 유리하다.

정리

  • EntityManager는 CRUD API와 변경 추적(Unit of Work)을 담당하며, flush 시점에 변경된 속성만 골라 쿼리를 생성한다
  • Identity Map으로 같은 Entity의 중복 인스턴스를 방지하고, NestJS 요청 스코프와 결합해 요청 간 캐시 격리를 보장한다
  • populate로 필요한 관계만 명시적으로 로드하고, nativeUpdate로 대량 수정 시 변경 추적을 우회해 성능을 확보한다

관련 문서