현재 진행하는 프로젝트에서 쿠폰 관련 복잡한 쿼리를 작성하던 중이었다. SQL로 먼저 작업을 해보는데 서브쿼리를 from 절에 넣어야 데이터가 제대로 나오는데, QueryDSL에서는 from 절에 서브쿼리를 넣을 수 없다는 문제를 있었다. 그래서 해결 방법을 찾아보다가 @Subselect라는 Hibernate 기능을 사용하면 쿼리를 캡슐화해서 View를 만들어 엔티티처럼 사용할 수 있다는 걸 알게 되었다. 그래서 @Subselect를 활용하여 서브쿼리를 효과적으로 사용하는 방법을 공유하려고 한다.

Image

서브쿼리로 사용할 클래스 선언

@Subselect(
        "select c.id, ROW_NUMBER() OVER (ORDER BY cd.valid_end_date ASC) as rn " +
                "from coupons c " +
                "inner join coupon_duration_settings cd on c.id = cd.coupon_id " +
                "where c.discount_type = 'RATE' " +
                    "and c.discount_value = (" +
                        "select max(c2.discount_value) " +
                        "from coupons c2 " +
                        "where c2.discount_type = 'RATE'" +
                    ")"
)
@Immutable
@Synchronize("coupons")
@NoArgsConstructor
@AllArgsConstructor
@Entity
public class MaxRateCouponSubDto {

    @Id
    @Column(name = "id")
    private Long id;

    @Column(name = "rn")
    private long rn;
}
  • select문에서 사용한 컬럼명과 필드에 @Column 애노테이션으로 설정해준 name이 동일해야 찾을 수 있다.
  • @Subselect를 사용한 엔티티는 어떤 특정 엔티티에 종속된 것이 아니기 때문에 @Immutable을 선언해주어야 하여 불변(immutable), 즉 읽기 전용으로 변경 불가함을 선언해야 한다.
    • @Immutable : 해당 엔티티가 변경되지 않는 데이터임을 Hibernate에게 알려주는 애노테이션으로, 변경 감지를 방지하여 성능을 최적화할 수 있다.
      Image
  • 또한, 동시성 문제를 제어하여 데이터의 일관성을 보장하기 위해 @Sychronize을 사용했다.

QueryDSL에서 사용

private static final QMaxRateCouponSubDto maxRateCouponSub = QMaxRateCouponSubDto.maxRateCouponSubDto;
private static final QMaxAmountCouponSubDto maxAmountCouponSub = QMaxAmountCouponSubDto.maxAmountCouponSubDto;

public List<HotelAndCouponResponse> findHotelAndCoupons(HotelSearchConditionDto condition) {
 BooleanBuilder whereClause = new BooleanBuilder();
 ... 
 return queryFactory.select(Projections.constructor(HotelAndCouponResponse.class,
                        hotel.id,
                        Projections.constructor(CouponDto.class,
                                rateCouponDuration.isTimeDeal.stringValue().max(),
                                rateCouponDuration.validStartDate.max(),
                                rateCouponDuration.validEndDate.max(),
                                rateCoupon.discountType.max(),
                                rateCoupon.discountValue.max(),
                                rateCoupon.maxDiscountPrice.max()
                        ),
                        Projections.constructor(CouponDto.class,
                                amountCouponDuration.isTimeDeal.stringValue().max(),
                                amountCouponDuration.validStartDate.max(),
                                amountCouponDuration.validEndDate.max(),
                                amountCoupon.discountType.max(),
                                amountCoupon.discountValue.max(),
                                amountCoupon.maxDiscountPrice.max()
                        )
                ))
                .from(hotel)
                .leftJoin(couponUsageConditionObject).on(couponUsageConditionObject.hotel.id.eq(hotel.id))
                .leftJoin(rateCoupon).on(rateCoupon.id.eq(couponUsageConditionObject.coupon.id)
                        .and(rateCoupon.id.eq(
                                JPAExpressions.select(maxRateCouponSub.id)
                                        .from(maxRateCouponSub)
                                        .where(maxRateCouponSub.rn.eq(1L))
                        )))
                .leftJoin(rateCouponDuration).on(rateCouponDuration.coupon.id.eq(rateCoupon.id))
                .leftJoin(amountCoupon).on(amountCoupon.id.eq(couponUsageConditionObject.coupon.id)
                        .and(amountCoupon.id.eq(
                                JPAExpressions.select(maxAmountCouponSub.id)
                                        .from(maxAmountCouponSub)
                                        .where(maxAmountCouponSub.rn.eq(1L))
                        )))
                .leftJoin(amountCouponDuration).on(amountCouponDuration.coupon.id.eq(amountCoupon.id))
                .where(whereClause)
                .groupBy(hotel.id)
                .fetch();
}
  • 위에서 생성해준 클래스를 Q클래스로 선언하고, from 절에 넣어 사용하면 @Subselect에 작성한 코드가 그대로 서브쿼리로 들어간다.

