grep

Backend

JPA N+1 쿼리 문제와 해결

NHN

2016년 6월 15일

원문에서 보기 ↗

JPA N+1 쿼리 문제와 해결

목차

들어가기 전에... JPA N + 1 쿼리 문제 JPQL 실행 시 N + 1 쿼리 문제에 대한 해결방안

들어가기 전에...

이러한 고객/주문 ERD가 있다고 했을 때

001.png

JPA Entity로 표현해보면


OrderEntity

@Entity
@Table(name = "Orders")
public class OrderEntity {
    @Id
    @Column(name = "order_id")
    private Long orderId;

    // default fetch type = EAGER
    @ManyToOne(optional = false)
    @JoinColumn(name = "customer_id")
    CustomerEntity customer;

    // default fetch type = LAZY
    @OneToMany
    @JoinColumn(name = "order_id")
    List<OrderItemEntity> orderItems;

}

OrderItemEntity

@Entity
@Table(name = "OrderItems")
public class OrderItemEntity {
    @Id
    @Column(name = "order_line_id")
    private Long orderLineId;

    // default fetch type = EAGER
    @ManyToOne
    @JoinColumn(name = "item_id")
    ItemEntity item;

}

Repository interface


OrderRepository

@Repository("orderRepository")
public interface OrderRepository extends JpaRepository<OrderEntity, Long> {
    OrderEntity findOne(Long orderId);

    List<OrderEntity> findAll();

}

JPA N + 1 쿼리 문제

OrderRepository.findOne() 호출 시 실행되는 쿼리


자바 코드
OrderEntity orderEntity = orderRepository.findOne(1L);
실제 수행되는 쿼리
SELECT
    orderentit0_.`order_id`      AS order_id1_22_0_,
    orderentit0_.`customer_id`   AS customer2_22_0_,
    orderentit0_.`order_date`    AS order_da3_22_0_,
    customeren1_.`customer_id`   AS customer1_4_1_,
    customeren1_.`customer_name` AS customer2_4_1_
FROM `Orders` orderentit0_
INNER JOIN `Customers` customeren1_
    ON orderentit0_.customer_id=customeren1_.`customer_id`
WHERE orderentit0_.`order_id`=1;

1 쿼리! NO PROBLEM

OrderRepository.findAll()을 호출한다면?


자바 코드
List<OrderEntity> orders = orderRepository.findAll();
실제 수행되는 쿼리
SELECT * FROM `Orders` orderentit0_;
위 쿼리 결과로 N개의 레코드가 반환된다고 했을 때 추가로 수행되는 쿼리
SELECT * FROM `Customers` customeren0_ WHERE customeren0_.`customer_id`=1;
SELECT * FROM `Customers` customeren0_ WHERE customeren0_.`customer_id`=2;
SELECT * FROM `Customers` customeren0_ WHERE customeren0_.`customer_id`=3;
SELECT * FROM `Customers` customeren0_ WHERE customeren0_.`customer_id`=4;
SELECT * FROM `Customers` customeren0_ WHERE customeren0_.`customer_id`=5;
-- ...
-- (N개)

헉?! 총 N + 1개의 쿼리가 수행됨!!! - 이것이 바로 N + 1 쿼리 문제

거기다 OrderItem까지 참조한다면?


자바 코드
List<OrderEntity> orders = orderRepository.findAll();

// 전체 주문에 포함된 모든 상품 목록
List<ItemEntity> items = orders.stream()
        .flatMap(e -> e.getOrderItems().stream())
        .map(OrderItemEntity::getItem)
        .collect(Collectors.toList());
실제 수행되는 쿼리
SELECT * FROM `Orders` orderentit0_;
SELECT * FROM `Customers` customeren0_ WHERE customeren0_.`customer_id`=1;
SELECT * FROM `Customers` customeren0_ WHERE customeren0_.`customer_id`=2;
SELECT * FROM `Customers` customeren0_ WHERE customeren0_.`customer_id`=3;
-- ...
SELECT * 
FROM `OrderItems` orderitems0_ LEFT OUTER JOIN `Items` itementity1_ ON orderitems0_.item_id=itementity1_.`item_id` 
WHERE orderitems0_.order_id=1;

SELECT * 
FROM `OrderItems` orderitems0_ LEFT OUTER JOIN `Items` itementity1_ ON orderitems0_.item_id=itementity1_.`item_id` 
WHERE orderitems0_.order_id=2;
-- ...

글로벌 페치 전략을 EAGER로 변경하면?


N + 1 쿼리 문제는 컬렉션에 대한 Lazy Loading때문에 발생하니까 글로벌 페치 전략을 EAGER Fetch(즉시 로딩)로 변경하면 해결이 되지 않을까?

OrderEntity에서 orderItems를 EAGER Fetch로 변경해보자
@Entity
@Table(name = "Orders")
public class OrderEntity {
    // ...

    // default fetch type = LAZY <--- EAGER로 변경하면?
    @OneToMany(fetch = FetchType.EAGER)
    @JoinColumn(name = "order_id")
    List<OrderItemEntity> orderItems;

}

글로벌 페치 전략을 EAGER로 변경 - OrderRepository.findOne() 호출 시


자바 코드
OrderEntity orderEntity = orderRepository.findOne(1L);
실제 수행되는 쿼리
SELECT
     *
FROM `Orders` orderentit0_
INNER JOIN `Customers` customeren1_
    ON orderentit0_.customer_id=customeren1_.`customer_id`
