본문 바로가기

Spring

[Spring] Data JPA Delete() Query가 실행되지 않는 문제

문제

CRUD 비즈니스 로직을 구현하는 중 특정 엔티티의 삭제를 위해 쿼리를 날렸지만 JPA의 DELETE 쿼리가 실행되지 않았다. SELECT 쿼리만 로그에 찍힐 뿐 분명 삭제쿼리를 실행했음에도 데이터베이스에는 데이터가 그대로 남아있고, 로그에도 DELETE의 'D'도 찾을 수 없었다.

 

현재 데이터베이스내에 테이블간 관계는 다음과 같다.

비즈니스 로직은 아래와 같다.

캠페인이 광고를 진행하면 일반적으로 캠페인 내 광고제품을 등록, 수정, 삭제를 할 수 있다. 

 

캠페인 수정 API 호출

  1. 수정을 하려는 캠페인 ID(PK)를 파라미터로 넣는다.
  2. RequestBody에 광고제품명의 수정(UPDATE), 삭제(DELETE) , 등록(INSERT)에 맞게 작성한다. (등록의 경우에는 AUTO INCREMENT 전략을 사용하기에 ID가 필요없다.)
  3. application/json으로 서버에 API를 요청한다.

Swagger API 문서

API 호출 로직은 아래와 같다.

@Transactional
public void update(Long campaignId, CampaignUpdateRecord campaignUpdateRecord) {
    List<ProductRecord> productRecords = campaignUpdateRecord.productRecords();

    Campaign campaign = campaignRepository.findById(campaignId)
            .orElseThrow(() -> new AppException(ErrorResponseRecord.of(HttpStatus.NOT_FOUND.value(), ErrorCode.NOT_FOUND_CAMPAIGN.getMessage())));

    for (ProductRecord productRecord : productRecords) {
        switch (productRecord.productStatus()) {
            case DELETE -> {
                Product product = productRepository.findById(productRecord.id())
                        .orElseThrow(() -> new AppException(ErrorResponseRecord.of(HttpStatus.NOT_FOUND.value(), ErrorCode.NOT_FOUND_CAMPAIGN_PRODUCT.getMessage())));
                productRepository.delete(product);
            }
            case EDIT -> {
                Product product = productRepository.findById(productRecord.id())
                        .orElseThrow(() -> new AppException(ErrorResponseRecord.of(HttpStatus.NOT_FOUND.value(), ErrorCode.NOT_FOUND_CAMPAIGN_PRODUCT.getMessage())));
                product.changeName(productRecord.name());
            }
            case ADD -> campaign.addCampaignProduct(Product.of(productRecord.name()));
        }
    }
}

실행 결과

CampaignRepository와 ProductRepository에서 데이터를 읽어오는 SELECT 쿼리는 정상적으로 실행된다.

Product를 등록하는 INSERT 쿼리 역시 정상적으로 실행된다.

ProductRepository에서 데이터를 읽어와 영속화된 객체를 변경감지로 UPDATE 쿼리 역시 정상적으로 실행된다.


DELETE 쿼리는 실행이 안 됐다.

디버그를 찍어서 findById(productId)를 통해 가져오는 Product 엔티티와 delete(product)를 할 때 가져오는 Product 엔티티 두 개가 동일한 인스턴지인지를 먼저 확인해 보았으나 둘 다 같은 Product@17084임을 알 수 있다.

findById(ID id)
delete(T entity)

Class<?> type = ProxyUtils.getUserClass(entity);
T existing = this.entityManager.find(type, this.entityInformation.getId(entity));

ProxyUtils.getUserClass(entity)를 사용하여 실제 엔티티의 클래스를 가져온다.
this.entityInformation.getId(entity)를 통해 엔티티의 ID를 가져와, entityManager.find를 호출하여 데이터베이스에서 해당 엔티티를 검색한다.

if (existing != null) {
    this.entityManager.remove(this.entityManager.contains(entity) ? entity : this.entityManager.merge(entity));
}


검색된 엔티티가 null이 아닌 경우, 즉 데이터베이스에 존재하는 경우 삭제 작업을 수행한다.
this.entityManager.contains(entity)로 현재 영속성 컨텍스트에 존재하는지 확인하고, 존재한다면 바로 삭제하고 존재하지 않는다면 merge(entity)를 통해 엔티티를 병합한 후 삭제한다.

 

