NestJS + MikroORM Events

MikroORM 이벤트 시스템 개요: 왜 Lifecycle Hook이 중요한가

운영 환경에서 엔티티가 생성·수정·삭제될 때 자동으로 타임스탬프를 기록하거나, 감사 로그를 남기거나, 관련 캐시를 무효화하는 작업은 피할 수 없습니다. 이런 횡단 관심사를 서비스 레이어에 흩뿌리면 유지보수가 어려워집니다. MikroORM은 두 가지 이벤트 메커니즘을 제공합니다: 엔티티에 직접 선언하는 Lifecycle Hook 데코레이터와, 별도 클래스로 분리하는 EventSubscriber입니다.

Lifecycle Hook 데코레이터: 엔티티에 직접 선언하기

MikroORM 공식 문서(Events 섹션)에 따르면, 엔티티 클래스의 메서드에 데코레이터를 붙여 특정 시점에 자동 실행되도록 할 수 있습니다. 지원하는 데코레이터는 다음과 같습니다:

데코레이터 실행 시점 용도
@BeforeCreate() INSERT 전 기본값 설정, UUID 생성
@AfterCreate() INSERT 후 알림 발송, 로그
@BeforeUpdate() UPDATE 전 updatedAt 갱신, 검증
@AfterUpdate() UPDATE 후 캐시 무효화
@BeforeDelete() DELETE 전 참조 정리
@AfterDelete() DELETE 후 연쇄 정리
@OnInit() 엔티티가 Identity Map에 로드될 때 계산 필드 초기화
@OnLoad() DB에서 로드 완료 후 역직렬화 후 가공

핵심 차이 — @OnInit vs @OnLoad: @OnInit()em.create()로 엔티티를 새로 만들 때도 호출되지만, @OnLoad()는 DB에서 실제로 조회한 경우에만 호출됩니다. 공식 문서에서 이 구분을 명시하고 있으며, 혼동하면 새 엔티티 생성 시 초기화 코드가 실행되지 않는 버그가 발생합니다.

import {
  Entity, PrimaryKey, Property,
  BeforeCreate, BeforeUpdate, OnInit,
} from '@mikro-orm/core';
import { v4 as uuid } from 'uuid';

@Entity()
export class Article {
  @PrimaryKey()
  id!: number;

  @Property()
  title!: string;

  @Property()
  slug!: string;

  @Property()
  createdAt: Date = new Date();

  @Property({ onUpdate: () => new Date() })
  updatedAt: Date = new Date();

  @BeforeCreate()
  generateSlug() {
    // INSERT 전에 slug 자동 생성
    this.slug = this.title
      .toLowerCase()
      .replace(/[^a-z0-9]+/g, '-')
      .replace(/(^-|-$)/g, '');
  }

  @OnInit()
  initDefaults() {
    // em.create() 및 DB 로드 시 모두 실행
    if (!this.createdAt) {
      this.createdAt = new Date();
    }
  }
}

주의: Lifecycle Hook 메서드 내에서 EntityManager에 접근할 수 없습니다. Hook은 엔티티 인스턴스의 this 컨텍스트에서만 동작합니다. EM이 필요한 복잡한 로직은 EventSubscriber를 사용해야 합니다.

EventSubscriber: 엔티티 외부에서 이벤트 처리하기

EventSubscriber는 엔티티와 분리된 별도 클래스에서 이벤트를 처리합니다. MikroORM 공식 문서에 따르면 Subscriber는 EventSubscriber 인터페이스를 구현하며, EventArgs 객체를 통해 EntityManager, 변경된 엔티티, ChangeSet 등에 접근할 수 있습니다.

import {
  EventSubscriber, EventArgs, FlushEventArgs,
  EntityName, ChangeSet,
} from '@mikro-orm/core';

export class ArticleSubscriber implements EventSubscriber<Article> {

  // 특정 엔티티에만 반응하도록 제한
  getSubscribedEntities(): EntityName<Article>[] {
    return [Article];
  }

  async afterCreate(args: EventArgs<Article>): Promise<void> {
    const article = args.entity;
    const em = args.em;

    // EM에 접근 가능 — 감사 로그 엔티티 생성
    const log = em.create(AuditLog, {
      action: 'CREATE',
      entityType: 'Article',
      entityId: article.id,
      timestamp: new Date(),
    });
    em.persist(log);
    // 주의: 여기서 em.flush()를 호출하면 안 됩니다 (아래 설명)
  }

