지워도 남기는 삭제

Soft Delete, 왜 쓰고 어떻게 구현하고 어떻게 정리하는가
2026년 3월 30일

🗑️ Soft Delete 배우고 삭제 코드 다시 짜기

NestJS 스터디에서는 인프런 강의를 통해 기본적인 CRUD를 학습하고 있다. 이번주에는 board CRUD 강의를 듣게 되었는데, 해당 강의에서는 delete를 구현할 때 delete와 remove에 차이에 대해서만 설명하고 soft delete와 같은 개념을 설명해주지는 않았다.

바로 삭제하지 않는 것이 soft delete라고는 알고있었지만, 구체적으로 어떻게 동작하는 것인지에 대해서는 알아본 적이 없어 이번 기회에 정리해보고자 한다.


Hard Delete의 문제

DELETE문은 되돌릴 수 없다.

개인 프로젝트라면 크게 상관없을 수 있다. 하지만 실제 상황에서는 고려해야할 부분이 많다.

상황Hard Delete일 때
"어제 삭제한 게시글 복원해주세요"불가능
삭제된 데이터 기반 통계/분석불가능
결제·주문 등 법적 보관 의무위반
삭제 후 원인 추적불가능

그래서 실무에서는 실제로 지우지 않고 "삭제됨" 표시만 남기는 패턴을 쓴다. 이게 Soft Delete다.


Soft Delete란?

테이블에 deletedAt 컬럼을 하나 추가하고, 삭제 요청이 오면 DELETE 대신 이 컬럼에 날짜를 채운다.

조회할 때는 deleted_at IS NULL인 행만 가져온다. 삭제된 것처럼 보이지만 데이터는 테이블에 그대로 남는다.


TypeORM의 삭제 메서드 정리

TypeORM은 삭제 관련 메서드를 네 가지 제공한다.

Hard Delete: delete vs remove

둘 다 데이터를 물리적으로 삭제한다. 차이는 입력 방식이다.

delete(id)remove(entity)
입력ID나 조건엔티티 객체
엔티티 로드X (바로 SQL)O (먼저 조회 필요)
subscriber/listener안 탐

delete는 가볍고 빠르다. remove는 쿼리가 한 번 더 나가지만, subscriber나 listener가 트리거된다.

*listener : 엔티티 클래스 안에 다는 데코레이터 메서드 *subscriber : 엔티티 바깥에서 전역/공통으로 이벤트를 받는 클래스

Soft Delete: softDelete vs softRemove

구조는 Hard Delete와 같다. soft가 붙으면 DELETE 대신 UPDATE SET deletedAt = NOW()를 실행한다.

softDelete(id)softRemove(entity)
입력ID나 조건엔티티 객체
엔티티 로드X (바로 SQL)O (먼저 조회 필요)
subscriber/listener안 탐

대부분의 경우 softDelete(id)로 충분하다. "삭제될 때 알림 보내기" 같은 로직을 subscriber에 걸어뒀을 때 softRemove가 필요하다.


TypeORM으로 구현하기

NestJS + TypeORM 기반 게시판에 Soft Delete를 적용했다.

1. 공통 시간 엔티티 분리

createdAt, updatedAt, deletedAt은 거의 모든 테이블에서 쓴다. 추상 클래스로 분리하면 매 엔티티마다 반복하지 않아도 된다.

@DeleteDateColumn()을 선언하면 TypeORM이 두 가지를 자동으로 처리한다.

  • 조회 시: 모든 find() 계열 메서드에 WHERE deletedAt IS NULL 자동 추가
  • 삭제 시: softDelete() 호출 시 DELETE 대신 UPDATE SET deletedAt = NOW() 실행

2. 엔티티에서 상속받기

BaseTimeEntity를 상속하는 것만으로 deletedAt 컬럼이 추가된다. Board 엔티티 자체에는 삭제 관련 코드가 없다.

3. Repository에서 softDelete 호출

this.softDelete(id) 한 줄로 끝난다. 실제 실행 SQL은 이렇다.

DELETE가 아니라 UPDATE다. 이후 find(), findOneBy() 등으로 조회하면 이 행은 자동으로 걸러진다.

softDelete 실행 SQL
softDelete() 호출 시 DELETE 대신 UPDATE가 실행된다

쌓이는 데이터 문제

Soft Delete에는 트레이드오프가 있다. 데이터가 지워지지 않으니 테이블이 계속 커진다.

지금은 스터디용 게시판이라 괜찮지만, 하루에 수만 건씩 삭제가 일어나는 서비스라면 얘기가 달라진다. 삭제된 행이 수백만 건 쌓이면 조회 성능이 떨어지고 스토리지 비용도 늘어난다.

실무에서는 보관 기한을 정해두고, 기한이 지난 데이터는 물리 삭제한다.


크론잡으로 배치 정리하기

매일 새벽 3시에 30일 지난 Soft Delete 데이터를 물리 삭제하는 크론잡을 구현했다.

Repository 대신 DataSource를 쓰는 이유

@DeleteDateColumn()이 선언된 엔티티는 Repository를 통한 모든 조회에 WHERE deletedAt IS NULL이 자동으로 붙는다. Repository로는 삭제된 데이터를 찾을 수 없다.

삭제된 행을 찾아 물리 삭제하려면 이 자동 필터를 우회해야 한다. DataSource.createQueryBuilder()로 직접 쿼리를 만들면 TypeORM의 자동 필터가 적용되지 않는다.

이 크론잡의 한계

@nestjs/schedule의 크론잡은 서버 프로세스 안에서 도는 인메모리 스케줄러다. 서버가 꺼지면 크론도 같이 멈추고, 보상 실행은 없다. 서버가 여러 대로 스케일 아웃되면 각 서버에서 크론이 동시에 실행되는 중복 문제도 생긴다.

일단 나의 경우는 처음 이 개념에 대해 배우는 것이기 때문에 @Cron으로만 먼저 구현해보았다. 운영 환경이라면 AWS EventBridge 같은 외부 스케줄러로 트리거를 분리하거나, BullMQ 같은 Redis 기반 작업 큐를 쓰는 것도 고려해봐야할 것 같다.


구현하면서 알게 된 것들

유니크 제약조건 충돌

이메일처럼 유니크 컬럼이 있는 테이블에 Soft Delete를 쓰면 문제가 생긴다.

Soft Delete된 행이 여전히 테이블에 남아있기 때문이다. 해결 방법은 두 가지다.

  • 유니크 인덱스에 WHERE deletedAt IS NULL 조건 추가 (Partial Index)
  • 삭제 시 이메일을 user@email.com_deleted_1711670400 형태로 변경

연관 관계

게시글을 Soft Delete했는데 댓글은 살아있으면? 외래 키 관계가 있는 테이블은 함께 Soft Delete하거나, 조회 시 부모의 삭제 여부를 같이 확인해야 한다.

인덱스 전략

삭제된 데이터가 많아지면 deletedAt IS NULL 조건이 매번 전체 테이블을 스캔하게 될 수 있다. deletedAt 컬럼에 인덱스를 걸거나 Partial Index를 활용하면 성능을 유지할 수 있다.


마치며

삭제는 단순하게 구현할 수 있지만, 세부 요구사항들을 다 반영하기 위해서 삭제 하나에도 생각보다 많은 고려사항이 있다는 것을 깨닫게 되었다. 현재는 단순하게 인메모리 스케줄러로 구현하게 되었지만, BullMQ 같은 Redis 기반 작업 큐도 직접 구현해보며 다양한 경험을 쌓아볼 예정이다.