BackEnd/JPA

[JPA] 컬렉션 조회 최적화 - 2

샤아이인 2022. 4. 23.

내가 공부한 것을 올리며, 중요한 단원은 저 자신도 곱씹어 볼 겸 상세히 기록하고 얕은 부분들은 가볍게 포스팅하겠습니다.

 

3. 주문 조회 V3: 엔티티를 DTO로 변환 - 페치 조인 최적화

이번시간에는 Fetch Join을 통해 N+1 문제를 해결해보자.

이번 글에서 원래목적은 Order : OrderItems = 1 : N 의 관계에서, Order 기준에서 페이징처리를 하는 것 이 목적이다.

 

1) 컬력션 Fetch Join과 문제점

변경된 컨트롤러는 다음과 같다.

@GetMapping("/api/v3/orders")
public List<OrderDto> ordersV3() {
    List<Order> orders = orderRepository.findAllWithItem();
    return orders.stream()
        .map(OrderDto::new)
        .collect(Collectors.toList());
}

findAllWithItem() 메서드는 JPQL 을 사용하여 쿼리를 날리고 있다.

@Repository
@RequiredArgsConstructor
public class OrderRepository {

    private final EntityManager em;

    // 생략...

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

문제는 Order 와 OrderItems 를 Join할 때 발생한다.

Order에는 2개의 tuple(행)이 있으며, OrderItems에는 4개의 tuple이 있다.

이를 Join 하면 총 4개의 tuple이 생기게 된다.

 

다음 쿼리문을 살펴보자!

마지막 조인문의 결과를 보면 4개로 늘어난것을 확인할 수 있다.

4번 주문과 11번 주문이 각각 2건의 item을 가지고 있어 데이터가 뻥튀기 된 것 이다.

 

실행시 다음과 같은 결과가 나온다.

Json 데이터가 중복되어 전달된 것 이다. 11번 주문도 마찬가지로 2번 전달되어 왔다.

 

실행된 쿼리문은 다음과 같다.

    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

이를 H2에서 그대로 실행해 보면 중복된 결과를 확인할 수 있다.

그럼 에플리케이션 상에서는 객체들이 동일할까?

다음과 같이 코드 에서 객체의 주소와 id값을 출력해보면 완벽하게 동일한 객체임을 확인 할 수 있다.

@GetMapping("/api/v3/orders")
public List<OrderDto> ordersV3() {
    List<Order> orders = orderRepository.findAllWithItem();

    for (Order order : orders) {
        System.out.println("Order ref = " + order + " id = " + order.getId());
    }

    List<OrderDto> collect = orders.stream()
            .map(o -> new OrderDto(o))
            .collect(Collectors.toList());
    return collect;
}

결과는 다음과 같다.

id값이 동일함은 물론, 주소까지 동일함을 알 수 있다.

 

이러한 문제를 해결하기 위해 JPA의 distinct 키워드를 사용하면 된다.

 

2) JPA의 Distinct로 데이터 중복 제거하기

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

실행해보면 2건의 객체만 조회되는것을 확인할 수 있다.

distinct 키워드를 사용하면 JPA는 총 2가지 작업을 해준다.

 

2 - 1 : SQl 문 앞에 distinct 키워드 추가해주기

데이터베이스에서 distinct 키워드는 정말 데이터 한건의 모든 column값이 동일해야 중복 제거가 된다.

 

2 - 2 : JPA의 같은 id값 객체에 대한 중복제거

우선 distinct 키워드가 추가된 위 쿼리문을 통해 데이터를 불러온 후, 에플리케이션 level에서 id값을 기반으로 중복을 제거해준다.

JPA의 영속성 컨텍스트에서는 id값이 같다면 동일한 객체로 인식한다.

 

컬렉션의 Fetch Join에는 아주 큰 단점 이 있다!!

 

3) 컬렉션 Fetch Join의 단점

3 - 1 : 페이징 불가

1 : N 에서는 Fetch Join 하는순간 페이징 쿼리가 아예 나가지 않는다. (페이징 불가능)

하이버네이트는 경고 로그를 남기면서 모든 데이터를 DB에서 읽어오고, 메모리에서 페이징 해버린다(매우 위험하다).

 

예를 들어 다음과 같이 page 처리를 위한 코드를 작성해 보자.

public List<Order> findAllWithItem() {
    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
            ).setFirstResult(1)
            .setMaxResults(100)
            .getResultList();
}

시작 지점이 1번부터고 100개의 결과를 가져오라는 쿼리 이다.

우리의 데이터에는 2개의 데이터가 들어가 있으니 1번째 데이터 하나만 출력되게 된다.

결과 또한 1건만 잘 출력된다.

 

하지만 뭔가 이상하다??.....

 

쿼리 문에 limit, offset 과 같은 키워드가 전혀 보이지 않는다...

 

    select
        distinct 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