  async beforeUpdate(args: EventArgs<Article>): Promise<void> {
    // changeSet에서 변경된 필드 확인 가능
    const cs = args.changeSet;
    if (cs) {
      console.log('Changed fields:', Object.keys(cs.payload));
    }
  }
}

NestJS에서 EventSubscriber 등록하기

NestJS에서 MikroORM EventSubscriber를 등록하는 방법은 두 가지입니다.

방법 1 — mikro-orm.config에서 등록 (권장):

// mikro-orm.config.ts
import { defineConfig } from '@mikro-orm/core';
import { ArticleSubscriber } from './subscribers/article.subscriber';

export default defineConfig({
  // ... 기타 설정
  subscribers: [new ArticleSubscriber()],
});

방법 2 — NestJS DI 활용 (DI가 필요한 경우):

Subscriber에서 NestJS의 다른 서비스(Logger, NotificationService 등)를 주입받아야 하는 경우, @mikro-orm/nestjs v5.1+ 에서 제공하는 패턴을 사용합니다.

import { Injectable } from '@nestjs/common';
import { EntityManager, EventArgs, EventSubscriber } from '@mikro-orm/core';
import { InjectEntityManager } from '@mikro-orm/nestjs';

@Injectable()
export class ArticleSubscriber implements EventSubscriber<Article> {
  constructor(
    private readonly notificationService: NotificationService,
    em: EntityManager,
  ) {
    // EM의 이벤트 매니저에 수동 등록
    em.getEventManager().registerSubscriber(this);
  }

  getSubscribedEntities() {
    return [Article];
  }

  async afterCreate(args: EventArgs<Article>): Promise<void> {
    // NestJS DI로 주입된 서비스 사용 가능
    await this.notificationService.send(
      `New article created: ${args.entity.title}`,
    );
  }
}

이 방식은 @Injectable()로 NestJS 프로바이더로 등록하고, 생성자에서 em.getEventManager().registerSubscriber(this)를 호출합니다. 모듈의 providers 배열에 추가하는 것을 잊지 마세요.

onFlush: 가장 강력하고 위험한 이벤트

MikroORM 공식 문서에서 별도 섹션으로 다루는 onFlush 이벤트는 Unit of Work가 변경 사항을 DB에 커밋하기 직전에 발생합니다. 이 시점에서 ChangeSet을 직접 조작할 수 있어 매우 강력하지만, 잘못 사용하면 무한 루프나 데이터 정합성 문제를 일으킵니다.

import {
  EventSubscriber, FlushEventArgs, ChangeSetType,
  ChangeSet,
} from '@mikro-orm/core';

export class TimestampSubscriber implements EventSubscriber {

  async onFlush(args: FlushEventArgs): Promise<void> {
    const changeSets = args.uow.getChangeSets();

    for (const cs of changeSets) {
      if (cs.type === ChangeSetType.UPDATE) {
        // 모든 UPDATE에 updatedBy 필드 자동 설정
        const entity = cs.entity as any;
        if ('updatedBy' in entity) {
          // computeChangeSet으로 새 변경사항을 UoW에 반영
          entity.updatedBy = 'system';
          args.uow.computeChangeSet(entity);
        }
      }
    }
  }
}

onFlush의 3가지 함정:

  1. em.flush() 호출 금지: onFlush 핸들러 안에서 em.flush()를 호출하면 무한 재귀가 발생합니다. 대신 args.uow.computeChangeSet(entity)를 사용하여 변경 사항을 현재 flush 사이클에 포함시킵니다.
  2. 새 엔티티 추가: onFlush에서 새 엔티티를 persist하려면 args.uow.computeChangeSet(entity)를 반드시 호출해야 합니다. 단순히 em.persist()만 하면 해당 flush 사이클에 포함되지 않습니다.
  3. 순서 의존성: 여러 Subscriber가 onFlush를 구현하면 등록 순서대로 실행됩니다. Subscriber 간 의존 관계가 있으면 등록 순서를 명시적으로 관리해야 합니다.

afterFlush vs onFlush: 언제 어떤 것을 쓸 것인가

구분 onFlush afterFlush
실행 시점 DB 쿼리 실행 전 DB 쿼리 실행 후 (커밋 완료)
ChangeSet 수정 ✅ 가능 ❌ 이미 커밋됨
새 엔티티 추가 ✅ computeChangeSet으로 가능 ❌ 별도 flush 필요
외부 부수효과 ❌ 롤백 가능성 있어 위험 ✅ 안전 (커밋 확인 후)
적합한 용도 필드 자동 채우기, 변경 가로채기 알림 발송, 캐시 무효화, 외부 API 호출
export class NotificationSubscriber implements EventSubscriber {
  async afterFlush(args: FlushEventArgs): Promise<void> {
    // DB 커밋 완료 후에만 외부 알림 발송
    const changeSets = args.uow.getChangeSets();
    const created = changeSets.filter(
      (cs) => cs.type === ChangeSetType.CREATE && cs.name === 'Article',
    );

    for (const cs of created) {
      // 이 시점에서는 ID가 할당되어 있음
      console.log(`Article #${cs.entity.id} committed, sending notification`);
    }
  }
}

실무 패턴: 전역 Soft Delete Timestamp with onFlush

MikroORM의 @Filter와 결합하여 onFlush에서 자동으로 soft delete 관련 필드를 관리하는 전역 패턴입니다.

export class SoftDeleteTimestampSubscriber implements EventSubscriber {
  async onFlush(args: FlushEventArgs): Promise<void> {
    const changeSets = args.uow.getChangeSets();

    for (const cs of changeSets) {
      if (cs.type === ChangeSetType.DELETE) {
        const entity = cs.entity as any;

        // 물리 삭제를 soft delete로 변환
        if ('deletedAt' in entity) {
          // DELETE를 취소하고 UPDATE로 변경
          args.uow.cancelRemoval(entity);
          entity.deletedAt = new Date();
          args.uow.computeChangeSet(entity);
        }
      }
    }
  }
}

이 패턴은 MikroORM v5+에서 args.uow.cancelRemoval() 메서드가 존재하는 경우 사용 가능합니다. 공식 문서의 “Extracting Common Lifecycle Logic” 섹션에서 비슷한 접근 방식을 설명합니다.

흔한 실수와 운영 체크리스트

실수 1: Hook에서 비동기 작업 누락

Lifecycle Hook 데코레이터 (@BeforeCreate 등)는 async 메서드를 지원합니다. 그러나 반환된 Promise를 MikroORM이 올바르게 await하려면, 메서드가 반드시 Promise를 반환해야 합니다. void 반환하면서 내부에서 비동기 작업을 수행하면 완료 전에 flush가 진행됩니다.

실수 2: 순환 flush

afterCreateafterUpdate에서 다른 엔티티를 persist하고 em.flush()를 호출하면 해당 엔티티의 이벤트가 다시 발생하여 무한 루프에 빠질 수 있습니다. afterFlush에서는 변경이 필요하면 별도 포크된 EM을 사용하세요:

async afterFlush(args: FlushEventArgs): Promise<void> {
  // 별도 EM fork로 순환 방지
  const forkedEm = args.em.fork();
  const log = forkedEm.create(AuditLog, { /* ... */ });
  await forkedEm.persistAndFlush(log);
}

실수 3: getSubscribedEntities를 생략하면 전역 Subscriber

getSubscribedEntities()를 구현하지 않으면 해당 Subscriber는 모든 엔티티의 이벤트를 수신합니다. 의도적인 전역 Subscriber가 아니라면 반드시 대상 엔티티를 명시하세요. 그렇지 않으면 매 flush마다 불필요한 로직이 실행되어 성능 저하를 유발합니다.

정리: 이벤트 메커니즘 선택 기준

요구사항 권장 방식 이유
단순 필드 자동 설정 (slug, timestamp) Lifecycle Hook 데코레이터 엔티티에 가깝고 단순
EM 접근이 필요한 감사 로그 EventSubscriber EventArgs로 EM 접근
NestJS 서비스 의존성 주입 EventSubscriber + @Injectable DI 컨테이너 활용
ChangeSet 가로채기/수정 onFlush EventSubscriber UoW 직접 조작 가능
외부 API 호출/알림 afterFlush EventSubscriber 커밋 확인 후 안전하게 실행

MikroORM의 이벤트 시스템은 Lifecycle Hook 데코레이터로 간단한 자동화를 처리하고, EventSubscriber로 복잡한 횡단 관심사를 분리하는 2계층 구조입니다. 특히 onFlush는 ChangeSet을 직접 조작할 수 있어 강력하지만, 무한 루프와 flush 순서 이슈에 주의해야 합니다. NestJS에서는 @Injectable() + em.getEventManager().registerSubscriber() 패턴으로 DI와 자연스럽게 결합할 수 있습니다.

참고: MikroORM 공식 문서 — Events and Lifecycle Hooks, Unit of Work, Lifecycle Hooks

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