TypeORM Soft Delete

Soft Delete가 필요한 이유: 삭제는 하되, 데이터는 보존

사용자 탈퇴, 게시글 삭제, 주문 취소 — 비즈니스에서 “삭제”는 흔하지만, 실제로 데이터를 물리적으로 지우면 감사 로그, 복구, 통계 분석이 불가능해진다. Soft Delete는 행을 실제로 삭제하지 않고 “삭제됨” 표시만 하는 패턴이다. TypeORM은 @DeleteDateColumn 데코레이터로 이를 네이티브 지원한다.

TypeORM 공식 문서는 Soft Delete를 “엔티티를 실제로 삭제하지 않고 deletedAt 컬럼을 설정하는 것”으로 정의한다. softRemove()softDelete()를 호출하면 DELETE 대신 UPDATE가 실행되고, 이후 일반 조회에서 자동으로 필터링된다. 이 글에서는 설정 방법, 글로벌 필터 동작, 복원, 관계에서의 함정, 그리고 MySQL 인덱스 전략까지 공식 문서 기반으로 정리한다.

@DeleteDateColumn 설정

import {
  Entity, PrimaryGeneratedColumn, Column,
  CreateDateColumn, UpdateDateColumn, DeleteDateColumn,
} from 'typeorm';

@Entity()
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  email: string;

  @Column()
  name: string;

  @CreateDateColumn()
  createdAt: Date;

  @UpdateDateColumn()
  updatedAt: Date;

  @DeleteDateColumn()       // 이 컬럼이 Soft Delete의 핵심
  deletedAt: Date | null;   // null이면 활성, 값이 있으면 삭제됨
}

@DeleteDateColumn이 있는 엔티티는 TypeORM이 자동으로 Soft Delete 모드로 동작한다. 별도의 설정이나 글로벌 플래그 없이, 이 데코레이터 하나로 해당 엔티티의 삭제 동작이 바뀐다.

삭제 API 비교: softRemove vs softDelete vs remove vs delete

// 1. softRemove — 엔티티 인스턴스 기반 (Subscriber/Listener 트리거됨)
const user = await userRepository.findOneBy({ id: 1 });
await userRepository.softRemove(user);
// SQL: UPDATE user SET deletedAt = NOW() WHERE id = 1

// 2. softDelete — ID 기반 (Subscriber 트리거 안 됨)
await userRepository.softDelete(1);
// SQL: UPDATE user SET deletedAt = NOW() WHERE id = 1

// 3. remove — 물리 삭제 (엔티티 인스턴스)
await userRepository.remove(user);
// SQL: DELETE FROM user WHERE id = 1

// 4. delete — 물리 삭제 (ID 기반)
await userRepository.delete(1);
// SQL: DELETE FROM user WHERE id = 1
메서드 실행 SQL Subscriber 트리거 입력
softRemove(entity) UPDATE (deletedAt 설정) ✅ beforeSoftRemove, afterSoftRemove 엔티티 인스턴스
softDelete(id) UPDATE (deletedAt 설정) ID 또는 조건
remove(entity) DELETE (물리 삭제) ✅ beforeRemove, afterRemove 엔티티 인스턴스
delete(id) DELETE (물리 삭제) ID 또는 조건

자동 필터링: 삭제된 행이 조회에서 제외되는 원리

@DeleteDateColumn이 있는 엔티티를 조회하면 TypeORM이 자동으로 WHERE deletedAt IS NULL 조건을 추가한다.

// 일반 조회 — 삭제된 행 자동 제외
const users = await userRepository.find();
// SQL: SELECT * FROM user WHERE deletedAt IS NULL

const user = await userRepository.findOneBy({ id: 1 });
// SQL: SELECT * FROM user WHERE id = 1 AND deletedAt IS NULL

// QueryBuilder에서도 동일
const users = await userRepository
  .createQueryBuilder('user')
  .where('user.email = :email', { email: 'test@example.com' })
  .getMany();
// SQL: SELECT ... FROM user WHERE email = ? AND user.deletedAt IS NULL

삭제된 행도 포함해서 조회: withDeleted

// find 옵션
const allUsers = await userRepository.find({
  withDeleted: true,    // 삭제된 행 포함
});
// SQL: SELECT * FROM user  (deletedAt 조건 없음)

// QueryBuilder
const allUsers = await userRepository
  .createQueryBuilder('user')
  .withDeleted()        // 삭제된 행 포함
  .getMany();

// 삭제된 행만 조회
const deletedUsers = await userRepository.find({
  withDeleted: true,
  where: { deletedAt: Not(IsNull()) },
});
// SQL: SELECT * FROM user WHERE deletedAt IS NOT NULL

복원(Recover): Soft Delete 되돌리기

// recover — 엔티티 인스턴스 기반 (Subscriber 트리거됨)
const user = await userRepository.findOne({
  where: { id: 1 },
  withDeleted: true,      // 삭제된 행을 찾아야 하므로 필수
});
await userRepository.recover(user);
// SQL: UPDATE user SET deletedAt = NULL WHERE id = 1

// restore — ID 기반 (Subscriber 트리거 안 됨)
await userRepository.restore(1);
// SQL: UPDATE user SET deletedAt = NULL WHERE id = 1
메서드 동작 Subscriber 입력
recover(entity) deletedAt = NULL ✅ beforeRecover, afterRecover 엔티티 인스턴스
restore(id) deletedAt = NULL ID 또는 조건

관계(Relations)에서의 Soft Delete 함정

함정 1: cascade로 자식만 물리 삭제됨

@Entity()
export class Post {
  @PrimaryGeneratedColumn()
  id: number;

  @DeleteDateColumn()
  deletedAt: Date | null;

  @OneToMany(() => Comment, (comment) => comment.post, {
    cascade: true,        // cascade는 save/remove에 적용
  })
  comments: Comment[];
}

@Entity()
export class Comment {
  @PrimaryGeneratedColumn()
  id: number;

  @DeleteDateColumn()
  deletedAt: Date | null;

  @ManyToOne(() => Post, (post) => post.comments)
  post: Post;
}

// softRemove는 cascade로 자식에게 전파됨 (TypeORM 공식 지원)
const post = await postRepository.findOne({
  where: { id: 1 },
  relations: ['comments'],
});
await postRepository.softRemove(post);
// Post의 deletedAt 설정 + 각 Comment의 deletedAt도 설정

주의: softRemove에서 cascade가 동작하려면 관계가 로드(relations)되어 있어야 한다. 관계를 로드하지 않고 softRemove를 호출하면 부모만 Soft Delete되고 자식은 그대로 남는다.

함정 2: 관계 조회 시 삭제된 자식이 필터링됨

// Post를 조회하면서 comments를 eager/relations로 로드
const post = await postRepository.findOne({
  where: { id: 1 },
  relations: ['comments'],
});
// comments 중 deletedAt이 설정된 것은 자동 제외됨

// 삭제된 댓글도 포함하려면?
// find 옵션의 withDeleted는 루트 엔티티에만 적용됨
// 관계의 삭제된 행을 포함하려면 QueryBuilder 사용
const post = await postRepository
  .createQueryBuilder('post')
  .leftJoinAndSelect('post.comments', 'comment')
  .withDeleted()          // 모든 엔티티에 적용
  .where('post.id = :id', { id: 1 })
  .getOne();

함정 3: Unique 제약 조건 충돌

// email에 UNIQUE 제약이 있는 경우
@Column({ unique: true })
email: string;

// 문제: user@test.com을 Soft Delete 후 같은 이메일로 가입 시도
// → UNIQUE 위반 (deletedAt이 있어도 행은 존재하므로)

// 해결 1: MySQL Partial Index 불가 → 복합 유니크
// TypeORM에서는 @Index 데코레이터로 복합 인덱스 생성
@Entity()
@Index(['email', 'deletedAt'], { unique: true })
export class User {
  @Column()
  email: string;

  @DeleteDateColumn()
  deletedAt: Date | null;
}
// 주의: MySQL에서 NULL은 UNIQUE에서 서로 다른 값으로 취급
// → (email, NULL)과 (email, NULL)은 중복으로 판단됨 (MySQL 8.0)
// → 이 방식은 MySQL에서 제대로 동작하지 않음

// 해결 2 (실무 권장): Soft Delete 시 email을 변조
// user@test.com → deleted_1708416000_user@test.com
async softDeleteUser(id: number) {
  const user = await this.userRepository.findOneBy({ id });
  user.email = `deleted_${Date.now()}_${user.email}`;
  await this.userRepository.softRemove(user);
}

MySQL 인덱스 전략: deletedAt IS NULL 최적화

-- 모든 조회에 WHERE deletedAt IS NULL이 붙으므로
-- 자주 사용하는 컬럼과 deletedAt을 복합 인덱스로

-- 예: email 검색 최적화
CREATE INDEX idx_user_email_deleted ON user (email, deletedAt);

-- 예: 목록 조회 최적화
CREATE INDEX idx_user_deleted_created ON user (deletedAt, createdAt DESC);

