지금까지의 연관 관계는 X to One의 관계를 처리했다.
이 관계의 경우는 외부에서 데이터를 조회하는 로직만 최적화를 거친다면 성능 저하를 대부분 피할 수 있지만 X to Many 관계의 경우 훨씬 복잡하다.
이렇게 다수의 연관 관계가 얽혀있는 상태에서 단순히 Many에 접근한다면 접근하는 데이터의 수만큼 데이터가 늘어나거나 연관 관계가 얽혀있으니 쿼리가 막 남발되는 문제 등이 발생한다.
따라서 이런 문제점들을 해결하기 위하여 사용하는 기법들과 최적화 방법을 알아보려 한다.
2020-09-01 22:21:21.872 ERROR 60970 --- [nio-8080-exec-1] o.a.c.c.C.[.[.[/].[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is org.springframework.http.converter.HttpMessageConversionException: Type definition error: [simple type, class org.hibernate.proxy.pojo.bytebuddy.ByteBuddyInterceptor]; nested exception is com.fasterxml.jackson.databind.exc.InvalidDefinitionException: No serializer found for class org.hibernate.proxy.pojo.bytebuddy.ByteBuddyInterceptor and no properties discovered to create BeanSerializer (to avoid exception, disable SerializationFeature.FAIL_ON_EMPTY_BEANS) (through reference chain: java.util.ArrayList[0]->jpabook.jpashop.api.OrderApiController$OrderDto["orderItems"]->org.hibernate.collection.internal.PersistentBag[0]->jpabook.jpashop.domain.OrderItem["item"]->jpabook.jpashop.domain.item.Item$HibernateProxy$0Xny9iLM["hibernateLazyInitializer"])] with root cause
com.fasterxml.jackson.databind.exc.InvalidDefinitionException: No serializer found for class org.hibernate.proxy.pojo.bytebuddy.ByteBuddyInterceptor and no properties discovered to create BeanSerializer (to avoid exception, disable SerializationFeature.FAIL_ON_EMPTY_BEANS) (through reference chain: java.util.ArrayList[0]->jpabook.jpashop.api.OrderApiController$OrderDto["orderItems"]->org.hibernate.collection.internal.PersistentBag[0]->jpabook.jpashop.domain.OrderItem["item"]->jpabook.jpashop.domain.item.Item$HibernateProxy$0Xny9iLM["hibernateLazyInitializer"])
지연 로딩으로 인한 프록시 객체를 Jackson 라이브러리가 해석할 수 없기에 발생하는
타입 문제(Jason Serialization Error)
orderItem 부분의 지연로딩때문에 발생하는 걸 알 수 있고 지연 로딩 시 실제 엔티티를 가져오기 전에는 프록시 객체로 대체되어 있기때문에 이런 문제가 발생한다.
해결법은 지연 로딩을 하지 않거나 fetch 조인을 사용해야하는데
지연 로딩을 사용하지 않는 방법은 의미가 없는 방법이니 fetch 조인을 사용하여 엔티티를 select 쿼리 시점에서 객체 그래프 탐색을 통해 미리 로딩하도록 해야한다.
fetch 조인을 사용하지 않으면 1:N 관계에서는 지연로딩으로 인해 쿼리가 증가하는 문제가 발생한다.
예로 OneToMany 관계를 가진 Order와 OrderItem이 있다고 가정 시
Order 데이터가 중복되어 Order 1개, 각 Order가 2개의 Item을 가진다면 OrderItem이 4개가 되어버리는 문제가 발생한다.
따라서 이런 일을 방지하기 위해 fetch 조인을 사용하여 쿼리를 튜닝할 수 있는데
문제는 fetch join이든 join이든 DB의 입장에서는 둘 다 join이기 때문에 객체의 중복이 일어난다는 것이다.
이로 인해서 페이징이 불가능한 문제가 발생하기 때문에 이를 해결하기 위해서 1:N 관계에서는 다른 방법을 사용해야한다.
2020-09-01 23:34:55.386 WARN 60970 --- [nio-8080-exec-2] o.h.h.internal.ast.QueryTranslatorImpl : HHH000104: firstResult/maxResults specified with collection fetch; applying in memory!
fetch 조인을 사용하면 쿼리는 효율적으로 줄어들지만 사실 상 페이징이 불가능해진다.
이런 fetch 조인에서도 페이징을 할 수 있는 방법을 알아본다.
@GetMapping("/api/v3.1/orders")
public List<OrderDto> ordersV3_page(
@RequestParam(value = "offset", defaultValue = "0") int offset,
@RequestParam(value = "limit", defaultValue = "100") int limit){
List<Order> orders = orderRepository.findAllWithMemberDelivery(offset, limit);
return orders.stream()
.map(OrderDto::new)
.collect(toList());
}
public List<Order> findAllWithMemberDelivery(int offset, int limit) {
return em.createQuery(
"select o from Order o" +
" join fetch o.member m" +
" join fetch o.delivery d", Order.class)
.setFirstResult(offset)
.setMaxResults(limit)
.getResultList();
}
이렇게 toOne 관계의 엔티티에만 fetch 조인을 적용하고 페이징을 하면
2020-09-02 22:03:54.200 DEBUG 62062 --- [nio-8080-exec-2] org.hibernate.SQL :
select
order0_.order_id as order_id1_6_0_,
member1_.member_id as member_i1_4_1_,
delivery2_.delivery_id as delivery1_2_2_,
order0_.delivery as delivery4_6_0_,
order0_.member_id as member_i5_6_0_,
order0_.order_date as order_da2_6_0_,
order0_.status as status3_6_0_,
member1_.city as city2_4_1_,
member1_.street as street3_4_1_,
member1_.zipcode as zipcode4_4_1_,
member1_.name as name5_4_1_,
delivery2_.city as city2_2_2_,
delivery2_.street as street3_2_2_,
delivery2_.zipcode as zipcode4_2_2_,
delivery2_.status as status5_2_2_
from
orders order0_
inner join
member member1_
on order0_.member_id=member1_.member_id
inner join
delivery delivery2_
on order0_.delivery=delivery2_.delivery_id limit ?
toOne 관계는 모두 fetch 조인을 통해 select 쿼리에서 최적화 되고
페이징 적용을 통해 0(default)부터 limit 100까지 페이징이 적용된다.
하지만 여전히 orderItem의 갯수만큼 쿼리가 나가는 문제가 존재한다.
(Order + N(Member) + M(OrderItem))
default_batch_fetch_size: 100
batch 사이즈를 적용하면 JPA를 통해 이만큼의 쿼리는 무조건 최적화가 된다
2020-09-02 22:08:40.047 DEBUG 62062 --- [nio-8080-exec-3] org.hibernate.SQL :
select
order0_.order_id as order_id1_6_0_,
member1_.member_id as member_i1_4_1_,
delivery2_.delivery_id as delivery1_2_2_,
order0_.delivery as delivery4_6_0_,
order0_.member_id as member_i5_6_0_,
order0_.order_date as order_da2_6_0_,
order0_.status as status3_6_0_,
member1_.city as city2_4_1_,
member1_.street as street3_4_1_,
member1_.zipcode as zipcode4_4_1_,
member1_.name as name5_4_1_,
delivery2_.city as city2_2_2_,
delivery2_.street as street3_2_2_,
delivery2_.zipcode as zipcode4_2_2_,
delivery2_.status as status5_2_2_
from
orders order0_
inner join
member member1_
on order0_.member_id=member1_.member_id
inner join
delivery delivery2_
on order0_.delivery=delivery2_.delivery_id limit ?
fetch 조인 적용 toOne 엔티티
2020-09-02 22:08:40.051 DEBUG 62062 --- [nio-8080-exec-3] org.hibernate.SQL :
select
orderitems0_.order_id as order_id5_5_1_,
orderitems0_.order_item_id as order_it1_5_1_,
orderitems0_.order_item_id as order_it1_5_0_,
orderitems0_.count as count2_5_0_,
orderitems0_.item_id as item_id4_5_0_,
orderitems0_.order_id as order_id5_5_0_,
orderitems0_.order_price as order_pr3_5_0_
from
order_item orderitems0_
where
orderitems0_.order_id in ( // IN
?, ?
)
select
item0_.item_id as item_id2_3_0_,
item0_.name as name3_3_0_,
item0_.price as price4_3_0_,
item0_.stock_quantity as stock_qu5_3_0_,
item0_.author as author6_3_0_,
item0_.isbn as isbn7_3_0_,
item0_.actor as actor8_3_0_,
item0_.director as director9_3_0_,
item0_.artist as artist10_3_0_,
item0_.etc as etc11_3_0_,
item0_.dtype as dtype1_3_0_
from
item item0_
where
item0_.item_id in ( // IN
?, ?, ?, ?
)
fetch 조인을 적용받지 못하는 엔티티들은 JPA에 의해 batch 사이즈만큼 IN 쿼리가 적용되어 쿼리 최적화가 가능하다.
이렇게되면 기존의 1:N 관계를 적용한 상태에서 단순히 fetch 조인을 적용했을 때 보다 쿼리가 50% 감소했고 페이징까지 적용할 수 있는 최적화를 적용할 수 있다.
이 이상의 최적화는 Redis나 JPQL 작성을 통한 최적화가 필요하다.
이게 정말 정말 정말 중요한 페이징 쿼리 최적화 기법이다.
->컬렉션 쿼리 최적화를 위해서는 지연 로딩을 사용해야한다.
-->지연 로딩으로 인해 N + 1 문제가 발생한다.
--->N + 1 문제를 해결하기 위해선 fetch 조인을 사용해야 한다.
---->fetch 조인을 사용하면 페이징이 불가능하다.
----->페이징을 사용하기 위해선 컬렉션에 fetch 조인을 걸면 안된다.
------>따라서 batch size를 통해 컬렉션 페이징을 한다.
결론적으로 컬렉션이 아닌 엔티티는 fetch 조인
컬렉션은 batch size로 최적화
뭔가 점점 이렇게까지 해야하는건가? 싶은 최적화가 되는 중