MikroORM Unit of Work 심화

Unit of Work란?

Unit of Work는 하나의 비즈니스 트랜잭션 동안 발생한 모든 엔티티 변경사항을 추적하고, 마지막에 한 번에 DB에 반영하는 패턴입니다. MikroORM은 이 패턴을 핵심 아키텍처로 채택한 Node.js ORM으로, JPA/Hibernate의 영속성 컨텍스트와 동일한 개념입니다.

TypeORM이 Active Record와 Data Mapper를 모두 지원하는 반면, MikroORM은 Identity Map + Unit of Work를 강제하여 더 예측 가능한 데이터 접근 패턴을 제공합니다.

Identity Map: 같은 엔티티는 같은 객체

Identity Map은 하나의 요청(EntityManager 스코프) 안에서 같은 PK를 가진 엔티티를 단 하나의 객체 참조로 유지합니다.

// 같은 요청 안에서 두 번 조회
const user1 = await em.findOne(User, 1);
const user2 = await em.findOne(User, 1);

console.log(user1 === user2); // true — 두 번째 조회는 DB를 치지 않음

// 관계를 통해 접근해도 같은 객체
const order = await em.findOne(Order, 10, { populate: ['user'] });
console.log(order.user === user1); // true — Identity Map 덕분

장점:

  • 불필요한 DB 쿼리 제거 (1차 캐시 역할)
  • 객체 참조 일관성 보장 — 한 곳에서 수정하면 모든 참조에 반영
  • 변경 감지(dirty checking)의 기반

변경 감지와 자동 flush

MikroORM은 엔티티의 원본 스냅샷을 보관하고, flush() 시점에 현재 상태와 비교하여 변경된 필드만 UPDATE합니다.

const user = await em.findOne(User, 1);
user.name = 'New Name';        // 메모리에서만 변경
user.email = 'new@example.com';

// flush() 호출 시 변경된 필드만 UPDATE
await em.flush();
// 실행된 SQL: UPDATE user SET name = 'New Name', email = 'new@example.com' WHERE id = 1

// 변경이 없으면 flush()는 아무 쿼리도 실행하지 않음
await em.flush(); // NO-OP

핵심: em.persist()는 새 엔티티를 Identity Map에 등록만 합니다. 실제 INSERT/UPDATE/DELETE는 모두 em.flush()에서 일어납니다.

NestJS에서의 EntityManager 스코프

Unit of Work가 올바르게 동작하려면 요청(Request)마다 독립된 EntityManager가 필요합니다. MikroORM의 NestJS 통합은 이를 자동으로 처리합니다.

// app.module.ts
@Module({
  imports: [
    MikroOrmModule.forRoot({
      // ... DB 설정
      registerRequestContext: true,  // 요청별 EM 자동 생성 (기본값)
    }),
  ],
})
export class AppModule {}

// user.service.ts
@Injectable()
export class UserService {
  constructor(
    // 요청 스코프의 EntityManager가 자동 주입됨
    private readonly em: EntityManager,
    private readonly userRepo: EntityRepository<User>,
  ) {}

  async updateProfile(id: number, dto: UpdateProfileDto) {
    const user = await this.userRepo.findOneOrFail(id);
    
    // wrap()으로 부분 업데이트
    wrap(user).assign(dto);
    
    // flush — 변경 사항 DB 반영
    await this.em.flush();
    return user;
  }
}

주의: registerRequestContext: true가 없으면 모든 요청이 같은 EntityManager를 공유하여, A 요청의 변경이 B 요청에 보이는 심각한 버그가 발생합니다.

persist와 remove 패턴

// 새 엔티티 생성
const user = new User('Alice', 'alice@example.com');
const profile = new Profile('Seoul', 'Developer');
user.profile = profile;

em.persist(user);  // user + profile 모두 Identity Map에 등록 (cascade)
await em.flush();  // INSERT user → INSERT profile (순서 자동 결정)

// 삭제
em.remove(user);   // 삭제 마킹
await em.flush();  // DELETE 실행

