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