Notice
Recent Posts
Recent Comments
Link
«   2025/05   »
1 2 3
4 5 6 7 8 9 10
11 12 13 14 15 16 17
18 19 20 21 22 23 24
25 26 27 28 29 30 31
Tags
more
Archives
Today
Total
관리 메뉴

Kuma's Curious Paradise

Soft Delete 구현할 때 고려해야 할 사항 본문

스프링

Soft Delete 구현할 때 고려해야 할 사항

쿠마냥 2024. 8. 10. 21:11

문제 상황

특정 상품이 삭제되어 더 이상 노출되지 않더라도, 거래 내역과 같은 로그에는 남아 있어야 하므로 SoftDelete를 적용하여 특정 상황에서만 노출시키려 한다.

IsDeleted vs DeletedAt

크게 세 가지 방법의 있다.

  1. isDeleted 컬럼 추가
    • true / false로 관리
    • 간편한 구현
  2. deletedAt 타임스탬프 컬럼 추가
    • 삭제 시점 기록 가능. 데이터 히스토리 관리에 유리.
  3. 삭제 데이터를 별도의 테이블에 저장하여 관리
    • 원본 테이블에는 삭제되지 않은 데이터만 남게 되니, 원본 테이블 크기 유지 가능.
    • 데이터 이동 작업이 추가적으로 필요.

 

서비스가 작은 단계이니, 간단하게 구현하고 싶어서 1, 2번 중에서 고민을 시작했다.

 

<팀원 1의 의견>

 

<팀원 2의 의견>

 

이런 대화를 나눴더랜다… 이후 한참의 대화가 더 오가다가 

결국 구현이 간단하고 null 값이 생기지 않는 isDeleted 컬럼을 추가하기로 했다. 비즈니스상 삭제 시점을 저장해야 할 필요를 아직 발견하지 못했기 때문이다. updatedAt에 기록될 마지막 시간을 삭제 시점으로 두는 것으로 합의하였다. 

 

SoftDelete 적용 방법

SoftDelete를 적용할 때는 여러 가지 방법이 있다.

  1. @SoftDelete 어노테이션을 이용한 적용
  2. @SQLDelete + @Where 어노테이션을 이용한 적용과 동시에 강제적인 조건절 적용
  3. @SQLDelete 어노테이션만 적용

@SoftDelete:

  • 하이버네이트 6.4 버전에 처음 제공된 어노테이션.
  • 일반적으로 클래스나 필드, 메서드에 걸 것으로 예상된다.
  • 필드나 메서드에 걸 때는, jakarta의 elementCollection 타입이나, ManyToMany 테이블에만 걸 수 있다.
  • 현재 시범 도입 단계라 아직 사용하기 힘들 것 같다. (자식 테이블에 lazy로딩 불가, 지정된 필드 외에 다른 필드로 변경 시 오버헤드 등)

@SQLDelete:

  • 삭제가 일어날 때 delete쿼리 대신 실행시켜줄 ‘커스텀’ sql 구문.
  • ‘UPDATE book SET isDeleted = true WHERE id = 1;’와 같이 작성한다.
  • @where 어노테이션과 함께 쓰는 경우가 많다.
  • EntityManager.remove(entity) 를 날리면 지정된 update쿼리가 대신 수행되는데, 영속성 컨텍스트는 해당 엔티티를 ‘삭제된 것’으로 표시하므로, 삭제 시점으로부터 엔티티는 더 이상 영속성 컨텍스트에 존재하지 않는다. 따라서 EntityManager.find 를 사용해 해당 엔티티를 조회할 수 없다.

@Where : 

  • 디폴트로 적용할 where 구문.
  • @Where(clause = “isDeleted = false”) 로 걸어 두어 삭제된 것을 제외하고 조회되도록 한다.
  • 이 경우 해당 엔티티 관련 select를 만들 때마다 무조건 where isDeleted = 'false'가 적용된다. 만약 삭제된 엔티티를 조회하고 싶으면 어떡하지? 이 경우 @SQLDelete만 적용하고, isDeleted = true / false는 각 상황에 맞게 적용하는 방법도 있을 테다. 
  • 하지만 애초에 주문내역 테이블에 상품 이름과 상품 옵션, 상품 이미지 데이터를 모두 저장하면 어떨까? 그럼 itemOption 테이블에 가서 isDeleted = true인 객체를 가져오지 않아도 주문내역 테이블에서 해결이 가능하다. 정규화에 위배되겠지만, 주문 내역은 어느 정도 로그성 데이터기도 하고... 
  • 따라서 문제는 아래와 같다. 

 

SoftDelete 된 레코드에 대한 조회를 어떻게 진행할 것인가?

 

