항해 실전 프로젝트 Day 18,19 - N+1 문제 개선

박서윤·2023년 12월 18일

N+1 문제란?

N+1 문제는 ORM을 사용할 때 발생하는 성능 문제로, 데이터 베이스와 상호 작용하는 과정에서 하나의 객체를 조회할 때 그 객체와 연관된 다른 객체들을 각각 별도의 쿼리로 추가로 조회하는 경우에 발생한다. 이렇게 되면 예상 보다 많은 수의 데이터베이스 쿼리가 실행되어 어플리케이션의 성능이 저하될 수 있다.

특정 이벤트가 진행 중이고 삭제되지 않은 제품들을 찾아서, 그 제품들의 상세 정보와 연관된 이미지들을 가져오는 과정

현재 getProducts는

Hibernate: 
    /* <criteria> */ select
        p1_0.id,
        p1_0.category,
        p1_0.created_at,
        p1_0.deleted,
        p1_0.discount_rate,
        p1_0.event,
        p1_0.info,
        p1_0.modified_at,
        p1_0.name,
        p1_0.price,
        p1_0.sales_count,
        p1_0.stock 
    from
        product p1_0 
    where
        p1_0.deleted=? 
        and p1_0.event=? 
    order by
        p1_0.modified_at desc 
    limit
        ?, ?
Hibernate: 
    /* <criteria> */ select
        count(p1_0.id) 
    from
        product p1_0 
    where
        p1_0.deleted=? 
        and p1_0.event=?
Hibernate: 
    /* <criteria> */ select
        p1_0.id,
        p1_0.category,
        p1_0.created_at,
        p1_0.deleted,
        p1_0.discount_rate,
        p1_0.event,
        p1_0.info,
        p1_0.modified_at,
        p1_0.name,
        p1_0.price,
        p1_0.sales_count,
        p1_0.stock 
    from
        product p1_0 
    where
        p1_0.deleted=? 
        and p1_0.event=? 
    order by
        p1_0.modified_at desc 
    limit
        ?, ?
Hibernate: 
    /* <criteria> */ select
        i1_0.id,
        i1_0.img_url,
        i1_0.product_id 
    from
        image i1_0 
    where
        i1_0.product_id=?
Hibernate: 
    /* <criteria> */ select
        i1_0.id,
        i1_0.img_url,
        i1_0.product_id 
    from
        image i1_0 
    where
        i1_0.product_id=?
Hibernate: 
    /* <criteria> */ select
        i1_0.id,
        i1_0.img_url,
        i1_0.product_id 
    from
        image i1_0 
    where
        i1_0.product_id=?
Hibernate: 
    /* <criteria> */ select
        i1_0.id,
        i1_0.img_url,
        i1_0.product_id 
    from
        image i1_0 
    where
        i1_0.product_id=?

총 8회의 쿼리가 날라가고 있다.

N+1의 문제가 발생하고 있으므로 분석한 후 해결해보자.

    @Transactional(readOnly = true)
    public ProductListResponseDto getProducts(int eventPage, int page) {
        Pageable eventPageable = getPageable(eventPage, eventPageSize);
        Pageable pageable = getPageable(page, pageSize);

        Page<Product> eventProducts = productRepository.findAllByDeletedAndEvent(false, Event.DISCOUNT, eventPageable);
        Page<Product> products = productRepository.findAllByDeletedAndEvent(false, Event.NORMAL, pageable);

        Page<ProductResponseDto> eventProductsResponse = getResponseDtoFromProducts(eventProducts);
        Page<ProductResponseDto> productsResponse = getResponseDtoFromProducts(products);

        return ProductListResponseDto.of(eventProductsResponse, productsResponse);
    }

getProducts 메서드는 두개의 페이지 네이션 매개변수를 받는다.
두 종류의 제품을 조회하기 위해서 두개의 별도 쿼리가 실행되는데
1. 할인 이벤트 제품을 조회하는 쿼리(eventProducts)
2. 일반 이벤트 제품을 조회하는 쿼리(products)

각 쿼리는 findAllByDeletedAndEvent라는 메서드를 사용해서 삭제되지 않은 이벤트 상품들을 조회하고있다. 이 쿼리들은 Pageable 객체를 매개변수로 받아 페이징 처리를 수행한다.

