🗄️ TypeORM9. 실전 사례04 — EventSubscriber의 함정

04 — EventSubscriber의 함정

한 줄 답: EventSubscriber강력하다@BeforeInsert 같은 entity-level hook보다 글로벌하고 재사용 가능하다. 하지만 트랜잭션 commit 이전에 실행된다는 사실, *event.manager*를 안 쓰면 별도 트랜잭션에 떨어진다는 사실, async가 완전히 await되지 않는 경계 케이스가 있다는 사실을 모르면 — 데이터 일관성이 조용히 깨진다.


Why — 왜 subscriber의 함정을 따로 다루나

03장에서 audit log를 EntitySubscriber로 풀었다. 같은 메커니즘이 낙관적 락 자동 적용, slug 자동 생성, outbox 패턴, 알림 발송 등에 쓰인다. subscriber는 TypeORM의 가장 강력한 확장 지점이다.

하지만 공식 문서가 짧고, 실제 동작이 비자명한 부분이 많다. 흔한 오해 셋:

흔한 오해실제
”Subscriber는 await event.manager.save(...)만 잘 쓰면 안전”트랜잭션 commit 이전에 실행되므로 — commit 후에만 안전한 작업(외부 API)을 여기 두면 사고
afterInsertINSERT 완료 + commit 완료 후 실행”INSERT는 완료지만 commit은 아직. 부모 트랜잭션이 롤백되면 subscriber 변경도 같이 사라진다
”Subscriber는 subscribers: [Class] 옵션에 안 적어도 @EventSubscriber() 데코레이터로 자동 등록”DataSource 옵션에 명시하지 않으면 NestJS DI에서 안 잡힌다. issue 트래커에 반복 등장

이 챕터는 subscriber 동작의 정확한 모델실전에서 자주 깨지는 케이스를 본다.


How — 어떻게 동작하는가

1) Subscriber가 실행되는 정확한 시점

핵심: afterInserttransaction commit 이전에 실행된다. commit 후에만 안전한 작업(이메일 발송, 외부 API 호출)은 여기 두면 위험.

2) event.manager vs event.connection.manager

@EventSubscriber()
export class BadAuditSubscriber implements EntitySubscriberInterface {
  constructor(private dataSource: DataSource) {} // ❌ 안티패턴
 
  async afterInsert(event: InsertEvent<any>) {
    // ❌ 별도 트랜잭션에 기록 — 부모 롤백 시 audit만 남음
    await this.dataSource.getRepository(AuditLog).insert({ /* ... */ });
  }
}
 
@EventSubscriber()
export class GoodAuditSubscriber implements EntitySubscriberInterface {
  async afterInsert(event: InsertEvent<any>) {
    // ✅ 같은 트랜잭션 — 부모 롤백 시 audit도 같이 사라진다
    await event.manager.getRepository(AuditLog).insert({ /* ... */ });
  }
}
호출 방식트랜잭션
event.manager.getRepository(...)같은 트랜잭션 (안전)
event.queryRunner.manager.getRepository(...)같은 트랜잭션 (동일)
dataSource.getRepository(...)별도 트랜잭션 (위험)
dataSource.manager.getRepository(...)별도 트랜잭션 (위험)

3) Subscriber 등록 — 명시 vs 자동 스캔

// 방법 (A) — DataSource options.subscribers 명시 ✅
new DataSource({
  type: 'postgres',
  // ...
  subscribers: [AuditSubscriber, SlugSubscriber],
});
 
// 방법 (B) — glob 패턴
new DataSource({
  // ...
  subscribers: [__dirname + '/**/*.subscriber{.ts,.js}'],
});

NestJS에서의 현실: @EventSubscriber() 데코레이터를 DI 등록된 클래스에 붙여도 — subscribers 옵션에 명시하지 않으면 작동하지 않는 경우가 있다. 0.2 → 0.3 마이그레이션 즈음 이 동작이 흐려졌고, issue #8804 등 다수 보고됨.

권장 패턴: forRootAsyncuseFactory에서 명시 배열로 넘기기. NestJS DI에 등록된 instance를 직접 build해서 넘기는 패턴(아래)이 안전.

TypeOrmModule.forRootAsync({
  imports: [SubscriberModule],
  inject: [AuditSubscriber, SlugSubscriber],
  useFactory: (audit, slug) => ({
    type: 'postgres',
    // ...
    subscribers: [audit, slug], // 인스턴스 그대로 — DI 의존성 살아 있음
  }),
});

4) Listener 종류 — 모든 hook

시점hookevent 객체
INSERT 전beforeInsertInsertEvent<T>
INSERT 후afterInsertInsertEvent<T>
UPDATE 전beforeUpdateUpdateEvent<T>
UPDATE 후afterUpdateUpdateEvent<T>
REMOVE 전beforeRemoveRemoveEvent<T>
REMOVE 후afterRemoveRemoveEvent<T>
SOFT REMOVE 전/후beforeSoftRemove/afterSoftRemove같음
RESTORE 전/후beforeRecover/afterRecover같음
Entity load 후afterLoadentity 직접
TX 시작/커밋/롤백beforeTransactionStart/afterTransactionCommit/afterTransactionRollbackQueryRunner 직접