또한, 메서드가 @Transactional로 선언되어 있어, 이 안에서의 데이터베이스 작업은 하나의 트랜잭션으로 묶인다.

  • 삭제할 엔티티가 null인지 확인
  • 신규 엔티티가 아닌 경우만 삭제
  • 데이터베이스에서 해당 엔티티를 찾아서 삭제

원인은 Cascade 설정으로 인한 관계 제약 조건

문제의 원인은 campaignRepository.findById(campaignId) 메서드로 보여진다. 이 메서드는 PK(ID)를 통해 Campaign 엔티티를 조회하는데, 이때 일대다 관계로 연결된 Product 엔티티가 CascadeType.PERSIST를 통해 영속화된다. 즉, 이 삭제 작업이 수행되는 트랜잭션 내에서 Product 엔티티는 영속 상태가 유지되고 있는 것이다.

Product 엔티티를 삭제하기 위해 delete() 메서드를 사용할 때, 트랜잭션이 끝나는 맨 마지막에 EntityManager의 flush()가 실행되며 자식 엔티티인 Product를 삭제하는 쿼리가 실행된다. 하지만 이 과정에서 Campaign 엔티티는 여전히 영속 상태의 Product 엔티티를 참조하고 있기 때문에, 삭제 쿼리가 실행되지 않는 것이었다.

따라서, 부모 엔티티를 삭제하기 전에 자식 엔티티와의 관계를 먼저 끊어줘야 한다. 데이터베이스 레벨과 애플리케이션 레벨에서 JPA가 연관 관계가 없다고 생각할 수 있지만, ORM의 특성상 깊은 상관관계가 작용하는 것이다.

 

다시 생각하면, Campaign 엔티티에서 Product와의 연관관계를 끊어준다면, CascadeType=REMOVE, orphanRemoval=true로 인해 고아객체가 된 Product는 delete()를 처리하지 않아도 자동으로 삭제될 것이다.

 

실험해보자.

@Entity
@RequiredArgsConstructor
public class Campaign {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "campaign_id")
    private Long id;

    private String title;

    private String brandName;

    @OneToMany(mappedBy = "campaign", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<Product> products = new ArrayList<>();
    
    ...
    
    // 자식 엔티티와 연관관계 끊기
    public void remove(Product product) {
        this.products.remove(product);
    }
}

 

그 다음, ProductRepository.delete(product)가 아닌, campaign.remove(product)만 해보자.

case DELETE -> {
    Product product = productRepository.findById(productRecord.id())
            .orElseThrow(() -> new AppException(ErrorResponseRecord.of(HttpStatus.NOT_FOUND.value(), ErrorCode.NOT_FOUND_CAMPAIGN_PRODUCT.getMessage())));
    campaign.remove(product); // productRepository.delete(product)는 생략
}

드디어 정상적으로 DELETE 쿼리가 실행되었다.


JPQL로 실행한다면 ?

직접 SQL 실행: JPQL DELETE 쿼리는 JPA를 통해 데이터베이스에 직접 실행되며, 영속성 컨텍스트의 상태와 관계없이 동작할 것이다.

public interface ProductRepository extends JpaRepository<Product, Long> {

    @Modifying
    @Query("DELETE FROM Product p WHERE p.id = :productId")
    void deleteByProductId(@Param("productId") Long productId);
}


영속성 컨텍스트 무시: JPQL DELETE 쿼리는 특정 엔티티 인스턴스에 의존하지 않기 때문에, 영속성 컨텍스트에 있는 엔티티가 아닌 경우에도 삭제가 가능하다. 삭제할 엔티티가 영속 상태이든 아니든 상관없이, 조건에 맞는 레코드를 데이터베이스에서 직접 삭제한다.

Cascade 설정 무시: JPQL DELETE 쿼리는 엔티티 간의 관계(CascadeType 등)를 고려하지 않는다.

Querydsl로 실행한다면 ?

@Repository
@RequiredArgsConstructor
public class ProductRepositoryImpl implements ProductRepositoryCustom {

    private final JPAQueryFactory queryFactory;

    @Override
    public void deleteByProductId(Long productId) {
        queryFactory
                .delete(QProduct.product)
                .where(QProduct.product.id.eq(productId))
                .execute();
    }
}

 

QueryDSL을 사용한 수정이나 삭제 작업에서도 JPQL과 마찬가지로 데이터베이스에 쿼리를 먼저 날리고, 영속성 컨텍스트에 반영되지 않는다.