• 카테고리

    질문 & 답변
  • 세부 분야

    백엔드

  • 해결 여부

    해결됨

@SQLRestriction으로 논리적 삭제 필드에 대한 tearDown에 대해 궁금합니다.

24.03.04 23:31 작성 24.03.04 23:33 수정 조회수 420

0

안녕하세요.

강의를 들은 후 테스트 코드 작성을 연습하고 있습니다.

엔티티에서 Soft delete를 위한 필드(isDeleted)가 정의되어 있습니다.

Soft delete가 된 데이터는 조회할 필요가 없기 때문에 엔티티에 @SQLRestriction("is_deleted = false")를 정의했습니다.

테스트 코드에서 해당 어노테이션 때문에 tearDown() 메서드 동작시 문제가 발생했습니다.

.deleteAllInBatch()가 실행될 때, where 조건이 포함되기 때문에 데이터가 삭제되지 않습니다.

 

상품 목록 조회에 대한 테스트 코드를 작성할 때, soft delete 된 데이터는 빠지고 정상적인 데이터만 조회되는지 보려고 isDeleted = true 값을 준 테스트 데이터도 생성해서 테스트 코드를 작성했습니다. 해당 경우에는 어떤식으로 테스트 코드를 작성해야 할까요?

 

  1. isDeleted = true 테스트 데이터를 만들지 않는다.

  2. @SQLRestriction("is_deleted = false") 를 사용하지 않고, 쿼리 조회 시 IsDeletedTrue 조건을 주도록 한다.

위 방법이 아닌 Best Practice가 있는지 궁금합니다.

 

아래는 테스트 코드 예시 입니다.

  • @SQLRestriction("is_deleted = false") 적용된 엔티티 예시입니다.

@Entity
@SQLRestriction("is_deleted = false")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Product extends BaseDateTimeEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "product_id")
    private Long id;

    @Column(length = 50)
    private String name;

    private Boolean isDeleted;

    @OneToMany(mappedBy = "product", cascade = CascadeType.ALL)
    private List<ProductOption> productOptions = new ArrayList<>();

   // ... 중략
}
@Entity
@SQLRestriction("is_deleted = false")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ProductOption extends BaseDateTimeEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "product_option_id")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "product_id")
    private Product product;

    @Column(length = 30)
    private String name;

    private Boolean isDeleted;

    // ... 중략
}

 

  • isDeleted = true 테스트 데이터를 포함한 테스트 코드 예시입니다.

class ProductServiceTest extends ServiceTestSupport {

    @Autowired private ProductRepository productRepository;

    @AfterEach
    void tearDown() {
        productOptionRepository.deleteAllInBatch();
        productRepository.deleteAllInBatch();
    }

    @Test
    @DisplayName("상품 목록을 조회한다.")
    void getSellingProducts() throws Exception {
        // given
        ProductOption productOption1 = createProductOption("옵션1", false);
        ProductOption productOption2 = createProductOption("옵션2", false);
        ProductOption productOption3 = createProductOption("옵션3", false);
        ProductOption productOption4 = createProductOption("옵션4", true);  // isDeleted = true
        ProductOption productOption5 = createProductOption("옵션5", false);
        ProductOption productOption6 = createProductOption("옵션6", true);  // isDeleted = true

        Product product1 = createProduct("상품1", false, List.of(productOption1, productOption2));
        Product product2 = createProduct("상품2", false, List.of(productOption3, productOption4));
        Product product3 = createProduct("상품3", true, List.of(productOption5, productOption6)); // isDeleted = true
        productRepository.saveAll(List.of(product1, product2, product3));

        // ... 중략

        // when
        List<product> products = productService.getSellingProducts();

       // then
       // ... 중략
    }

    // ... 중략
}

 

  • deleteAllInBatch() 메서드가 실행된 후 로그 입니다.

Hibernate: 
    delete      
    from
        product_option      
    where
        (
            product_option.is_deleted = false         
        )

Hibernate: 
    delete 
    from
        product 
    where
        (
            product.is_deleted = false
        )

org.springframework.dao.DataIntegrityViolationException: JDBC exception executing SQL [delete from product where (product.is_deleted = false)] [Referential integrity constraint violation: "FKN4HMM6EX1VGN60C6UIQTE400F: PUBLIC.PRODUCT_OPTION FOREIGN KEY(PRODUCT_ID) REFERENCES PUBLIC.PRODUCT(PRODUCT_ID) (CAST(3 AS BIGINT))"; SQL statement:
delete from product where (product.is_deleted = false) [23503-224]] [n/a]; SQL [n/a]; constraint ["FKN4HMM6EX1VGN60C6UIQTE400F: PUBLIC.PRODUCT_OPTION FOREIGN KEY(PRODUCT_ID) REFERENCES PUBLIC.PRODUCT(PRODUCT_ID) (CAST(3 AS BIGINT))"; SQL statement:
delete from product where (product.is_deleted = false) [23503-224]]
 

 