문제는 getResponseDtoFromProducts부분인데

getResponseDtoFromProducts는 아래와 같이 이루어져 있다.

    private Page<ProductResponseDto> getResponseDtoFromProducts(Page<Product> products) {
        return products.map(product -> {
            List<String> imgUrlList = getImgUrlList(product);
            return ProductResponseDto.of(product, imgUrlList);
        });
    }
    private List<String> getImgUrlList(Product product) {
        return imageRepository.findAllByProductId(product.getId())
                .stream()
                .map(Image::getImgUrl)
                .toList();
    }

getImageUrlList메서드가 Product 객체마다 호출되어서 Image 엔티티의 목록을 조회하고 있다.
이 메서드는 각 Product의 Id를 사용해서 imageRepository에서 해당 제품의 모든 이미지 URL을 가져오는데 이 과정이 각 Product 엔티티에 대해 개별적으로 수행되기 때문에 N+1쿼리 문제를 발생시키는 원인이 된다.

결론적으로 getResponseDtoFromProducts 메서드는 Page< Product >의 각 항목을 ProductResponseDto로 매핑하는데, 이 과정에서 각 제품에 대한 이미지 URL 리스트를 가져오기위해 getImgUrlList를 호출한다.
반환된 제품 페이지에 N개의 제품이 있다면 이 메서드가 N번 호출되어서 N개의 추가 쿼리를 데이터 베이스로 보내게 된다. 즉 Product엔티티와 연관된 Image 엔티티를 로딩하는 getImgUrlList가 각 제품에 대해 별도의 쿼리를 실행해서 이미지를 로드하고 있는것이다.

N+1 해결방법 01

  1. Fetch Join 사용
  2. Batch Fetching 사용
  3. DTO 최적화
    여러 선택지가 있지만 이중에서 Fetch Join을 사용해서 N+1문제를 해결해보고자 한다.
    Fetch Join은 연관된 엔티티나 컬렉션을 한 번에 같이 조회하는 기능인데 연관된 엔티티까지 영속성 컨텍스트에 전부 올려버린다.
    Fetch Join을 사용한 주된 이유는 데이터 로딩과 쿼리의 간결함 때문인데 이미지 데이터가 제품 데이터와 밀접하게 연관이 되어있기때문에 한 번의 쿼리로 함꼐 로드하는 것이 자연스럽고 이를 통해 성능 최적화를 할 수 있다. Batch Fetching은 아직 잘 모르기에 선택에서 배제했고 DTO최적화는 유연성이 떨어질 수 있을것 같아 Fetch Join으로 선택했다.

1차 수정

기존 ProductRepository에 findAllByDeletedAndEvent 메서드

 Page<Product> findAllByDeletedAndEvent(boolean isDeleted, Event event, Pageable pageable);

변경 후

    @Query("select p from Product p left join fetch p.images where p.deleted =:isDeleted and p.event =:event")
    Page<Product> findAllByDeletedAndEvent(@Param("isDeleted") boolean isDeleted,
                                           @Param("event") Event event, Pageable pageable);

Fetch Join을 사용해서 left join fetch p.images 구문을 통해 Product엔티티를 조회할 때 관련된 Image 엔티티도 함께 로드되도록 하고 조건에는 p.deleted =:isDeleted and p.event =:event를 사용해서 삭제되지 않은 제품 중 특정 이벤트에 해당하는 제품만 필터링 하도록 설정했다.

프로그램을 실행시켜보니 fetch join으로 해결 될 줄 알았던 문제가 여전히 해결되지 않고있었다. 좀 찾아보니 일대다 테이블을 조인하면 데이터의 수가 변하기 때문에 Fetch join으로 페이징을 한다는 것은 불가능하다고 한다. 또한 'HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory' 경고가 뜨는데 Hiberante가 페이징 처리를 위해 메모리 내에서 추가 작업을 수행한다는 말이다. 즉 fetch join을 통해 pagination을 처리 할 시 모든 데이터를 메모리에 불러와서 작업한다. 이것은 OOM(Out Of Memory)성능 문제를 야기할 수 있으므로 치명적이다.

N+1 해결방법 02

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

default_batch_fetch_size 적용

0개의 댓글