NestJS + TypeORM Soft Delete

왜 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 NULL partial 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

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux