TypeORM Soft Delete 운영 함정

Soft Delete가 필요한 이유

데이터를 물리적으로 삭제하면 복구가 불가능하고, 감사 로그나 참조 무결성이 깨집니다. Soft Delete는 deletedAt 컬럼으로 삭제를 표시하되 실제 데이터는 유지하는 패턴입니다. TypeORM은 @DeleteDateColumn으로 이를 내장 지원하지만, 쿼리 누수, 유니크 제약, 관계 Cascade, 성능 이슈 등 프로덕션에서 마주치는 함정이 많습니다.

이 글에서는 TypeORM Soft Delete의 내부 동작 원리, Global Filter 패턴, 유니크 제약 우회, 관계 엔티티 연쇄 삭제, 그리고 성능 최적화 전략까지 심층적으로 다룹니다.

기본 설정과 동작 원리

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

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

  @Column({ length: 100 })
  name: string;

  @Column({ unique: true })
  email: string;

  @CreateDateColumn()
  createdAt: Date;

  @UpdateDateColumn()
  updatedAt: Date;

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

@DeleteDateColumn이 있으면 TypeORM의 동작이 변합니다:

// softRemove: deletedAt에 현재 시각 설정
await userRepo.softRemove(user);
// SQL: UPDATE users SET deletedAt = NOW() WHERE id = 1

// softDelete: ID로 직접 소프트 삭제
await userRepo.softDelete(1);
// SQL: UPDATE users SET deletedAt = NOW() WHERE id = 1

// find 계열: 자동으로 삭제된 레코드 제외
await userRepo.find();
// SQL: SELECT * FROM users WHERE deletedAt IS NULL

// remove: 여전히 물리 삭제! 주의!
await userRepo.remove(user);
// SQL: DELETE FROM users WHERE id = 1  ← 진짜 삭제됨!

// restore: 삭제 복원
await userRepo.restore(1);
// SQL: UPDATE users SET deletedAt = NULL WHERE id = 1

QueryBuilder에서의 함정

find 메서드는 자동으로 삭제된 레코드를 제외하지만, QueryBuilder는 자동 필터링하지 않습니다:

// ✅ Repository 메서드 — 자동 필터링
const users = await userRepo.find();
// WHERE deletedAt IS NULL (자동 추가)

// ❌ QueryBuilder — 필터링 안 됨!
const users = await userRepo
  .createQueryBuilder('user')
  .where('user.age > :age', { age: 18 })
  .getMany();
// WHERE user.age > 18  ← deletedAt 조건 없음! 삭제된 것도 포함!

// ✅ QueryBuilder에서 수동 필터링
const users = await userRepo
  .createQueryBuilder('user')
  .where('user.age > :age', { age: 18 })
  .andWhere('user.deletedAt IS NULL')     // 수동 추가 필요
  .getMany();

이 문제를 체계적으로 해결하려면 Global Subscriber를 사용합니다:

// soft-delete.subscriber.ts
import { EventSubscriber, EntitySubscriberInterface, SelectQueryBuilder } from 'typeorm';

// TypeORM 0.3.x에서는 Subscriber로 글로벌 필터 불가
// → 커스텀 Repository Base 클래스로 해결

export class SoftDeleteRepository<T> extends Repository<T> {

  createQueryBuilder(alias?: string, queryRunner?: QueryRunner) {
    const qb = super.createQueryBuilder(alias, queryRunner);

    // 엔티티에 deletedAt이 있으면 자동 필터 추가
    const metadata = this.metadata;
    const deleteColumn = metadata.deleteDateColumn;

    if (deleteColumn && alias) {
      qb.andWhere(`${alias}.${deleteColumn.propertyName} IS NULL`);
    }

    return qb;
  }

  // 삭제된 레코드도 포함하는 메서드
  createQueryBuilderWithDeleted(alias?: string) {
    return super.createQueryBuilder(alias);
  }
}

// NestJS에서 사용
@Injectable()
export class UsersRepository extends SoftDeleteRepository<User> {
  constructor(private dataSource: DataSource) {
    super(User, dataSource.createEntityManager());
  }
}

유니크 제약 문제와 해결

Soft Delete의 가장 까다로운 문제입니다. 사용자가 이메일 a@test.com으로 가입 → 탈퇴(soft delete) → 같은 이메일로 재가입하면 유니크 제약 위반이 발생합니다:

// 문제: deletedAt이 있어도 email 유니크 제약 위반
// users 테이블:
// id=1, email='a@test.com', deletedAt='2026-03-01'  ← 삭제됨
// id=2, email='a@test.com', deletedAt=NULL           ← 중복 에러!

// 해결 1: Partial Unique Index (PostgreSQL)
@Entity('users')
@Index('UQ_users_email_active', ['email'], {
  unique: true,
  where: '"deletedAt" IS NULL',  // 활성 레코드만 유니크 검사
})
export class User {
  @Column()
  email: string;

  @DeleteDateColumn()
  deletedAt: Date | null;
}

// 해결 2: 삭제 시 email 변경 (MySQL 호환)
async softDeleteUser(id: number) {
  const user = await this.userRepo.findOneByOrFail({ id });
  // email을 유니크하게 변경 후 soft delete
  user.email = `deleted_${user.id}_${Date.now()}@${user.email}`;
  await this.userRepo.save(user);
  await this.userRepo.softDelete(id);
}