// 벌크 작업 — flush 한 번으로 모든 변경 반영
const users = await em.find(User, { status: 'inactive' });
users.forEach(u => em.remove(u));
await em.flush();  // 단일 트랜잭션으로 모든 DELETE 실행

flush 모드와 성능 최적화

MikroORM은 3가지 flush 모드를 제공합니다.

모드 동작 사용 시점
FlushMode.COMMIT 명시적 flush() 호출 시만 기본값, 대부분의 경우
FlushMode.AUTO 쿼리 실행 전 자동 flush 변경 후 즉시 조회가 필요할 때
FlushMode.ALWAYS 모든 쿼리 전 flush 디버깅용 (성능 저하)
// 특정 작업에서만 AUTO 모드 사용
async transferPoints(fromId: number, toId: number, points: number) {
  em.setFlushMode(FlushMode.AUTO);

  const from = await em.findOneOrFail(User, fromId);
  from.points -= points;
  // AUTO 모드: 아래 조회 전에 from의 변경이 자동 flush됨

  const to = await em.findOneOrFail(User, toId);
  to.points += points;

  await em.flush();  // to의 변경만 flush
  em.setFlushMode(FlushMode.COMMIT);  // 원복
}

트랜잭션과 Unit of Work

em.flush()는 자동으로 트랜잭션을 감싸지만, 명시적 트랜잭션이 필요한 경우도 있습니다.

// 방법 1: em.transactional() — 권장
async createOrder(dto: CreateOrderDto) {
  return this.em.transactional(async (em) => {
    const user = await em.findOneOrFail(User, dto.userId);
    
    const order = new Order(user, dto.items);
    em.persist(order);
    
    user.orderCount += 1;
    
    // transactional 블록이 끝나면 자동 flush + commit
    // 예외 발생 시 자동 rollback
    return order;
  });
}

// 방법 2: @UseRequestContext() — NestJS 데코레이터 기반
// CQRS 이벤트 핸들러 등 HTTP 요청 컨텍스트 밖에서 사용
@UseRequestContext()
async handleOrderCreated(event: OrderCreatedEvent) {
  const user = await this.em.findOneOrFail(User, event.userId);
  user.lastOrderAt = new Date();
  await this.em.flush();
}

em.clear()와 메모리 관리

Identity Map은 메모리에 엔티티를 보관하므로, 대량 데이터 처리 시 메모리 관리가 필요합니다.

// 배치 처리 — 주기적으로 clear
async migrateUsers(batchSize = 100) {
  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.migrated = true;
    }

    await this.em.flush();  // 변경 반영
    this.em.clear();         // Identity Map 초기화 — 메모리 해제
    
    offset += batchSize;
  }
}

주의: em.clear() 후에는 이전에 조회한 엔티티 객체가 detached 상태가 됩니다. 해당 객체를 수정해도 flush에 반영되지 않습니다.

TypeORM과의 비교

기능 MikroORM TypeORM
Identity Map 기본 내장 없음
변경 감지 자동 dirty checking save() 호출 시 전체 UPDATE
flush 시점 명시적 flush() save() 즉시 실행
배치 최적화 flush()에서 자동 배치 수동 bulk insert 필요

TypeORM Cascade에서는 관계 저장 시 cascade 옵션이 필요하지만, MikroORM은 persist 시 연관 엔티티를 자동 추적합니다. TypeORM QueryBuilder와 달리 MikroORM은 QueryBuilder 없이도 Identity Map 덕분에 N+1 문제를 줄일 수 있습니다.

정리

MikroORM의 Unit of Work는 “변경을 모아서 한 번에 반영”하는 패턴입니다. Identity Map으로 객체 일관성을 보장하고, dirty checking으로 변경된 필드만 UPDATE하며, flush()로 모든 변경을 단일 트랜잭션에 반영합니다. 이 패턴을 이해하면 em.persist(), em.flush(), em.clear()의 역할이 명확해지고, 예측 가능한 데이터 접근 계층을 구축할 수 있습니다.

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