Flush 경계로 운영 규칙 만들기: Data Mapper + UoW 관점에서 보는 MikroORM

Flush 경계로 운영 규칙 만들기: Data Mapper + UoW 관점에서 보는 MikroORM
Data Mapper + Unit of Work는 도메인 변경을 ‘언제 DB에 쓰는지’로 통제하는 방식이다.
이 글은 MikroORM을 “한 번 써볼 ORM”이 아니라, flush 경계로 운영 규칙을 고정하는 도구로 다룬다. 실무에서 더 자주 터지는 문제는 “ORM이 제공하는 기능이 부족해서”가 아니라, 운영 이슈가 들어왔을 때 원인을 빨리 특정하지 못해서 발생한다.
따라서 이 글은 기능 나열을 피하고, 아래 흐름으로 정리한다.
- 문제: 사고가 왜 “반영 경계”에서 반복되는지
- MikroORM 소개: Data Mapper + UoW가 무엇을 고정하는지
- 이 문제에서 MikroORM을 선택한 이유(비교): Prisma/TypeORM 대비 운영 강제 비용 비교
- 규칙: 실행 단위/재시도/관측 경계를 팀 규약으로 고정
- 검증 흐름: 예시 코드와 체크리스트로 규칙이 지켜지는지 확인
TL;DR
- 이 글은 문제 → 소개 → 선택 이유(비교) → 규칙 → 검증 흐름 순서로, 반영 경계를 운영 규칙으로 고정하는 방법을 정리한다.
- MikroORM의 강점은 “기능 개수”보다 Unit of Work + flush 경계 + EM 스코프를 기본 모델로 삼아 규칙 강제를 일관되게 만들기 쉽다는 점이다.
- Prisma/TypeORM도 같은 목표를 구현할 수 있지만, 복잡한 쓰기 흐름에서는 경계 규약을 애플리케이션/리뷰로 유지하는 비용이 더 커질 수 있다.
목차
정리하면 핵심은 ‘변경을 언제 확정하는가’를 팀이 합의 가능한 형태로 만들 수 있느냐이다. 다음 섹션에서는 그 필요성이 드러났던 문제 상황(사고 3패턴)부터 짚고, 그 위에서 선택 기준을 세운다.
1. 운영 문제 정의
이 글의 중심 질문은 기능 비교가 아니라, “어디서 반영이 확정되는지 운영에서 설명 가능한가?”다.
- 반영 경계가 흐리면 실패 지점도 흐려지고, 장애 대응 속도가 급격히 떨어진다.
- 실행 단위(HTTP/Queue/Cron)와 재시도 단위가 어긋나면 중복 처리/부분 성공이 반복된다.
- 관측 포맷이 경계 기준으로 정리되지 않으면 같은 사고가 다른 이름으로 재발한다.
실무에서 이 문제는 아래 3가지 패턴으로 반복된다.
- 혼용: tx-bound manager/repository와 전역 repository(또는 다른 EM)가 섞여 부분 반영이 발생
- late failure: 실패가 마지막 반영 경계에서 터져 원인 위치가 멀어짐
- side effect 타이밍: 커밋 전 외부효과가 먼저 나가 롤백 후 유령 상태 발생
아래부터는 이 3패턴을 줄일 수 있는지 기준으로 도구를 비교한다.
2. MikroORM이란
Mikro ORM을 선택하기 전에, 이 글에서 말하는 MikroORM의 특징이 무엇인지 짚고 간다. MikroORM은 TypeScript/Node.js 생태계에서 Data Mapper + Unit of Work를 전면에 둔 ORM이다. 엔티티 변경을 “즉시 DB 반영”이 아니라, 트랜잭션과 flush 경계를 기준으로 모아 반영하는 스타일에 가깝다.
MikroORM은 ‘도메인 상태 변화 → flush로 동기화’라는 흐름이 기본값이라, 쓰기 경계를 운영 규칙으로 고정하기 좋다. Prisma는 모델링보다 쿼리 인터페이스가 앞에 있어, 로직에서 DB 상호작용을 더 직접적으로 드러내는 편이다. TypeORM은 유연함이 강점이지만, 그 유연함만큼 컨텍스트/레포지토리 사용 규칙을 강하게 잡아야 한다.
JPA/Hibernate 관점에서 보기(개념 대응)
- MikroORM의 Unit of Work/Identity Map은(개념적으로) Hibernate의 persistence context + dirty checking과 같은 문제를 푼다.
- flush는 “트랜잭션 커밋”이 아니라, 지금까지의 변경을 SQL로 DB에 동기화하는 시점이다(커밋과 분리해서 운영 규칙을 만들 수 있다).
- optimistic lock은 JPA의
@Version처럼 “경쟁 갱신”을 감지하는 축이고, 이 글의 예시는 이를 제한 재시도로 흡수한다.
등장 배경
실무에서 어려운 지점은 보통 “쿼리를 못 짠다”가 아니라, 쓰기 흐름이 복잡해질 때 경계가 흐려지는 것이다. MikroORM은 이런 문제를 “엔티티 그래프/변경 추적/flush 경계”라는 프레임으로 다룬다.
핵심 개념
- Unit of Work: 변경을 모아 한 번에 반영한다(= flush 경계를 규칙으로 만들 수 있다).
- Identity Map: 같은 트랜잭션/컨텍스트 내에서 동일 엔티티는 동일 인스턴스로 유지된다.
- Request Context: 요청 단위로 EntityManager를 분리해, “누가 어떤 엔티티를 들고 있는가”를 통제한다.
강점
여기서 말하는 강점은 기능이 아니라, 운영 규칙을 코드 형태로 고정하기 쉬운가다.
- 트랜잭션 경계/flush 경계를 팀 규칙으로 만들기 쉽다.
- 엔티티 중심 모델링에서 “규칙을 어디에 둘지”가 선명해진다.
- 사고가 났을 때도 “이 변경은 언제 DB에 반영됐나?”를 경계 기준으로 추적하기 쉽다(단, 경계를 합의했을 때).
3. 이러한 문제 상황에서 MikroORM을 선택한 이유 (비교)
“선택 이유”는 기능 목록이 아니라, 이 문제(혼용/late failure/side effect 타이밍)를 줄이는 데 필요한 운영 강제력으로 본다.
비교 질문은 아래 3가지다.
- 주문 생성 + 재고 차감을 동일 트랜잭션/동일 반영 시점으로 강제하기 쉬운가?
- 조회 shape와 쿼리 예산을 코드 리뷰에서 판정 가능한 형태로 고정하기 쉬운가?
- 실패 시점(rollback)과 관측 포맷(tx begin/commit/rollback)을 일관되게 수렴시키기 쉬운가?
핵심은 “무엇이 가능하냐”보다 “팀이 기본 경로를 얼마나 흔들림 없이 강제할 수 있냐”다.
| 비교 축 | MikroORM | Prisma | TypeORM(Data Mapper) |
|---|---|---|---|
| 반영 경계 통제 | UoW + em.transactional(...) + EM 스코프(em.fork)로 경계 고정이 자연스럽다 | $transaction(...)으로 경계를 명시하기 쉽다 | 제어력은 높지만 manager/repository 혼용을 팀 규약으로 강하게 막아야 한다 |
| 엔티티 상태 일관성 | Identity Map으로 동일 컨텍스트 내 동일 엔티티를 같은 인스턴스로 유지한다 | managed entity/Identity Map이 기본 모델은 아니며, 호출 중심으로 다룬다 | 패턴에 따라 일관성이 달라져 컨텍스트 규약을 별도로 강제해야 한다 |
| 조회 비용 예측 | populate 화이트리스트로 shape 규칙을 만들기 좋다 | select/include가 코드에 직접 드러나 예측성이 높다 | QueryBuilder로 정밀 제어 가능하나 팀 스타일이 분산되면 표준화가 어렵다 |
| 장애 대응/관측 수렴 | flush 경계 중심으로 실패 노출 지점과 로그 포맷을 통일하기 쉽다 | 호출 지점 기준으로 에러 파악이 직관적이다 | 패턴이 분산되면 후킹/헬퍼/컨텍스트 혼용으로 운영 관측이 흔들릴 수 있다 |
정리하면 세 ORM 모두 구현은 가능하다. 다만 복잡한 쓰기 플로우에서 “경계를 규칙으로 고정”하는 비용은 다르고, 이 글의 조건에서는 MikroORM이 가장 맞았다.
4. 규칙
MikroORM을 쓰더라도 규칙이 없으면 이점이 사라진다. 그래서 아래를 기본 경로로 강제했다.
- 단일 EM 강제: 요청 단위
em.fork()+em.transactional(...)내부에서만 쓰기 허용 - flush 경계 단일화: 같은 유스케이스 경계에서 flush(변경 SQL 동기화) → commit(트랜잭션 종료) 순서를 고정하고, 외부효과는 commit 이후로만 허용
- 조회 shape 명시: populate 화이트리스트, 기본은 lazy 접근 금지
- side effect 커밋 이후: Outbox/after-commit 표준화
- 관측 포맷 표준화: tx begin/commit/rollback + flush 경계 전후 이벤트 고정
실행 단위 경계(RequestContext) 운영 규칙
- HTTP 요청 1건 / Queue 메시지 1건 / Cron chunk 1회마다
RequestContext1개 생성 - 재시도 단위와 컨텍스트 단위를 일치시켜 부분 성공/중복 처리 리스크 축소
- Outbox 소비는
lock + idempotency claim + retry metadata(attempt/nextAttemptAt/lastError)를 같은 Unit of Work에서 갱신 (즉, 소비 성공/실패 상태 전이와 재시도 메타데이터 갱신을 같은 실행 경계에서 처리)
규칙(강제) + 예외(조건부 허용) 표
| Rule (강제) | Allow-exception-if (관리자 상세 조회) | Guardrails |
|---|---|---|
| 주문+재고 차감은 동일 tx | (해당 없음) | tx 밖 쓰기 금지 + 체크리스트 |
| 조회 shape는 명시적으로 | 상세 화면에 한해 제한적 관계 로딩 | 쿼리 상한 + 화이트리스트 |
| lazy 접근 기본 금지 | 상세 화면에서만 허용 가능 | 쿼리 수/DB time 계측 |
| side effect는 커밋 이후만 | (해당 없음) | Outbox/after-commit 표준화 |
5. 검증 흐름
규칙은 문장으로 끝내지 않고, “코드 흐름 + 운영 체크리스트”로 검증한다.
익명 예시 코드
아래 코드는 실제 도메인/필드/에러 이름을 바꾼 익명 예시다. 목적은 규칙이 코드에서 강제되는지 확인하는 것이다.
1) 도메인: 재고 불변 조건
// modules/inventory/domains/entities/inventory-item.entity.ts
export class InventoryItem extends BaseEntity {
private constructor(
id: string,
private readonly _sku: string,
private readonly _priceCurrency: string,
private readonly _priceAmountMinor: number,
private _availableQuantity: number,
private _reservedQuantity: number,
) {
super(id);
}
reserve(quantity: number): void {
const normalized = Number(quantity ?? 0);
if (!Number.isFinite(normalized) || normalized <= 0) {
throw DomainErrorFactory.create(
INVENTORY_DOMAIN_ERRORS.INVENTORY_QUANTITY_INVALID,
{ details: { quantity } },
);
}
if (this._availableQuantity < normalized) {
throw DomainErrorFactory.create(
INVENTORY_DOMAIN_ERRORS.INVENTORY_STOCK_INSUFFICIENT,
{
message: `insufficient stock: sku=${this._sku} available=${this._availableQuantity} need=${normalized}`,
details: {
sku: this._sku,
availableQuantity: this._availableQuantity,
requestedQuantity: normalized,
},
},
);
}
this._availableQuantity -= normalized;
this._reservedQuantity += normalized;
}
}
2) 트랜잭션 + flush: 반영 경계 단일화
// lib/database/unit-of-work.ts
@Injectable()
export class UnitOfWork {
constructor(private readonly rootEm: EntityManager) {}
async transaction<T>(
work: (em: EntityManager) => Promise<T>,
options: { requiresNew?: boolean } = {},
): Promise<T> {
const existing = unitOfWorkStorage.getStore();
if (existing && !options.requiresNew) {
existing.depth += 1;
try {
return await work(existing.em);
} finally {
existing.depth -= 1;
}
}
return await this.rootEm.transactional(async (tx) => {
return await RequestContext.create(tx, async () => {
return await unitOfWorkStorage.run(
{ em: tx, depth: 1 },
async () => {
const result = await work(tx);
await tx.flush();
return result;
},
);
});
});
}
}
// modules/ordering/application/commands/create-order.command.ts
export class CreateOrderCommand extends Command<{ orderId: string }> {
public readonly userId: string;
public readonly amount: number;
public readonly currency: string;
public readonly items?: Array<{ sku: string; quantity: number }>;
constructor(input: {
userId: string;
amount: number;
currency: string;
items?: Array<{ sku: string; quantity: number }>;
}) {
super();
this.userId = input.userId;
this.amount = input.amount;
this.currency = input.currency;
this.items = input.items;
}
}
// modules/ordering/application/commands/handlers/create-order.handler.ts
@CommandHandler(CreateOrderCommand)
export class CreateOrderHandler implements ICommandHandler<CreateOrderCommand> {
constructor(
@Inject(IOrderRepositorySymbol)
private readonly orderRepository: IOrderRepository,
private readonly uow: UnitOfWork,
) {}
async execute(command: CreateOrderCommand): Promise<{ orderId: string }> {
return this.uow.transaction(async () => {
const { userId, amount, currency } = command;
const total = Money.of(amount, currency);
const items =
command.items?.map((item) =>
OrderItem.of(item.sku, item.quantity),
) ?? [];
const order = Order.create({ userId, total, items });
await this.orderRepository.persist(order);
return { orderId: order.id };
});
}
}
// modules/inventory/application/commands/handlers/reserve-inventory-for-order.handler.ts
@CommandHandler(ReserveInventoryForOrderCommand)
export class ReserveInventoryForOrderHandler implements ICommandHandler<ReserveInventoryForOrderCommand> {
constructor(
@Inject(InventoryReservationService)
private readonly InventoryReservationService: InventoryReservationService,
private readonly uow: UnitOfWork,
) {}
@LogAsyncExecution<[ReserveInventoryForOrderCommand], void>({
completed: {
step: "reserve_inventory_for_order_completed",
durationFieldName: "handlerTotalMs",
getPayload: ([command]) => ({
orderId: command.orderId,
itemCount: command.items.length,
}),
},
})
async execute(command: ReserveInventoryForOrderCommand): Promise<void> {
await this.uow.transaction(async () => {
await this.InventoryReservationService.reserve(command);
});
}
}
3) 관측: 경계 전후 이벤트 기록
// common/logging/log-async-execution.decorator.ts
@LogAsyncExecution<[ReserveInventoryForOrderCommand], void>({
completed: {
step: "reserve_inventory_for_order_completed",
durationFieldName: "handlerTotalMs",
getPayload: ([command]) => ({
orderId: command.orderId,
itemCount: command.items.length,
}),
},
})
// modules/outbox/application/commands/handlers/dispatch-outbox-event.handler.ts
writeStructuredLog(DispatchOutboxEventHandler.name, {
step: "outbox_event_enqueued",
outboxId,
messageGroupId,
enqueueMs,
markPublishedMs,
totalMs: Date.now() - startedAt,
});
writeStructuredLog(
DispatchOutboxEventHandler.name,
{
step: "outbox_enqueue_failed",
outboxId,
messageGroupId,
error: message,
markFailureMs,
totalMs: Date.now() - startedAt,
},
"error",
);
// shared/outbox/domain/entities/outbox-event.entity.ts
recordFailure(error: string, nextAttemptAt: Date): void {
this._status = OutboxEventStatus.FAILED;
this._attempt += 1;
this._lastError = error;
this._nextAttemptAt = nextAttemptAt;
this._lockedUntil = null;
}
운영 시나리오
체크리스트
선택(도입 전)- 트랜잭션 경계를 Application Command Handler/UnitOfWork 경계에서 강제할 수 있는가?
- 조회 shape(관계 로딩) 규칙을 코드 리뷰에서 판정할 수 있는가?
- 핵심 API의 쿼리 예산(쿼리 수/DB time 상한)을 합의하고 계측할 수 있는가?
- 사고(혼용/late failure/side effect 타이밍) 시 원인 범위를 좁힐 관측 포맷이 있는가?
- 핵심 API의
요청당 쿼리 수/총 DB 시간이 수집되는가? - 관리자 상세 조회 예외에 상한선/화이트리스트가 있는가?
- “tx 밖 쓰기”가 코드 구조상 어렵게 되어 있는가?
- RequestContext가 실행 단위(HTTP/Queue/Cron chunk)마다 분리되는가?
- 재시도 경계와 컨텍스트 경계가 일치하는가?
- Outbox 상태별 운영 기준(PENDING 체류/FAILED 누적/CONSUMED 처리량)이 정의되어 있는가?
마무리하며
이 글에서 말한 “선택 기준”은 결국 하나로 수렴한다.
우리 팀은 사고가 났을 때, 원인 범위를 경계 기준으로 빠르게 좁힐 수 있는가?
MikroORM/Prisma/TypeORM은 모두 같은 목표에 도달할 수 있다. 차이는 기능 유무보다,트랜잭션/flush/조회 shape/side effect 타이밍을 팀 규칙으로 얼마나 낮은 비용으로 강제할 수 있는지에 있다. 즉, 도구 선택보다 경계 규칙(레이어 책임, UoW, Outbox, Query/Command 분리)을 팀이 일관되게 실행하는지가 더 중요하다.
어떤 ORM을 고르든, 아래 두 가지는 먼저 고정하는 편이 좋다.
- 트랜잭션 경계와 쓰기 반영 시점(flush/commit)을 어디에 고정할지
- 조회 shape(관계 로딩)와 예외(관리자 상세 조회)를 어떤 가드레일로 둘지