-- TypeORM에서 인덱스 선언
@Entity()
@Index(['email', 'deletedAt'])
@Index(['deletedAt', 'createdAt'])
export class User {
  // ...
}
인덱스 전략 적합한 쿼리 주의사항
(email, deletedAt) email 검색 + Soft Delete 필터 email이 선행 컬럼이어야 인덱스 활용
(deletedAt, createdAt) 활성 행 목록 + 최신순 정렬 deletedAt IS NULL 범위 스캔
단일 (deletedAt) 삭제/미삭제 비율이 극단적일 때 선택도 낮으면 효과 미미

NestJS 서비스 계층에서의 Soft Delete 패턴

// users.service.ts
@Injectable()
export class UsersService {
  constructor(
    @InjectRepository(User)
    private readonly userRepository: Repository<User>,
  ) {}

  // 조회 — 삭제된 행 자동 제외
  async findAll(): Promise<User[]> {
    return this.userRepository.find();
  }

  // 삭제 — Soft Delete
  async remove(id: number): Promise<void> {
    const user = await this.userRepository.findOneByOrFail({ id });
    await this.userRepository.softRemove(user);
  }

  // 복원
  async restore(id: number): Promise<void> {
    const result = await this.userRepository.restore(id);
    if (result.affected === 0) {
      throw new NotFoundException('삭제된 사용자를 찾을 수 없습니다');
    }
  }

  // 관리자: 삭제된 행 포함 조회
  async findAllIncludingDeleted(): Promise<User[]> {
    return this.userRepository.find({ withDeleted: true });
  }

  // 영구 삭제 (GDPR 등)
  async permanentDelete(id: number): Promise<void> {
    await this.userRepository.delete(id);  // 물리 삭제
  }
}

실전 체크리스트: Soft Delete 설계 7단계

  1. @DeleteDateColumn 추가 — Soft Delete가 필요한 엔티티에만 선택적으로 적용
  2. softRemove vs softDelete 선택 — Subscriber가 필요하면 softRemove(엔티티), 대량 삭제면 softDelete(조건)
  3. cascade + relations 로딩 확인 — 자식 엔티티도 Soft Delete하려면 관계를 로드한 상태에서 softRemove
  4. UNIQUE 제약 조건 해결 — Soft Delete 후 같은 값으로 재생성이 필요하면 삭제 시 값 변조
  5. 복합 인덱스 설계 — 자주 검색하는 컬럼 + deletedAt으로 복합 인덱스
  6. 영구 삭제 API 별도 제공 — GDPR 등 법적 요구로 물리 삭제가 필요한 경우 delete() 사용
  7. 삭제된 데이터 정리 배치 — 오래된 Soft Delete 행을 주기적으로 물리 삭제하는 배치 작업 설계

흔한 실수 4가지와 방지법

실수 1: relations 없이 softRemove해서 자식이 고아(orphan)로 남음

증상: 부모 Post를 Soft Delete했는데 Comment는 deletedAt이 NULL인 채로 남아 있다. API에서 부모 없는 댓글이 조회된다.

방지: cascade가 있더라도 softRemove 호출 전에 relations: ['comments']로 자식을 로드해야 cascade가 동작한다.

실수 2: withDeleted를 빠뜨려 삭제된 행을 복원 못 함

증상: findOneBy({ id })로 삭제된 사용자를 찾으려 하는데 null이 반환된다.

방지: 복원 대상을 조회할 때는 반드시 withDeleted: true를 사용하거나, restore(id)를 직접 호출한다.

실수 3: COUNT나 통계 쿼리에서 삭제된 행을 포함

증상: “총 사용자 수” 통계가 탈퇴한 사용자를 포함한다. Raw Query나 QueryBuilder에서 withDeleted()를 실수로 호출.

방지: 통계/대시보드 쿼리에서 withDeleted를 사용하지 않는 것을 기본으로 한다. 의도적으로 전체 데이터가 필요한 경우만 명시한다.

실수 4: Soft Delete 행이 누적되어 테이블 비대화

증상: 수년간 Soft Delete된 행이 쌓여 테이블 크기가 10배 이상 증가. 인덱스 효율 저하, 풀 스캔 성능 악화.

방지: 보존 기간 정책(예: 90일)을 정하고, 크론 배치로 오래된 Soft Delete 행을 물리 삭제하거나 아카이브 테이블로 이동한다.

마무리

TypeORM의 @DeleteDateColumn은 Soft Delete를 선언 한 줄로 활성화하는 강력한 기능이다. 조회 시 자동 필터링, softRemove/recover의 Subscriber 트리거, cascade 전파까지 잘 설계되어 있다. 다만 UNIQUE 제약 충돌, 관계 로딩 누락, 데이터 비대화는 직접 관리해야 하는 영역이다. 이 글의 모든 내용은 TypeORM 공식 문서(Soft Delete, Listeners and Subscribers)를 근거로 한다.

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