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가지 함정:
- em.flush() 호출 금지:
onFlush핸들러 안에서em.flush()를 호출하면 무한 재귀가 발생합니다. 대신args.uow.computeChangeSet(entity)를 사용하여 변경 사항을 현재 flush 사이클에 포함시킵니다. - 새 엔티티 추가:
onFlush에서 새 엔티티를 persist하려면args.uow.computeChangeSet(entity)를 반드시 호출해야 합니다. 단순히em.persist()만 하면 해당 flush 사이클에 포함되지 않습니다. - 순서 의존성: 여러 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
afterCreate나 afterUpdate에서 다른 엔티티를 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