Kuma's Curious Paradise
Soft Delete 구현할 때 고려해야 할 사항 본문
문제 상황
특정 상품이 삭제되어 더 이상 노출되지 않더라도, 거래 내역과 같은 로그에는 남아 있어야 하므로 SoftDelete를 적용하여 특정 상황에서만 노출시키려 한다.
IsDeleted vs DeletedAt
크게 세 가지 방법의 있다.
- isDeleted 컬럼 추가
- true / false로 관리
- 간편한 구현
- deletedAt 타임스탬프 컬럼 추가
- 삭제 시점 기록 가능. 데이터 히스토리 관리에 유리.
- 삭제 데이터를 별도의 테이블에 저장하여 관리
- 원본 테이블에는 삭제되지 않은 데이터만 남게 되니, 원본 테이블 크기 유지 가능.
- 데이터 이동 작업이 추가적으로 필요.
서비스가 작은 단계이니, 간단하게 구현하고 싶어서 1, 2번 중에서 고민을 시작했다.
<팀원 1의 의견>
<팀원 2의 의견>
이런 대화를 나눴더랜다… 이후 한참의 대화가 더 오가다가
결국 구현이 간단하고 null 값이 생기지 않는 isDeleted 컬럼을 추가하기로 했다. 비즈니스상 삭제 시점을 저장해야 할 필요를 아직 발견하지 못했기 때문이다. updatedAt에 기록될 마지막 시간을 삭제 시점으로 두는 것으로 합의하였다.
SoftDelete 적용 방법
SoftDelete를 적용할 때는 여러 가지 방법이 있다.
- @SoftDelete 어노테이션을 이용한 적용
- @SQLDelete + @Where 어노테이션을 이용한 적용과 동시에 강제적인 조건절 적용
- @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에 대해 수행될 예정이다.
- @Where 어노테이션을 이용하여 모든 조회 쿼리에 deleted = false 를 붙인다.
- 모든 조회하는 쿼리를 커스텀하여 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();
}
'스프링' 카테고리의 다른 글
토스 결제 코드 : 프론트가 없으면 만들어서 확인하기 (1) | 2024.09.05 |
---|---|
스프링부트 2차캐시 적용하기 (0) | 2024.08.25 |
테스트 코드 7 - 일관적인 테스트 코드를 위한 설정들 (0) | 2024.08.05 |
테스트 코드 6 - ItemCartRepositoryTest: TransientPropertyValueException (0) | 2024.07.16 |
테스트 코드 5 - CascadeType.PERSIST와MERGE의 차이 / DataIntegrityException (0) | 2024.07.10 |