NestJS에서 MikroORM을 실무처럼 쓰기

NestJS에서 MikroORM을 “실무처럼” 쓰기: RequestContext(AsyncLocalStorage)·Identity Map·Unit of Work를 한 번에 정리

TypeScript ORM을 운영 단계까지 끌고 가면, 단순 CRUD보다 요청 단위 상태 격리, flush()/트랜잭션 경계, 메모리/응답 불안정 같은 문제가 먼저 터집니다. MikroORM은 이를 위해 Identity Map, Unit of Work, 그리고 RequestContext(AsyncLocalStorage 기반)를 핵심 메커니즘으로 제공합니다.

이 글은 NestJS 환경에서 MikroORM을 쓸 때 가장 자주 헷갈리는 지점을 “운영 관점”에서 묶어 설명합니다. (본문의 사실/주장은 모두 공식 문서/공식 README 원문 근거만 사용했습니다.)

1) 왜 RequestContext가 핵심인가: “요청마다 Identity Map은 분리”

MikroORM은 Identity Map 패턴을 사용해, 같은 엔티티(같은 PK)를 한 요청 내에서 재조회해도 동일 인스턴스를 돌려줄 수 있고, 불필요한 SELECT를 줄일 수 있다고 설명합니다. 또한 Identity Map은 결과 캐시가 아니라, 한 요청(단일 작업 범위) 안에서 성능/일관성을 돕기 위한 것이라고 명시합니다.

그리고 “요청마다 고유한 Identity Map을 유지해야 한다”는 결론으로 이어집니다. 문서에는 이를 위해 EntityManager를 fork() 해서 요청마다 별도의 컨텍스트(Identity Map)를 갖게 하라고 안내합니다.

아키텍처/요청 흐름 도식

NestJS + MikroORM RequestContext/EntityManager/Unit of Work 요청 흐름
그림 1. NestJS 요청 흐름에서 RequestContext(AsyncLocalStorage)가 fork된 EntityManager(Identity Map)를 제공하고, Unit of Work가 flush() 시 변경분을 트랜잭션으로 반영하는 개념도 (Pillow로 직접 생성).

2) RequestContext는 무엇을 해주나: “전역 EM을 호출해도 요청 전용 EM로 라우팅”

MikroORM 공식 문서(Identity Map and Request Context)에는, DI 컨테이너(예: NestJS)에서 Repository/EntityManager 인스턴스가 싱글턴처럼 주입되는 상황에서 요청 단위로 깨끗한 상태를 만들기 어렵다는 설명이 있습니다. 이를 해결하기 위해 RequestContext helper를 제공하며, 내부적으로 Node.js의 AsyncLocalStorage를 사용해 요청 컨텍스트를 격리한다고 명시합니다.

핵심은 이겁니다:

  • 요청 시작 시점에 RequestContext.create(orm.em, next)orm.em.fork()를 호출해 요청 전용 EntityManager를 async context에 붙입니다.
  • 이후 코드에서 전역 orm.em를 사용하더라도, 내부적으로 em.getContext()가 async context의 fork를 우선 사용하도록 해 요청 범위로 자동 라우팅됩니다.

3) 실전 코드/설정 스니펫 (1): NestJS에서 @mikro-orm/nestjs 기본 구성

@mikro-orm/nestjs 공식 README는, 루트 모듈에 MikroOrmModule.forRoot()를 등록하고, 이후 EntityManagerMikroORM를 주입받아 사용할 수 있다고 안내합니다. 또한 특정 모듈 스코프에서 Repository를 등록하려면 forFeature()를 쓰라고 설명합니다.

import { Module } from '@nestjs/common';
import { MikroOrmModule } from '@mikro-orm/nestjs';

@Module({
  imports: [
    MikroOrmModule.forRoot({
      entities: ['../dist/entities'],
      entitiesTs: ['../src/entities'],
      dbName: 'my-db-name.sqlite3',
      type: 'sqlite',
      baseDir: __dirname,
    }),

    // feature module에서 Repository 등록
    // MikroOrmModule.forFeature([Photo])
  ],
})
export class AppModule {}

4) Unit of Work/flush()를 운영에 맞게 이해하기: “flush()가 변경분을 트랜잭션으로 반영”

MikroORM의 Unit of Work 문서에는 다음이 명시되어 있습니다:

  • em.flush() 시점에 Identity Map이 관리(managed) 중인 엔티티들에서 변경을 감지해 UPDATE/INSERT 등의 작업을 계산합니다.
  • em.flush()는 (드라이버가 지원한다면) DB 트랜잭션 내부에서 계산된 변경분을 실행합니다. 문서에서 이를 “Implicit Transactions”로 설명합니다.
  • 트랜잭션 경계를 직접 제어하려면 em.transactional(cb)를 사용할 수 있다고 예시를 제공합니다.

