Events & Subscribers
이벤트가 필요한 이유
User 엔티티가 있다고 가정해볼게요. 사용자가 생성될 때 이런 작업이 필요해요:
- 환영 이메일 발송
- 감사 로그 기록
- 캐시 무효화
이 로직을 사용자 생성 서비스에 전부 넣을 수도 있지만, 그러면 사용자 생성 코드가 이메일, 감사 로그, 캐시를 모두 알아야 해요. 사이드 이펙트가 추가될 때마다 같은 서비스를 수정해야 하고, 코드가 점점 엉키게 돼요.
이벤트는 발생한 일(사용자 생성)과 그에 대한 반응(이메일, 로그, 캐시)을 분리해줘요. 생성자는 이벤트를 발행하고, 리스너들은 독립적으로 반응해요. 서로의 존재를 알 필요가 없어요.
Stingerloom은 세 가지 수준의 이벤트 처리를 제공해요:
- Lifecycle hooks (
@BeforeInsert,@AfterUpdate, ...) -- 엔티티 클래스에 직접 붙이는 데코레이터 - Global event listeners (
em.on()) -- 모든 엔티티에 대해 실행되는 콜백 - Entity subscribers (
EntitySubscriber) -- 특정 엔티티의 이벤트 로직을 캡슐화하는 클래스
Lifecycle Hooks -- 엔티티 자체의 이벤트
엔티티 라이프사이클 이벤트에 반응하는 가장 간단한 방법이에요. 엔티티 클래스에 데코레이터를 붙이면 되고, 별도 등록이 필요 없어요 -- ORM이 메타데이터를 통해 자동으로 인식해요.
import { Entity, Column, BeforeInsert, AfterUpdate } from "@stingerloom/orm";
@Entity()
export class Post {
@Column()
title!: string;
@Column()
slug!: string;
@BeforeInsert()
generateSlug() {
this.slug = this.title.toLowerCase().replace(/\s+/g, "-");
}
@AfterUpdate()
logUpdate() {
console.log(`Post "${this.title}" was updated`);
}
}각 hook이 실행되는 타이밍을 SQL 기준으로 보면 이래요:
Your code: em.save(Post, { title: "Hello World" })
|
v
@BeforeInsert fires --> generateSlug() runs
| this.slug is now "hello-world"
v
SQL executes: INSERT INTO "posts" ("title", "slug") VALUES ('Hello World', 'hello-world');
|
v
@AfterInsert fires --> (if you had one, it would run here)핵심은 이거예요: before hook은 데이터를 변경할 수 있어요 (SQL 실행 전이니까요). 반면 after hook은 사이드 이펙트 전용이에요 (SQL이 이미 실행된 후거든요).
사용 가능한 Hooks
| Decorator | 실행 시점 | 데이터 변경 가능? | 활용 예시 |
|---|---|---|---|
@BeforeInsert() | INSERT SQL 전 | Yes | slug 생성, 기본값 설정, 유효성 검사 |
@AfterInsert() | INSERT SQL 후 | No (이미 저장됨) | 알림 발송, 생성 로그 |
@BeforeUpdate() | UPDATE SQL 전 | Yes | 파생 필드 재계산, 유효성 검사 |
@AfterUpdate() | UPDATE SQL 후 | No (이미 저장됨) | 변경 로그, webhook 트리거 |
@BeforeDelete() | DELETE SQL 전 | No | 권한 확인, 제약 조건 체크 |
@AfterDelete() | DELETE SQL 후 | No (이미 삭제됨) | 관련 리소스 정리 |
Hooks vs. 다른 방식
Hook은 엔티티 고유의 로직에 가장 적합해요 -- 어떤 서비스에서 저장하든 항상 실행되어야 하는 것들이요. 예를 들어 title에서 slug를 생성하는 건 Post가 어떻게 만들어지든 항상 필요하잖아요.
만약 엔티티에 대한 반응 (이메일 발송, 캐시 갱신)이라면, subscriber나 global listener를 사용하는 게 맞아요. 엔티티가 이메일 서비스를 알 필요는 없으니까요.
Global Event Listeners -- 모든 엔티티에 반응하기
Global listener는 시스템의 모든 엔티티에 대해 실행돼요. EntityManager의 em.on()으로 등록해요.
// Log every insert across all entities
em.on("afterInsert", ({ entity, data }) => {
console.log(`[AUDIT] ${entity.name} created:`, data);
});
// Log every delete across all entities
em.on("afterDelete", ({ entity, data }) => {
console.log(`[AUDIT] ${entity.name} deleted:`, data);
});Global listener의 SQL 타임라인이에요:
Your code: em.save(User, { name: "Alice" })
|
v
"beforeInsert" listeners fire (all registered listeners, sequentially)
|
v
SQL executes: INSERT INTO "users" ("name") VALUES ('Alice') RETURNING "id";
|
v
"afterInsert" listeners fire (all registered listeners, sequentially)리스너 관리
// Register a listener (returns nothing)
em.on("afterInsert", listener);
// Remove a specific listener
em.off("afterInsert", listener);
// Remove ALL listeners for ALL events
em.removeAllListeners();사용 가능한 이벤트
| Event | 실행 시점 |
|---|---|
beforeInsert | INSERT 전 |
afterInsert | INSERT 후 |
beforeUpdate | UPDATE 전 |
afterUpdate | UPDATE 후 |
beforeDelete | DELETE 전 |
afterDelete | DELETE 후 |
언제 Global Listener를 쓸까?
모든 엔티티에 적용되는 **횡단 관심사(cross-cutting concerns)**에 이상적이에요:
- 감사 로깅 -- 모든 테이블의 모든 변경 기록
- 메트릭 -- 초당 insert/update/delete 횟수 집계
- 디버깅 -- 개발 중 모든 DB 작업 로깅
특정 엔티티에만 반응해야 한다면 (예: User 변경 시 캐시 무효화), EntitySubscriber를 대신 사용하세요.
EntitySubscriber -- 엔티티별 이벤트 클래스
EntitySubscriber는 가장 강력한 방식이에요. 특정 엔티티의 모든 이벤트 로직을 하나의 전용 클래스에 캡슐화할 수 있어요. Global listener와 달리, 관심 있는 엔티티의 이벤트만 받아요.
전체 예제: Audit Trail
User 엔티티의 모든 변경을 기록하는 subscriber예요:
// user-audit.subscriber.ts
import {
EntitySubscriber,
InsertEvent,
UpdateEvent,
DeleteEvent,
} from "@stingerloom/orm";
import { User } from "./user.entity";
class UserAuditSubscriber implements EntitySubscriber<User> {
listenTo() {
return User; // Only receive events for User -- not Post, not Comment, only User
}
async beforeInsert(event: InsertEvent<User>) {
// Runs BEFORE the INSERT SQL.
// You can mutate event.entity here to change what gets inserted.
console.log("About to create user:", event.entity);
}
async afterInsert(event: InsertEvent<User>) {
// Runs AFTER the INSERT SQL.
// The user is already in the database. Use this for side effects.
console.log("User created:", event.entity);
await this.writeAuditLog("INSERT", event.entity);
}
async afterUpdate(event: UpdateEvent<User>) {
console.log("User updated:", event.entity);
await this.writeAuditLog("UPDATE", event.entity);
}
async afterDelete(event: DeleteEvent<User>) {
console.log("User deleted, criteria:", event.criteria);
await this.writeAuditLog("DELETE", { criteria: event.criteria });
}
private async writeAuditLog(action: string, data: any) {
// Write to an audit_logs table, send to an external service, etc.
}
}Subscriber의 SQL 타임라인
Your code: em.save(User, { name: "Alice", email: "alice@example.com" })
|
v
UserAuditSubscriber.beforeInsert() fires
| (event.entity = { name: "Alice", email: "alice@example.com" })
| (you can mutate event.entity here)
v
SQL executes: INSERT INTO "users" ("name", "email")
VALUES ('Alice', 'alice@example.com')
RETURNING "id";
|
v
UserAuditSubscriber.afterInsert() fires
| (event.entity = { id: 1, name: "Alice", email: "alice@example.com" })
| (side effects only -- the row is already committed)
v
Return to your code등록
// Register the subscriber
em.addSubscriber(new UserAuditSubscriber());
// Unregister later if needed
em.removeSubscriber(subscriber);전체 Subscriber 이벤트 목록
EntitySubscriber는 global listener보다 더 많은 이벤트를 지원해요. 트랜잭션 라이프사이클 이벤트도 포함돼요:
| Method | 실행 시점 | 활용 예시 |
|---|---|---|
afterLoad(entity) | DB에서 엔티티를 로드한 후 | 필드 복호화, 파생 값 계산 |
beforeInsert(event) | INSERT 전 | 유효성 검사, 데이터 보강/변환 |
afterInsert(event) | INSERT 후 | 감사 로그, 환영 이메일 발송 |
beforeUpdate(event) | UPDATE 전 | 변경 유효성 검사, 필드 diff 추적 |
afterUpdate(event) | UPDATE 후 | 캐시 무효화, 구독자 알림 |
beforeDelete(event) | DELETE 전 | 권한 확인, 보호된 삭제 방지 |
afterDelete(event) | DELETE 후 | 파일 정리, 검색 인덱스 제거 |
beforeTransactionStart() | BEGIN 전 | 진단, 로깅 |
afterTransactionStart() | BEGIN 후 | 진단, 로깅 |
beforeTransactionCommit() | COMMIT 전 | 최종 유효성 검사, 사이드 이펙트 일괄 처리 |
afterTransactionCommit() | COMMIT 후 | 도메인 이벤트를 메시지 큐에 발행 |
beforeTransactionRollback() | ROLLBACK 전 | 로깅 |
afterTransactionRollback() | ROLLBACK 후 | 정리, 알림 |
모든 메서드는 선택 사항이에요 -- 필요한 것만 구현하면 돼요.
활용 예시
캐시 무효화:
class ProductCacheSubscriber implements EntitySubscriber<Product> {
listenTo() { return Product; }
async afterUpdate(event: UpdateEvent<Product>) {
await redis.del(`product:${event.entity.id}`);
}
async afterDelete(event: DeleteEvent<Product>) {
await redis.del(`product:${event.criteria.id}`);
}
}알림 발송:
class OrderNotificationSubscriber implements EntitySubscriber<Order> {
listenTo() { return Order; }
async afterInsert(event: InsertEvent<Order>) {
await emailService.send({
to: event.entity.customerEmail,
subject: "Order confirmed",
body: `Your order #${event.entity.id} has been placed.`,
});
}
}도메인 이벤트 발행 (commit 후에만):
class PaymentEventSubscriber implements EntitySubscriber<Payment> {
listenTo() { return Payment; }
async afterTransactionCommit() {
// Only publish to the message queue AFTER the transaction is committed.
// If you published in afterInsert and the transaction rolled back,
// consumers would process an event for data that does not exist.
await messageQueue.publish("payment.completed", { ... });
}
}NestJS Integration
NestJS에서는 OnModuleInit 라이프사이클 훅을 사용해서 모듈 초기화 시점에 subscriber를 등록해요:
import { Injectable, OnModuleInit } from "@nestjs/common";
import { InjectEntityManager } from "@stingerloom/orm/nestjs";
import { EntityManager } from "@stingerloom/orm";
@Injectable()
export class AppService implements OnModuleInit {
constructor(
@InjectEntityManager()
private readonly em: EntityManager,
) {}
onModuleInit() {
// Register all subscribers when the module starts
this.em.addSubscriber(new UserAuditSubscriber());
this.em.addSubscriber(new ProductCacheSubscriber());
this.em.addSubscriber(new OrderNotificationSubscriber());
}
}어떤 방식을 선택할까?
판단 기준이에요:
"이 로직이 누가 저장하든 항상 실행되어야 하나요?"
- Yes -- 엔티티 클래스에 lifecycle hook (
@BeforeInsert등)을 사용하세요. - 예시: slug 생성, 기본 상태값 설정.
"이 로직이 시스템의 모든 엔티티에 적용되어야 하나요?"
- Yes -- global listener (
em.on())를 사용하세요. - 예시: 모든 변경 감사 로깅, 메트릭용 연산 횟수 집계.
"이 로직이 특정 엔티티 하나에만 적용되고, 외부 시스템과 연동되나요?"
- Yes -- EntitySubscriber를 사용하세요.
- 예시: User 생성 시 이메일 발송, Product 변경 시 캐시 무효화.
요약 표:
| 필요한 기능 | 사용할 방식 |
|---|---|
| 저장 전 엔티티 데이터 변경 | @BeforeInsert() / @BeforeUpdate() hooks |
| 모든 엔티티에 전역 반응 (로깅, 메트릭) | em.on() |
| 특정 엔티티에 반응 (감사, 캐시, 알림) | EntitySubscriber |
| 트랜잭션 라이프사이클에 반응 (commit, rollback) | EntitySubscriber |
Next Steps
- Entities -- 엔티티 정의, 컬럼, 유효성 검사 데코레이터
- Transactions -- 트랜잭션 관리와 격리 수준
- API Reference -- EntitySubscriber 타입 시그니처