[JPA] N+1 문제 해결하기!

JeongYong Park·2023년 10월 20일
1

프로젝트를 진행하며 다음과 같은 문제를 해결해야했습니다.

쿠폰그룹 조회시 쿠폰그룹 내의 쿠폰 들의 이름과 쿠폰그룹들의 [잔여 개수 / 총개수] 를 계산하고 페이징 처리하여 쿠폰그룹 목록을 반환

이번 포스팅에서는 이 문제를 해결하는 과정을 포스팅해보려 합니다.

테이블간의 관계

쿠폰그룹쿠폰 은 다음과 같은 연관관계를 가지고 있습니다.

위의 그림에서 볼 수 있듯이 쿠폰그룹쿠폰일대다 연관관계를 가지고 있습니다. 하나의 쿠폰그룹 조회시 여러개의 쿠폰 을 보여줘야 했는데요. JPA를 사용하고 있는 저희 프로젝트에서 간단하게 떠올릴 수 있는 방법은 다음과 같았습니다.

  • OneToMany 양방향 연관관계를 사용해 하나의 쿠폰그룹 조회시 해당 쿠폰그룹의 쿠폰들을 조회

OneToMany 양방향 연관관계를 이용?

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번 날라가는 것을 확인할 수 있습니다. 이는 쿠폰그룹의 개수가 많아질수록 성능이 떨어지기 때문에 개선해야합니다.

Fetch Join

N + 1 문제를 해결하는 다양한 방법 중 먼저 FETCH JOIN을 사용해 문제를 해결해보고자 했습니다.

  • OneToMany 관계에서 조인을 하게 되면 카테시안 곱이 발생해 의도와는 다른 결과를 얻을 수 있기 때문에 DISTINCT 키워드를 붙여 중복을 제거합니다.
  • 페이징을 수행하기 위해서는 @QuerycountQuery 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)문제를 발생시킬 수 있기 때문에 좋지 않은 방법입니다.

default_batch_fetch_size 를 통한 문제 해결

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 문제를 해결할 수 있었습니다.

profile
다음 단계를 고민하려고 노력하는 사람입니다

0개의 댓글