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가 각 제품에 대해 별도의 쿼리를 실행해서 이미지를 로드하고 있는것이다.
기존 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)성능 문제를 야기할 수 있으므로 치명적이다.
@OneToMany 애너테이션으로 관계가 맺어져 있는 경우 조인과 페이징 처리를 동시에 처리하기가 힘들다 join fetch를 inner join으로 변경하더라도 페이징 처리는 되지만, 지연 로딩(lazy loading)으로 N+1 문제가 다시 발생한다.
default_batch_fetch_size 적용