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()의 역할이 명확해지고, 예측 가능한 데이터 접근 계층을 구축할 수 있습니다.