Development/썬카(SunCar) - 개인 프로젝트

[썬카/백엔드] 커스텀 쿼리를 이용한 판매 차량 삭제 기능 구현 - Soft Delete 방식의 간접 CASCADE

양선규 2025. 4. 18. 18:08
728x90
반응형

개선된 쿼리 일부

 

 

일반적인 Hard Delete 방식을 사용했다면 외래키 관계를 이용한 엔티티와 DB 레벨에서의 cascade를 이용할 수 있었을 테지만, 중고차 거래 플랫폼 특성상 데이터의 신뢰도가 중요했기 때문에 Soft Delete 방식을 채용했다. 그로 인해 연쇄 삭제 기능은 서비스 레벨에서의 delete() 메서드 조합이나 QueryDsl 커스텀 쿼리를 통해 직접 구현해야 했다.

 

처음엔, 모든 엔티티가 상속받는 BaseEntity에 존재하는 softDelete() 메서드를 Facade 서비스 클래스에서 전부 호출해서 삭제할까 생각했다. 하지만, 그러면 제공되는 인자는 CarListing.id(판매 차량 id) 한개 뿐일텐데 이걸로 repository 조회해서 연관된 엔티티 id를 얻고 하는 일련의 작업을 서비스 메서드 하나에 다 작성해야 했다. 뭐 그렇게 해도 코드가 크게 더러울 거 같지도 않지만 영 끌리는 방법은 아니었다.

 

그래서 차라리 Facade 서비스는 좀 더 복잡한 작업을 할 때 사용하도록 두고, 차량 판매등록 정보의 시작 엔티티인 CarListingService에서 QueryDsl로 작성된 커스텀 쿼리 메서드를 호출해 처리하기로 했다. 그렇게 하면 삭제 로직은 쿼리 메서드 안에 전부 추상화되어 있게 되고, 서비스 계층은 깔끔하게 유지된다. 

 

차량 삭제 커스텀 쿼리 ( 완성 )

    /**
     * 판매중인 차량을 soft delete 방식으로 삭제합니다.
     * CarListing 엔티티를 시작으로, 연관된 모든 엔티티를 함께 삭제합니다.
     */
    @Override
    @Transactional
    public void softDeleteCarListingWithRelatedEntities(Long listingId) {

        QCarListing carListing = QCarListing.carListing;
        QCarListingImage carListingImage = QCarListingImage.carListingImage;
        QCar car = QCar.car;
        QCarAccident carAccident = QCarAccident.carAccident;
        QCarAccidentRepair carAccidentRepair = QCarAccidentRepair.carAccidentRepair;
        QCarMileage carMileage = QCarMileage.carMileage;
        QCarOption carOption = QCarOption.carOption;
        QCarOwnershipChange carOwnershipChange = QCarOwnershipChange.carOwnershipChange;
        QCarUsage carUsage = QCarUsage.carUsage;

        /** 0. car.id 가져오기 */
        Long carId = getQueryFactory()
                .select(carListing.car.id)
                .from(carListing)
                .where(carListing.id.eq(listingId)
                        .and(carListing.isDeleted.eq(false)))
                .fetchOne();

        if (carId == null) {
            return;
        }

        /** 1. CarListingImage 삭제 */
        getQueryFactory()
                .update(carListingImage)
                .set(carListingImage.isDeleted, true)
                .where(carListingImage.carListing.id.eq(listingId)
                        .and(carListingImage.isDeleted.eq(false)))
                .execute();

        /** 2. CarAccidentRepair 삭제 */
        getQueryFactory()
                .update(carAccidentRepair)
                .set(carAccidentRepair.isDeleted, true)
                .where(carAccidentRepair.carAccident.id.in(
                        JPAExpressions
                                .select(carAccident.id)
                                .from(carAccident)
                                .where(carAccident.car.id.eq(carId)
                                        .and(carAccident.isDeleted.eq(false)))
                        )
                        .and(carAccidentRepair.isDeleted.eq(false)))
                .execute();

        /** 3. CarAccident 삭제 */
        getQueryFactory()
                .update(carAccident)
                .set(carAccident.isDeleted, true)
                .where(carAccident.car.id.eq(carId)
                        .and(carAccident.isDeleted.eq(false)))
                .execute();

        /** 4. CarMileage 삭제 */
        getQueryFactory()
                .update(carMileage)
                .set(carMileage.isDeleted, true)
                .where(carMileage.car.id.eq(carId)
                        .and(carMileage.isDeleted.eq(false)))
                .execute();

        /** 5. CarOption 삭제 */
        getQueryFactory()
                .update(carOption)
                .set(carOption.isDeleted, true)
                .where(carOption.car.id.eq(carId)
                        .and(carOption.isDeleted.eq(false)))
                .execute();

        /** 6. CarOwnershipChange 삭제 */
        getQueryFactory()
                .update(carOwnershipChange)
                .set(carOwnershipChange.isDeleted, true)
                .where(carOwnershipChange.car.id.eq(carId)
                        .and(carOwnershipChange.isDeleted.eq(false)))
                .execute();

        /** 7. CarUsage 삭제 */
        getQueryFactory()
                .update(carUsage)
                .set(carUsage.isDeleted, true)
                .where(carUsage.car.id.eq(carId)
                        .and(carUsage.isDeleted.eq(false)))
                .execute();

        /** 8. CarListing 삭제 */
        getQueryFactory()
                .update(carListing)
                .set(carListing.isDeleted, true)
                .where(carListing.id.eq(listingId)
                        .and(carListing.isDeleted.eq(false)))
                .execute();

        /** 9. Car 삭제 */
        getQueryFactory()
                .update(car)
                .set(car.isDeleted, true)
                .where(car.id.eq(carId)
                        .and(car.isDeleted.eq(false)))
                .execute();
    }

 