어딜 봐도 페이징 처리에 해당하는 limit, offset에 대한 키워드는 확인할수가 없다.

 

다음과 같은 WARN Log를 확인할 수 있다.

이는 fetch join을 사용했을 때 페이징 쿼리가 추가됬다면 메모리에 전부 로딩해온 다음에 정렬하겠다는 의미이다.

1:N join을 해버리는 순간 원하는 수의 데이터가 나오지 않는다. 이전에 말한 데이터 뻥튀기 문제 때문이다.

이렇게 데이터가 증가한 상태에서 페이징 처리가 들어가면 정상적으로 데이터를 불러올수가 없다.

 

위에서 작성한 JPQL만 봐도 Order를 기준(1 입장)으로 페이징처리를 하고 싶은데, OrderItems(N 입장)기준으로 데이터가 뻥튀기가 되어 버려 페이징 자체가 불가능해진다.

 

member, delivery 는 1:N이 아닌, 1:1 이기 때문에 fetch join을 해도 된다.

 

3 - 2 : 1개의 fetch join만 사용 가능

컬렉션 페치 조인은 1개만 사용할 수 있다. 컬렉션 둘 이상에 페치 조인을 사용하면 안된다. 데이터가 부정합하게 조회될 수 있다.

예를 들어 1 : N : M 의 관계가 있다면, 컬력션 페치 조인을 2번 하면 총 N * M 개의 데이터로 뻥튀기 되어있다.

이렇게 데이터가 많아지면 정합성을 보장하기가 어렵다.

 

4. 주문 조회 V3.1: 엔티티를 DTO로 변환 - 페이징과 한계 돌파

 

직전 글 에서는 일다대에서 일(1)을 기준으로 페이징을 하는 것이 목적이였다.

그런데 데이터는 다(N)를 기준으로 row 가 생성된다.
Order를 기준으로 페이징 하고 싶은데, 다(N)인 OrderItem을 조인하면 OrderItem이 기준이 되어버린다.

 

그러면 페이징 + 컬렉션 엔티티를 함께 조회하려면 어떻게 해야할까?

이번시간에 대부분의 페이징 + 컬렉션 엔티티 조회 문제를 해결하는 방법에대하여 알아보자!

(지난 시간에는 Fetch Join을 통해 1번의 쿼리로 데이터를 전부 가져오기는 했었다. 다만 중복 데이터가 많았을 뿐...)

 

1) 한계점 돌파하기

  • xxxToOne(OneToOne, ManyToOne) 관계를 모두 페치조인 한다. ToOne 관계는 row수를 증가시키지 않으므로 페이징 쿼리에 영향을 주지 않기 때문이다.
  • 컬렉션은 지연 로딩으로 조회한다.
  • 지연 로딩 성능 최적화를 위해 hibernate.default_batch_fetch_size , @BatchSize 를 적용한다.
  • 이 옵션을 사용하면 컬렉션이나, 프록시 객체를 한꺼번에 설정한 size 만큼 IN 쿼리로 조회한다.

 

우선 3.1 버전의 컨트롤러는 다음과 같다.

@GetMapping("/api/v3.1/orders")
public List<OrderDto> ordersV3_page() {
    List<Order> orders = orderRepository.findAllWithMemberDelivery(); //fetch join으로 가져옴

    return orders.stream()
            .map(o -> new OrderDto(o))
            .collect(Collectors.toList());
}

---- OrderRepository ----
public List<Order> findAllWithMemberDelivery() {
    return em.createQuery(
            "select o from Order o " +
                    "join fetch o.member m " +
                    "join fetch o.delivery d", Order.class
    ).getResultList();
}

toOne 관계에 있는 member, delivery는 하나의 쿼리로 전부 fetch join 해오게 된다.

하지만 OrderDto를 통해 OrderItems 를 가져오면서 N+1 문제가 발생한다.

 

코드를 다음과 같이 조금 수정해 보자.

@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); //fetch join으로 가져옴

    return orders.stream()
            .map(o -> new OrderDto(o))
            .collect(Collectors.toList());
}


---- OrderRepository ----
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();
}

이후 postman으로 실행해보면 다음과 같은 결과가 나온다.

한건만 조회된것을 확인할 수 있다! offset이 1이였기 때문에 0번째 order인 4번 주문은 보이지 않는다.

실행된 쿼리는 다음과 같다.

사실 이는 당연하다 ToOne 관계들만 Fetch Join 했기 때문에 페이징 처리가 정상적으로 된다.

 

반면 ToMany 관계들은 LAZY 로딩 되어있기 때문에 OrderDto를 통해 초기화 하다보면 N+1 문제가 발생하여 3개의 쿼리가 추가 발생한다.

