firstResult/maxResults specified with collection fetch; applying in memory

Gogh·2023년 8월 29일

Trouble Shooting

목록 보기
2/3
post-thumbnail

QueryDsl 활용 동적 쿼리 구현 중 FetchJoin과 Pagination 사용시 발생 이슈

  • [WARN Log] HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory
  • GroupBy 적용 전 데이터 Offset 으로 인한 Pagination Size 오류

Issue

  • 1:N 관계(OneToMany)의 Entity 데이터를 조회할 때, FetchJoin을 활용 해 한방 쿼리로 조회를 하려고 시도

  • 쿼리 확인 결과 limit,offset 절이 날아가지 않음 확인, 그러나 Pagination 정상 동작 확인

  • [WARN Log] HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory 발생

  • 모든 데이터를 조회 하여 메모리에 저장 중 이라고 한다, 데이터가 많아진다면 성능 이슈가 발생할 수 있을거라 예상됨'

  • Code

    // ..........
            JPQLQuery<Order> query = from(order)
                    .innerJoin(order.user,QUser.user)
                    .fetchJoin()
                    .innerJoin(order.propOrders, QPropOrder.propOrder)
                    .fetchJoin()
                    .innerJoin(propOrder.prop, prop)
                    .fetchJoin()
                    .innerJoin(prop.propImage, QPropImage.propImage)
                    .fetchJoin()
                    .innerJoin(prop.propFile, QPropFile.propFile)
                    .fetchJoin()
                    .innerJoin(prop.detailCategory, QDetailCategory.detailCategory)
                    .fetchJoin()
                    .innerJoin(prop.middleCategory, QMiddleCategory.middleCategory)
                    .fetchJoin()
                    .innerJoin(prop.largeCategory, QLargeCategory.largeCategory)
                    .fetchJoin()
                    ;
    
            if (keyWord != null && !keyWord.isEmpty()) {
                query.where(
                        prop.title.containsIgnoreCase(keyWord)
                                .or(prop.contents.containsIgnoreCase(keyWord))
                );
            }
            //............. Pagination 적용 부
            	  .orElseThrow(() -> new ServiceLogicException(ErrorCode.DATA_ACCESS_ERROR))
                  .applyPagination(pageable, query)
                  .fetch();
            // ........
  • Order 테이블이 여러 테이블과 관계가 있어 우선 무지성 FetchJoin을 하였다.

  • 경고 문구

  • Query문의 offset & limit 절 없음

  • 요구하는 응답 데이터 형태(List Data 중 하나의 객체 추출)

    {
                "orderId": 4,
                "orderStatus": "APPROVED",
                "payType": "PAYPAL",
                "userId": 2,
                "nickName": "test2",
                "email": "test2@test.com",
                "orderPlatformId": "12HJK14GK23BDDDdK3",
                "orderPlatformStatus": "APPROVED",
                "orderProps": [
                    {
                        "propId": 3,
                        "baseFileName": "Base File Name2",
                        "extension": ".fbx",
                        "detailImageUrl": "https://grdd....",
                        "propFileId": 1,
                        "title": "Title2",
                        "contents": "Content2",
                        "propPrice": "9.99",
                        "downloadCount": 3,
                        "orderCount": 2,
                        "likesCount": 19,
                        "propStatus": "SALE",
                        "largeCategoryId": 1,
                        "largeCategoryName": "Large Category",
                        "middleCategoryId": 1,
                        "middleCategoryName": "Middle Category",
                        "detailCategoryId": 1,
                        "detailCategoryName": "Detail Category",
                        "createAt": "2023-08-31T13:01:31.068189",
                        "updateAt": "2023-08-31T13:01:31.068189"
                    },
                    {
                        "propId": 4,
                        "baseFileName": "Base File Name3",
                        "extension": ".fbx",
                        "detailImageUrl": "https://grdd....",
                        "propFileId": 1,
                        "title": "Title3",
                        "contents": "Content3",
                        "propPrice": "9.99",
                        "downloadCount": 3,
                        "orderCount": 2,
                        "likesCount": 19,
                        "propStatus": "SALE",
                        "largeCategoryId": 1,
                        "largeCategoryName": "Large Category",
                        "middleCategoryId": 1,
                        "middleCategoryName": "Middle Category",
                        "detailCategoryId": 1,
                        "detailCategoryName": "Detail Category",
                        "createAt": "2023-08-31T13:01:31.068189",
                        "updateAt": "2023-08-31T13:01:31.068189"
                    }
                ],
                "totalPrice": "19.98",
                "createAt": "2023-08-31T13:01:31.07019",
                "updateAt": "2023-08-31T13:01:31.07019"
    
  • 응답 데이터에 해당하는 Entity 들의 관계

Cause

  • One 테이블이 Many 테이블을 여러개를 소유하고 있는 관계로 인해 테이블을 Join 하는 과정에서 One 테이블이 중복되어 조회 되는 경우가 발생함.
  • 실제 코드에서 데이터 조회시 중복되는 데이터를 제거(JVM 메모리 상에서 필터링) 하기 때문에 확인 할수 없지만, 중복되는 데이터로 인하여 Pagination이 불가능 하다.
  • 그로 인해 데이터를 모두 조회하여 메모리에 저장하고 저장한 상태에서 Pagination 처리를 하기 때문에 정상적으로 동작한며, Query문에 limit절이 없는 것이다.

Solve #1

  • FetchJoin은 엔티티 그래프를 탐색 하여야 하는 경우, 즉 서비스 로직, 데이터의 수정과 같은 경우에 사용한다.

  • 단순한 조회 로직에서는 사용할 필요가 없으며, 데이터의 조회만 할 경우 Dto를 반환하는 로직으로 구현 하여 처리하는 것이 좋다.

  • 추가로, 나의 경우 N:M 관계를 1:N / N:1로 풀어 놓은 상태라서, 조금 더 유동적으로 조회 하기 위해 N 테이블의 엔티티 Repository를 구현하여 개선 하였다.

  • 위 JSON 데이터에서 orderProps 객체는 리스트 타입으로, orderId에 의해 분류 된다 (하나의 Order에 여러개의 Prop 데이터가 있다고 생각하면 됨)

  • GroupBy로 order별 prop을 묶어서 데이터 조회가 필요 했고, 필요한 테이블에 Join 후 Transform으로 groupBy하여 Dto 데이터로 응답 하는 방향으로 수정 하였다.

  • 첫번째 개선 시도 Code

     List<PropOrderResponseDto> fetch = Optional.ofNullable(getQuerydsl())
                  .orElseThrow(() -> new ServiceLogicException(ErrorCode.DATA_ACCESS_ERROR))
                  .applyPagination(pageable, query)
                  .transform(
                          groupBy(propOrder.order.orderId)
                                  .list(
                                          Projections.constructor(
                                                  PropOrderResponseDto.class,
                                                  propOrder.order.orderId,
                                                  propOrder.order.orderStatus,
                                                  propOrder.order.payType,
                                                  propOrder.order.user.userId,
                                                  propOrder.order.user.nickName,
                                                  propOrder.order.user.email,
                                                  propOrder.order.orderPlatformId,
                                                  propOrder.order.orderPlatformStatus,
                                                  GroupBy.list(
                                                        Projections.constructor(
                                                                   PropResponseDto.class,
                                                                   propOrder.prop.propId,
                                                                   propOrder.prop.baseFileName,
                                                                   propOrder.prop.extension,
                                                                   propOrder.prop.propImage.url,
                                                                   propOrder.prop.propFile.propFileId,
                                                                   propOrder.prop.title,
                                                                   propOrder.prop.contents,
                                                                   propOrder.prop.propPrice,
                                                                   propOrder.prop.downloadCount,
                                                                   propOrder.prop.orderCount,
                                                                   propOrder.prop.likesCount,
                                                                   propOrder.prop.propStatus,
                                                                   propOrder.prop.largeCategory.largeCategoryId,
                                                                   propOrder.prop.largeCategory.name,
                                                                   propOrder.prop.middleCategory.middleCategoryId,
                                                                   propOrder.prop.middleCategory.name,
                                                                   propOrder.prop.detailCategory.detailCategoryId,
                                                                   propOrder.prop.detailCategory.name,
                                                                   propOrder.prop.createAt,
                                                                   propOrder.prop.updateAt
                                                                  )
                                                          )
                                                  ,
                                                  propOrder.order.totalPrice,
                                                  propOrder.order.createAt,
                                                  propOrder.order.updateAt
                                          )
                                  )
                  );
  • Projections.constructor를 활용하여 DTO 생성자에 알맞은 필드를 바인딩 한다

  • GroupBy.list를 활용해 List 객체 필드도 바인딩 해준다

  • 개선 후 쿼리

    • [WARN Log] HHH90003004 로그도 발생하지 않고, offset 절이 정상적으로 날아가는걸 확인

    • 다른 문제가 발생한다

    • DB에 들어있는 데이터는 12개의 더미 데이터고, 실제 응답으로 나오는 데이터는 9개로 조회 되고 있다

    • groupBy가 적용되기 이전 데이터의 상태를 그림으로 보면

      • 위와 같이 데이터가 조회 될것이라고 예상된다, 하나의 order 에서 가지고 있는 prop을 묶어 주기 위해 groupBy 속성을 사용하는 것이다.
    • groupBy가 적용되고 난 이후 데이터 상태를 확인 해 보면 아래와 같을 것이다.

      • 위와 같은 상태로 offset을 하게 되면, 10개의 데이터를 조회하더라도 offset의 기준이 groupBy가 적용되기 이전 데이터에서 잘리기 때문에 3개의 데이터가 최종 데이터로 조회 된다.

      • 뤼튼 똑똑해

Solve #2

  • 위 문제를 해결하기 위해 Order 테이블을 먼저 조회 후 orderId를 통해 Prop 데이터를 매칭 시켜 추가 조회 후 응답 Dto에 서비스 로직에서 매핑 시켜주기로 함

  • 추기로 orderId 리스트로 prop을 조회 하게 되면, orderId Size 만큼 쿼리가 발생하기 때문에 default_batch_fetch_size: 1000 옵션을 주어 in 절로 묶어 한번에 최대 1000개의 쿼리를 하나로 날려 주도록 설정

  • 개선 Code

// OrderRepositoryImpl.java
JPQLQuery<PropOrderResponseDto> query = from(order)
                .join(prop)
                .distinct()
                .select(
                        Projections.constructor(
                                PropOrderResponseDto.class,
                                order.orderId,
                                order.orderStatus,
                                order.payType,
                                order.user.userId,
                                order.user.nickName,
                                order.user.email,
                                order.orderPlatformId,
                                order.orderPlatformStatus,
                                order.totalPrice,
                                order.createAt,
                                order.updateAt
                        )
                )
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .orderBy(
                        order.createAt.desc()
                )
                ;
                // ......
                List<PropOrderResponseDto> fetch = query.fetch();
        return new PageImpl<>(fetch, pageable, query.fetchCount());
                
                //.....
                
//PropOrderRepositoryImpl.java
public List<OrderPropQueryDto> findAllPropOrderResponseDto(List<Long> orderIds) {

        return from(propOrder)
                .select(
                        Projections.constructor(
                                OrderPropQueryDto.class,
                                propOrder.order.orderId,
                                Projections.constructor(
                                        PropResponseDto.class,
                                        propOrder.prop.propId,
                                        propOrder.prop.baseFileName,
                                        propOrder.prop.extension,
                                        propOrder.prop.propImage.url,
                                        propOrder.prop.propFile.propFileId,
                                        propOrder.prop.title,
                                        propOrder.prop.contents,
                                        propOrder.prop.propPrice,
                                        propOrder.prop.downloadCount,
                                        propOrder.prop.orderCount,
                                        propOrder.prop.likesCount,
                                        propOrder.prop.propStatus,
                                        propOrder.prop.largeCategory.largeCategoryId,
                                        propOrder.prop.largeCategory.name,
                                        propOrder.prop.middleCategory.middleCategoryId,
                                        propOrder.prop.middleCategory.name,
                                        propOrder.prop.detailCategory.detailCategoryId,
                                        propOrder.prop.detailCategory.name,
                                        propOrder.prop.createAt,
                                        propOrder.prop.updateAt
                                )
                        )
                ).where(propOrder.order.orderId.in(orderIds))
                .fetch();
    }
    
//OrderService.java

    public Page<PropOrderResponseDto> findAllOrderSearch(
            LocalDate startDate,
            LocalDate endDate,
            String keyWord,
            Long userId,
            Pageable pageable,
            String orderStatus
    ) {
        LocalDateTime start = LocalDateTime.of(startDate, LocalTime.MIN);
        LocalDateTime end = LocalDateTime.of(endDate, LocalTime.MAX);
        OrderStatus orderStatusEnum = null;
        if (!orderStatus.isEmpty() && OrderStatus.getValueStringList().contains(orderStatus)) {
            orderStatusEnum = OrderStatus.valueOf(orderStatus);
        }

        Page<PropOrderResponseDto> findOrderPage = orderRepository.findAllProp(
                pageable,
                userId,
                start,
                end,
                keyWord,
                orderStatusEnum
        );
        List<Long> idList = findOrderPage.map(PropOrderResponseDto::getOrderId).toList();
        List<OrderPropQueryDto> findPropList = propOrderRepository.findAllPropOrderResponseDto(idList);

        return findOrderPage.map(por ->
                {
                    List<PropResponseDto> list = findPropList
                            .stream()
                            .filter(a -> a.getOrderId().equals(por.getOrderId()))
                            .map(OrderPropQueryDto::getProp)
                            .toList();
                    por.setOrderProps(list);
                    return por;
                }
        );
    }
  • OrderRepository에서 Order 응답 Dto 리스트 필드가 비어있는 상태로 데이터를 1차 조회 해 온다
  • 서비스 로직에서는 응답 받은 데이터로 orderId 리스트를 생성하고 해당 리스트로 PropOrderRepository에서 응답 Dto 리스트 필드에 들어갈 Prop 테이블 데이터를 2차로 조회 한다
  • Count 쿼리까지 포함하여 총 3개의 쿼리가 발생하며, 2차 조회 시 쿼리는 IN 절로 묶여서 쿼리가 날아가는 것을 확인 할 수 있다
  • 데이터도 정상적으로 응답 되며, 쿼리도 의도한 대로 날아가는 것을 확인 하였다

QueryDSL에 대한 학습이 더 필요하다, 쿼리를 나눠 조회하는 방식 외에 개선할 수 있는 방법이 있는지 더 찾아보고 학습을 해 나갈 예정이다.

profile
컴퓨터가 할일은 컴퓨터가

0개의 댓글