사실 뭐 별거 없다. 인자로 받은 CarListing.id 를 통해 참조하고 있는 Car.id를 얻고, 그걸로 연관된 엔티티를 전부 삭제하면 된다. 특별히 신경 쓸 점은 delete가 아니라 update이고, isDeleted 필드만 false -> true로 바꿔준다는 점이다.

 

문제 발생

그러나 문제는 예상치 못한 곳에서 발생했다.

// 기존 쿼리 ( 문제 발생 ) 
 
        /** 2. CarAccidentRepair 삭제 */
        getQueryFactory()
            .update(carAccidentRepair)
            .set(carAccidentRepair.isDeleted, true)
            .where(carAccidentRepair.carAccident.car.id.eq(carId)
                .and(carAccidentRepair.isDeleted.eq(false)))
            .execute();

 

이 부분에서 문제가 발생했다. 쿼리를 보면 set(carAccidentRepair.isDeleted, true) 부분이 있다. 수리내역을 삭제하라는 의미인데, 이 부분을 QueryDsl이 제대로 인식하지 못해서 오류가 났다. 

 

내 코드로는 carAccidentRepair.isDeleted 라고 명시적인 테이블 지정을 했지만, QueryDsl이 실제 쿼리를 생성하는 과정에서 이것을 제대로 수행해 내지 못했다.

 

update car_accident_repair car1_0 
join car_accident ca1_0 on ca1_0.id=car1_0.accident_id 
set is_deleted=? 
where ca1_0.car_id=? and car1_0.is_deleted=?

 

이게 내 코드를 기반으로 QueryDsl이 생성한 쿼리이다.set is_deleted=? 부분이 있는데, 정확히 어떤 테이블의 컬럼을 수정할 것인지 명시되지 않고 있다. 이 부분 때문에 쿼리 실행 시점에서 오류가 터졌다. 그런데 이상하다. 코드에서 나는 분명히 명시적 테이블 지정을 했는데 말이다.

 

일단 결론부터 말하자면 이것은 QueryDsl이나 다른 ORM들이 가진 일반적이고 흔한 문제 중 하나이다. 즉 내 탓이 아니다. QueryDsl은 가능하다면 JOIN 대신 서브쿼리나 직접 참조를 사용하려고 하는데, 엔티티 관계가 복잡해질 경우 자동으로 JOIN 방식을 택하게 된다. 이 때 업데이트 쿼리가 JOIN과 함께 이뤄질 경우 이런 문제가 흔하게 발생한다고 한다.

 