(OrderItems 쿼리 1개 -> Item 쿼리 2개)

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

    public OrderDto(Order order) {
        orderId = order.getId();
        name = order.getMember().getName(); // Fetch Join으로 초기화 완료됨
        orderDate = order.getOrderDate();
        orderStatus = order.getStatus();
        address = order.getDelivery().getAddress(); // Fetch Join으로 초기화 완료됨
        orderItems = order.getOrderItems()
                .stream()
                .map(OrderItemDto::new)
                .collect(Collectors.toList());
    }
}

@Getter
static 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();
    }
}

 

이번에는 global 설정으로 batch 사이즈를 추가해 보자

spring:
  datasource:
    url: jdbc:h2:tcp://localhost/~/jpashop;
    username: sa
    password:
    driver-class-name: org.h2.Driver

  jpa:
    hibernate:
      ddl-auto: create
    properties:
      hibernate:
#        show_sql: true
        format_sql: true
        default_batch_fetch_size: 100   <-추가된 Batch 사이즈 설정!!!!

logging:
  level:
    org.hibernate.SQL: debug
    org.hibernate.type: trace

이 상황에서 다시 실행해보자.

http://localhost:8080/api/v3.1/orders?offset=0&limit=100

위 URL 로 요청 하였다. offset이 0부터 100개의 아이템이라 4, 11번 주문이 조회된다.

 

신기한점은 쿼리가 단 3번 나왔다는 점 이다. (이전 글 에서는 11번 쿼리가 나왔었다...)

이전과 동일하게 member, delivery는 다음과 같이 1개의 쿼리로 전부 조회된다.

딱 2건의 데이터만 조회되고 있다.

 

이후 Loop를 돌면서 OrderItems 를 가져오게 된다. 이때 다음과 같이 where 절에 in 키워드가 추가되었다.

이전에는 OrderItems를 User A, B 에서 자신의 OrderItems를 조회하는 쿼리를 날리고,

또한 각 OrderItems를 조회한 후에 OrderItems에 포함되어 있는 Item을 가져오는 쿼리가 나갔었다.

 

p6spy의 도움을 받아 in(?, ?) 에 들어간 내용을 확인해보면 다음과 같다.

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 (4, 11);

단 한번의 in 쿼리문으로 DB에 있는 User A, B의 OrderItems를 모두 가져오게 된 것 이다.

위에서 설정한 batch_size는 이 in 쿼리의 최대 수를 지정한 것 이다.

 

2번의 OrderItems를 가져오던 쿼리가 1개로 끝나버렸다. H2에서 실행해보면 다음과 같다.

데이터 중복 없이 딱 4건의 orderItems를 잘 찾아오고 있다.

 

그럼 Item을 가져오는 쿼리는 어떻게 될까? 원래는 각 OrderItems당 2건씩 총 4번 쿼리가 나갔었다.

 

하지만 이번에는 단 1번 쿼리가 발생한다.

이또한 p6spy로 쿼리를 확인해보면 다음과 같다.

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 (2, 3, 9, 10);

이로 인해 1 : N : M 의 쿼리가 1 : 1 : 1 로 바뀌어 버렸다. 이는 엄청난 최적화 이다!

H2에서의 실행 결과는 다음과 같다.

4건의 Item이 정상적으로 조회 되었다!

 

위와같이 batch_size를 global 하게 하는 설정 말고, 디테일 하게 적용시키고 싶다면 다음과 같이 사용하면 된다.

@Entity
@Table(name = "orders")
@Getter @Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Order {

    @Id @GeneratedValue
    @Column(name = "order_id")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id")
    private Member member;

    @BatchSize(size = 1000) // 이부분!!!!!!!!!!!!!
    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
    private List<OrderItem> orderItems = new ArrayList<>();

    @OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
    @JoinColumn(name = "delivery_id")
    private Delivery delivery;
}

위와 같이 Collection인 경우 컬렉션 바로위에 @BatchSize를 적어주면 되지만,

Collection이 아닌 경우에는 다음과 같이 Entity class 위에 적어주어야 한다.

@BatchSize(size = 100) // 이부분!!!!!
@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "dtype")
@Getter @Setter
public abstract class Item {

    @Id @GeneratedValue
    @Column(name = "item_id")
    private Long id;

    // 일부 생략...
}

 

장점

쿼리호출수가1+N에서 1+1로최적화된다. 조인보다 DB 데이터 전송량이 최적화 된다.

(Order와 OrderItem을 조인하면 Order가 OrderItem 만큼 중복해서 조회된다. 이 방법은 각각 조회하므로 전송해야할 중복 데이터가 없다.)

페치 조인 방식과 비교해서 쿼리 호출 수가 약간 증가하지만, DB 데이터 전송량이 감소한다. 컬렉션 페치 조인은 페이징이 불가능 하지만 이 방법은 페이징이 가능하다.

 

결론

ToOne 관계는 페치 조인해도 페이징에 영향을 주지 않는다. 따라서 ToOne 관계는 페치조인으로 쿼리 수를 줄이고 해결하고, 나머지는 hibernate.default_batch_fetch_size 로 최적화 하자.

댓글