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)을 여기 두면 사고 |
”afterInsert는 INSERT 완료 + commit 완료 후 실행” | INSERT는 완료지만 commit은 아직. 부모 트랜잭션이 롤백되면 subscriber 변경도 같이 사라진다 |
”Subscriber는 subscribers: [Class] 옵션에 안 적어도 @EventSubscriber() 데코레이터로 자동 등록” | DataSource 옵션에 명시하지 않으면 NestJS DI에서 안 잡힌다. issue 트래커에 반복 등장 |
이 챕터는 subscriber 동작의 정확한 모델과 실전에서 자주 깨지는 케이스를 본다.
How — 어떻게 동작하는가
1) Subscriber가 실행되는 정확한 시점
핵심: afterInsert는 transaction 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 등 다수 보고됨.
권장 패턴:
forRootAsync의useFactory에서 명시 배열로 넘기기. NestJS DI에 등록된 instance를 직접 build해서 넘기는 패턴(아래)이 안전.
TypeOrmModule.forRootAsync({
imports: [SubscriberModule],
inject: [AuditSubscriber, SlugSubscriber],
useFactory: (audit, slug) => ({
type: 'postgres',
// ...
subscribers: [audit, slug], // 인스턴스 그대로 — DI 의존성 살아 있음
}),
});4) Listener 종류 — 모든 hook
| 시점 | hook | event 객체 |
|---|---|---|
| INSERT 전 | beforeInsert | InsertEvent<T> |
| INSERT 후 | afterInsert | InsertEvent<T> |
| UPDATE 전 | beforeUpdate | UpdateEvent<T> |
| UPDATE 후 | afterUpdate | UpdateEvent<T> |
| REMOVE 전 | beforeRemove | RemoveEvent<T> |
| REMOVE 후 | afterRemove | RemoveEvent<T> |
| SOFT REMOVE 전/후 | beforeSoftRemove/afterSoftRemove | 같음 |
| RESTORE 전/후 | beforeRecover/afterRecover | 같음 |
| Entity load 후 | afterLoad | entity 직접 |
| TX 시작/커밋/롤백 | beforeTransactionStart/afterTransactionCommit/afterTransactionRollback | QueryRunner 직접 |
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: EntityManager | queryRunner.manager — 같은 트랜잭션 |
entity: T | 저장 중인 entity |
metadata: EntityMetadata | entity 메타 (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된다 (이론상).
| hook | await 보장 |
|---|---|
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 호출은 안전”이라고 믿으면
→ afterInsert는 commit 이전. 부모 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된다”고 믿으면
→ afterTransactionCommit은 TX 끝난 뒤 발화 — 여기서 event.manager.save()를 호출하면 별도 TX다.
대응: hook의 시점을 매번 확인. commit 이전 / commit 이후 구분.
6) “Subscriber + softRemove가 afterRemove를 호출한다”고 믿으면
→ 별도 hook: afterSoftRemove. afterRemove는 hard 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 Outbox는 MassTransit·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 Subscribers —
typeorm.io/listeners-and-subscribers - TypeORM issue #2074 — subscriber async 동작 보고
- TypeORM issue #8804 —
subscribers옵션 등록 문제 - Microsoft .NET, Outbox pattern —
learn.microsoft.com/en-us/azure/architecture/patterns/outbox nestjs-cls—github.com/Papooch/nestjs-cls@nestjs/cqrs공식 —docs.nestjs.com/recipes/cqrs
다음 문서:
05-migration-from-typeorm-to-prisma.mdx— 같은 NestJS 진영에서 왜 옮겨가는가, 어떻게 옮기는가.