감사합니다.

답변 2

·

답변을 작성해보세요.

1

안녕하세요, Anonymous 님!

 

1. isDeleted = true 테스트 데이터를 만들지 않는다.

2. @SQLRestriction("is_deleted = false") 를 사용하지 않고, 쿼리 조회 시 IsDeletedTrue 조건을 주도록 한다.

먼저, 사용하신 SQLRestriction과 같이 애플리케이션 단에서 데이터를 조작할 수 없는 제약을 걸어놓은 설계라면, 1번 방법이 자연스러운 접근이라고 생각됩니다.
테스트 코드는 어디까지나 프로덕션 코드와 비즈니스 요구사항에 기반한 내용과 맥락 안에서 작성되어야 하기 때문에, SQLRestriction을 건 맥락 안에서는 soft delete 된 데이터는 빠지고 정상적인 데이터만 조회되는지 보려고 작성하신 테스트가 어색하다는 생각이 드네요.
(애플리케이션 입장에서는 soft-delete된 데이터가 존재하는지 여부조차 알 수 없는 형태니까요)

그와 별개로, 보통의 저라면 2번 방법을 사용하는데요. ㅎㅎ
물론 isDeletedTrue 조건을 누락할 수 있는 여지가 있어 쿼리를 작성할 때마다 항상 팀원 모두가 유의해야 한다는 단점이 있긴 하지만, soft-delete 된 데이터여도 상황에 따라 애플리케이션에서 충분히 다룰 수 있는 여지가 있기 때문에 제약을 두지 않고 설계하는 편입니다.

도움이 되셨기를 바랍니다.
감사합니다. 🙂

Anonymous님의 프로필

Anonymous

질문자

2024.03.12

감사합니다!

0

인프런 AI 인턴님의 프로필

인프런 AI 인턴

2024.03.06

안녕하세요, 인프런 AI 인턴입니다.

Soft delete에 대한 테스트 이슈로 ‘tearDown()’ 메서드에서 문제가 생겼다고 하셨군요. 이때 주로 사용하는 전략 중 하나는 테스트 시에 ‘isDeleted’ 필드에 대한 제약을 무시하는 설정을 임시로 적용하는 것입니다. 안타깝게도 제공해주신 정보에서 적절한 설정 방법에 대한 내용은 나와 있지 않습니다.

하지만 일반적인 Best Practice를 언급하자면, 테스트 데이터에 대한 정리(clean up)를 위해 @SQLRestriction을 무시하고 모든 데이터를 강제로 삭제하는 방법을 사용할 수 있습니다. 이를 위해 직접 SQL 혹은 JPQL을 사용하여 삭제 쿼리를 실행하는 방법을 사용할 수도 있고, 혹은 EntityManager 등을 사용하여 isDeleted 상태에 관계없이 모든 엔티티를 조회하여 하나씩 삭제하는 방법을 사용할 수 있습니다.

제시해주신 두 가지 해결 방법도 유효한 접근 방식입니다만, 테스트 환경에서는 보통 별도의 데이터 소스(인메모리 데이터베이스 등)를 사용하여 테스트의 독립성을 확보하는 것이 일반적입니다. 이렇게 하면 프로덕션 환경의 데이터에 영향을 주지 않으면서도 실제 데이터베이스와 유사한 환경을 테스트 할 수 있습니다.

또한, 일부 테스트 프레임워크에서는 테스트가 끝날 때 상태를 청소하는 다양한 방법을 제공하므로, 이러한 기능을 사용하는 것도 좋은 방법일 수 있습니다. 예를 들어, Spring Boot의 @DataJpaTest는 기본적으로 각 테스트가 끝날 때 트랜잭션을 롤백하는 동작을 함으로써 데이터베이스 상태를 초기 상태로 되돌리는 데 도움을 줍니다.

결국, 베스트 프랙티스는 테스트 환경과 프로덕션 환경의 분리, 테스트의 독립성 확보, 그리고 테스트 후 초기 상태로 복원하는 가이드라인을 따르는 것입니다.

어떤 방법을 선택하시든, 테스트 환경을 충분히 고려하여 한 프로젝트 내에서 일관된 전략으로 적용하는 것을 권장합니다. 도움이 되셨기를 바라며, 추가적인 질문이 있으시면 언제든 문의해 주세요.