Skip to content

Conversation

mete0rfish
Copy link
Member

#️⃣ 이슈

🔎 작업 내용

1️⃣ N+1 문제 발생

기존 코드의 경우, �키워드를 통한 Blueprint를 검색하는 JPQL을 사용하고 있다. 이 경우에 당연하게도 Blueprint에 있는 연관관계에 의해 N+1이 발생하게 된다.

// Blueprint
@OneToMany(mappedBy = "blueprint")
private List<OrderBlueprint> orderBlueprints = new ArrayList<>();

@OneToMany(mappedBy = "blueprint")
private List<CartBlueprint> cartBlueprints = new ArrayList<>();
// BlueprintRepository
@Query(value = "SELECT b FROM Blueprint b WHERE (b.blueprintName LIKE %:keyword% OR b.creatorName LIKE %:keyword%) AND b.inspectionStatus = :status AND b.isDeleted = false")
Page<Blueprint> findAllNameAndCreatorContaining(@Param("keyword") String keyword, @Param("status") InspectionStatus status, Pageable pageable);

현재 기본 값으로 지연 로딩이 설정되어 있다. Blueprint를 조회하게 되면 연관관계를 가진 orderBlueprints와 cartBlueprints를 가져오게 된다. 이 경우, Blueprint의 orderBlueprints와 cartBlueprints의 ID마다 조회하는 쿼리가 실행되기 때문에 N+1이 발생한다.

N+1 문제를 해결하기 위해 Fetch Join을 통해 해결하려고 한다.

Fetch Join을 사용한 이유

  1. @EntityGraph의 경우, JPQL 외에 별도로 설정을 해야하기 때문에 번거롭다고 판단.
  2. BatchSize는 정확한 연관관계의 데이터 크기를 알기 어렵기 대문에 사용하기 알맞지 않다고 판단.
  3. Fetch Join을 통해 JPQL 내에서 간단하게 해결이 가능.

2️⃣ FetchJoin 시 Pageable 사용 불가능 문제 발생

Fetch Join 시, 부모 자식 관계를 조인하여 데이터를 한 번에 가져온다. 이 경우 부모 엔티티가 페이징 범위에 포함되더라도 조인된 자식 엔티티가 많으면 중복 데이터가 발생한다. 따라서, 페이징은 부모 엔티티 기준으로 페이징을 하지만, Hibernate는 조인된 겨로가 전체에 대한 페이징을 수행하게 된다. 결국 데이터 중복이 발생하여 페이징 결과가 왜곡될 수 있다.

예를 들어, 부모 엔티티가 3개 있고 자식 엔티티가 3개씩 있을 경우, 페이징 결과는 3개의 부모가 아닌 9개의 겨로가를 가진다.

따라서, Fetch Join 사용 시 중복 문제를 해결하게 되면 자연스럽게 Pageable의 왜곡도 해결될 것 이다.

해결 방법

  1. DISTINCT를 이용한 중복 제거
    • 데이터 셋이 클 경우 성능에 영향을 줄 수 있음
    • Hibernate6 부터 복제본에 대해 Hibernate 자체적으로 필터링이 적용되어 사용할 필요가 없다.
  2. Count Query를 통해 데이터 조회 쿼리와 카운트 쿼리 분리

3️⃣ CountQuery 적용 후 문제 발생

2025-01-06T10:46:57.990+09:00  WARN 85640 --- [server] [nio-8080-exec-2] org.hibernate.orm.query                  : HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory
2025-01-06T10:46:58.014+09:00 ERROR 85640 --- [server] [nio-8080-exec-2] c.o.s.global.handler.ExceptionAdvice     : org.hibernate.loader.MultipleBagFetchException: cannot simultaneously fetch multiple bags: [com.onetool.server.api.blueprint.Blueprint.cartBlueprints, com.onetool.server.api.blueprint.Blueprint.orderBlueprints]

Fetch Join의 경우 다음 조건이 있다.

  • ToOne : 여러 개 가능
  • To Many : 1개만 가능

따라서, 현재 OneToMany를 2개 사용하는 Fetch Join으로 인해 MultipleBagFetchException이 발생하는 것이다.

원인

Bag 컬렉션은 List 기반의 out-of-order와 중복을 허용하기에 Fetch Join이 많아질 경우, 동시에 가져올 때 카테시안 곱이 매우 많이 발생하여 중복이 많이 발생한다. 따라서 ToMany에 대한 제약을 두는 것으로 보인다.

해결 방안

  1. List를 Set으로 변경
    • 중복 문제를 해결할 수 있지만, Fetch Join으로 인한 많은 카테시안 곱을 해결하진 못한다.
  2. 컬렉션 Fetch Join을 쿼리 분리로 처리
    • 컬렉션 당 하나의 fetch를 통해 가져오도록 하는 방법
  3. @batchsize를 활용
    • @batchsize는 Hibernate가 지연 로딩 시 연관 엔티티를 한 번에 배치(batch)로 가져오도록 지시. 즉, 개별 쿼리가 아닌 배치 쿼리를 통해 여러 부모 엔티티의 자식 엔티티를 한 번에 가져온다.
      - 문제점: 각 연관관계에 대한 적절한 배치 크기를 설정해야 N+1 문제를 회피할 수 있다.

컬렉션 Fetch Join을 쿼리 분리

위 3가지 방법 중 2번을 통해 해결하기로 결정했다. 1번의 경우 명확하게 카테시안 곱을 해결해주지 못해 성능상 문제가 발생할 여지가 있고, 3번의 경우 적절한 @batchsize를 컬렉션마다 적절한 크기로 할당해야 하는데 이에 대한 지식이 많이 부족하기 때문이다.

public Page<SearchResponse> searchNameAndCreatorWithKeyword(String keyword, Pageable pageable) {
      Page<Blueprint> page = blueprintRepository.findAllNameAndCreatorContaining(keyword, InspectionStatus.PASSED, pageable);
    List<Blueprint> withOrderBlueprints = blueprintRepository.findWithOrderBlueprints(page.getContent());
    List<Blueprint> withCartBlueprints = blueprintRepository.findWithCartBlueprints(withOrderBlueprints);

    List<SearchResponse> list = convertToSearchResponseList(withCartBlueprints);
    return new PageImpl<>(list, pageable, page.getTotalElements());
}

키워드를 가진 전체 Blueprint를 먼저 조회한다. 그 이후 각 Blueprint의 orderBlueprints를 조회하고, 이렇게 조회된 blueprints로 다시 cartBlueprints를 조회한다. 이를 통해 총 3번의 쿼리로 해당 문제를 해결할 수 있다.

수정 후
Hibernate: 
    select
        b1_0.id,
        b1_0.blueprint_details,
        b1_0.blueprint_img,
        b1_0.blueprint_name,
        b1_0.category_id,
        b1_0.created_at,
        b1_0.creator_name,
        b1_0.download_link,
        b1_0.extension,
        b1_0.hits,
        b1_0.inspection_status,
        b1_0.is_deleted,
        b1_0.program,
        b1_0.sale_expired_date,
        b1_0.sale_price,
        b1_0.second_category,
        b1_0.standard_price,
        b1_0.updated_at 
    from
        blueprint b1_0 
    where
        (
            b1_0.blueprint_name like replace(?, '\\', '\\\\') 
            or b1_0.creator_name like replace(?, '\\', '\\\\')
        ) 
        and b1_0.inspection_status=? 
        and b1_0.is_deleted=0 
    order by
        b1_0.id desc 
    limit
        ?, ?
Hibernate: 
    select
        distinct b1_0.id,
        b1_0.blueprint_details,
        b1_0.blueprint_img,
        b1_0.blueprint_name,
        b1_0.category_id,
        b1_0.created_at,
        b1_0.creator_name,
        b1_0.download_link,
        b1_0.extension,
        b1_0.hits,
        b1_0.inspection_status,
        b1_0.is_deleted,
        ob1_0.blueprint_id,
        ob1_0.id,
        ob1_0.created_at,
        ob1_0.orders_id,
        ob1_0.updated_at,
        b1_0.program,
        b1_0.sale_expired_date,
        b1_0.sale_price,
        b1_0.second_category,
        b1_0.standard_price,
        b1_0.updated_at 
    from
        blueprint b1_0 
    left join
        order_blueprint ob1_0 
            on b1_0.id=ob1_0.blueprint_id 
    where
        b1_0.id in (?, ?, ?, ?, ?, ?)
Hibernate: 
    select
        distinct b1_0.id,
        b1_0.blueprint_details,
        b1_0.blueprint_img,
        b1_0.blueprint_name,
        cb1_0.blueprint_id,
        cb1_0.id,
        cb1_0.cart_id,
        cb1_0.created_at,
        cb1_0.updated_at,
        b1_0.category_id,
        b1_0.created_at,
        b1_0.creator_name,
        b1_0.download_link,
        b1_0.extension,
        b1_0.hits,
        b1_0.inspection_status,
        b1_0.is_deleted,
        b1_0.program,
        b1_0.sale_expired_date,
        b1_0.sale_price,
        b1_0.second_category,
        b1_0.standard_price,
        b1_0.updated_at 
    from
        blueprint b1_0 
    left join
        cart_blueprint cb1_0 
            on b1_0.id=cb1_0.blueprint_id 
    where
        b1_0.id in (?, ?, ?, ?, ?, ?)

총 3개의 쿼리로 성능을 최적화 할 수 있다.

🤔 고민해볼 부분 & 코드 리뷰 희망사항

현재 JPQL을 이용하여 모든 쿼리를 실행하기 때문에 가독성이 매우 떨어진다. 추후 QueryDSL과 같은 라이브러리를 통해 가독성을 챙기는 방향으로 리렉토링하는 방향을 생각해보려 한다.

🧲 참고 자료 및 공유 사항

N+1 문제 원인 및 해결방법
MultipleBagFetchException 발생시 해결 방법

@mete0rfish mete0rfish added the ♻️ refactor 리펙터링된 코드 label Jan 6, 2025
Copy link
Contributor

@day024 day024 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

안녕하세요 ! PR을 읽어보니 N+1 문제를 해결하기 위해서 고심한 흔적이 느껴집니다.
제가 보기엔 큰 문제가 없어 바로 merge해도 좋을 것 같아요!!

Fatch Join를 통해 N+1문제를 깔끔하게 해결 하신 것 같습니다. PR에 적어주신 글과 참고링크를 통해서 어떤식으로 문제를 해결했는지 이해가 잘되었습니다

언급해주신 QueryDSL에 대해서도 찾아보았습니다.
repository에 작성한 쿼리가 꽤 늘어나서 QueryDSL을 적용해보면 코드가 더 효율적으로 개선될 것 같아요.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

반환 타입을 void로 변경하셨는데,
void로 변경했을때 이점이나 void가 왜 더 적절한지 이유가 궁금합니다!!

@mete0rfish mete0rfish merged commit a8b3ad7 into likelion-onetool:dev Jan 7, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
♻️ refactor 리펙터링된 코드
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants