[SpringBoot] JPA/QueryDSL Pagination과 N+1 (Feat. Pagination에서 FetchJoin 사용 시 문제점)

Ogu·2024년 7월 11일
post-thumbnail

상황

현재 저는 메인 페이지에 나오는 전체 상품 페이징(+정렬)과 카테고리별 상품 리스트 페이징을 구현 중이었습니다.

페이지네이션 구현에서 N+1 문제를 해결하기 위해 가장 많이 사용되는 해결 방법인 FetchJoin을 적용했지만 너무 느린 응답 속도와 메모리 문제가 발생했습니다.

이번 포스팅에서는 페이지네이션에서 FetchJoin시 문제점과, Hibernate의 페이지네이션에서 FetchJoin을 처리하는 방식을 알아보겠습니다.

(해당 구현에서는 동적 정렬(동적 쿼리)를 위해 QueryDSL을 사용하였으며, QueryDSL을 적용한 Repository 구조는 다음과 같습니다.)

우선 기존 코드를 보면 다음과 같습니다.

ProductLine, Product, Category Entity

ProductLine은 상품을, Product는 상품 옵션을 의미합니다.

[ProductLineEntity.java]

@Entity(name = "product_line")
public class ProductLineEntity extends BaseTimeEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long productLineId;
    private String name;
    private String content;
    private int price;
    private Long saleCount;

    @Enumerated(EnumType.STRING)
    private ProductLineStatus status;


    @OneToMany(mappedBy = "productLine", fetch = FetchType.LAZY, cascade = CascadeType.ALL)
    @JsonIgnore
    private List<ProductEntity> products;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "category_id", nullable = false)
    private CategoryEntity category;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id", nullable = false)
    private Seller seller;
    
    ... 메서드

}

[ProductEntity.java]

@Entity(name = "product")
public class ProductEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long productId;
    private String name;
    private int extraCharge;
    private Long stock;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "product_line_id", nullable = false)
    private ProductLineEntity productLine;

	... 메서드
}

Service

Service 코드에서는 Repository에서 ProductLine 엔티티 타입의 Page/Slice를 가져와 DTO 타입으로 변환 및 가공합니다.

@Slf4j
@Service
@RequiredArgsConstructor
public class ProductLineService {

    private final ProductLineJPARepository productLineRepository;
    private final SellerRepository sellerRepository;
    private final CategoryJpaRepository categoryRepository;

    @Transactional
    public Page<ProductLineWithProductsJPAResponse> getProductLinesByCategoryWithOffsetPaging(Long categoryId, Pageable pageable, String keyword) {
        Page<ProductLineEntity> productLineEntities = productLineRepository.findEntitiesByCategoryWithOffsetPaging(categoryId, pageable, keyword);
        return productLineEntities.map(this::convertToDtoWithProducts);
    }

    @Transactional
    public Slice<ProductLineWithProductsJPAResponse> getProductLinesByCategoryWithSlicePaging(Long categoryId, Pageable pageable, String keyword) {
        Slice<ProductLineEntity> productLineEntities = productLineRepository.findEntitiesByCategoryWithSlicePaging(categoryId, pageable, keyword);
        return productLineEntities.map(this::convertToDtoWithProducts);
    }

    private ProductLineWithProductsJPAResponse convertToDtoWithProducts(ProductLineEntity productLine) {
        // 전체 재고량 계산
        Long totalStock = productLine.getProducts().stream().mapToLong(ProductEntity::getStock).sum();

        // ProductLineWithProductsJPAResponse 객체 생성
        ProductLineWithProductsJPAResponse dto = new ProductLineWithProductsJPAResponse(
                productLine,
                productLine.getSeller(),
                totalStock
        );

        // productList 설정
        List<ProductResponse> productResponses = productLine.getProducts().stream()
                .map(ProductResponse::from)
                .collect(Collectors.toList());
        dto.setProductList(productResponses);

        return dto;
    }
}

N+1과 Fetch Join

N+1 문제란, 쿼리 1번으로 N건의 엔티티를 가져왔는데, 연관된 엔티티를 얻기 위해 쿼리를 N번 추가로 수행하는 현상을 의미합니다. 페이징으로 조회한 N개의 ProductLine 엔티티를 순회하며 하위 엔티티인 Product(옵션) 리스트를 조회해봅시다.

[JPA] 즉시로딩과 지연로딩, N+1

Hibernate6부터 더 이상 JPQL 및 HQL에서 distinct를 사용할 필요가 없습니다. 반환되는 엔티티의 복제본은 이제 항상 Hibernate에 의해 필터링됩니다.

Starting with Hibernate ORM 6 it is no longer necessary to use distinct in JPQL and HQL to filter out the same parent entity references when join fetching a child collection. The returning duplicates of entities are now always filtered by Hibernate.

[ProductLineRepositoryCustomImpl.java] - QueryDSLCustom을 implements한 Repository

각 Offset/Slice 페이징 메서드는 우선 공통적으로 getProductLineEntitiesByCategory 메서드를 사용해 카테고리별로 ProductLine 엔티티 리스트를 가져온 후 Offset/Slice 페이징을 적용합니다.

  • findEntitiesByCategoryWithOffsetPaging : 카테고리별 상품 조회 Offset 페이지네이션
  • findEntitiesByCategoryWithSlicePaging : 카테고리별 상품 조회 Slice 페이지네이션
  • getProductLineEntitiesByCategory : 카테고리별로 ProductLine 엔티티 리스트를 가져옴
  • getSearchCondition : 검색 키워드가 있을 경우 필터링
  • getOrderSpecifiers : Sort 정렬 조건이 있을 경우 해당 OrderSpecifier 반환
@Repository
@RequiredArgsConstructor
public class ProductLineRepositoryCustomImpl implements ProductLineRepositoryCustom {

    private final JPAQueryFactory jpaQueryFactory;

    QProductLineEntity qProductLine = QProductLineEntity.productLineEntity;
    QProductEntity qProduct = QProductEntity.productEntity;
    QSellerEntity qSeller = QSellerEntity.sellerEntity;
    QMemberEntity qMember = QMemberEntity.memberEntity;

    @Override
    public Page<ProductLineEntity> findEntitiesByCategoryWithOffsetPaging(Long categoryId, Pageable pageable, String keyword) {
        List<ProductLineEntity> content = getProductLineEntitiesByCategory(categoryId, pageable, keyword);

        boolean hasNext = false;
        if (content.size() > pageable.getPageSize()) {
            content.remove(content.size() - 1);
            hasNext = true;
        }

        JPAQuery<Long> totalCount = jpaQueryFactory
                .select(qProductLine.countDistinct())
                .from(qProductLine)
                .where(qProductLine.category.categoryId.eq(categoryId)
                        .and(qProductLine.deletedAt.isNull())
                        .and(getSearchCondition(keyword)));

        return PageableExecutionUtils.getPage(content, pageable, totalCount::fetchOne);
    }

    @Override
    public Slice<ProductLineEntity> findEntitiesByCategoryWithSlicePaging(Long categoryId, Pageable pageable, String keyword) {
        List<ProductLineEntity> content = getProductLineEntitiesByCategory(categoryId, pageable, keyword);

        boolean hasNext = false;
        if (content.size() > pageable.getPageSize()) {
            content.remove(content.size() - 1);
            hasNext = true;
        }

        return new SliceImpl<>(content, pageable, hasNext);
    }

    private List<ProductLineEntity> getProductLineEntitiesByCategory(Long categoryId, Pageable pageable, String keyword) {
        List<OrderSpecifier<?>> orderSpecifiers = getOrderSpecifiers(pageable.getSort());

        // 카테고리별로 ProductLine 엔티티를 가져옴
        return jpaQueryFactory
                .selectDistinct(qProductLine)
                .from(qProductLine)
                .where(qProductLine.category.categoryId.eq(categoryId)
                        .and(qProductLine.deletedAt.isNull())
                        .and(getSearchCondition(keyword)))
                .orderBy(orderSpecifiers.toArray(new OrderSpecifier[0]))
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize() + 1)
                .fetch();
    }

    private BooleanExpression getSearchCondition(String keyword) {
        if (keyword == null || keyword.isEmpty()) {
            return qProductLine.isNotNull();  // 조건이 없을 경우 항상 true를 반환
        }
        return qProductLine.name.containsIgnoreCase(keyword)
                .or(qProductLine.content.containsIgnoreCase(keyword));
    }

    private List<OrderSpecifier<?>> getOrderSpecifiers(Sort sort) {

        List<OrderSpecifier<?>> orderSpecifiers = new ArrayList<>();

        if (sort != null) {
            for (Sort.Order order : sort) {
                switch (order.getProperty()) {
                    case "saleCount" ->
                            orderSpecifiers.add(order.isAscending() ? qProductLine.saleCount.asc() : qProductLine.saleCount.desc());
                    case "createdAt" ->
                            orderSpecifiers.add(order.isAscending() ? qProductLine.createdAt.asc() : qProductLine.createdAt.desc());
                    case "price" ->
                            orderSpecifiers.add(order.isAscending() ? qProductLine.price.asc() : qProductLine.price.desc());
                    default -> {
                    }
                }
            }
        }

        return orderSpecifiers;
    }
}

Hibernate: SQL

다음 주소로 요청을 보낼 때 생성되는 SQL은 다음과 같습니다.
http://localhost:8080/v1/categories/3/productLines/offset?page=1&size=3&sort=createdAt,asc&keyword=슬랙스

Page의 size=3이므로 3개의 productLine을 가져오고, 연관된 Product(옵션) 엔티티를 얻기 위해 3번의 쿼리가 추가로 수행되고 있습니다.

이는 innerJoin을 포함시켜도 , 글로벌 패치 전략이 지연 로딩(lazy loading)이기 때문에 다시 N+1 문제가 발생합니다.

(count 쿼리는 총 레코드 수를 알기 위함입니다. 총 레코드 수를 알아야 전체 페이지 수를 계산하고, 페이지네이션 정보를 클라이언트에게 제공할 수 있습니다.)

Hibernate: 
    select
        distinct ple1_0.product_line_id,
        ple1_0.category_id,
        ple1_0.content,
        ple1_0.created_at,
        ple1_0.deleted_at,
        ple1_0.modified_at,
        ple1_0.name,
        ple1_0.price,
        ple1_0.sale_count,
        ple1_0.member_id,
        ple1_0.status 
    from
        product_line ple1_0 
    where
        ple1_0.category_id=? 
        and ple1_0.deleted_at is null 
        and (
            lower(ple1_0.name) like ? escape '!' 
            or lower(ple1_0.content) like ? escape '!'
        ) 
    order by
        ple1_0.created_at 
    limit
        ?, ?
        
        
 Hibernate: 
    select
        count(distinct ple1_0.product_line_id) 
    from
        product_line ple1_0 
    where
        ple1_0.category_id=? 
        and ple1_0.deleted_at is null 
        and (
            lower(ple1_0.name) like ? escape '!' 
            or lower(ple1_0.content) like ? escape '!'
        )
        

Hibernate: 
    select
        p1_0.product_line_id,
        p1_0.product_id,
        p1_0.extra_charge,
        p1_0.name,
        p1_0.stock 
    from
        product p1_0 
    where
        p1_0.product_line_id=?

 
Hibernate: 
    select
        p1_0.product_line_id,
        p1_0.product_id,
        p1_0.extra_charge,
        p1_0.name,
        p1_0.stock 
    from
        product p1_0 
    where
        p1_0.product_line_id=? 

FetchJoin 사용

N+1 문제를 해결하기 위해 FetchJoin을 아래와 같이 적용해보겠습니다.
.leftJoin(qProductLine.products, qProduct).fetchJoin() 를 추가함으로써 ProductList들을 가져올 때 Product(옵션)들도 같이 가져오도록 합니다.

[ProductLineRepositoryCustomImpl.java] - QueryDSLCustom을 implements한 Repository

private List<ProductLineEntity> getProductLineEntitiesByCategory(Long categoryId, Pageable pageable, String keyword) {
        List<OrderSpecifier<?>> orderSpecifiers = getOrderSpecifiers(pageable.getSort());

        // 카테고리별로 ProductLine 엔티티를 가져옴
        return jpaQueryFactory
                .selectDistinct(qProductLine)
                .from(qProductLine)
                .leftJoin(qProductLine.products, qProduct).fetchJoin()
                .where(qProductLine.category.categoryId.eq(categoryId)
                        .and(qProductLine.deletedAt.isNull())
                        .and(getSearchCondition(keyword)))
                .orderBy(orderSpecifiers.toArray(new OrderSpecifier[0]))
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize() + 1)
                .fetch();
    }

Hibernate: SQL

Hibernate: 
    select
        distinct ple1_0.product_line_id,
        ple1_0.category_id,
        ple1_0.content,
        ple1_0.created_at,
        ple1_0.deleted_at,
        ple1_0.modified_at,
        ple1_0.name,
        ple1_0.price,
        p1_0.product_line_id,
        p1_0.product_id,
        p1_0.extra_charge,
        p1_0.name,
        p1_0.stock,
        ple1_0.sale_count,
        ple1_0.member_id,
        ple1_0.status 
    from
        product_line ple1_0 
    left join
        product p1_0 
            on ple1_0.product_line_id=p1_0.product_line_id 
    where
        ple1_0.category_id=? 
        and ple1_0.deleted_at is null 
        and ple1_0.product_line_id is not null
        
        
 Hibernate: 
    select
        count(distinct ple1_0.product_line_id) 
    from
        product_line ple1_0 
    where
        ple1_0.category_id=? 
        and ple1_0.deleted_at is null 
        and ple1_0.product_line_id is not null

ProductLine 전체 조회 쿼리 1개 (1) + 각 ProductLine(상품)에 해당하는 Product(옵션) 조회 쿼리 3개 (N) 총 쿼리가 4개가 날아가던 것이 Fetch Join을 적용함으로써 1개로 줄어들었습니다. 분명 성능이 최적화된 것으로 보이지만 여러 문제가 생겼습니다.

FetchJoin 사용 시 문제점

  1. 메모리 문제
    먼저, 페이징할 때 사용하던 기존의 SQL LIMIT 구문이 등장하지 않았습니다.
    또한, 위와 같이 콘솔 로그에서 firstResult/maxResults가 컬렉션 페치와 함께 지정된 경우, 메모리 내에서 이를 적용하겠다는 ⚠️경고(WARN)를 표시합니다. 이는 큰 데이터 셋을 다룰 때 성능 문제가 발생할 수 있음을 경고하고 있습니다.

이러한 경고 문구는 Hibernate에서 컬렉션 페치를 통한 페이지네이션을 수행하는 방식 때문에 발생합니다.

🤔 Hibernate에서 컬렉션 페치를 통한 페이지네이션
Hibernate는 컬렉션 페치와 함께 페이지네이션을 사용할 때 메모리 내에서 페이지네이션을 적용합니다.
OneToMany, ManyToMany 같은 컬렉션 관계는 일대다 테이블을 조인할 경우 데이터의 수가 변하기 때문입니다.
이는 대용량 데이터 처리에서 큰 성능 문제를 일으킬 수 있습니다.

ProductLine 엔티티가 3개 있고, 각각의 Post 엔티티는 연관된 Comment가 7개 존재한다고 가정해봅시다. 1:N 관계를 Join하면 총 21(3 * 7)개의 DB Row가 조회됩니다. 데이터의 수가 변경되기 때문에 단순하게 LIMIT 구문을 사용하는 쿼리로 페이지네이션을 적용하기 어렵습니다. 따라서 조회한 결과를 모두 메모리로 가져와서 JPA가 페이지네이션 계산을 진행합니다.

따라서 1:N (OneToMany) 또는 N:N (ManyToMany) 관계의 컬렉션을 Fetch Join하면서 동시에 Pagination API를 사용하면 OutOfMemoryError가 발생할 수 있기 때문에, 이 둘을 동시에 사용하는 것은 피해야 합니다.

Inflearn Q&A- fetch join 시 paging 문제

  1. 응답 시간 문제
    또한 위와 같은 메모리 문제(1:N 데이터 전체를 어플리케이션 메모리에 로드하여 페이지네이션 처리) 때문에 기존 FetchJoin을 적용하지 않았을 때에 비해 응답시간이 비약적으로 증가했습니다.

즉, 해당 카테고리에 해당하는 전체 ProductLine과 각 ProductLine에 해당하는 Product(옵션)을 모두 가져오는 것이 되는 셈이 되는 것입니다.
(카테고리별 상품 조회가 아닌, 전체 상품 조회라면 DB의 ProductLine, Product 테이블 전체를 메모리에 적재하는 셈이 되어 훨씬 큰 처리 시간이 소요할 것입니다.)

약 1백만건의 데이터가 비교적 고르게 카테고리가 분포되어 있고, 카테고리 별 페이징 조회를 했을 때 아래와 같이 35~40초의 시간이 걸렸습니다. 이 정도의 응답 시간이라면 사용자 입장에서는 거의 서버가 다운됐다고 느낄 것입니다.

서드파티 모니터링 도구인 nGrinder로 테스트 시 아래와 같은 결과가 나왔습니다.
총 10번 실행시 평균 응답 시간은 약 30s입니다.
(해당 테스트는 postMan 테스트 이후 성능이 좋은 맥북 프로로 local 테스트 하여 기존 테스트보다 나은 성능을 보여줍니다.)

(실제로, 전체 상품 조회에서는 약 50~55초의 시간이 걸렸습니다.)

  1. 2개 이상의 1:N 관계의 컬렉션에서 Fetch Join 불가능
    이전에는 Seller, Category 등의 다른 연관관계도 FetchJoin을 시도한 적이 있습니다.
    하지만 MultipleBagFetchException 가 발생했는데요, 이는 2개 이상의 1:N 관계의 컬렉션을 Fetch Join할 경우 카테시안 곱(Cartesian Product) 의 형태로 조회 데이터가 급격하게 많아져 JPA에서 막아두었습니다.

(N+1 쿼리 + DB 커넥션 증가) vs (N+1 해결 + DB 커넥션 감소)

해결 방법

~ToOne 관계는 Fetch Join해도 괜찮지만, ~ToMany 관계의 경우 FetchJoin을 하면 데이터의 수가 변경되고, 페이지네이션에서 사용 시 OutOfMemory가 발생하기 때문에 다른 방법으로 해결해야 합니다.

해결하는 방법으로는 배치 패치 사이즈를 설정하는 방법이 있습니다.

default_batch_fetch_size 활용

@~ToMany 애너테이션으로 관계가 맺어져 있는 경우 조인과 페이징 처리를 동시에 처리하기 어려웠습니다. join fetch를 inner join으로 변경하더라도 페이징 처리는 되지만, 지연 로딩(lazy loading)으로 N+1 문제가 다시 발생했습니다.

그래서 해결 방법으로 조인을 제거하고, N+1 문제를 위해 default_batch_fetch_size 설정을 추가했습니다. 해당 설정은 application.yml 파일에 추가합니다.
(@BatchSize 어노테이션을 사용할 수도 있지만 어플리케이션 전역적으로 설정하기 위해 해당 방법을 선택했습니다.)

spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 1000

@BatchSize, default_batch_fetch_size 동작 방법

  1. X 타입 엔티티가 지연 로딩된 ~ToMany 관계의 Y 타입 컬렉션을 최초 조회할 때
  2. 이미 조회한 X 타입 엔티티(즉, 영속성 컨텍스트에서 관리되고 있는 엔티티)들의 ID들을 모아서
  3. WHERE Y.X_ID IN (?, ?, ?...) 와 같은 SQL IN 구문에 담아 Y 타입 데이터 조회 쿼리를 날립니다.
  4. X 타입 엔티티들이 필요로 하는 모든 Y 타입 데이터를 한 번에 조회합니다.

Batch Size 옵션에 할당되는 숫자는 IN 구문에 넣을 부모 엔티티 Key(ID)의 최대 개수입니다.

예를 들어, Batci_Size 옵션을 1000으로 지정했다고 가정해봅시다.

  1. 영속성 컨텍스트에서 관리되고 있는 1000개의 ProductLine(상품) 엔티티 ID가
  2. Product(옵션) 쿼리의 IN 구문 WHERE Y.X_ID IN (?, ?, ?...)에 포함되어 날아갑니다.
  3. 1개의 추가 Product 조회 쿼리로 1000개의 ProductLine 엔티티가 필요로하는 모든 관련 Product 데이터를 같이 조회할 수 있습니다.

결과

다음과 같은 요청을 다시 Postman에 보내보겠습니다.
http://localhost:8080/v1/categories/3/productLines/offset?page=1&size=3

우선 Hibernate 쿼리를 보면 다음과 같습니다.

Hibernate: 
    select
        distinct ple1_0.product_line_id,
        ple1_0.category_id,
        ple1_0.content,
        ple1_0.created_at,
        ple1_0.deleted_at,
        ple1_0.modified_at,
        ple1_0.name,
        ple1_0.price,
        ple1_0.sale_count,
        ple1_0.member_id,
        ple1_0.status 
    from
        product_line ple1_0 
    where
        ple1_0.category_id=? 
        and ple1_0.deleted_at is null 
        and ple1_0.product_line_id is not null 
    limit
        ?, ?
        
Hibernate: 
    select
        count(distinct ple1_0.product_line_id) 
    from
        product_line ple1_0 
    where
        ple1_0.category_id=? 
        and ple1_0.deleted_at is null 
        and ple1_0.product_line_id is not null
Hibernate: 
    select
        p1_0.product_line_id,
        p1_0.product_id,
        p1_0.extra_charge,
        p1_0.name,
        p1_0.stock 
    from
        product p1_0 
    where
        p1_0.product_line_id in (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
        
Hibernate: 
    select
        s1_0.member_id,
        s1_0.biz_no,
        s1_0.brand_name,
        s1_0.created_at,
        s1_0.deleted_at,
        m1_0.member_id,
        m1_0.created_at,
        m1_0.deleted_at,
        m1_0.email,
        m1_0.grade,
        m1_0.point,
        m1_0.total_payment_price,
        m1_0.name,
        m1_0.password,
        m1_0.role,
        m1_0.tel_no,
        m1_0.updated_at,
        s1_0.total_sell_price,
        s1_0.updated_at 
    from
        seller s1_0 
    join
        member m1_0 
            on m1_0.member_id=s1_0.member_id 
    where
        s1_0.member_id=?

총 쿼리를 보면 아래와 같습니다.

  1. limit을 포함한 offset/slice 페이징 쿼리
  2. 페이지 수 계산을 위한 count 쿼리
  3. default_batch_fetch_size 설정을 통한 IN 쿼리
  4. 연관된 Seller 정보를 조회 쿼리

또한 p6spy 를 사용하면 쿼리의 ? 에 들어가는 값을 알 수 있는데요, 아래와 같이 PageSize + 1 의 개수만큼 IN절에 포함되는 것이 보입니다.
QueryDSL에서 다음 페이지의 여부를 확인하기 위해 PageSize + 1만큼 productLine을 select하도록 했는데, 해당 ProductLine ID들이 IN절에 포함된 것입니다.

페이징도 잘 되고있고, 페이지네이션 사이즈만큼 Fetch Batch를 하고 있기 때문에 응답 시간 또한 매우 준수하게 나오는 것을 볼 수 있습니다.

서드파티 모니터링 도구인 nGrinder를 사용해 테스트 하였을 경우 아래와 같습니다.
총 79번 실행의 평균 결과가 약 0.7s로 꽤 준수하게 성능이 개선되었습니다.

기타 테스트

page size를 1000개로 BatchSize만큼 MAX로 설정했을 때의 결과는 아래와 같습니다.

  1. 검색 키워드가 없을 때 평균 약 1.2초의 시간이 소요되었습니다.

  2. 정렬 조건, 검색 키워드도 포함되어 있을 경우 처음 시도했을 때는 약 3.5초, 그 이후 시도했을 때는 평균 1.4초가 소요되었습니다.

결론

지연로딩으로 인한 N+1 문제는 보통의 경우 fetchJoin으로 해결하는 경우가 많습니다.
하지만 ~ToMany의 관계인 경우 fetchJoin시 테이블 조인에 따라 결과 데이터 수가 변경되고, 페이지네이션에서 사용할 경우 관련 엔티티를 모두 어플리케이션 메모리에 적재하기 때문에 동시 사용하는 것은 피해야 했습니다.

이에 default_batch_fetch_size 를 적용해 Page Size 1000 기준, 응답 시간을 평균 45초 -> 1.2 초로 대폭 줄여 성능을 개선했습니다.

JPA란 기술은 정말 멋지고 편리하지만, Hibernate의 동작 과정이나 JPA의 동작 과정 및 쿼리 생성 방식에 대해 잘 알지 못할 경우 종종 에러 및 성능 이슈를 마주치게 되는 것 같습니다.

다음에는 패치 조인 및 배치에 관해 자세히 알아보겠습니다.

참고

profile
Hello! I am Ogu, a developer who loves learning and sharing! 🐤🐤 <br> こんにちは!学ぶことと共有することが好きな開発者のOguです!🐤

0개의 댓글