LEFT OUTER JOIN `OrderItems` orderitems2_
    ON orderentit0_.`order_id`=orderitems2_.order_id
LEFT OUTER JOIN `Items` itementity3_
    ON orderitems2_.item_id=itementity3_.`item_id`
WHERE orderentit0_.`order_id`=1;

OrderEntity를 가져오면서 customer와 orderItems까지 JOIN으로 한 쿼리로 가져온다. 해결 되는 듯?!

글로벌 페치 전략을 EAGER로 변경 - OrderRepository.findAll() 호출 시


자바 코드
List<OrderEntity> orders = orderRepository.findAll();
실제 수행되는 쿼리
SELECT * FROM `Orders` orderentit0_;
SELECT * FROM `Customers` customeren0_ WHERE customeren0_.`customer_id`=1;
SELECT * FROM `Customers` customeren0_ WHERE customeren0_.`customer_id`=2;
SELECT * FROM `Customers` customeren0_ WHERE customeren0_.`customer_id`=3;
-- ...
SELECT * FROM `OrderItems` orderitems0_ LEFT OUTER JOIN `Items` itementity1_ 
ON orderitems0_.item_id=itementity1_.`item_id` WHERE orderitems0_.order_id=1;

SELECT * FROM `OrderItems` orderitems0_ LEFT OUTER JOIN `Items` itementity1_ 
ON orderitems0_.item_id=itementity1_.`item_id` WHERE orderitems0_.order_id=2;
-- ...

JPQL을 실행할 때는 LAZY Fetch나 EAGER Fetch나 차이가 없다....;;;

글로벌 페치 전략을 EAGER로 변경, 결론


단일 엔터티 조회 시 (ex. OrderRepository.findOne() )에는 글로벌 페치 전략을 EAGER로 변경하면 JOIN을 통해 실제 실행되는 쿼리 수를 줄일 수 있다.

하지만, 글로벌 페치 전략은 JPQL 실행 시 (ex. OrderRepository.findAll() )에는 적용되지 않는다.

즉, JPQL 실행 시 N + 1 쿼리 문제는 EAGER Fetch로는 해결되지 않는다. 002.png

JPQL 실행 시 N + 1 쿼리 문제에 대한 해결방안

Fetch Join 사용


~~List orders = orderRepository.findAll();~~

List<OrderEntity> orders = orderRepository.getAllOrders();
// using QueryDSL
public List<OrderEntity> getAllOrders() {
    QOrderEntity order = QOrderEntity.orderEntity;
    QCustomerEntity customer = QCustomerEntity.customerEntity;
    QOrderItemEntity orderItem = QOrderItemEntity.orderItemEntity;

    return from(order)
            .innerJoin(order.customer, customer)
            .fetch() // <--- FETCH JOIN
            .leftJoin(order.orderItems, orderItem)
            .fetch() // <--- FETCH JOIN
            .list(order);
}
-- JPQL
select orderEntity
from OrderEntity orderEntity
  inner join fetch orderEntity.customer as customerEntity
  left join fetch orderEntity.orderItems as orderItemEntity;

Fetch Join 사용 시 주의할 점


MultipleBagFetchException 발생: 컬렉션 타입을 List -> Set으로 변경
@Entity
@Table(name = "Orders")
public class OrderEntity {
    @OneToMany
    @JoinColumn(name = "order_id")
    Set<OrderItemEntity> orderItems;

}

글로벌 페치 전략 - LAZY 사용


글로벌 페치 전략 기본값

Fetch Join을 사용하더라도 글로벌 페치 전략이 EAGER로 설정된 연관관계에 대해서는 즉시 로딩을 위해 추가로 쿼리들이 실행된다

select * from `Items` itementity0_ where itementity0_.`item_id`=1;
select * from `Items` itementity0_ where itementity0_.`item_id`=2;
select * from `Items` itementity0_ where itementity0_.`item_id`=3;
-- ...

따라서, 즉시 로딩이 필요하지 않은 @OneToOne, @ManyToOne 연관관계에 대해서는 글로벌 페치 전략을 LAZY로 변경해서 불필요한 쿼리 실행 방지

@Entity
@Table(name = "Orders")
public class OrderEntity {
    // default fetch type = EAGER -> LAZY fetch로 변경
    @ManyToOne(optional = false, fetch = FetchType.LAZY)
    @JoinColumn(name = "customer_id")
    CustomerEntity customer;

}
@Entity
@Table(name = "OrderItems")
public class OrderItemEntity {
    // default fetch type = EAGER -> LAZY fecth로 변경
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "item_id")
    ItemEntity item;

}

OSIV 적용


글로벌 페치 전략을 LAZY로 변경하면, 영속성 컨텍스트를 벗어나서 Lazy Loading 시도 시 LazyInitializationException이 발생

-> OSIV를 적용을 통해 해결

OSIV : 영속성 컨텍스트를 뷰까지 확장
Spring Data JPA에서 OSIV 사용하는 법
org.springframework.orm.jpa.support.OpenEntityManagerInViewInterceptor
org.springframework.orm.jpa.support.OpenEntityManagerInViewFilter
OSIV Interceptor 설정 예
<mvc:interceptors>
  <mvc:interceptor>
    <mvc:mapping path="/**" />
    <bean
class="org.springframework.orm.jpa.support.OpenEntityManagerInViewInterceptor"
    />
  </mvc:interceptor>
</mvc:interceptors>