REST API와 성능 최적화, 지연 로딩과 페이징 문제

dropKick·2020년 9월 1일
0

페이징과 한계 돌파

컬렉션 타입 조회 최적화

X to Many 관계의 복잡성

지금까지의 연관 관계는 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 조인을 통한 쿼리 최적화

fetch 조인을 사용하지 않으면 1:N 관계에서는 지연로딩으로 인해 쿼리가 증가하는 문제가 발생한다.
예로 OneToMany 관계를 가진 Order와 OrderItem이 있다고 가정 시

  • SQL 실행 수
    order 1번
    member , address NN번(order select)
    orderItem NN번 (order select)
    item NN번(order select)

Order 데이터가 중복되어 Order 1개, 각 Order가 2개의 Item을 가진다면 OrderItem이 4개가 되어버리는 문제가 발생한다.

따라서 이런 일을 방지하기 위해 fetch 조인을 사용하여 쿼리를 튜닝할 수 있는데
문제는 fetch join이든 join이든 DB의 입장에서는 둘 다 join이기 때문에 객체의 중복이 일어난다는 것이다.
이로 인해서 페이징이 불가능한 문제가 발생하기 때문에 이를 해결하기 위해서 1:N 관계에서는 다른 방법을 사용해야한다.

fetch 조인 인 메모리 페이징 문제

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!
  • 1:N 관계에서 fetch 조인 시 페이징 불가능
    DB 입장에서는 사실 N으로 인해 데이터가 중복 된 상태이기때문에 개수의 차이로 페이징이 걸리지 않음
    따라서 애플리케이션에 모든 데이터를 끌어와 페이징 하게 되고, 이는 OutOfMemory 문제를 일으킬 수 있어서 매우 크리티컬하며 사용하면 안된다.
    1:N이 아닌 연관 관계에서는 fetch 조인을 남발해도 된다. 오히려 성능적으로 좋다.

fetch 조인과 페이징 적용

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());
    }
  • RequestParm을 이용하여 페이징 범위를 받는다.

페이징 적용하기

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로 최적화


뭔가 점점 이렇게까지 해야하는건가? 싶은 최적화가 되는 중

0개의 댓글