MikroORM Unit of Work 패턴

MikroORM Unit of Work란?

Unit of Work는 MikroORM의 핵심 설계 패턴이다. 엔티티의 변경 사항을 추적하고, em.flush() 호출 시 모든 변경을 한 번의 트랜잭션으로 일괄 반영한다. TypeORM의 즉시 저장 방식과 달리, MikroORM은 엔티티를 메모리에서 자유롭게 수정한 뒤 명시적으로 DB에 동기화한다. 이 패턴이 DDD(Domain-Driven Design)와 궁합이 좋은 이유다.

이 글에서는 Identity Map, Change Tracking, flush 동작 원리, 트랜잭션 전략, 그리고 Request Scope 관리까지 실무에서 반드시 알아야 할 패턴을 다룬다.

Identity Map: 동일 엔티티는 하나의 객체

MikroORM의 EntityManager는 내부에 Identity Map을 유지한다. 같은 PK를 가진 엔티티를 여러 번 조회해도 항상 동일한 객체 참조를 반환한다.

// 두 번 조회해도 같은 객체
const user1 = await em.findOne(User, 1);
const user2 = await em.findOne(User, 1);

console.log(user1 === user2);  // true — 동일 참조!

// 관계를 통해 접근해도 동일
const order = await em.findOne(Order, 10, { populate: ['user'] });
console.log(order.user === user1);  // true (user.id === 1이라면)

// Identity Map 없이는 이런 버그가 발생:
// user1.name = 'Alice';
// user2.name = 'Bob';  ← 같은 레코드인데 다른 값!
// flush → 어떤 값이 저장될까? Identity Map이 이를 방지

Identity Map의 이점:

이점 설명
일관성 같은 레코드에 대한 모든 참조가 동일 객체를 가리킴
성능 이미 로드된 엔티티는 DB 재조회 없이 캐시에서 반환
변경 추적 원본 스냅샷과 현재 상태 비교로 자동 변경 감지

Change Tracking: 자동 변경 감지

MikroORM은 엔티티를 로드할 때 원본 스냅샷을 저장한다. flush() 시점에 현재 상태와 스냅샷을 비교하여 변경된 필드만 UPDATE한다.

const user = await em.findOneOrFail(User, 1);
// 스냅샷 저장: { name: 'Alice', email: 'alice@old.com', tier: 'FREE' }

user.email = 'alice@new.com';
user.tier = UserTier.PREMIUM;
// 현재 상태: { name: 'Alice', email: 'alice@new.com', tier: 'PREMIUM' }

await em.flush();
// 생성되는 SQL: name은 변경되지 않았으므로 제외
// UPDATE users SET email = 'alice@new.com', tier = 'PREMIUM' WHERE id = 1

// em.persist()는 새 엔티티에만 필요
const newUser = new User('Bob', 'bob@test.com');
em.persist(newUser);  // INSERT 예약
await em.flush();     // 이 시점에 실제 INSERT 실행

핵심 규칙: 이미 관리되는 엔티티(조회된 엔티티)는 persist() 없이 프로퍼티만 수정하면 된다. flush()가 자동으로 변경을 감지한다.

flush(): 변경 사항 일괄 반영

flush()가 호출되면 MikroORM은 다음 순서로 SQL을 실행한다:

// flush 실행 순서
// 1. 새 엔티티 INSERT (persist된 것들)
// 2. 관리 엔티티 UPDATE (변경 감지된 것들)
// 3. 제거 엔티티 DELETE (em.remove된 것들)
// 모두 하나의 트랜잭션으로 래핑