프로젝트 내에서 softDelete 된 레코드를 조회하는 행위는 Item, ItemOption에 대해 수행될 예정이다.

  1. @Where 어노테이션을 이용하여 모든 조회 쿼리에 deleted = false 를 붙인다.
  2. 모든 조회하는 쿼리를 커스텀하여 where deleted = false를 명시적으로 붙인다.
  • 1 번을 선택할 경우, 각 엔티티의 삭제 된 레코드를 비즈니스 로직 내에서 조회 할 방법이 없으므로 조회를 하기 위해 삭제 된 쿼리를 다른 DB에 저장한다던가 하는 다른 로직이 필요하겠다.
  • 2 번을 선택할 경우 좀 더 유연하게 적용 가능하겠지만, 모든 조회 쿼리를 커스텀해야 한다.
  • @SQLDelete(sql = "update item set deleted = true where id = ?") 를 적용하여 삭제 쿼리를 실행시킨 상태

→ 각각에 대한 삭제 쿼리가 레코드 당 하나씩 날아가게 된다. 따라서 한 개의 레코드를 삭제할 때는 괜찮겠지만 여러 개의 쿼리를 삭제할 때는 다른 방법을 생각해야 한다.

 

현재는 단건 삭제이므로 쿼리 한두개 쯤 더 나가는게 성능상 큰 문제가 되지 않기 때문에 해당 어노테이션을 사용한 SoftDelete를 적용하지만, 추후에 특정 브랜드가 계약 만료가 되어 모든 아이템 정보를 기록으로만 남겨야 할 때는 배치를 이용하거나 stored procedure를 이용하여 삭제를 진행하는 방법을 생각해 봐야 한다. 

 

구현할 때 알고 있으면 좋을 사항

1. 부모 객체가 soft delete될 때 자식 객체로 전이시키는 방법

1-1. @SqlDelete + @Where을 사용한다면, 부모와 자식 테이블 모두에 @SqlDelete + @Where를 붙인다.

import org.hibernate.annotations.SQLDelete; 
import org.hibernate.annotations.Where; 

@Entity 
@SQLDelete(sql = "UPDATE parent_entity SET is_deleted = true WHERE id = ?") 
@Where(clause = "is_deleted = false") 
public class ParentEntity { 
	// ... 
} 

@Entity 
@SQLDelete(sql = "UPDATE child_entity SET is_deleted = true WHERE id = ?") 
@Where(clause = "is_deleted = false") 
public class ChildEntity { 
	// ... 
}

 

1-2. @SqlDelete + @Where 사용하지 않는다면, @PreRemove를 메서드에 달고, delete가 수행될 때 일어날 일을 세세하게 적는 방법도 있다.

 

2. deleteAllInBatch 해야 할 때, soft delete를 적용시키는 방법

1-1. stored procedure

db에 저장된(stored) 절차(procedure). db에 stored procedure를 아래와 같이 작성한 뒤, JPA 레포지토리에서 이 stored procedure를 호출하여 soft delete를 수행한다.

CREATE PROCEDURE soft_delete_books()
BEGIN
    UPDATE book SET is_deleted = true WHERE is_deleted = false;
END;
@Repository
public interface BookRepository extends JpaRepository<Book, Long> {

    @Procedure("soft_delete_books")
    void softDeleteBooks();
}

 

<장점>

  • db에 대한 직접적인 접근이므로, 성능이 빠르고 코드도 간결화된다.
  • 대량 데이터 처리에 유리하다.

<단점>

  • db에 종속적이며, 유지보수가 어려울 수 있다.
  • 테스트와 디버깅할 때 어려움을 겪을 수 있다.

 

1-2. QueryDSL

QueryDSL은 쿼리 작성을 도와주는 도구. 엔티티에 대한 Q클래스 생성, 쿼리 작성 등 적용해야 하는 것이 많다. 타입 안전한 쿼리를 제공하기 때문에, 컴파일 시점에 오류를 잡을 수 있다.

@Service
public class BookService {

    @Autowired
    private EntityManager entityManager;

    public void deleteAllInBatchSoftDelete() {
        QBook book = QBook.book;
        new JPAUpdateClause(entityManager, book)
            .set(book.isDeleted, true)
            .where(book.isDeleted.eq(false))
            .execute();
    }
}

 

1-3. custom repository method

JPA 레포지토리를 확장해서, 커스텀 메서드를 구현하는 방법 레포지토리 클래스가 JPA 레포지토리 인터페이스와, 커스텀 레포지토리 인터페이스를 모두 implement하도록 만든다.

custom repository 를 만들면서 거기에 query dsl을 적용할 수도 있다.

public interface BookRepositoryCustom {
    void softDeleteAllInBatch();
}

public class BookRepositoryImpl implements BookRepositoryCustom {

    @PersistenceContext
    private EntityManager entityManager;

    @Override
    public void softDeleteAllInBatch() {
        String jpql = "UPDATE Book b SET b.isDeleted = true WHERE b.isDeleted = false";
        entityManager.createQuery(jpql).executeUpdate();
    }
}

 

1-4. @Query

@Repository
public interface BookRepository extends JpaRepository<Book, Long> {

    @Modifying
    @Transactional
    @Query("UPDATE Book b SET b.isDeleted = true WHERE b.isDeleted = false")
    void softDeleteAllInBatch();
}