// 해결 3: Composite Unique (deletedAt 포함)
// MySQL 8+에서 함수 기반 인덱스
// CREATE UNIQUE INDEX UQ_email_active
// ON users (email, (COALESCE(deletedAt, '1970-01-01')));

관계 엔티티 연쇄 Soft Delete

TypeORM의 cascade: ['soft-remove']를 활용합니다:

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

  @OneToMany(() => Order, order => order.user, {
    cascade: ['soft-remove'],     // softRemove 시 연쇄 삭제
  })
  orders: Order[];

  @OneToOne(() => Profile, profile => profile.user, {
    cascade: ['soft-remove'],
  })
  profile: Profile;

  @DeleteDateColumn()
  deletedAt: Date | null;
}

@Entity('orders')
export class Order {
  @ManyToOne(() => User, user => user.orders)
  user: User;

  @OneToMany(() => OrderItem, item => item.order, {
    cascade: ['soft-remove'],     // 2단계 연쇄도 가능
  })
  items: OrderItem[];

  @DeleteDateColumn()
  deletedAt: Date | null;
}

// 사용: 관계 데이터를 로드한 후 softRemove
async deleteUser(id: number) {
  const user = await this.userRepo.findOne({
    where: { id },
    relations: ['orders', 'orders.items', 'profile'],  // 관계 반드시 로드!
  });
  
  // user + orders + orderItems + profile 모두 soft delete
  await this.userRepo.softRemove(user);
}

// 복원도 연쇄 가능
async restoreUser(id: number) {
  const user = await this.userRepo.findOne({
    where: { id },
    withDeleted: true,          // 삭제된 레코드 포함
    relations: ['orders', 'orders.items', 'profile'],
  });
  await this.userRepo.recover(user);  // 모든 관계 복원
}

삭제된 데이터 조회 패턴

// 삭제된 레코드 포함 조회
const allUsers = await userRepo.find({
  withDeleted: true,
});

// 삭제된 레코드만 조회
const deletedUsers = await userRepo.find({
  withDeleted: true,
  where: { deletedAt: Not(IsNull()) },
});

// QueryBuilder에서
const deleted = await userRepo
  .createQueryBuilder('user')
  .withDeleted()                    // 삭제 포함
  .where('user.deletedAt IS NOT NULL')
  .getMany();

// findOne도 동일
const user = await userRepo.findOne({
  where: { id: 1 },
  withDeleted: true,                // 삭제된 사용자도 찾기
});

성능 최적화: 인덱스 설계

Soft Delete 테이블은 시간이 지날수록 삭제된 레코드가 쌓여 쿼리 성능이 저하됩니다:

-- 핵심: deletedAt IS NULL 조건이 모든 쿼리에 포함됨
-- → Partial Index로 활성 레코드만 인덱싱

-- PostgreSQL: Partial Index
CREATE INDEX idx_users_email_active
ON users (email) WHERE "deletedAt" IS NULL;

CREATE INDEX idx_orders_user_active
ON orders (user_id, created_at) WHERE "deletedAt" IS NULL;

-- MySQL: 함수 인덱스 (8.0.13+)
CREATE INDEX idx_users_active
ON users ((deletedAt IS NULL), email);

-- 또는 가상 컬럼 활용
ALTER TABLE users ADD COLUMN is_active BOOLEAN
  GENERATED ALWAYS AS (deletedAt IS NULL) STORED;
CREATE INDEX idx_users_is_active ON users (is_active, email);
// TypeORM 엔티티에 인덱스 정의
@Entity('users')
@Index('idx_users_email_active', ['email'], {
  where: '"deletedAt" IS NULL',
})
@Index('idx_users_deleted', ['deletedAt'], {
  where: '"deletedAt" IS NOT NULL',  // 삭제 레코드 조회용
})
export class User {
  // ...
}

Hard Delete 스케줄링

삭제된 데이터를 영구히 보관하면 스토리지와 성능에 부담이 됩니다. 일정 기간 후 물리 삭제하는 스케줄러:

@Injectable()
export class DataRetentionService {
  constructor(private readonly dataSource: DataSource) {}

  // 90일 이전 삭제 데이터 영구 제거
  @Cron('0 3 * * *')  // 매일 새벽 3시
  async purgeOldData() {
    const cutoff = new Date();
    cutoff.setDate(cutoff.getDate() - 90);

    // 배치 삭제 (한 번에 1000건씩)
    let deleted: number;
    do {
      const result = await this.dataSource
        .createQueryBuilder()
        .delete()
        .from(User)
        .where('deletedAt IS NOT NULL')
        .andWhere('deletedAt < :cutoff', { cutoff })
        .limit(1000)
        .execute();

      deleted = result.affected || 0;
      this.logger.log(`Purged ${deleted} users`);
    } while (deleted === 1000);
  }
}

마무리

TypeORM Soft Delete는 @DeleteDateColumn 하나로 시작할 수 있지만, QueryBuilder 필터 누수, 유니크 제약 충돌, 관계 연쇄 삭제, 성능 저하 등 프로덕션에서 반드시 해결해야 할 문제가 있습니다. Partial Index로 성능을 보장하고, Hard Delete 스케줄러로 데이터를 정리하면 안정적인 Soft Delete 아키텍처를 유지할 수 있습니다.

관련 글로 TypeORM Subscriber 트랜잭션 훅TypeORM Index·Unique 최적화도 함께 참고하세요.

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