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 최적화도 함께 참고하세요.