왜 Soft Delete인가: 물리 삭제 대신 논리 삭제를 선택하는 이유
운영 환경에서 데이터를 DELETE FROM으로 물리 삭제하면 복구가 불가능합니다. 감사 로그, 법적 데이터 보존 의무, 실수 복구를 위해 대부분의 실무 프로젝트는 Soft Delete(논리 삭제)를 채택합니다. TypeORM은 0.2.28부터 @DeleteDateColumn 데코레이터를 도입하여 프레임워크 수준의 Soft Delete를 지원합니다.
@DeleteDateColumn 기본 설정과 동작 원리
TypeORM 공식 문서에 따르면 @DeleteDateColumn은 엔티티에 deletedAt 타입스탬프 컬럼을 추가하고, softRemove() 또는 softDelete() 호출 시 해당 컬럼에 현재 시각을 기록합니다. 이후 기본 find 쿼리에서 deletedAt IS NOT NULL인 레코드를 자동 제외합니다.
import { Entity, PrimaryGeneratedColumn, Column, DeleteDateColumn } from 'typeorm';
@Entity()
export class Article {
@PrimaryGeneratedColumn()
id: number;
@Column()
title: string;
@DeleteDateColumn()
deletedAt: Date | null;
}
이 설정만으로 repository.find()는 WHERE "Article"."deletedAt" IS NULL 조건을 자동 추가합니다. TypeORM 내부적으로 FindOptionsUtils에서 @DeleteDateColumn이 있는 엔티티에 대해 글로벌 필터를 적용합니다.
softRemove vs softDelete: 결정적 차이
TypeORM은 Soft Delete를 수행하는 두 가지 메서드를 제공합니다. 이 둘의 차이를 정확히 이해하지 못하면 운영 장애로 이어질 수 있습니다.
| 구분 | softRemove(entity) | softDelete(id | criteria) |
|---|---|---|
| 입력 | 엔티티 인스턴스 (또는 배열) | ID 또는 조건 객체 |
| 엔티티 로딩 | 메모리에 로드된 인스턴스 필요 | SELECT 없이 UPDATE 직접 실행 |
| Cascade | ✅ cascade: true에 따라 관계 엔티티도 softRemove |
❌ 관계 무시, 대상 테이블만 UPDATE |
| Subscriber/Listener | ✅ beforeSoftRemove, afterSoftRemove 트리거 |
❌ 트리거 안 됨 (QueryBuilder 경유) |
| 성능 | SELECT + UPDATE (N+1 가능) | 단일 UPDATE 쿼리 |
| 반환값 | softRemove된 엔티티 | UpdateResult |
핵심 함정: softDelete()는 EntitySubscriber의 beforeSoftRemove / afterSoftRemove 이벤트를 트리거하지 않습니다. 감사 로그를 Subscriber로 기록하고 있다면, softDelete()를 사용하면 로그가 누락됩니다. 이것은 TypeORM의 remove() vs delete() 관계와 동일한 패턴입니다.
// ✅ Subscriber 트리거됨, cascade 동작
const article = await repo.findOneByOrFail({ id: 1 });
await repo.softRemove(article);
// ❌ Subscriber 트리거 안 됨, cascade 무시
await repo.softDelete(1);
Cascade Soft Delete: 관계 전파의 함정
TypeORM의 cascade 옵션은 soft delete에도 적용되지만, 반드시 softRemove()를 사용해야 합니다. 공식 소스코드의 EntityPersistExecutor를 보면, softRemove는 cascade 대상 엔티티를 재귀적으로 수집한 뒤 각각에 대해 soft remove를 실행합니다.
@Entity()
export class Post {
@PrimaryGeneratedColumn()
id: number;
@OneToMany(() => Comment, (c) => c.post, { cascade: true })
comments: Comment[];
@DeleteDateColumn()
deletedAt: Date | null;
}
@Entity()
export class Comment {
@PrimaryGeneratedColumn()
id: number;
@ManyToOne(() => Post, (p) => p.comments)
post: Post;
@DeleteDateColumn()
deletedAt: Date | null;
}
함정 1 — 관계 엔티티에 @DeleteDateColumn이 없으면: cascade soft delete 시 TypeORM은 해당 관계 엔티티를 건너뜁니다. 에러도 발생하지 않습니다. Post를 softRemove해도 Comment에 @DeleteDateColumn이 없으면 Comment는 그대로 남습니다.
함정 2 — relations 로딩 필수: softRemove()가 cascade를 처리하려면 관계 데이터가 엔티티에 로드되어 있어야 합니다. findOne()만으로는 관계가 로드되지 않으므로, 반드시 relations 옵션을 지정해야 합니다.
// ❌ comments 로드 안 됨 → cascade soft delete 동작 안 함
const post = await postRepo.findOneByOrFail({ id: 1 });
await postRepo.softRemove(post);
// ✅ comments 로드 → cascade soft delete 정상 동작
const post = await postRepo.findOneOrFail({
where: { id: 1 },
relations: ['comments'],
});
await postRepo.softRemove(post);
삭제된 데이터 조회: withDeleted 옵션
Soft Delete된 레코드를 조회해야 하는 경우(관리자 페이지, 복구 기능 등) TypeORM은 withDeleted 옵션을 제공합니다.
// soft delete된 레코드 포함 전체 조회
const allArticles = await repo.find({ withDeleted: true });
// QueryBuilder에서도 동일
const result = await repo
.createQueryBuilder('article')
.withDeleted()
.where('article.id = :id', { id: 1 })
.getOne();
withDeleted는 find 계열 메서드와 QueryBuilder 모두에서 사용 가능합니다. 단, withDeleted를 지정하면 soft delete된 레코드 만 가져오는 것이 아니라, 삭제 여부와 무관하게 전체를 가져옵니다. 삭제된 것만 조회하려면 추가 조건이 필요합니다:
const deletedOnly = await repo.find({
withDeleted: true,
where: { deletedAt: Not(IsNull()) },
});
restore: Soft Delete 복구
TypeORM은 restore() 메서드로 soft delete를 되돌립니다. deletedAt 컬럼을 NULL로 업데이트합니다.
// ID로 복구
await repo.restore(1);
// 조건으로 복구
await repo.restore({ title: 'Draft' });
// recover(): softRemove의 역연산 (엔티티 인스턴스 기반, Subscriber 트리거)
const article = await repo.findOne({ where: { id: 1 }, withDeleted: true });
await repo.recover(article);
restore()와 recover()의 관계는 softDelete()와 softRemove()의 관계와 동일합니다. recover()는 엔티티 인스턴스 기반이며 Subscriber를 트리거하고, restore()는 QueryBuilder 경유로 직접 UPDATE를 실행합니다.
NestJS 서비스 계층에서의 Soft Delete 실무 패턴
NestJS에서 TypeORM Soft Delete를 사용할 때 서비스 계층의 설계 패턴입니다.
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Article } from './article.entity';
@Injectable()
export class ArticleService {
constructor(
@InjectRepository(Article)
private readonly repo: Repository<Article>,
) {}
/** Soft delete - Subscriber 트리거 필요 시 */
async remove(id: number): Promise<Article> {
const article = await this.repo.findOneOrFail({
where: { id },
relations: ['comments'], // cascade 대상 로드
});
return this.repo.softRemove(article);
}
/** Soft delete - 대량 처리, 성능 우선 */
async bulkRemove(ids: number[]): Promise<void> {
await this.repo.softDelete(ids);
}
/** 복구 */
async restore(id: number): Promise<void> {
const result = await this.repo.restore(id);
if (result.affected === 0) {
throw new NotFoundException(`Article #${id} not found or not deleted`);
}
}
/** 삭제된 항목 포함 조회 (관리자용) */
async findAllIncludingDeleted(): Promise<Article[]> {
return this.repo.find({ withDeleted: true });
}
}
Global Subscriber로 Soft Delete 감사 로그 구현
softRemove()를 사용할 때 EntitySubscriber의 afterSoftRemove 이벤트로 감사 로그를 자동 기록할 수 있습니다. TypeORM 0.3.x에서 지원하는 soft delete 관련 이벤트는 다음 네 가지입니다: beforeSoftRemove, afterSoftRemove, beforeRecover, afterRecover.
import { Injectable } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import {
DataSource,
EntitySubscriberInterface,
EventSubscriber,
SoftRemoveEvent,
} from 'typeorm';
@EventSubscriber()
@Injectable()
export class SoftDeleteAuditSubscriber implements EntitySubscriberInterface {
constructor(@InjectDataSource() dataSource: DataSource) {
dataSource.subscribers.push(this);
}
afterSoftRemove(event: SoftRemoveEvent<any>): void {
const entity = event.entity;
if (!entity) return;
const entityName = event.metadata.targetName;
const entityId = event.metadata.primaryColumns
.map((col) => col.getEntityValue(entity))
.join(',');
console.log(
`[AUDIT] Soft deleted: ${entityName}#${entityId} at ${new Date().toISOString()}`,
);
// 실무에서는 별도 audit 테이블에 INSERT
}
}
NestJS에서 이 Subscriber를 사용하려면 해당 모듈의 providers에 등록해야 합니다. @InjectDataSource()로 DataSource를 주입받아 dataSource.subscribers.push(this)를 호출하는 패턴은 NestJS 공식 문서의 TypeORM Subscriber 등록 방법입니다.
흔한 실수와 운영 체크리스트
실수 1: Unique 제약 조건 충돌
Soft delete된 레코드가 남아있으므로 UNIQUE 인덱스에 걸릴 수 있습니다. 예를 들어 email 컬럼에 UNIQUE 제약이 있는 User 테이블에서 사용자를 soft delete 후 같은 이메일로 재가입하면 중복 에러가 발생합니다. 해결 방법:
- MySQL/MariaDB: Partial unique index를 지원하지 않으므로,
UNIQUE(email, deletedAt)복합 유니크를 사용하거나, soft delete 시 email 값을 변경하는 패턴을 사용합니다. - PostgreSQL:
CREATE UNIQUE INDEX ... WHERE "deletedAt" IS NULLpartial index를 사용합니다.
// PostgreSQL partial unique index - TypeORM으로 직접 생성
@Entity()
@Index(['email'], { unique: true, where: '"deletedAt" IS NULL' })
export class User {
@Column()
email: string;
@DeleteDateColumn()
deletedAt: Date | null;
}
실수 2: 관계 쿼리에서 삭제된 데이터 노출
TypeORM의 자동 필터는 루트 엔티티에만 적용됩니다. relations로 로드하는 관계 엔티티에 대해서는 별도로 soft delete 필터가 적용되지 않을 수 있습니다 (TypeORM 버전에 따라 동작이 다릅니다). QueryBuilder에서 JOIN으로 관계를 로드할 때는 명시적으로 deletedAt IS NULL 조건을 추가하는 것이 안전합니다.
// QueryBuilder에서 관계의 soft delete 필터를 명시적으로 적용
const posts = await postRepo
.createQueryBuilder('post')
.leftJoinAndSelect('post.comments', 'comment', 'comment.deletedAt IS NULL')
.getMany();
실수 3: Raw Query / Query Runner에서 필터 누락
query()나 QueryRunner의 raw SQL에서는 soft delete 필터가 적용되지 않습니다. raw 쿼리를 사용할 때는 반드시 WHERE deletedAt IS NULL을 수동으로 추가해야 합니다.
정리: 언제 어떤 메서드를 선택할 것인가
| 상황 | 권장 메서드 | 이유 |
|---|---|---|
| 단건 삭제 + 감사 로그 | softRemove() |
Subscriber 트리거, cascade 지원 |
| 대량 삭제 (배치) | softDelete() |
단일 UPDATE, 성능 우선 |
| 단건 복구 + 감사 로그 | recover() |
Subscriber 트리거 |
| 대량/조건부 복구 | restore() |
단일 UPDATE |
| 삭제 포함 조회 | find({ withDeleted: true }) |
글로벌 필터 우회 |
TypeORM의 Soft Delete는 @DeleteDateColumn 하나로 간편하게 시작할 수 있지만, cascade 전파 조건, Subscriber 트리거 여부, UNIQUE 제약 충돌 같은 함정이 존재합니다. 이 글에서 다룬 차이점을 이해하고 서비스 계층에서 일관된 패턴을 적용하면, 운영 환경에서 안전하고 예측 가능한 Soft Delete를 구현할 수 있습니다.
참고: TypeORM 공식 문서 — @DeleteDateColumn, Repository API, Listeners and Subscribers