5) 실전 코드/설정 스니펫 (2): HTTP 밖(큐/크론)에서도 RequestContext 만들기

MikroORM 공식 문서는 HTTP 미들웨어로 RequestContext를 여는 방식을 설명하면서, “미들웨어는 일반 HTTP 요청에서만 실행된다”는 한계를 함께 언급합니다. 그리고 큐 핸들러/스케줄러 같은 환경에서는 @CreateRequestContext() 데코레이터를 사용할 수 있다고 안내합니다(또한 v6 이전에는 이름이 @UseRequestContext()였다고 문서에 명시).

@mikro-orm/nestjs 공식 README 역시 같은 맥락으로, 큐/스케줄 작업에서 @CreateRequestContext() 사용 예시를 제공합니다.

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

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

  @CreateRequestContext()
  async runNightlyJob() {
    // 이 메서드 내부는 별도의 request context(=forked EM)에서 실행됨
  }
}

6) 의사결정 표: NestJS에서 “요청 단위 EM 격리”를 어떻게 구현할까

아래 선택지는 모두 공식 문서/README에 근거가 있는 것만 정리했습니다.

선택지 언제 쓰나 장점 주의/한계
RequestContext 미들웨어(AsyncLocalStorage) 일반적인 HTTP API 전역 orm.em 호출도 요청 전용 컨텍스트로 자동 라우팅(문서에 내부 동작 설명 있음) 문서에서 “요청 핸들러 직전, ORM 사용하는 커스텀 미들웨어보다 앞”에 등록 권장(등록 순서 주의)
@CreateRequestContext() 데코레이터 큐/스케줄/크론 등 HTTP 밖 작업 미들웨어 없는 실행 경로에도 request context 생성 가능 문서에서 “top-level 메서드에만 사용, 중첩 사용 금지”를 명시
Global Context 허용(allowGlobalContext / 환경변수) 주로 테스트 등 특수 케이스 컨텍스트 체크를 완화(문서에 언급) 문서에서 “전역 EM을 request context 없이 쓰는 것은 거의 항상 잘못”이라고 경고하며, v5부터 global identity map은 기본적으로 불가라고 명시

7) 장애 징후 → 원인 → 조치 요약표 (운영에서 자주 만나는 패턴)

아래는 공식 문서에 명시된 위험 시나리오(전역 Identity Map 공유로 인한 문제)와, 문서/README에서 제시한 해결책만 정리한 것입니다.

장애 징후 가능한 원인(공식 근거) 조치(공식 근거)
프로세스 메모리 사용량이 요청 처리량에 비례해 계속 증가 문서: 요청 간에 Identity Map을 공유하면 “요청 종료 후 clear할 수 없어” 점점 메모리 풋프린트가 커질 수 있음 문서: 요청마다 고유 Identity Map을 유지(=EntityManager fork + RequestContext 사용). 필요 시 요청 종료 후 정리(요청 컨텍스트 종료로 자연 정리)
같은 엔드포인트인데 응답 형태가 이전 요청의 populate 상태에 영향을 받는 듯 불안정 문서: populate 여부 같은 상태가 Identity Map에 저장되어, 전역 공유 시 다른 엔드포인트 호출이 다음 응답을 오염시킬 수 있음 문서: “요청마다 고유 Identity Map”을 유지해야 함(RequestContext 권장)
RequestContext 없이 전역 EM 사용 시 이상한 버그/오염이 발생 문서: v5부터 global identity map은 불가(전역 EM을 request context 없이 사용하는 것은 거의 항상 잘못) 문서: RequestContext 도입. 테스트 등 특수 상황이면 allowGlobalContext/환경변수로 완화 가능(단, 운영에선 권장되지 않음)
프로세스 종료(SIGTERM 등) 후에도 DB 커넥션이 남아 리소스를 소모하는 듯함 @mikro-orm/nestjs README: NestJS가 기본적으로 종료 시그널을 듣지 않으면 MikroORM shutdown 로직이 실행되지 않을 수 있음 README(또한 NestJS 공식 문서 원문): 애플리케이션 부트스트랩에서 app.enableShutdownHooks() 호출

8) 체크리스트(요약)

  • HTTP 요청 경로: RequestContext 미들웨어로 요청 단위 EM(fork) 확보
  • HTTP 밖 경로(큐/크론): @CreateRequestContext() 사용
  • flush()를 트랜잭션 경계의 기본 단위로 보고, 필요한 경우 em.transactional()로 명시적 경계 설정
  • 전역 컨텍스트 허용은 테스트 등 특수 경우로 제한(문서에서 전역 EM 무컨텍스트 사용을 경고)

References (공식 원문)

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