내 차량 삭제 쿼리는 수많은 엔티티를 삭제하지만, 유일하게 이 부분에서만 문제가 발생했다. 왜냐하면 다른 엔티티들은 외래키 관계가 딱 1번 중첩되어 있다. 그러나 CarAccidentRepair 테이블만 2번 중첩되어 있다.

 

Car -> CarAccident -> CarAccidentRepair 이렇게 말이다. 엔티티 관계가 복잡했기 때문에 QueryDsl은 JOIN을 선택했고, JOIN과 업데이트 쿼리가 함께 사용되게 되어 set 부분에서 업데이트할 테이블을 명확히 지정해주지 못한 쿼리가 생성되어 버린 것이다.

 

이것은 QueryDsl의 문제이지만.. 어쨌든 내가 해결해야 한다.

 

 

해결 방법

해결방법은 크게 2가지가 있다. JOIN 없이 업데이트 하거나, 서브 쿼리를 이용하거나. 일반적으로는 서브 쿼리 방식이 단일 쿼리로 동작하기 때문에 효율적이고, 코드가 간결하여 가독성이 좋아 많이 사용된다. 나도 서브 쿼리 방식을 이용했다.

 

개선 코드

// 개선 코드

        /** 2. CarAccidentRepair 삭제 */
        getQueryFactory()
                .update(carAccidentRepair)
                .set(carAccidentRepair.isDeleted, true)
                .where(carAccidentRepair.carAccident.id.in(
                        JPAExpressions
                                .select(carAccident.id)
                                .from(carAccident)
                                .where(carAccident.car.id.eq(carId)
                                        .and(carAccident.isDeleted.eq(false)))
                        )
                        .and(carAccidentRepair.isDeleted.eq(false)))
                .execute();

 

기존 where절에서 carAccidentRepair.carAccident.car.id.eq() 로 비교하던 것을, car.id 를 구하는 쿼리를 서브쿼리로 빼내어 따로 구했다. 서브쿼리 라는 것은 단지 메인 쿼리의 조건 확인용이기 때문에, QueryDsl은 어떤 테이블에 업데이트를 적용해야 할지 헷갈리지 않고 제대로 된 쿼리를 작성할 수 있었다.

 

이것만 수정하니 연쇄 삭제 쿼리는 제대로 동작했다.

 

서비스 메서드

// service/car/CarListingServiceImpl

    /**
     * 판매중인 차량과 관련 엔티티를 전부 삭제합니다.( soft delete )
     * 본인이 등록한 차량만 삭제할 수 있습니다.
     *
     * @param listingId 차량 판매등록 ID
     * @param userId 삭제 요청자 로그인 아이디
     */
    @Override
    @Transactional
    public void softDeleteCarListingWithRelatedEntities(
            Long listingId,
            String userId
    ) {

        /** 수정할 차량 찾기  */
        CarListing target = carListingRepository.findById(listingId)
                .orElseThrow(() -> new NotFoundException(ErrorMessages.CAR_LISTING_NOT_FOUND));

        /** 본인의 차량인지 확인 */
        if (!target.getUser().getUserId().equals(userId)) {
            throw new ForbiddenException(ErrorMessages.NOT_OWNER_OF_CAR_LISTING);
        }

        /** 판매중인 차량과 관련 엔티티 전부 삭제 */
        carListingRepository.softDeleteCarListingWithRelatedEntities(listingId);
    }

 

만들어진 쿼리 메서드는 이런 식으로 서비스 계층에서 호출하여 사용했다. 쿼리를 repository 내에 작성해 두니 복잡한 로직이 추상화되어 서비스 코드가 한결 깔끔해졌다.

 

참고로 반환형을 void로 한 것은, 그것이 Restful한 설계이기 때문이다. RestAPI는 delete 시 반환형을 void로 하고, 삭제 실패 시 예외를 던지는 것을 권장한다. 컨트롤러 코드는 안 봐도 뻔하니 굳이 올리지 않겠다.

 

이렇게 Soft Delete 방식에서의 CASCADE를 간접적 쿼리로 직접 구현해 보았다.

 

 

=============

 

 

 

 

728x90
반응형