SQL 예시

SELECT h.id,
       MAX(rcd.is_time_deal),
       MAX(rcd.valid_start_date),
       MAX(rcd.valid_end_date),
       MAX(rc.discount_type),
       MAX(rc.discount_value),
       MAX(rc.max_discount_price),
       MAX(acd.is_time_deal),
       MAX(acd.valid_start_date),
       MAX(acd.valid_end_date),
       MAX(ac.discount_type),
       MAX(ac.discount_value),
       MAX(ac.max_discount_price)
FROM hotels h
         LEFT JOIN coupon_usage_conditions cuc
                   ON cuc.hotel_id = h.id
         LEFT JOIN rate_coupons rc
                   ON rc.id = cuc.coupon_id
                       AND rc.id = (
                           SELECT mrt.id
                           FROM (
                                    select c.id, ROW_NUMBER() OVER (ORDER BY cd.valid_end_date ASC) as rn
                                    from coupons c
                                             inner join coupon_duration_settings cd on c.id = cd.coupon_id
                                    where c.discount_type = 'RATE'
                                      and c.discount_value = (
                                        select max(c2.discount_value)
                                        from coupons c2
                                        where c2.discount_type = 'RATE'
                                    )
                                ) mrt
                           WHERE mrt.rn = 1
                       )
         LEFT JOIN rate_coupon_durations rcd
                   ON rcd.coupon_id = rc.id
         LEFT JOIN amount_coupons ac
                   ON ac.id = cuc.coupon_id
                       AND ac.id = (
                           SELECT mac.id
                           FROM (
                                    select c3.id, ROW_NUMBER() OVER (ORDER BY cd2.valid_end_date ASC) as rn
                                    from coupons c3
                                             inner join coupon_duration_settings cd2 on c3.id = cd2.coupon_id
                                    where c3.discount_type = 'AMOUNT'
                                      and c3.discount_value = (
                                        select max(c4.discount_value)
                                        from coupons c4
                                        where c4.discount_type = 'AMOUNT'
                                    )
                                ) mac
                           WHERE mac.rn = 1
                       )
         LEFT JOIN amount_coupon_durations acd
                   ON acd.coupon_id = ac.id
WHERE h.has_deleted = false
GROUP BY h.id;



장점

  • 중복으로 여러 곳에서 사용할 경우 하나의 클래스로 두는 것이 가독성이나 유지보수면에서 좋다.
  • 읽기 전용(immutable)이기 때문에 불필요하게 엔티티를 저장 및 수정하지 않고 데이터의 일관성을 유지할 수 있다.
  • 기존 테이블(여기선 coupons)를 변경하지 않고 새로운 엔티티를 정의하여 사용할 수 있다.
  • @Subselect는 서브쿼리를 가상의 엔티티로 매핑하는 방식이므로, ORM이 이를 단순한 조회로 처리하기 때문에 복잡한 조인 쿼리보다 성능이 더 나을 수 있다.

단점

  • @Subselect 문 안의 쿼리에 동적으로 쿼리를 추가할 수 없다. (제일 아쉬운 부분)
  • 쿼리가 복잡한 경우 성능 저하를 유발할 수 있다.
  • View는 실제 데이터가 존재하지 않기 때문에 조회만 가능하다.

insight

이번 작업을 통해 QueryDSL에서 FROM 절의 서브쿼리 문제를 해결하는 다양한 방법을 고민할 수 있었다. 처음에는 단순히 JPAExpressions로 해결하려 했지만, 쿠폰처럼 여러 곳에서 중복으로 사용할 경우 성능과 유지보수를 고려할 때 이렇게 @Subselect를 붙인 엔티티를 사용하는 게 더 적절하다는 결론을 냈다. 다행히 쿼리도 기존보다 느려지지 않아서 좋았지만, 이런 상황에서 동적 쿼리가 필요하다면 어떻게 해야 하는지 알아볼 필요가 있다.


참고