NestJS + MikroORM 컨텍스트 관리

커버 이미지
직접 생성. 무단 사용 금지

NestJS + MikroORM 심화: RequestContext(AsyncLocalStorage)와 Unit of Work를 “안전하게” 쓰는 법

이 글은 NestJS에서 MikroORM을 실무적으로 운영할 때 가장 자주 사고가 나는 지점(전역 EntityManager, 요청 간 Identity Map 공유, 트랜잭션 경계 설정)을 공식 문서 근거만으로 정리한 심화 아티클입니다. 특히 HTTP 요청이 아닌 큐/크론/백그라운드 잡에서 컨텍스트가 깨질 때의 대응(Decorator 활용)을 중심으로 설명합니다.

1) 왜 RequestContext가 필요한가: Identity Map은 “요청 단위”가 기본 전제

MikroORM은 Identity Map 패턴을 사용합니다. 동일한 엔티티(동일 PK)를 같은 컨텍스트에서 여러 번 조회하면, 같은 인스턴스를 되돌려주며(=== 비교가 true), 그 결과로 요청 내 최적화배치 처리가 가능해집니다. 하지만 이 특성은 “요청 단위로 분리된 컨텍스트”라는 전제가 무너지면, 오히려 심각한 버그/메모리 문제를 유발합니다.

  • 문제 1: 메모리 풋프린트 증가 — 하나의 공유 Identity Map을 전 요청이 같이 쓰면 요청 종료 시점을 기준으로 안전하게 clear 할 수 없어, 관리되는 엔티티가 누적될 수 있습니다.
  • 문제 2: API 응답이 불안정 — 어떤 엔드포인트에서 populate 된 상태가 Identity Map에 남아, 같은 엔티티를 다른 엔드포인트에서 조회해도 예상과 다른(더 많은 필드/연관이 포함된) 출력이 섞일 수 있습니다.

이 부분은 MikroORM 공식 문서의 “Why is Request Context needed?”에서 명시적으로 설명합니다. (Identity Map/RequestContext 문서 참고)

2) 전역 EntityManager(글로벌 컨텍스트)를 피해야 하는 이유 (v5 이후 기본적으로 금지)

MikroORM 문서에 따르면 v5부터는 Global Identity Map 사용이 불가능해졌고, 요청 컨텍스트 없이 전역 EntityManager를 사용하는 것은 “거의 항상 잘못”이라고 경고합니다. 예외적으로 테스트 등에서만 allowGlobalContext 설정(또는 환경 변수 MIKRO_ORM_ALLOW_GLOBAL_CONTEXT)으로 검사를 완화할 수 있다고 설명합니다.

즉, 실무 NestJS 앱에서는 “전역 DI로 주입된 EntityManager를 아무 데서나 쓰는 패턴”을 기본적으로 위험한 것으로 보고, 요청/작업 단위로 분리된 컨텍스트를 만드는 것이 정상적인 운영 모델입니다.

3) MikroORM의 해법: RequestContext(AsyncLocalStorage) + EM fork

MikroORM은 Node.js의 AsyncLocalStorage를 활용한 RequestContext helper를 제공하며, 이를 통해 같은 DI 인스턴스를 쓰더라도 “현재 요청 컨텍스트에 묶인(fork된) EntityManager”를 자동으로 해석하도록 설계되어 있습니다.

핵심은 아래 2가지입니다.

  1. 요청 시작 시점에 RequestContext.create(orm.em, next)로 컨텍스트를 만들면, 내부적으로 orm.em.fork()가 만들어져 async context에 붙습니다.
  2. 코드에서는 전역 orm.em을 호출하더라도, 내부적으로 em.getContext()를 통해 현재 RequestContext의 EntityManager fork가 사용됩니다.

공식 문서 예시는 Express 미들웨어 형태로 제시되며, NestJS에서도 “bodyParser 이후, 라우트 핸들러 직전”에 컨텍스트 미들웨어가 위치해야 한다는 주의사항이 있습니다(특히 GraphQL 조합 시). 이 지점이 어긋나면 컨텍스트가 기대대로 잡히지 않아 ‘간헐적’ 문제가 생길 수 있습니다.

4) HTTP가 아닌 곳(큐/스케줄러)에서 터지는 문제: @CreateRequestContext()가 필요한 이유

NestJS에서 MikroORM 모듈이 설치하는 RequestContext 미들웨어는 일반 HTTP 요청에서만 자동으로 실행됩니다. 하지만 실무에서는 다음과 같은 “HTTP 바깥” 작업이 더 위험합니다.

  • Bull/BullMQ 등 큐 소비자(consumer)
  • cron/scheduler 기반 배치
  • 웹훅 처리 파이프라인 일부가 요청 lifecycle과 분리되어 실행되는 경우