commit 후에만 안전한 작업이 있다면 afterTransactionCommit써야 한다 — afterInsert가 아니다.

5) Outbox 패턴 — commit 이후 외부 호출

audit과 외부 알림을 섞지 않는 패턴.

@EventSubscriber()
export class UserOutboxSubscriber implements EntitySubscriberInterface<User> {
  listenTo() { return User; }
 
  async afterInsert(event: InsertEvent<User>) {
    await event.manager.getRepository(OutboxEvent).insert({
      topic: 'user.created',
      payload: event.entity,
      status: 'pending',
    });
    // ⚠️ 여기서 SMS·이메일 발송 X — outbox에만 기록
  }
}
 
// 별도 worker
@Injectable()
export class OutboxWorker {
  @Cron('*/5 * * * * *') // 5초마다
  async processOutbox() {
    const events = await this.outbox.find({ where: { status: 'pending' }, take: 100 });
    for (const e of events) {
      try {
        await this.externalApi.send(e.topic, e.payload);
        await this.outbox.update(e.id, { status: 'sent' });
      } catch {
        // 재시도 로직
      }
    }
  }
}

What — 구체 사양

InsertEvent<T>/UpdateEvent<T> 객체

출처: TypeORM 저장소 src/subscriber/event/InsertEvent.ts

필드의미
connection: DataSource현재 DataSource
queryRunner: QueryRunner현재 트랜잭션의 runner
manager: EntityManagerqueryRunner.manager같은 트랜잭션
entity: T저장 중인 entity
metadata: EntityMetadataentity 메타 (name, columns 등)
databaseEntity (UpdateEvent만)DB에 기존에 있던 값 — 변경 전과 비교 가능
updatedColumns (UpdateEvent만)변경된 컬럼 목록

listenTo() — entity 한정

@EventSubscriber()
export class UserOnly implements EntitySubscriberInterface<User> {
  listenTo() {
    return User; // User entity의 이벤트만 받음
  }
 
  async afterInsert(event: InsertEvent<User>) { /* ... */ }
}

listenTo()생략하면 모든 entity에 대해 발화한다. audit subscriber에서는 생략이 자연스럽고, 도메인별 작업(slug 생성 등)에서는 지정해야 한다.

Async 동작 — await되는가?

출처: TypeORM subjectExecutor.ts 내부 — subscriber promise는 await된다 (이론상).

hookawait 보장
beforeInsert / beforeUpdate / beforeRemove✅ 호출자가 await save()를 기다림
afterInsert / afterUpdate / afterRemove✅ 같은 트랜잭션
afterLoad✅ — entity load 후 await
afterTransactionCommit⚠️ commit 발화 — 트랜잭션 안에서 추가 작업 불가

단 — issue #2074, #7898 등에서 cascade save 안의 nested subscriber완전히 await되지 않는 경계 케이스가 보고된 바 있다. complex cascade에서는 직접 검증 필요.

Subscriber에서 event.entity 수정

@EventSubscriber()
export class SlugSubscriber implements EntitySubscriberInterface<Post> {
  listenTo() { return Post; }
 
  beforeInsert(event: InsertEvent<Post>) {
    if (!event.entity.slug) {
      event.entity.slug = slugify(event.entity.title);
    }
  }
}

beforeInsert에서 event.entity를 수정하면 그대로 DB에 들어간다. afterInsert에서 수정하면 DB에 반영 안 됨 — 새 UPDATE를 명시적으로 해야 함.

NestJS DI에 등록된 subscriber 전달 패턴

// (1) Subscriber를 NestJS provider로
@Injectable()
@EventSubscriber()
export class AuditSubscriber implements EntitySubscriberInterface {
  constructor(private readonly cls: ClsService) {} // DI 사용
 
  async afterInsert(event: InsertEvent<any>) {
    const actorId = this.cls.get('actorId');
    await event.manager.getRepository(AuditLog).insert({ /* ... */, actorId });
  }
}
 
// (2) AppModule에서 인스턴스 그대로 전달
TypeOrmModule.forRootAsync({
  imports: [SubscriberModule, ClsModule],
  inject: [AuditSubscriber],
  useFactory: (audit) => ({
    type: 'postgres',
    // ...
    subscribers: [audit],
  }),
});

What-if — 잘못 이해하면

1) “Subscriber에서 외부 API 호출은 안전”이라고 믿으면

afterInsertcommit 이전. 부모 TX가 롤백되어도 API는 이미 호출됨. 되돌릴 수 없음. 대응: commit 후 호출은 outbox 패턴 또는 afterTransactionCommit hook. 외부 호출과 audit을 분리.

2) “dataSource.manager.save()로 audit을 써도 같은 효과”라고 믿으면

별도 connection·트랜잭션. 부모 롤백 시 audit만 commit되어 사실과 다른 로그가 남는다. 대응: 반드시 event.manager같은 QueryRunner·트랜잭션에서.

