Backend
JPA N+1 쿼리 문제와 해결
2016년 6월 15일
원문에서 보기 ↗JPA N+1 쿼리 문제와 해결
목차
들어가기 전에... JPA N + 1 쿼리 문제 JPQL 실행 시 N + 1 쿼리 문제에 대한 해결방안
들어가기 전에...
이러한 고객/주문 ERD가 있다고 했을 때

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로는 해결되지 않는다. 
JPQL 실행 시 N + 1 쿼리 문제에 대한 해결방안
- Fetch Join 사용
- 글로벌 페치 전략 - LAZY 사용
- OSIV 적용
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;
}
Bag: 하이버네이트에서 중복 요소를 허용하는 비순차(unordered) 컬렉션- 2개 이상의 @OneToMany 컬렉션(
Bag)에 대한 EAGER Fetch 시 그 결과 만들어지는 카테시안 곱(Cartesian Product)에서 어느 행이 유효한 중복을 포함하고 어느 행이 그렇지 않은 지 판단할 수 없어Bag컬렉션으로 변환될 수 없기 때문에 예외 발생 - List를 Set으로 변경하면 중복을 비허용
- 이 때, List는 입력 순서가 보장되나 Set은 입력 순서가 보장되지 않기 때문에 입력 순서 보장이 필요한 경우 LinkedHashSet 등의 다른 자료구조를 사용
글로벌 페치 전략 - LAZY 사용
글로벌 페치 전략 기본값
- @OneToOne, @ManyToOne : EAGER
- @OneToMany, @ManyToMany : 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 : 영속성 컨텍스트를 뷰까지 확장
- Open Session In View : Hibernate
- Open EntityManager In View: JPA
Spring Data JPA에서 OSIV 사용하는 법
- Interceptor 이용
org.springframework.orm.jpa.support.OpenEntityManagerInViewInterceptor
- Servlet Filter 이용
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>