이때 MikroORM 문서는 @CreateRequestContext() 데코레이터를 제공하며, 이 데코레이터는 주입된 MikroORM 인스턴스를 기반으로 내부에서 새 RequestContext를 만들고(RequestContext.create() 사용), 해당 메서드를 그 컨텍스트 안에서 실행한다고 설명합니다.

중요 제한도 공식 문서에 명시되어 있습니다.

  • @CreateRequestContext()top-level 메서드에만 사용해야 하며, 중첩 사용(데코레이터된 메서드가 또 다른 데코레이터 메서드를 호출)은 피해야 합니다.

5) 실전 코드: NestJS 서비스/큐 핸들러에서 컨텍스트를 확실히 보장하기

아래 코드는 MikroORM 문서의 NestJS 섹션에 나온 패턴을 “그대로” 실무형 형태로 정리한 것입니다(개념은 동일, 엔티티/서비스명만 예시). 내용은 문서에서 설명한 동작 범위를 벗어나지 않습니다.

import { Controller } from '@nestjs/common';
import { MikroORM, CreateRequestContext } from '@mikro-orm/core';

@Controller()
export class JobsService {
  constructor(private readonly orm: MikroORM) {}

  // HTTP 요청이 아닌 환경(예: cron/queue)에서도
  // 메서드 실행을 RequestContext 안으로 강제
  @CreateRequestContext()
  async recalcSomething() {
    // 이 메서드는 별도 컨텍스트에서 실행됨
  }
}

큐/프로세서와 결합할 때는, 문서가 설명하듯 “큐 데코레이터가 기대하는 함수 시그니처” 문제를 피하기 위해 컨텍스트가 필요한 로직을 별도 메서드로 분리하는 접근이 안전합니다.

6) Unit of Work(변경 감지, flush)와 트랜잭션: 실무에서 경계(Boundary)를 어디에 두는가

MikroORM의 Unit of Work는 Identity Map을 기반으로 관리(Managed)되는 엔티티를 추적하고, em.flush() 시점에 변경 사항을 계산해 INSERT/UPDATE/DELETE를 수행합니다. 공식 문서에 따르면, em.flush()는 (드라이버가 지원하는 경우) 변경 사항을 단일 트랜잭션으로 감싸 실행합니다. 또한 명시적 경계를 위해 em.transactional(cb) 또는 begin/commit/rollback API를 사용할 수 있고, 데코레이터 @Transactional()도 제공됩니다.

7) “암묵적” vs “명시적” 트랜잭션: 언제 무엇을 쓰나 (표로 정리)

접근 공식 문서가 설명하는 방식 실무에서 유리한 상황 주의점
암묵적(implicit) em.flush()가 변경 사항을 모아 트랜잭션으로 처리 요청 단위의 단순한 커맨드(도메인 모델 변경이 ORM 내에서만 발생) ORM 밖(DBAL 직접 쿼리 등)과 섞이면 경계가 애매해질 수 있음
명시적(explicit) em.transactional(cb), 또는 begin/commit/rollback 여러 서비스/레포지토리가 얽힌 작업, 혹은 ORM 밖 작업을 포함해야 하는 단위 작업 트랜잭션 범위를 과도하게 넓히면 성능에 악영향(문서에서 demarcation 중요성 강조)

8) FlushMode.AUTO가 “자동 flush”를 유발하는 조건 (운영 관점)

Unit of Work 문서에는 FlushMode가 소개됩니다. 기본값인 FlushMode.AUTO는 필요하다고 판단되면 쿼리 전에 flush를 수행할 수 있으며, 문서 예시처럼 “쿼리 대상 엔티티와 변경된 엔티티가 겹치는 등” 상황에서 자동 flush가 발생할 수 있습니다. 반대로 FlushMode.COMMIT, FlushMode.ALWAYS처럼 정책을 바꿀 수 있으며, 설정 위치(ORM config, EM, fork, QueryBuilder, transactional scope 등)도 문서에 나열되어 있습니다.

운영 시에는 다음처럼 의도하지 않은 flush 타이밍이 성능/락 경합에 영향을 줄 수 있으므로, 트러블 슈팅 시 FlushMode를 확인하는 습관이 유용합니다(문서 참고).

9) 실무 체크리스트 (사고 예방용)

  • 요청 단위 EM 컨텍스트가 항상 잡히는지 확인 (RequestContext 미들웨어 위치, GraphQL/bodyparser 순서 주의)
  • HTTP 밖 작업(큐/배치)에는 @CreateRequestContext() 또는 @EnsureRequestContext() 적용 여부 점검
  • 테스트 편의를 위해 allowGlobalContext를 켰다면, 프로덕션 설정에 섞이지 않도록 분리
  • 트랜잭션 경계는 “작업 단위”로 명확히: 단순 커맨드는 flush로, 복잡한 단위 작업은 transactional로
  • 성능 이슈 시 FlushMode.AUTO로 인한 예상치 못한 flush를 의심하고 재현

10) 참고(원문 근거)

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