3) “@EventSubscriber() 데코레이터만 붙이면 자동 등록”이라고 믿으면

DataSource.options.subscribers명시 안 하면 작동 안 하는 케이스가 있다 (특히 NestJS의 forRootAsync + DI provider 조합). 대응: 항상 명시. DI 사용 시 inject: [Subscriber] + useFactory에서 인스턴스 전달.

4) “Subscriber에서 같은 entity의 save()를 재귀 호출해도 된다”고 믿으면

→ 무한 루프. beforeUpdate에서 manager.save(event.entity) 호출 → 다시 beforeUpdate → … 대응: 같은 entity 수정은 event.entity 직접 수정 (DB UPDATE는 자동). 다른 entity 수정은 가능 — 하지만 cascade 깊이에 주의.

5) “모든 hook이 같은 방식으로 await된다”고 믿으면

afterTransactionCommitTX 끝난 뒤 발화 — 여기서 event.manager.save()를 호출하면 별도 TX다. 대응: hook의 시점을 매번 확인. commit 이전 / commit 이후 구분.

6) “Subscriber + softRemoveafterRemove를 호출한다”고 믿으면

→ 별도 hook: afterSoftRemove. afterRemovehard delete만. 대응: soft delete 도메인에서는 두 hook 모두 구현afterRemove + afterSoftRemove.


Insight — 흥미로운 이야기

afterTransactionCommit늦게 추가됐다”

오랫동안 TypeORM에는 commit 후 hook명시적으로 없었다. afterInsert가 commit 이전이라는 사실이 공식 문서에 잘 명시되지 않아 — 많은 production 코드가 commit 후 안전 가정으로 외부 호출을 박았다. afterTransactionCommit listener가 0.2 후반에 추가된 뒤에도 기존 코드 마이그레이션이 늦었다. issue 트래커의 “my email got sent but the user wasn’t created” 류 보고가 그 흔적.

”Outbox 패턴은 MS의 .NET 진영에서 왔다”

Transactional OutboxMassTransit·NServiceBus 같은 .NET 메시징 라이브러리에서 정착된 패턴이다. DB 트랜잭션과 메시지 발송을 묶는 유일한 안전한 방법으로 알려졌고, TypeORM + NestJS 진영에서도 같은 이름으로 수입됐다. NestJS 공식 문서에는 명시 안 됨 — 또 다른 커뮤니티 표준.

@nestjs/cqrs + subscriber는 경계가 흐리다

CQRS 라이브러리(@nestjs/cqrs)의 Event와 TypeORM의 EntitySubscriber모양이 비슷하지만 완전히 다른 것이다. CQRS event는 애플리케이션 도메인 이벤트, subscriber는 DB 변경 hook. 두 시스템을 혼동해서 “DB가 바뀌면 event도 자동 발화”한다고 잘못 가정한 production 코드가 흔하다 — 직접 발화해야 한다.

event.databaseEntity항상 채워지지 않는다

UpdateEvent.databaseEntity변경 전 DB 값을 담는다 — 유용해 보이지만, update 호출 방식에 따라 undefined인 경우가 있다. 특히 repo.update({ id }, { ... })조건만 주고 entity 인스턴스 없이 호출하면 databaseEntity 미제공. issue 트래커에 반복 등장.

”Subscriber가 test 가능성을 깬다”

Unit test에서 subscriber를 어떻게 다룰지공식 가이드가 부재. 보통 subscriber를 등록하지 않은 별도 DataSource를 test용으로 만들지만 — production과 다른 동작이 된다. e2e test로 결합한 채로 검증하는 게 안전하지만, 그 비용이 subscriber를 적게 쓰자는 방향으로 코드를 끌어가기도 한다.


요약 + 다이어그램

EventSubscriber는 TypeORM의 가장 강력한 확장 지점이다 — audit, slug, outbox, 도메인 규칙을 글로벌하게 박을 수 있다. 비용은 세 가지 — (1) commit 이전에 실행되므로 외부 호출은 위험, (2) event.manager를 안 쓰면 별도 트랜잭션, (3) cascade async의 경계 케이스에서 await가 흐려진다. 안전 패턴은 (a) event.manager로 같은 트랜잭션에 머무르기, (b) commit 후 작업은 outbox로 분리, (c) 명시적 subscribers 옵션 등록. 이 세 가지를 지키면 subscriber는 우아한 무기다.

참고 자료

  • TypeORM 공식, Listeners and Subscriberstypeorm.io/listeners-and-subscribers
  • TypeORM issue #2074 — subscriber async 동작 보고
  • TypeORM issue #8804 — subscribers 옵션 등록 문제
  • Microsoft .NET, Outbox patternlearn.microsoft.com/en-us/azure/architecture/patterns/outbox
  • nestjs-clsgithub.com/Papooch/nestjs-cls
  • @nestjs/cqrs 공식 — docs.nestjs.com/recipes/cqrs

다음 문서: 05-migration-from-typeorm-to-prisma.mdx — 같은 NestJS 진영에서 옮겨가는가, 어떻게 옮기는가.