BackendJan 18, 2026

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

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

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

Data Mapper + Unit of Work는 도메인 변경을 ‘언제 DB에 쓰는지’로 통제하는 방식이다.

이 글은 MikroORM을 “한 번 써볼 ORM”이 아니라, flush 경계로 운영 규칙을 고정하는 도구로 다룬다. 실무에서 더 자주 터지는 문제는 “ORM이 제공하는 기능이 부족해서”가 아니라, 운영 이슈가 들어왔을 때 원인을 빨리 특정하지 못해서 발생한다.

따라서 이 글은 기능 나열을 피하고, 아래 흐름으로 정리한다.

  1. 문제: 사고가 왜 “반영 경계”에서 반복되는지
  2. MikroORM 소개: Data Mapper + UoW가 무엇을 고정하는지
  3. 이 문제에서 MikroORM을 선택한 이유(비교): Prisma/TypeORM 대비 운영 강제 비용 비교
  4. 규칙: 실행 단위/재시도/관측 경계를 팀 규약으로 고정
  5. 검증 흐름: 예시 코드와 체크리스트로 규칙이 지켜지는지 확인


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가지다.

  1. 주문 생성 + 재고 차감을 동일 트랜잭션/동일 반영 시점으로 강제하기 쉬운가?
  2. 조회 shape와 쿼리 예산을 코드 리뷰에서 판정 가능한 형태로 고정하기 쉬운가?
  3. 실패 시점(rollback)과 관측 포맷(tx begin/commit/rollback)을 일관되게 수렴시키기 쉬운가?

핵심은 “무엇이 가능하냐”보다 “팀이 기본 경로를 얼마나 흔들림 없이 강제할 수 있냐”다.

비교 축MikroORMPrismaTypeORM(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회마다 RequestContext 1개 생성
  • 재시도 단위와 컨텍스트 단위를 일치시켜 부분 성공/중복 처리 리스크 축소
  • 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) 도메인: 재고 불변 조건
ts
// 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: 반영 경계 단일화
ts
// 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) 관측: 경계 전후 이벤트 기록
ts
// 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(관계 로딩)와 예외(관리자 상세 조회)를 어떤 가드레일로 둘지

Share this post

N