[SPRING] JPA의 N+1 문제와 JPA의 조회 성능 최적화하기
N+1이란?
Lazy Loading을 사용할 때 일대다(OneToMany) 관계에서 흔하게 발생하는 문제로 일대다 관계가 설정된 엔티티를 조회할 때 다(N)에 해당되는 컬렉션의 데이터 개수만큼 조회 쿼리가 추가로 발생하는 상황을 말한다. 최종적으로 하나의 엔티티를 N+1번의 조회 쿼리로 읽어오면서 성능 상의 이슈가 발생한다.
예를 들어 하나의 게시물에 10개의 댓글이 달렸다고 생각해보자. 이 하나의 게시물을 읽어오는데에 게시물 조회 1번과 10개의 댓글 조회 10번, 총 11번의 조회 쿼리가 발생한다. 이 문제를 해결하기 위한 방법은 무엇이 있을까?
해결 방법
1. 엔티티 조회 방식을 최우선으로 사용하자.
1-1. Fetch Join으로 쿼리 수를 최적화한다.
먼저 ManyToOne, OneToOne 관계는 Fetch Join을 사용한다.
@Query("SELECT distinct m FROM Member m join fetch member.t")
public List<Member> findAllUsingFetchJoin();
Fetch Join?
- SQL에서 사용하는 Join과 달리 JPQL에서만 성능 최적화를 위해 Fetch Join이 존재한다.
- 연관된 엔티티나 컬렉션을 한 번의 쿼리로 함께 조회하기 위한 기능이다.
- 일반적인 Join과 달리 연관된 엔티티의 영속성 컨텍스트도 함께 관리한다는 특징이 있다.
1-2. 컬렉션(...ToMany 관계) 조회는 다음과 같이 최적화한다.
페이징이 필요한 경우
application.yml에서 jpa.properties.hibernate.default_batch_fetch_size를 100~1000 사이의 크기로 설정한다. 이 설정은 컬렉션을 조회할 때 사용하는 SQL의 IN 절의 배치 크기를 의미한다.
페이징이 필요하지 않은 경우
...ToOne 관계와 동일하게 Fetch Join을 사용해 조회 성능을 최적화한다. 단, 조회 과정에서 데이터의 중복이 발생하기 때문에 distinct 키워드를 함께 사용해야 한다. 즉, Fetch join을 사용하면 페이징 사용이 불가능하다.
public List<Order> findAllWithItem() {
return em.createQuery(
"select distinct o from Order o" +
" join fetch o.member m" +
" join fetch o.delivery d" +
" join fetch o.orderItems oi" + // 새로 추가된 부분
" join fetch oi.item i", Order.class) // 새로 추가된 부분
.getResultList();
}
2. 엔티티 조회 방식으로 해결이 안되면 DTO 조회 방식을 사용한다.
클라이언트의 요구 사항에 따라 성능과 코드의 복잡도를 생각해 Fetch Join, 직접 조회, SQL IN 절 등 다양한 방식을 고려해보자.
public List<OrderQueryDto> findAllByDto_optimization() {
//루트 조회(toOne 코드를 모두 한번에 조회)
List<OrderQueryDto> result = findOrders();
//orderItem 컬렉션을 MAP 한방에 조회
Map<Long, List<OrderItemQueryDto>> orderItemMap = findOrderItemMap(toOrderIds(result));
//루프를 돌면서 컬렉션 추가(추가 쿼리 실행X)
result.forEach(o -> o.setOrderItems(orderItemMap.get(o.getOrderId())));
return result;
}
private List<Long> toOrderIds(List<OrderQueryDto> result) {
return result.stream()
.map(o -> o.getOrderId())
.collect(Collectors.toList());
}
// SQL IN 절 사용
private Map<Long, List<OrderItemQueryDto>> findOrderItemMap(List<Long> orderIds) {
List<OrderItemQueryDto> orderItems = em.createQuery(
"select new jpabook.jpashop.repository.order.query.OrderItemQueryDto(oi.order.id, i.name, oi.orderPrice, oi.count)" +
" from OrderItem oi" +
" join oi.item i" +
" where oi.order.id in :orderIds", OrderItemQueryDto.class)
.setParameter("orderIds", orderIds)
.getResultList();
return orderItems.stream().collect(Collectors.groupingBy(OrderItemQueryDto::getOrderId));
}
3. 최후의 수단으로 NativeSQL이나 스프링의 JDBC Template을 사용한다.
참조: