[JPA] Spring Boot JPA 조회 성능 최적화 (2)

호성·2022년 1월 19일
0

지난 편에서 XToOne 관계의 최적화를 중심적으로 다뤘다면, 이번 편에서는 XtoMany 관계의 조회 성능 최적화를 중심으로 다룬다.

이렇게 XToOne 관계와 XToMany 관계를 분리한 이유는, XToOne 관계에서의 최적화 방법이 XToMany에서는 최적의 방법이 아닐 수 있고 그 반대도 마찬가지이기 때문이다.

ERD

DTO 정의

    @Getter
    class OrderDto {
        private Long orderId;
        private String name;
        private LocalDateTime orderDate;
        private OrderStatus orderStatus;
        private Address address;
        private List<OrderItemDto> orderItems;

        public OrderDto(Order o) {
            this.orderId = o.getId();
            this.name = o.getMember().getName();
            this.orderDate = o.getOrderDate();
            this.orderStatus = o.getStatus();
            this.address = o.getDelivery().getAddress();
            this.orderItems = o.getOrderItems().stream()
                    .map(orderItem -> new OrderItemDto(orderItem))
                    .collect(Collectors.toList());
        }
    }
    
    @Getter
    class OrderItemDto {

        private String itemName; // 상품명
        private int orderPrice; // 주문 가격
        private int count; // 주문 수량

        public OrderItemDto(OrderItem orderItem) {
            itemName = orderItem.getItem().getName();
            orderPrice = orderItem.getOrderPrice();
            count = orderItem.getCount();
        }
    }

API에서 클라이언트에게 반환하는 DTO 정의이다. OrderDto는 Order 내용을 위해, OrderItemDto는 OrderItem을 위해서 만들어진 DTO다.

도메인 자체는 직접적으로 노출시키지 않는 것이 좋다. 따라서 이렇게 DTO 객체를 만들어 클라이언트에게 도메인의 내용을 담아 전달한다.

테이블 상황

  • 두명의 member가 각각 order를 1번 씩하고, 각 주문에 대해서 다른 책 두 권씩 구입한 상황이다.

  • Member

  • Order

  • Delivery

  • OrderItem

  • Item

문제 상황

  • API
    @GetMapping("/api/v1/orders")
    public List<OrderDto> ordersV1() {
        List<Order> orders = orderRepository.findAll();
        List<OrderDto> collect = orders.stream()
                .map(o -> new OrderDto(o))
                .collect(Collectors.toList());

        return collect;
    }

api 요청이 들어오면, 데이터베이스로부터 단순히 모든 주문 테이블 필드를 조회하고 DTO로 변환하여 반환하는 코드이다.


    select
        order0_.order_id as order_id1_6_,
        order0_.delivery_id as delivery4_6_,
        order0_.member_id as member_i5_6_,
        order0_.order_date as order_da2_6_,
        order0_.status as status3_6_ 
    from
        orders order0_ 
    inner join
        member member1_ 
            on order0_.member_id=member1_.member_id limit ?


    select
        member0_.member_id as member_i1_4_0_,
        member0_.city as city2_4_0_,
        member0_.street as street3_4_0_,
        member0_.zipcode as zipcode4_4_0_,
        member0_.name as name5_4_0_ 
    from
        member member0_ 
    where
        member0_.member_id=?


    select
        delivery0_.delivery_id as delivery1_2_0_,
        delivery0_.city as city2_2_0_,
        delivery0_.street as street3_2_0_,
        delivery0_.zipcode as zipcode4_2_0_,
        delivery0_.status as status5_2_0_ 
    from
        delivery delivery0_ 
    where
        delivery0_.delivery_id=?


    select
        orderitems0_.order_id as order_id5_5_0_,
        orderitems0_.order_item_id as order_it1_5_0_,
        orderitems0_.order_item_id as order_it1_5_1_,
        orderitems0_.count as count2_5_1_,
        orderitems0_.item_id as item_id4_5_1_,
        orderitems0_.order_id as order_id5_5_1_,
        orderitems0_.order_price as order_pr3_5_1_ 
    from
        order_item orderitems0_ 
    where
        orderitems0_.order_id=?


    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_.artist as artist6_3_0_,
        item0_.etc as etc7_3_0_,
        item0_.author as author8_3_0_,
        item0_.isbn as isbn9_3_0_,
        item0_.actor as actor10_3_0_,
        item0_.director as directo11_3_0_,
        item0_.dtype as dtype1_3_0_ 
    from
        item item0_ 
    where
        item0_.item_id=?


    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_.artist as artist6_3_0_,
        item0_.etc as etc7_3_0_,
        item0_.author as author8_3_0_,
        item0_.isbn as isbn9_3_0_,
        item0_.actor as actor10_3_0_,
        item0_.director as directo11_3_0_,
        item0_.dtype as dtype1_3_0_ 
    from
        item item0_ 
    where
        item0_.item_id=?


    select
        member0_.member_id as member_i1_4_0_,
        member0_.city as city2_4_0_,
        member0_.street as street3_4_0_,
        member0_.zipcode as zipcode4_4_0_,
        member0_.name as name5_4_0_ 
    from
        member member0_ 
    where
        member0_.member_id=?


    select
        delivery0_.delivery_id as delivery1_2_0_,
        delivery0_.city as city2_2_0_,
        delivery0_.street as street3_2_0_,
        delivery0_.zipcode as zipcode4_2_0_,
        delivery0_.status as status5_2_0_ 
    from
        delivery delivery0_ 
    where
        delivery0_.delivery_id=?

    select
        orderitems0_.order_id as order_id5_5_0_,
        orderitems0_.order_item_id as order_it1_5_0_,
        orderitems0_.order_item_id as order_it1_5_1_,
        orderitems0_.count as count2_5_1_,
        orderitems0_.item_id as item_id4_5_1_,
        orderitems0_.order_id as order_id5_5_1_,
        orderitems0_.order_price as order_pr3_5_1_ 
    from
        order_item orderitems0_ 
    where
        orderitems0_.order_id=?


    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_.artist as artist6_3_0_,
        item0_.etc as etc7_3_0_,
        item0_.author as author8_3_0_,
        item0_.isbn as isbn9_3_0_,
        item0_.actor as actor10_3_0_,
        item0_.director as directo11_3_0_,
        item0_.dtype as dtype1_3_0_ 
    from
        item item0_ 
    where
        item0_.item_id=?

    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_.artist as artist6_3_0_,
        item0_.etc as etc7_3_0_,
        item0_.author as author8_3_0_,
        item0_.isbn as isbn9_3_0_,
        item0_.actor as actor10_3_0_,
        item0_.director as directo11_3_0_,
        item0_.dtype as dtype1_3_0_ 
    from
        item item0_ 
    where
        item0_.item_id=?

정말 가만히 보기 힘들 정도의 select 쿼리가 발생한다. Order Entity의 연관관계 필드들이 전부 N+1 문제를 가지고 조회가 되었다. 테이블 필드가 많아질 수록 더욱 더 많은 양의 쿼리가 발생해 성능을 저하시킬 것이다.

먼저 Member와 Delivery는 Order와 XToOne 관계이므로 1편과 같이 fetch join을 통해 N+1문제를 해결할 수 있다.

그렇다면 OneToMany 관계를 가지는 OrderItem도 그렇게 해결하면 되지않을까?

한번 적용해보자.

시도 1: XToMany에 Fetch Join 적용

    public List<Order> findAll() {
        return em.createQuery(
                "select 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();
    }

쿼리 결과

    select
        order0_.order_id as order_id1_6_0_,
        member1_.member_id as member_i1_4_1_,
        delivery2_.delivery_id as delivery1_2_2_,
        orderitems3_.order_item_id as order_it1_5_3_,
        item4_.item_id as item_id2_3_4_,
        order0_.delivery_id 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_,
        orderitems3_.count as count2_5_3_,
        orderitems3_.item_id as item_id4_5_3_,
        orderitems3_.order_id as order_id5_5_3_,
        orderitems3_.order_price as order_pr3_5_3_,
        orderitems3_.order_id as order_id5_5_0__,
        orderitems3_.order_item_id as order_it1_5_0__,
        item4_.name as name3_3_4_,
        item4_.price as price4_3_4_,
        item4_.stock_quantity as stock_qu5_3_4_,
        item4_.artist as artist6_3_4_,
        item4_.etc as etc7_3_4_,
        item4_.author as author8_3_4_,
        item4_.isbn as isbn9_3_4_,
        item4_.actor as actor10_3_4_,
        item4_.director as directo11_3_4_,
        item4_.dtype as dtype1_3_4_ 
    from
        orders order0_ 
    inner join
        member member1_ 
            on order0_.member_id=member1_.member_id 
    inner join
        delivery delivery2_ 
            on order0_.delivery_id=delivery2_.delivery_id 
    inner join
        order_item orderitems3_ 
            on order0_.order_id=orderitems3_.order_id 
    inner join
        item item4_ 
            on orderitems3_.item_id=item4_.item_id

일단 한 번의 쿼리로 무언가 결과가 나오긴 했는데..
위 쿼리로 테이블을 직접 조회해보면 아래와 같은 결과가 나온다.

Order, Delivery, Member, OrderItem, Item 테이블을 모두 조인했기 때문에 결과적으로 많은 필드 수를 가진 Item 테이블에 맞춰서 Order 내용이 중복으로 채워지게 된다. 즉, XToMany 관계의 테이블과 조인하면 위 결과와 비슷하게 필드 수가 늘어나게 된다.

따라서 해당 필드 수에 맞게끔 결과가 나오게 되는데, 중복된 결과가 여러 번 나오므로 우리가 원하던 결과랑은 조금 다르다.

이 또한 해결 가능하다.

시도 2: fetch join과 distinct 키워드


    public List<Order> findAll() {
        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();
    }

위와 같이 distinct 키워드를 select 뒤에 넣게 되면, 쿼리에 dinstict가 포함되고 중복된 값이 제거된 값을 얻을 수 있다.

단, 테이블 조회 시에는

위 결과와 같이 중복된 값이 여전히 동일하게 조회되는데, 이는 데이터베이스에서 중복값을 제거하여 가져오는 것이아니라 어플리케이션에서 JPA가 Order Entity가 중복된 값을 가지면 제거를 진행하기 때문이다.

데이터베이스에서는 모든 필드 내용이 같아야 distinct를 통한 중복 제거가 가능하다. 따라서 데이터베이스 쿼리 단계에서는 따라서 중복된 필드가 없다고 판단한다.

따라서 우리는 원하는 결과를 fetch join과 distinct를 통해 쿼리 1번에 얻게 되었다.

하지만 fetch join + distinct 방법은 문제점을 가지는데, 다음과 같다.

1. XToMany 관계의 fetch join을 한 번 밖에 사용하지 못한다.

  • 즉, 여러개의 XToMany 연관관계 필드를 조회할 수 없다. 위 예시는 1개라서 가능한 것이다.

2. 페이징 처리에 심각한 문제점을 가진다.

  • 중복된 필드를 어플리케이션 단계에서 제거한다고 했다. 따라서 페이징도 데이터베이스 자체에서는 불가하고(아마 중복된 필드가 여러개 있으므로 원하는 값도 아닐 것이다.) 어플리케이션 단계에서, 메모리 위에서 수행해야 한다.

  • 메모리 위에서 페이징 진행은 Out of Memory를 발생시킬 가능성이 크다.

따라서, 여러 개의 XtoMany 연관관계 필드를 조회할 수 있으면서, 함께 페이징도 가능한 방법을 찾아봐야한다.

시도 3: batch 설정

application.yml 파일에서 아래와 같이 설정하자.


spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 100

이는 Order Entity를 조회할 때 지정한 사이즈(batch_fetch_size) 만큼의 Order Id를 가지고 연관 관계에 있는 Entity를 in 쿼리를 통해 한 번에 조회하게 된다.

즉, OrderItem 조회 시 100개의 Order id를 가지고 in 쿼리와 함께 select 조회하고, 해당 Order Id를 가지고 있는 연관관계 Entity를 한 번에 조회하고, Item 조회 시엔 100개의 OrderItem id를 가지고 in 쿼리와 함께 select 조회를 하게 된다는 것이다.

    public List<Order> findAll() {
        return em.createQuery(
                "select o from Order o" +
                        " join fetch o.member m" +
                        " join fetch o.delivery d", Order.class)
                .getResultList();
    }
    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_id 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_id=delivery2_.delivery_id


    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 (
            ?, ?
        )

    
    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_.artist as artist6_3_0_,
        item0_.etc as etc7_3_0_,
        item0_.author as author8_3_0_,
        item0_.isbn as isbn9_3_0_,
        item0_.actor as actor10_3_0_,
        item0_.director as directo11_3_0_,
        item0_.dtype as dtype1_3_0_ 
    from
        item item0_ 
    where
        item0_.item_id in (
            ?, ?, ?, ?
        )

Order와 XToOne 관계를 가지는 Member와 Delivery Entity는 Fetch join을 통해 쿼리를 발생했기에 batch에 영향을 받지 않는다. 따라서 Order 조회 시 함께 조회되며 OrderItem과 각각 Item에 대해서만 in 쿼리와 함께 각각 조회된 것을 확인할 수 있다.

정리하자면, Order를 조회하는 쿼리 1번, OrderItem을 조회하는 쿼리 1번, Item을 조회하는 쿼리 1번으로 총 3번의 쿼리가 발생했다. (데이터가 더욱 더 증가해서 default_batch_fetch_size보다 많아진다면 쿼리 횟수가 좀 더 증가할 것이다.)

단순 fetch join 방식보다는 쿼리가 좀 더 증가했지만 여러 개의 XToMany 필드에 대해 조회할 수 있고, 페이징 처리가 가능하다는 점을 생각하면 감당할 수 있는 오버헤드라고 생각한다.

참고) 페이징 처리 추가

    public List<Order> findAll(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();
    }

Fetch Join과 Batch size 지정 중 Fetch Join이 우선해서 적용된다. 따라서 Member와 Delivery는 Batch size에 영향을 받지 않는다.

정리

XToOne의 경우

  • Fetch Join을 했을 때 테이블 필드 수가 늘어나지 않으므로 페이징 처리가 가능하고, 1번의 쿼리로 N+1 문제를 해결할 수 있다.

  • batch size를 지정해 in 쿼리를 추가한다면, XToOne 관계에서는 오히려 Fetch Join보다 이점은 없으면서 더 많은 쿼리를 발생시킨다.

즉, Fetch Join을 사용해서 성능 최적화를 진행하자.

XToMany의 경우

  • Fetch Join을 했을 때 테이블 Row 수가 늘어나고, 어플리케이션 단계에서 중복 제거 및 페이징 처리를 진행한다. 또한 여러 개의 필드를 Fetch Join 할 수 없다.

  • batch size를 지정한다면, Fetch Join 방식보다는 몇 개의 쿼리가 더 증가하지만 XToMany 관계의 여러 필드를 조회할 수 있고 중복된 테이블 Row를 만들지 않으므로 데이터베이스 단계에서 페이징 처리가 가능하다.

즉, Batch size를 사용해서 성능 최적화를 진행하자.

XToMany관계에서 DTO로 바로 조회, Flat형식으로 조회 등의 추가적인 성능 개선 방법이 있으나 이 글에서는 다루지 않는다.

profile
스프링 깎는 노인

0개의 댓글