soft delete

rabbit jack·2025년 6월 24일

TypeORM

목록 보기
1/3

유저로 부터 컨텐츠 삭제 요청이 들어 와도,

실제 table에서 record를 delete하는것이 아닌,

1. 소개

그냥 상태만 수정하는 soft delete를 수행하는 경우가 많다.

이는 아래와 같은 이유들 때문이다.

  1. 법적 대응 및 감사 용도 : 경찰, 검찰, 법원등에서 수사자료로 요청 할 수 있음
  2. 관계 테이블 정리의 번거로움 : 다른 테이블과의 정합성을 유지하며 삭제하기가 쉽지않음
  3. 복구 기능 : 유저가 실수로 삭제한 데이터를 복원하기 위해
  4. 이력 보존 / 통계 분석 : 사용자 행동 분석에 이용
  5. 데이터 백업/스냅샷 설계 용이 : 삭제가 아닌 상태변경 이므로 설계가 단순해짐

그렇기에 유저에게 삭제기능을 제공해야하는 콘텐츠는 상태 관리를 위한 column을 별도로 두며,

주로 삭제된 날짜를 상태판별에 사용한다.

위 table의 경우 deleted at이 삭제된 날짜를 관리하며,

null여부에 따라 삭제 여부를 판별할 수 있다.

2. TypeORM

TypeORM 에서는 아래와 같은 요소들로 자체적으로 soft delete 기능을 제공한다.

  • @DeleteDataColumn
  • softRemove()
  • softDelete()
  • withDeleted

2-1. @DeleteDataColumn

TypeORM에서 엔티티의 프로퍼티에게 소프트 삭제 시각을 저장하는 전용 컬럼
softRemove() 또는 softDelete 가 호출되면, 이 컬럼에 현재 시간(new Date())가 자동으로 기록된다.

선언방법

import { DeleteDateColumn } from 'typeorm';

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

  @Column()
  content: string;

  @DeleteDateColumn({ name: 'deleted_at' })
  deletedAt?: Date;
}

옵션

속성의미
nameDB에 실제 저장될 컬럼 이름 (deleted_at)
nullable기본값은 true, 생략 가능
type기본은 timestamp (PostgreSQL, MySQL 등 DB에 따라 적절히 자동 설정됨)

2-2. softRemove() & softDelete()

DeleteDateColumn 와 연계되어 논리 삭제를 제공하는 함수

제일 큰 차이는 사용 방식에 있으며, 내부 동작 또한 약간 다르다.

softRemove()
엔티티를 통해 삭제한다

const review = await this.reviewRepo.findOne({ where: { id: 3 } });
await this.reviewRepo.softRemove(review);

내부적으로 아래 sql을 수행

UPDATE reviews SET deleted_at = CURRENT_TIMESTAMP WHERE id = 3;

엔티티를 미리 로드해야 하고, Entity Lifecycle hook(@BeforeRemove, @AfterRemove)이 작동한다.

softDelete()
조건을 바탕으로 삭제한다

await this.reviewRepo.softDelete(3);
// 또는
await this.reviewRepo.softDelete({ user_id: 1 });

그리고 내부적으로 아래 sql을 수행한다.

UPDATE reviews SET deleted_at = CURRENT_TIMESTAMP WHERE id = 3;

엔티티를 로드하지 않으므로 빠르고, lifecycle hook이 작동하지 않는다.

결론

SQL 수준에서 보면 둘다 동일한 UPDATE이다.

성능저하를 감안하여 Entity Lifecycle hook과 연계할지(softRemove)

빠른 성능을 최우선 할지(softDelete)

용도에 따라 적절히 선택하여 사용한다.

2-3. find 함수

TypeORM에서 흔히 쓰이는 조회 함수이다.

await this.reviewRepo.findOne({
  where: { id: 1 }
});

이외에도 findOne, findOneBy, findBy 등의 함수들이 있으며,

모두 기본적으로 soft delete를 지원한다.

즉, @DeleteDataColumn이 설정된 column에 null 이외의 값이 저장되어 있다면,

soft delete 상태인것으로 간주하여 검색에서 자동 제외한다.

2-4. withDeleted 옵션

find 계열의 함수로 soft deleted 상태의 데이터를 포함하여 검색하고 싶다면

withDeleted: true 옵션을 사용하면 된다.

2-5. restore

soft delete 상태의 엔티티를 다시 복구해준다.

await repo.restore(id);               // 가능
await repo.restore({ user_id: 1 });   // 가능 (TypeORM v0.3 이후)

3. 주의사항

TypeORM 사용시 QueryBuildersoft delete를 자동 적용하지 않음에 주의해야한다.

await repo.createQueryBuilder('review').getMany(); // ❗ deletedAt 무시됨

해결책

  • 삭제 제외: where('review.deleted_at IS NULL')
  • 삭제 포함: .withDeleted()

4. DB에서 해야할 일

soft delete는 애플리케이션 레벨에서의 관리 기법 이므로, DB에서 직접 설정할 필요는 없다.

다만, 운영 환겡여선 DB 차원에서 아래와 같은 작업들을 고려하는것을 추천한다.

1) 인덱스 추가

CREATE INDEX idx_reviews_deleted_at ON reviews(deleted_at);

대부분의 쿼리에서 WHERE deleted_at IS NULL 조건을 사용하므로,

soft delete 상태의 레코드를 빠르게 넘길 수 있음

2) 데이터 정리용 배치 쿼리

오래된 soft delete 데이터를 영구 삭제하기 위한 작업은 아래와 같이 진행한다.

DELETE FROM reviews
WHERE deleted_at IS NOT NULL
  AND deleted_at < NOW() - INTERVAL '30 days';

3) constraint 적용

soft deleted 상태의 데이터를 절대 수정하지 못하게 막는 trigger를 적용하면

보안, 무결성 관점에서 도움이 된다.

CREATE OR REPLACE FUNCTION prevent_update_on_soft_deleted()
RETURNS TRIGGER AS $$
BEGIN
  IF OLD.deleted_at IS NOT NULL THEN
    RAISE EXCEPTION 'Deleted records cannot be updated';
  END IF;
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER block_update_after_soft_delete
BEFORE UPDATE ON reviews
FOR EACH ROW
EXECUTE FUNCTION prevent_update_on_soft_deleted();

위 트리거가 적용되면 soft delete 상태의 recordUPDATE 할 수 없게된다.

이는 금융, 의료, 법률등의 특정 도메인에서는 매우 유용하게 사용된다.

0개의 댓글