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 접근을 달성할 수 있다.