// 실전 예시: 주문 생성 로직
async createOrder(userId: number, items: OrderItemDto[]): Promise<Order> {
  const user = await this.em.findOneOrFail(User, userId);

  // 1. 새 주문 생성 (INSERT 예약)
  const order = new Order(user);
  this.em.persist(order);

  // 2. 주문 항목 추가 (INSERT 예약)
  for (const item of items) {
    const product = await this.em.findOneOrFail(Product, item.productId);
    product.stock -= item.quantity;  // UPDATE 자동 감지
    order.items.add(new OrderItem(order, product, item.quantity));
  }

  // 3. 사용자 포인트 차감 (UPDATE 자동 감지)
  user.points -= order.totalPoints;

  // 4. 한 번의 flush로 모든 변경을 원자적으로 반영
  await this.em.flush();
  // → BEGIN
  // → INSERT INTO orders ...
  // → INSERT INTO order_items ... (N건 배치)
  // → UPDATE products SET stock = ... WHERE id IN (...)
  // → UPDATE users SET points = ... WHERE id = ...
  // → COMMIT

  return order;
}

트랜잭션 관리: em.transactional()

명시적 트랜잭션 제어가 필요할 때는 em.transactional()을 사용한다. 콜백 내에서 예외가 발생하면 자동 롤백된다.

// em.transactional(): 자동 flush + commit/rollback
async transferPoints(fromId: number, toId: number, amount: number) {
  return this.em.transactional(async (em) => {
    const from = await em.findOneOrFail(User, fromId, {
      lockMode: LockMode.PESSIMISTIC_WRITE,  // SELECT ... FOR UPDATE
    });
    const to = await em.findOneOrFail(User, toId);

    if (from.points < amount) {
      throw new InsufficientPointsError(from.points, amount);
      // → 자동 롤백
    }

    from.points -= amount;
    to.points += amount;

    const log = new PointTransferLog(from, to, amount);
    em.persist(log);

    // 콜백 종료 시 자동 flush + commit
  });
}

// NestJS @UseRequestContext와 함께
@Injectable()
export class OrderService {
  constructor(
    private readonly em: EntityManager,
    private readonly eventEmitter: EventEmitter2,
  ) {}

  async cancelOrder(orderId: number): Promise<void> {
    await this.em.transactional(async (em) => {
      const order = await em.findOneOrFail(Order, orderId, {
        populate: ['items', 'items.product'],
      });

      // 재고 복구 (변경 자동 감지)
      for (const item of order.items) {
        item.product.stock += item.quantity;
      }

      order.status = OrderStatus.CANCELLED;
      order.cancelledAt = new Date();

      // 트랜잭션 커밋 후 이벤트 발행
    });

    this.eventEmitter.emit('order.cancelled', { orderId });
  }
}

Request Scope: 웹 요청별 EntityManager

Identity Map은 요청 간에 공유되면 안 된다. 요청 A에서 로드한 엔티티가 요청 B에서 보이면 데이터 정합성이 깨진다. MikroORM은 RequestContext로 이를 해결한다.

// NestJS: MikroOrmModule이 자동으로 Request Context 관리
@Module({
  imports: [
    MikroOrmModule.forRoot({
      // ...config
      registerRequestContext: true,  // 기본값: 미들웨어 자동 등록
    }),
  ],
})
export class AppModule {}

// 내부 동작 원리
// 1. 요청 시작 → em.fork() 호출 → 새로운 EntityManager 생성
// 2. 서비스에서 em 주입 시 forked EM 사용
// 3. 요청 종료 → forked EM의 Identity Map 클리어

// 수동 fork가 필요한 경우 (백그라운드 작업, 큐 워커)
@Injectable()
export class QueueWorker {
  constructor(
    @InjectEntityManager()
    private readonly em: EntityManager,
  ) {}

  async processJob(payload: JobPayload): Promise<void> {
    // 큐 워커는 Request Context가 없으므로 수동 fork
    const fork = this.em.fork();

    try {
      const entity = await fork.findOneOrFail(Order, payload.orderId);
      entity.status = OrderStatus.PROCESSED;
      await fork.flush();
    } finally {
      fork.clear();  // Identity Map 정리
    }
  }
}

em.clear()와 em.refresh(): 캐시 제어

