03 — Soft Delete & Audit
한 줄 답: TypeORM의
@DeleteDateColumn은 한 줄로 soft delete를 켜고,EntitySubscriber는 audit log를 자동화한다. 둘 다 데코레이터 ORM의 우아함을 보여주는 사례지만 — find가 자동으로 deleted를 제외하고, subscriber가 트랜잭션과 함께 롤백된다는 비자명한 동작을 모르면 사고가 난다.
Why — 왜 두 주제를 한 챕터에서 보나
Soft delete와 audit log는 거의 모든 production 시스템이 결국 만나는 요구다.
| 흔한 오해 | 실제 |
|---|---|
”Soft delete는 deleted_at 컬럼 하나 추가하면 끝” | 모든 쿼리에서 deleted_at IS NULL을 묻는 책임이 누군가에게 있어야 한다 — TypeORM은 find에서 자동으로 한다 |
| ”Audit log는 별도 trigger로 짜야 한다” | EntitySubscriber로 애플리케이션 레이어에서 자동 기록 가능 — 단 트랜잭션 경계 안에서 |
| ”두 개는 독립 주제다” | 같은 변경 lifecycle hook을 공유한다 — afterRemove, afterSoftRemove, afterUpdate. 같은 메커니즘으로 푼다 |
이 챕터는 데코레이터와 subscriber가 결합되어 얼마나 우아하게 두 요구를 푸는지 — 그리고 그 우아함의 비용이 어디에 있는지를 본다.
How — 어떻게 동작하는가
1) @DeleteDateColumn — 한 줄 soft delete
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column()
email: string;
@DeleteDateColumn({ name: 'deleted_at' })
deletedAt?: Date;
}이 한 줄이 만드는 세 가지 자동 동작은 다음과 같다.
2) softRemove vs softDelete 차이
// (a) softRemove — entity 객체로
const user = await repo.findOne({ where: { id: 1 } });
await repo.softRemove(user); // ✅ cascade·subscriber 모두 호출
// (b) softDelete — 조건으로
await repo.softDelete({ id: 1 }); // ✅ 빠름 (UPDATE만), 단 entity hook 일부 안 호출| 메서드 | 동작 | hook 호출 | 적합한 경우 |
|---|---|---|---|
softRemove(entity) | entity 객체 lifecycle 전체 | beforeSoftRemove/afterSoftRemove, cascade 포함 | 비즈니스 규칙·연쇄 작업 필요 |
softDelete({where}) | 직접 UPDATE | subscriber는 호출 — 그러나 entity 인스턴스 없음 | 단순 대량 삭제 |
3) deleted 포함하여 조회
// (1) find 옵션
await repo.find({ withDeleted: true });
// (2) QueryBuilder — 명시 안 하면 자동 필터 적용 안 됨에 주의
await repo
.createQueryBuilder('u')
.withDeleted() // 명시
.getMany();⚠️ QueryBuilder는 기본적으로
deleted_at IS NULL필터를 자동 적용한다 (0.3.x부터) — 하지만 raw query·subquery는 그렇지 않다. 03~04장의 함정 절 참고.
4) Audit log — EntitySubscriber로 자동 기록
import { DataSource, EntitySubscriberInterface, EventSubscriber, InsertEvent, UpdateEvent, RemoveEvent } from 'typeorm';
@EventSubscriber()
export class AuditSubscriber implements EntitySubscriberInterface {
async afterInsert(event: InsertEvent<any>) {
await event.manager.getRepository(AuditLog).insert({
entityName: event.metadata.name,
entityId: (event.entity as any).id,
action: 'INSERT',
payload: event.entity,
at: new Date(),
});
}
async afterUpdate(event: UpdateEvent<any>) {
await event.manager.getRepository(AuditLog).insert({
entityName: event.metadata.name,
entityId: (event.entity as any)?.id,
action: 'UPDATE',
changed: event.updatedColumns.map((c) => c.propertyName),
payload: event.entity,
at: new Date(),
});
}
async afterRemove(event: RemoveEvent<any>) {
await event.manager.getRepository(AuditLog).insert({
entityName: event.metadata.name,
entityId: event.entityId,
action: 'REMOVE',
at: new Date(),
});
}
}핵심:
event.manager를 써야 같은 트랜잭션 안에서 audit이 기록된다. *root manager(dataSource.manager)*를 쓰면 별도 트랜잭션에 기록되어 — 원본은 롤백, audit은 남는 사고가 난다.
5) Audit subscriber를 등록하는 두 방법
// 방법 (A) — @EventSubscriber() 데코레이터 + DataSource 자동 스캔 (deprecated 경향)
@EventSubscriber()
export class AuditSubscriber implements EntitySubscriberInterface { /* ... */ }
// 방법 (B) — DataSource options.subscribers 명시 ✅ 권장
new DataSource({
type: 'postgres',
// ...
subscribers: [AuditSubscriber],
});NestJS에서는 *방법 (B)*가 권장된다 — DI에 등록된 subscriber라도 subscribers 옵션에 명시해야 DataSource가 알아본다. (4장에서 더 깊이.)
What — 구체 사양
@DeleteDateColumn 옵션
| 옵션 | 기본값 | 의미 |
|---|---|---|
name | 클래스 프로퍼티명 | DB 컬럼명 (snake_case 추천) |
type | 드라이버별 timestamp | timestamp with time zone 등 |
nullable | (강제 true) | soft delete는 NULL일 때 살아있음 |
Soft delete가 자동 필터되는 경로
출처: TypeORM 저장소
SelectQueryBuilder.ts및Repository.ts
| 경로 | 자동 deleted_at IS NULL |
|---|---|
repo.find() / findOne() / findBy() | ✅ |
repo.createQueryBuilder() | ✅ (0.3.x+) |
repo.findAndCount() | ✅ |
Raw repo.query('SELECT ...') | ❌ — 직접 추가 |
| Subquery 안의 entity 참조 | ⚠️ 경우에 따라 — 명시적으로 .withDeleted() 또는 직접 조건 권장 |
EntitySubscriber.afterLoad에서 직접 join | ❌ |
Soft delete의 cascade
@Entity()
export class Post {
@OneToMany(() => Comment, (c) => c.post, { cascade: ['soft-remove'] })
comments: Comment[];
}
@Entity()
export class Comment {
@ManyToOne(() => Post, (p) => p.comments, { onDelete: 'CASCADE' })
post: Post;
@DeleteDateColumn()
deletedAt?: Date;
}cascade: ['soft-remove']로 부모 soft-remove 시 자식도 함께 soft-remove. onDelete: 'CASCADE'는 DB 레벨 hard delete라서 — soft delete와 섞이면 의도와 다르게 동작한다.
Audit log 테이블 권장 스키마
@Entity({ name: 'audit_logs' })
@Index(['entityName', 'entityId'])
@Index(['at'])
export class AuditLog {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
entityName: string;
@Column({ nullable: true })
entityId: string | null;
@Column({ type: 'varchar' })
action: 'INSERT' | 'UPDATE' | 'REMOVE' | 'SOFT_REMOVE' | 'RESTORE';
@Column({ type: 'jsonb', nullable: true })
payload: unknown;
@Column({ type: 'simple-array', nullable: true })
changed: string[] | null;
@Column({ name: 'actor_id', nullable: true })
actorId: string | null;
@Column({ name: 'at', type: 'timestamptz' })
at: Date;
}actorId를 subscriber로 어떻게 주입하나
이게 진짜 어려운 부분이다. subscriber는 DI 컨테이너 밖에서 실행되어 — 현재 요청의 사용자를 모른다.
| 방법 | 동작 | 단점 |
|---|---|---|
AsyncLocalStorage에 actorId 저장 | 요청 진입점에서 als.run({ actorId }, next) | 모든 진입점에서 set 필요 |
| Repository wrapper에서 명시적으로 넘김 | repo.save(entity, { actorId }) (TypeORM 표준 옵션 아님) | 모든 호출 지점이 알아야 함 |
Entity에 lastModifiedBy 컬럼 + 비즈니스 코드가 직접 set | subscriber는 그 값을 읽기만 | 비즈니스 코드가 매번 set해야 함 |
NestJS 진영에서는 AsyncLocalStorage가 사실상 표준이다 — nestjs-cls 라이브러리가 흔히 쓰인다.
What-if — 잘못 이해하면
1) “Soft delete를 켜면 모든 곳에서 자동 필터된다”고 믿으면
→ Raw dataSource.query(), subscriber에서 직접 join, dataSource.createQueryRunner().query() 같은 저레벨 경로는 자동 필터 안 된다. 그곳에서 deleted된 사용자가 부활한다.
대응: raw query에는 명시적으로 WHERE deleted_at IS NULL 추가. 코드 리뷰 체크리스트에.
2) “softDelete({ id })는 softRemove(entity)와 같다”고 믿으면
→ softDelete는 entity 인스턴스 없이 UPDATE만 친다. beforeSoftRemove 같은 entity-level hook은 호출되지 않거나 일부만 호출된다. cascade도 적용 안 된다.
대응: 비즈니스 규칙·cascade가 있으면 반드시 softRemove(entity) — entity를 먼저 find해서 객체로 받기.
3) “Audit subscriber에서 dataSource.manager를 써도 된다”고 믿으면
→ root manager는 별도 connection·트랜잭션. 원본 작업이 롤백되어도 audit은 commit됨 — 사실과 다른 로그가 남는다.
대응: 반드시 event.manager — 같은 트랜잭션 안. audit이 원본과 함께 commit·롤백된다.
4) “Audit subscriber에서 외부 API 호출(예: Slack 알림)을 해도 된다”고 믿으면
→ 트랜잭션이 롤백되어도 외부 호출은 이미 나갔다. 되돌릴 수 없는 사이드이펙트.
대응: 외부 호출은 transaction commit 이후로 분리 — AFTER COMMIT 패턴(outbox 테이블 + 별도 워커) 또는 dataSource.transaction() 바깥에서 명시 호출. 04장에서 더 깊이.
5) “@DeleteDateColumn과 unique constraint는 같이 잘 산다”고 믿으면
→ email이 unique인 entity에서 user A를 soft-delete → 새 user A’가 같은 email로 가입 → DB는 unique 충돌. soft-delete된 row가 여전히 존재하기 때문.
대응: PostgreSQL의 partial unique index(WHERE deleted_at IS NULL) — TypeORM @Index 데코레이터로는 직접 안 되고, migration SQL에서 raw CREATE UNIQUE INDEX ... WHERE deleted_at IS NULL 작성.
Insight — 흥미로운 이야기
”@DeleteDateColumn은 TypeORM 0.2 후반에야 추가됐다”
오랫동안 TypeORM 사용자들은 직접 deletedAt 컬럼과 *수동 where: { deletedAt: IsNull() }*을 짰다. @DeleteDateColumn이 추가된 건 2020년 즈음의 0.2.x 후반. 흔한 요구를 데코레이터로 흡수하는 우아한 결정이지만, 그 이전에 짠 코드가 섞여 있는 production 코드베이스에서는 두 패턴이 공존하면서 예상과 다른 자동 필터에 부딪힌다.
”Audit subscriber는 Rails의 paper_trail에서 영향을 받았다”
Ruby on Rails 진영의 paper_trail gem은 모든 변경 이력을 자동 기록하는 de facto 표준이다. TypeORM의 EntitySubscriber로 audit을 짤 때 paper_trail 스타일(versions 테이블, JSON payload, actor 필드)이 그대로 차용되는 경우가 많다. Rails의 Active Record 사상이 TypeORM의 DataMapper 위에 생각하기 좋은 패턴으로 살아남은 흥미로운 사례.
”Soft delete가 법적 요구가 되는 도메인이 있다”
GDPR의 잊혀질 권리(Article 17)는 완전 삭제를 요구하는 듯 보이지만, 실제로는 처리 기록의 보존도 함께 요구한다. 그래서 많은 시스템이 주 데이터는 hard delete, audit은 익명화된 상태로 보존 또는 soft delete + 일정 기간 후 hard delete의 2단계 정책을 쓴다. @DeleteDateColumn + 별도 cron(deleted_at < NOW() - INTERVAL '90 days' → hard delete) 패턴이 흔하다.
”nestjs-cls가 Audit의 조용한 표준”
NestJS 진영에서 audit subscriber + AsyncLocalStorage 조합은 nestjs-cls 라이브러리(github.com/Papooch/nestjs-cls)가 사실상 표준에 가깝게 쓰인다. 공식 NestJS 문서에는 명시 안 됨 — 커뮤니티가 비워진 자리를 채우는 또 다른 사례.
요약 + 다이어그램
@DeleteDateColumn은 한 줄로 soft delete를 켜고,EntitySubscriber는 audit log를 자동화한다. 둘 다 데코레이터 ORM의 우아함을 보여준다 — 같은 변경 lifecycle hook을 공유하고, 같은 EntitySubscriber 메커니즘으로 묶인다. 비용은 비자명한 동작에 있다. find는 자동 필터되지만 raw query는 아니고, subscriber는 트랜잭션과 함께 롤백되지만 외부 호출은 되돌릴 수 없다. 다음 장(04)은 그 subscriber의 함정에 집중한다.
참고 자료
- TypeORM 공식, Soft delete —
typeorm.io/decorator-reference#deletedatecolumn - TypeORM 공식, EntitySubscriber —
typeorm.io/listeners-and-subscribers nestjs-cls(AsyncLocalStorage 래퍼) —github.com/Papooch/nestjs-cls- PostgreSQL partial unique index —
postgresql.org/docs/current/indexes-partial.html - Ruby paper_trail (영향원) —
github.com/paper-trail-gem/paper_trail - GDPR Article 17 —
gdpr-info.eu/art-17-gdpr
다음 문서:
04-event-subscribers.mdx— subscriber의 트랜잭션 경계·async 동작이 만든 실제 사고들.