프로젝트를 진행하며 다음과 같은 문제를 해결해야했습니다.
쿠폰그룹
조회시쿠폰그룹
내의쿠폰
들의 이름과쿠폰그룹
들의 [잔여 개수 / 총개수] 를 계산하고 페이징 처리하여쿠폰그룹
목록을 반환
이번 포스팅에서는 이 문제를 해결하는 과정을 포스팅해보려 합니다.
쿠폰그룹
과 쿠폰
은 다음과 같은 연관관계를 가지고 있습니다.
위의 그림에서 볼 수 있듯이 쿠폰그룹
과 쿠폰
은 일대다 연관관계를 가지고 있습니다. 하나의 쿠폰그룹
조회시 여러개의 쿠폰
을 보여줘야 했는데요. JPA를 사용하고 있는 저희 프로젝트에서 간단하게 떠올릴 수 있는 방법은 다음과 같았습니다.
OneToMany 양방향 연관관계를 이용하면 다음과 같이 엔티티를 구성해야 했습니다.
@Entity
public class Coupon {
// ...
@JoinColumn(name = "coupon_group_id")
@ManyToOne(fetch = FetchType.LAZY)
private CouponGroup couponGroup;
// ...
}
public class CouponGroup {
// ...
@OneToMany(mappedBy = "couponGroup")
private List<Coupon> coupons;
}
이후 서비스 로직에서 아래와 같이 쿠폰그룹내의 쿠폰들을 조회할 수 있었습니다.
Page<CouponGroup> couponGroups = couponGroupRepository.findAll(pageRequest);
Map<CouponGroup, List<Coupon>> couponGroupCouponMap = couponGroups.getContent().stream()
.collect(Collectors.toMap(couponGroup -> couponGroup, CouponGroup::getCoupons));
하지만 막상 코드를 실행시켜보면 아래와 같은 쿼리가 날라가는 것을 확인할 수 있습니다.
[Hibernate]
select
coupongrou0_.id as id1_2_,
coupongrou0_.finished_at as finished2_2_,
coupongrou0_.promotion_id as promotio5_2_,
coupongrou0_.started_at as started_3_2_,
coupongrou0_.title as title4_2_
from
coupon_group coupongrou0_ limit ? // 쿠폰그룹을 전체 조회하는 쿼리
[Hibernate]
select
// 생략
from
coupon coupons0_
where
coupons0_.coupon_group_id=? // 특정 쿠폰그룹의 쿠폰 목록을 조회하는 쿼리 1
[Hibernate]
select
// 생략
from
coupon coupons0_
where
coupons0_.coupon_group_id=? // 특정 쿠폰그룹의 쿠폰 목록을 조회하는 쿼리 2
[Hibernate]
select
// 생략
from
coupon coupons0_
where
coupons0_.coupon_group_id=? // 특정 쿠폰그룹의 쿠폰 목록을 조회하는 쿼리 3
쿼리의 결과를 보면 쿠폰그룹
을 전체 조회하는 쿼리 1번, 각각의 쿠폰그룹에 대하여 쿠폰
목록이 조회되는 쿼리가 N번 날라가는 것을 확인할 수 있습니다. 이는 쿠폰그룹의 개수가 많아질수록 성능이 떨어지기 때문에 개선해야합니다.
N + 1 문제를 해결하는 다양한 방법 중 먼저 FETCH JOIN을 사용해 문제를 해결해보고자 했습니다.
DISTINCT
키워드를 붙여 중복을 제거합니다.@Query
에 countQuery
attribute도 넣어주어야 하기 때문에 아래와 같이 코드를 작성했습니다.@Query(value = "SELECT DISTINCT couponGroup FROM CouponGroup couponGroup JOIN FETCH couponGroup.coupons",
countQuery = "SELECT COUNT(DISTINCT couponGroup) FROM CouponGroup couponGroup INNER JOIN couponGroup.coupons")
Page<CouponGroup> findAll(Pageable pageable);
왜 countQuery를 넣어야 하는가?🧐 JPA에서
@Query
를 사용하지 않고 페이징을 수행하게 되면 의도하지 않았던 countQuery가 날라가는 것을 확인할 수 있는데요, 이는Page
인터페이스의getTotalElements()
의 메서드의 수행 결과를 반환해야 하기 때문이 아닌가 예상하고 있습니다. 따라서@Query
를 사용해 직접 쿼리를 날려 페이징을 수행하기 위해서는 별도의 countQuery가 필요합니다.
결과를 보면 다음과 같은 쿼리가 날라가고 있는 것을 확인할 수 있습니다.
select
distinct coupongrou0_.id as id1_2_0_,
coupons1_.id as id1_1_1_,
coupongrou0_.admin_nickname as admin_ni2_2_0_,
coupongrou0_.finished_at as finished3_2_0_,
coupongrou0_.promotion_id as promotio6_2_0_,
coupongrou0_.started_at as started_4_2_0_,
coupongrou0_.title as title5_2_0_,
coupons1_.created_at as created_2_1_1_,
coupons1_.coupon_group_id as coupon_g8_1_1_,
coupons1_.discount as discount3_1_1_,
coupons1_.initial_quantity as initial_4_1_1_,
coupons1_.remain_quantity as remain_q5_1_1_,
coupons1_.title as title6_1_1_,
coupons1_.type as type7_1_1_,
coupons1_.coupon_group_id as coupon_g8_1_0__,
coupons1_.id as id1_1_0__
from
coupon_group coupongrou0_
inner join
coupon coupons1_
on coupongrou0_.id=coupons1_.coupon_group_id
하나의 쿼리로 조인을 해오는 것을 확인할 수 있었습니다. 그런데 이상한 점은 LIMIT
이 안보인다는 점인데요. 로그를 보니 아래와 같은 메시지를 보내고 있었습니다.
WARN firstResult/maxResults specified with collection fetch; applying in memory!
JPA를 사용할 때 OneToMany 관계에서 FETCH JOIN과 Pagination을 함께 사용하면 쿼리의 결과를 모두 메모리에 적재한 뒤 메모리에서 페이징을 수행하기 때문입니다. 이와 같은 방법은 OOM(Out Of Memory)문제를 발생시킬 수 있기 때문에 좋지 않은 방법입니다.
JPA의 XXXToMany 관계에서 FETCH JOIN과 페이징을 함께 사용할 수는 없습니다. 그렇기 때문에 FETCH JOIN을 제거하고 N+1 문제를 해결하기 위해 default_batch_fetch_size
옵션을 통해 문제를 해결했습니다.
default_batch_fetch_size
옵션을 활용하면 Lazy Loading으로 인해 발생되는 N개의 쿼리를 설정한 옵션만큼 모아 IN 절로 처리하게 됩니다. 예를들어 100개의 추가 쿼리가 발생한다고 했을 때 default_batch_fetch_size
옵션을 10으로설정하게 되면 발생하는 추가 쿼리의 수를 100 / 10으로 줄일 수 있게 됩니다.
실제로 발생하는 쿼리를 확인해볼까요?
[Hibernate]
select
coupongrou0_.id as id1_2_,
coupongrou0_.admin_nickname as admin_ni2_2_,
coupongrou0_.finished_at as finished3_2_,
coupongrou0_.promotion_id as promotio6_2_,
coupongrou0_.started_at as started_4_2_,
coupongrou0_.title as title5_2_
from
coupon_group coupongrou0_
order by
coupongrou0_.id desc limit ?
[Hibernate]
select
count(coupongrou0_.id) as col_0_0_
from
coupon_group coupongrou0_
[Hibernate]
select
coupons0_.coupon_group_id as coupon_g8_1_1_,
coupons0_.id as id1_1_1_,
coupons0_.id as id1_1_0_,
coupons0_.created_at as created_2_1_0_,
coupons0_.coupon_group_id as coupon_g8_1_0_,
coupons0_.discount as discount3_1_0_,
coupons0_.initial_quantity as initial_4_1_0_,
coupons0_.remain_quantity as remain_q5_1_0_,
coupons0_.title as title6_1_0_,
coupons0_.type as type7_1_0_
from
coupon coupons0_
where
coupons0_.coupon_group_id in (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?
)
마지막 쿼리를 확인해보면 IN
절을 통해 데이터를 한 번에 가져오는 것을 확인할 수 있었습니다.
물론 이 방법에도 문제가 있습니다. 쿠폰그룹의 수가 굉장히 많다면 N/(옵션으로 설정한 값)번의 쿼리가 추가적으로 발생하는 것은 어쩔 수 없습니다. 하지만 저희 프로젝트에서는 페이징을 통해 한 페이지당 조회하는 쿠폰그룹의 수가 제한되어 있기 때문에 이 방법을 통해 N+1 문제를 해결할 수 있었습니다.