// em.clear(): Identity Map 전체 초기화
// 대량 데이터 처리 시 메모리 관리에 필수
async batchProcess(): Promise<void> {
  const batchSize = 500;
  let offset = 0;

  while (true) {
    const users = await this.em.find(User, {}, {
      limit: batchSize,
      offset,
      orderBy: { id: 'ASC' },
    });

    if (users.length === 0) break;

    for (const user of users) {
      user.tier = recalculateTier(user);
    }

    await this.em.flush();
    this.em.clear();  // 메모리 해제! 없으면 OOM 위험
    offset += batchSize;
  }
}

// em.refresh(): 단일 엔티티를 DB에서 다시 로드
const user = await em.findOneOrFail(User, 1);
// ... 다른 프로세스가 DB를 직접 업데이트했다면
await em.refresh(user);  // DB에서 최신 값으로 갱신
console.log(user.name);  // 최신 값 반영됨

// em.nativeUpdate(): Unit of Work 우회 (대량 업데이트)
// Identity Map을 거치지 않으므로 매우 빠름
const affected = await em.nativeUpdate(User, 
  { lastLoginAt: { $lt: thirtyDaysAgo } },
  { status: UserStatus.INACTIVE }
);
// 주의: Identity Map과 동기화되지 않음!
메서드 용도 주의점
flush() 변경 사항 DB 반영 트랜잭션 없으면 자동 래핑
clear() Identity Map 초기화 미flush 변경 사항 유실
refresh(entity) DB에서 최신 값 로드 로컬 변경 사항 덮어씀
nativeUpdate() 대량 업데이트 Identity Map 비동기화
fork() 독립 EM 생성 Request Context 외부에서 필수

Cascade와 관계 관리

@Entity()
export class Order {
  @OneToMany(() => OrderItem, item => item.order, {
    cascade: [Cascade.PERSIST, Cascade.REMOVE],
    orphanRemoval: true,
  })
  items = new Collection<OrderItem>(this);

  // cascade: PERSIST → order를 persist하면 items도 자동 persist
  // orphanRemoval → items에서 제거된 항목은 DELETE

  addItem(product: Product, qty: number): void {
    const item = new OrderItem(this, product, qty);
    this.items.add(item);
    // em.persist(item) 불필요 — cascade가 처리
  }

  removeItem(item: OrderItem): void {
    this.items.remove(item);
    // flush 시 자동 DELETE — orphanRemoval이 처리
  }
}

// 사용
const order = await em.findOneOrFail(Order, 1, { populate: ['items'] });
order.addItem(product, 3);      // INSERT 예약 (cascade)
order.removeItem(order.items[0]); // DELETE 예약 (orphanRemoval)
await em.flush();                 // 한 트랜잭션으로 반영

운영 체크리스트

  • flush 시점: 명시적으로 flush()를 호출하거나 em.transactional()로 자동 호출되도록 한다. flush를 잊으면 변경이 DB에 반영되지 않는다
  • 대량 처리: 수천 건 이상 처리 시 주기적으로 flush() + clear()를 호출하여 메모리를 관리한다
  • Request Context: 웹 요청에서는 자동이지만, Cron Job이나 큐 워커에서는 반드시 em.fork()를 사용한다
  • Lazy Loading: 관계를 접근하기 전에 populate했는지 확인한다. Identity Map 밖에서 lazy load하면 에러가 발생한다
  • nativeUpdate 후속 처리: nativeUpdate() 사용 후에는 em.clear()로 Identity Map을 초기화하여 stale 데이터를 방지한다
  • 디버깅: Interceptor에서 em.getUnitOfWork().getChangeSets()를 로깅하면 어떤 변경이 flush되는지 추적할 수 있다

MikroORM의 Unit of Work는 엔티티 변경을 메모리에서 자유롭게 수행한 뒤, 한 번의 flush로 원자적으로 반영하는 강력한 패턴이다. Identity Map으로 일관성을 보장하고, Change Tracking으로 변경된 필드만 업데이트한다. 이 패턴을 제대로 이해하면 도메인 로직에 집중하면서도 효율적인 DB 접근을 달성할 수 있다.

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