Home JPA - 컬렉션 조회 최적화
Post
Cancel

JPA - 컬렉션 조회 최적화

OneToMany 관계에서의 성능 최적화 예시를 위해 간단한 주문조회 API 컨트롤러와 엔티티를 구현하고 단계별로 어떻게 최적화 하는지 확인

엔티티 예시

example

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
@Entity
public class Order {
  @Id
  @GeneratedValue
  @Column(name = "order_id")
  private Long id;

  @OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
  private List<OrderItem> orderItems = new ArrayList<>();

  ...
}

@Entity
public class OrderItem {

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

  @ManyToOne(fetch = FetchType.LAZY)
  @JoinColumn(name = "item_id")
  private Item item;

  @ManyToOne(fetch = FetchType.LAZY)
  @JoinColumn(name = "order_id")
  private Order order;

  ...
}

@Entity
public class Item {

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

  private String name;
  private int price;
  private int stockQuantity;

  ...
}

주문조회 V1 - 엔티티 직접 노출

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
 * V1. 엔티티 직접 노출
 * - Hibernate5Module 모듈 등록, LAZY=null 처리
 * - 양방향 관계 문제 발생 -> @JsonIgnore
 */
@GetMapping("/api/v1/orders")
public List<Order> ordersV1() {
  List<Order> orders = orderRepository.findAllByString(new OrderSearch());
  for (Order order : orders) {
    // Lazy 강제 초기화
    order.getMember().getName();

    // Lazy 강제 초기화
    order.getDelivery().getAddress();

    // 컬렉션 내의 각 Item에 대하여 Lazy 강제 초기화
    List<OrderItem> orderItems = order.getOrderItems();
    orderItems.stream().forEach(o -> o.getItem().getName());
  }
  return orders;
}
  • orderItem, item 관계를 직접 초기화 하면 Hibernate5Module 설정에 의해 엔티티를 Json으로 생성함
  • 양방향 연관관계면 무한 루프에 걸리지 않도록 한쪽에 @JsonIgnore를 추가해야 함
  • 엔티티를 직접 노출하므로 좋은 방법이 아님

주문조회 V2 - 엔티티를 DTO로 반환

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
@Data
public 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();
    orderDate = order.getOrderDate();
    orderStatus = order.getStatus();
    address = order.getDelivery().getAddress();
    orderItems = order.getOrderItems()
                        .stream()
                          .map(orderItem -> new OrderItemDto(orderItem))
                          .collect(Collectors.toList());
  }
}

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

@Data
static class Result<T> {
  private T data;

  public Result(T data) {
    this.data = data;
  }
}

/**
 * V2. 엔티티를 조회해서 DTO로 변환(fetch join 사용X)
 * - 트랜잭션 안에서 지연 로딩 필요
 */
@GetMapping("/api/v2/orders")
public Result<List<OrderDto>> ordersV2() {
  List<Order> orders = orderRepository.findAllByString(new OrderSearch());
  List<OrderDto> orderDtos = orders.stream()
                              .map(o -> new OrderDto(o))
                              .collect(Collectors.toList());
  return new Result<>(orderDtos);
}
생성되는 SQL 로그
2023-10-27 15:58:40.546 DEBUG 89095 --- [nio-8080-exec-1] org.hibernate.SQL                        :
    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 ?
...

2023-10-27 15:58:40.559 DEBUG 89095 --- [nio-8080-exec-1] org.hibernate.SQL                        :
    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=?
...

2023-10-27 15:58:40.568 DEBUG 89095 --- [nio-8080-exec-1] org.hibernate.SQL                        :
    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=?
...

2023-10-27 15:58:40.570 DEBUG 89095 --- [nio-8080-exec-1] org.hibernate.SQL                        :
    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=?
...

2023-10-27 15:58:40.574 DEBUG 89095 --- [nio-8080-exec-1] org.hibernate.SQL                        :
    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=?
...

2023-10-27 15:58:40.576 DEBUG 89095 --- [nio-8080-exec-1] org.hibernate.SQL                        :
    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=?
...

2023-10-27 15:58:40.577 DEBUG 89095 --- [nio-8080-exec-1] org.hibernate.SQL                        :
    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=?
...

2023-10-27 15:58:40.579 DEBUG 89095 --- [nio-8080-exec-1] org.hibernate.SQL                        :
    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=?
...

2023-10-27 15:58:40.580 DEBUG 89095 --- [nio-8080-exec-1] org.hibernate.SQL                        :
    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=?
...

2023-10-27 15:58:40.582 DEBUG 89095 --- [nio-8080-exec-1] org.hibernate.SQL                        :
    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=?
...

2023-10-27 15:58:40.583 DEBUG 89095 --- [nio-8080-exec-1] org.hibernate.SQL                        :
    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=?
...


  • 지연 로딩으로 인하여 너무 많은 SQL 실행
  • SQL 실행 수
    • order 1번
    • member, address N번(order 조회 수 만큼)
    • orderItem N번(order 조회 수 만큼)
    • item N번(order 조회 수 만큼)

지연로딩은 영속성 컨텍스트에 있으면 영속성 컨텍스트에 있는 엔티티를 사용하고, 없으면 SQL을 실행함. 그러므로 같은 영속성 컨텍스트에서 이미 로딩한 회원 엔티티를 추가로 조회하면 SQL을 실행하지 않음.

주문조회 V3 - 엔티티를 DTO로 변환하고 페치조인으로 최적화

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Repository
public class OrderRepository {
  ...

  public List<Order> findAllItems() {
    String jpql =
      "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";

    return entityManager.createQuery(jpql, Order.class)
                          .getResultList();
  }

  ...
}
1
2
3
4
5
6
7
8
9
10
11
12
/**
 * V3. 엔티티를 조회해서 DTO로 변환(fetch join 사용O)
 * - 페이징 시에는 N 부분을 포기해야함(대신에 batch fetch size? 옵션 주면 N -> 1 쿼리로 변경 가능)
*/
@GetMapping("/api/v3/orders")
public Result<List<OrderDto>> ordersV3() {
  List<Order> orders = orderRepository.findAllItems();
  List<OrderDto> orderDtos = orders.stream()
                              .map(o -> new OrderDto(o))
                              .collect(Collectors.toList());
  return new Result<>(orderDtos);
}
생성되는 SQL 로그
2023-10-27 16:22:35.892 DEBUG 90627 --- [nio-8080-exec-1] org.hibernate.SQL                        :
    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
...


  • 페치조인으로 인하여 SQL이 1번만 실행 됨
  • 1대다 조인이 있으므로 데이터베이스의 row가 증가하고, 그 결과로 order 엔티티의 조회 수도 증가하게됨
    • JPA의 distinct는 SQL에 distinct 구문을 추가하고 더해서 같은 엔티티가 조회되면 어플리케이션에서 중복을 걸러줌
    • 위 예에선 order가 컬렉션 페치조인으로 중복 조회되는 것을 막아줌
  • 페이징이 불가능함
    • 컬렉션 패치조인을 사용하면 페이징이 불가능 함.
    • 하이버네이트는 경고 로그를 남기면서 모든 데이터를 데이터베이스에서 읽어오고 메모리에서 페이징 수행(매후 위험한 작업)
  • 컬렉션 페치조인은 1개만 사용 가능
    • 데이터가 부정합하게 조회될 수 있으므로 컬렉션 둘 이상의 페치조인을 사용하면 안됨

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

  • 컬렉션은 페치조인하면 페이징이 불가능
    • 컬렉션을 페치조인하면 일대다 조인이 발생하므로 데이터가 예측할 수 없이 증가 함
    • 일대다에서 일(1)을 기준으로 페이징을하는 것이 목적이나 데이터는 다(N)를 기준으로 Row가 생성됨
      • 위 예시를 보면 Order를 기준으로 페이징하려 했지만, 다(N)인 OrderItem을 조인하면 OrderItem이 기준이 되어버림
    • 이 결우 하이버네이트는 경고 로그를 남기고 모든 데이터베이스 데이터를 읽어서 메모리에서 페이징을 시도 함
      • out of memory 가 발생할 수 있는 매우 위험한 작업


위와같은 페이징 + 컬렉션 엔티티를 함께 조회하기 위해선 아래와 같은 방법이 있음. 해당 방법은 성능 최적화도 보장하고 코드도 단순하며 대부분의 페이징 + 컬렉션 엔티티 조회 문제도 해결할 수 있음

  • 먼저 ToOne(@OneToOne, @ManyToOne) 관계를 모두 페치조인 하도록 함
    • ToOne관계는 row수를 증가시키지 않으므로 페이징 쿼리에 영향을 주지 않음
  • 컬렉션은 지연로딩으로 조회
  • 지연 로딩 성능 최적화를 위해 hibernate.default_batch_fetch_size 옵션 혹은 @BatchSize 어노테이션을 적용
    • hibernate.default_batch_fetch_size : 글로벌 설정
    • @BatchSize : 개별 최적화
    • 해당 옵션을 사용하면 컬렉션이나 프록시 객체를 한번에 설정한 size만큼 IN 쿼리로 조회 함
1
2
3
4
5
# src/main/resources/application.yml
spring:
  jpa:
    properties:
      default_batch_fetch_size: 1000
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Repository
public class OrderRepository {
  ...

  public List<Order> findAllItemsWithPaging(int offset, int limit) {
    String jpql =
      "select o " +
      "from Order o " +
        "join fetch o.member m " +
        "join fetch o.delivery d";

    return entityManager.createQuery(jpql, Order.class)
                          .setFirstResult(offset)
                          .setMaxResults(limit)
                          .getResultList();
  }

  ...
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
 * V3.1 엔티티를 조회해서 DTO로 변환 페이징 고려
 * - ToOne 관계만 우선 모두 페치 조인으로 최적화
 * - 컬렉션 관계는 hibernate.default_batch_fetch_size, @BatchSize로 최적화
 */
@GetMapping("/api/v3.1/orders")
public Result<List<OrderDto>> ordersV3_1(
  @RequestParam(value = "offset", defaultValue = "0") int offset,
  @RequestParam(value = "offset", defaultValue = "100") int limit) {
  List<Order> orders = orderRepository.findAllItemsWithPaging(offset, limit);
  List<OrderDto> orderDtos = orders.stream()
                              .map(o -> new OrderDto(o))
                              .collect(Collectors.toList());
  return new Result<>(orderDtos);
}
생성되는 SQL 로그
2023-10-27 17:45:50.213 DEBUG 92869 --- [nio-8080-exec-1] 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_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 limit ?
...

2023-10-27 17:45:50.229 DEBUG 92869 --- [nio-8080-exec-1] 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 (
            ?, ?
        )

2023-10-27 17:45:50.231  INFO 92869 --- [nio-8080-exec-1] p6spy                                    : #1698396350231 | took 0ms | statement | connection 6| url jdbc:h2:mem:6a16d813-a863-46b8-9894-4e710e815fdf
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 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);

...

2023-10-27 17:45:50.240 DEBUG 92869 --- [nio-8080-exec-1] org.hibernate.SQL                        :
    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 (
            ?, ?, ?, ?
        )

2023-10-27 17:45:50.242  INFO 92869 --- [nio-8080-exec-1] p6spy                                    : #1698396350242 | took 0ms | statement | connection 6| url jdbc:h2:mem:6a16d813-a863-46b8-9894-4e710e815fdf
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 (?, ?, ?, ?)
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);

...


  • 장점
    • 쿼리 호출 수가 (N + 1) -> (1 + 1) 로 최적화 됨
    • 조인보다 데이터베이스 전송량이 최적화 됨
      • 위 코드의 예에서 Order와 OrderItem을 조인하면 Order가 OrderItem 만큼 중복해서 조회되지만, 이 방법은 각각 조회하므로 전송해야할 중복 데이터가 없음
    • 패치조인 방식과 비교하여 쿼리 호풀 수가 약간 증가하지만 데이터베이스의 데이터 전송량이 감소
    • 컬렉션 패치조인은 페이징이 불가능하지만 이 방법으로는 페이징이 가능


결론은 ToOne 관계는 페치조인해도 페이징에 영향을 주지 않기 때문에, ToOne 관계는 페치조인으로 쿼리 수를 줄여서 해결하고 나머지 지연로딩을 하는 엔티티들은 hibernate.default_batch_fetch_size로 최적화를 해야 함.

default_batch_fetch_size의 크기

default_batch_fetch_size의 크기는 적당한 사이즈를 골라야 하는데 보통 100 ~ 1000 사이를 선택해서 사용. 이 전략은 SQL IN 절을 사용하는데 데이터베이스에 따라 IN 절 파라미터를 1000으로 제한하기도 함.

1000으로 잡으면 한번에 1000개를 데이터베이스에서 어플리케이션으로 불러오므로 데이터베이스에 순산 부하가 증가 할 수 있음. 반면에 어플리케이션은 100이든 1000이든 결국 데이터 전체를 로딩해야 하므로 메모리 사용량은 같음.

1000으로 설정하는 것이 성능상 가장 좋지만 결국 데이터베이스든 어플리케이션이든 순간 부하를 어디까지 견딜수 있는지도 결정하면 됨.

참고 스프링 부트 3.1 및 하이버네이트 6.2 에서는 default_batch_fetch_size 옵션으로 인하여 생성되는 SQL에 where … in 문법을 사용하지 않고 where … array_contains 을 사용함

주문조회 V4 - JPA메서 DTO 직접 조회

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
@Data
public class OrderItemQueryDto {
  @JsonIgnore
  private Long orderId;
  private String itemName;
  private int orderPrice;
  private int count;

  public OrderItemQueryDto(Long orderId, String itemName, int orderPrice, int count) {
    this.orderId = orderId;
    this.itemName = itemName;
    this.orderPrice = orderPrice;
    this.count = count;
  }
}

@Data
@EqualsAndHashCode(of = "orderId")
public class OrderQueryDto {

  private Long orderId;
  private String name;
  private LocalDateTime orderDate;
  private OrderStatus orderStatus;
  private Address address;
  private List<OrderItemQueryDto> orderItems;

  public OrderQueryDto(Long orderId, String name, LocalDateTime orderDate, OrderStatus orderStatus, Address address) {
    this.orderId = orderId;
    this.name = name;
    this.orderDate = orderDate;
    this.orderStatus = orderStatus;
    this.address = address;
  }
}

@Repository
@RequiredArgsConstructor
public class OrderQueryRepository {

  private final EntityManager entityManager;

  /**
   * 컬렉션은 별도로 조회
   * Query: 루트 1번, 컬렉션 N 번
   * 단건 조회에서 많이 사용하는 방식
   */
  public List<OrderQueryDto> findOrderQueryDtos() {
    // 루트 조회(toOne 코드를 모두 한번에 조회)
    List<OrderQueryDto> orders = findOrders();

    // 루프를 돌면서 컬렉션 추가(추가 쿼리 실행)
    orders.forEach(order -> {
      List<OrderItemQueryDto> orderItems = findOrderItems(order.getOrderId());
      order.setOrderItems(orderItems);
    });
    return orders;
  }

  /**
   * 1:N 관계(컬렉션)를 제외한 나머지를 한번에 조회
   */
  private List<OrderQueryDto> findOrders() {
    String jpql =
      "select new jpabook.jpashop.repository.order.query.OrderQueryDto(o.id, m.name, o.orderDate, o.status, d.address) " +
      "from Order o " +
        "join o.member m " +
        "join o.delivery d";
    return entityManager.createQuery(jpql, OrderQueryDto.class).getResultList();
  }

  /**
   * 1:N 관계인 orderItems 조회
   */
  private List<OrderItemQueryDto> findOrderItems(Long orderId) {
    String jpql =
      "select new jpabook.jpashop.repository.order.query.OrderItemQueryDto(oi.order.id, i.name, oi.orderPrice, oi.count) " +
      "from OrderItem oi " +
        "join oi.item i " +
      "where oi.order.id = :orderId";
    return entityManager.createQuery(jpql, OrderItemQueryDto.class)
                          .setParameter("orderId", orderId)
                          .getResultList();
  }
}
1
2
3
4
5
6
7
8
9
/**
  * V4. JPA에서 DTO로 바로 조회, 컬렉션 N 조회 (1 + N Query)
  * - 페이징 가능
  */
@GetMapping("/api/v4/orders")
public Result<List<OrderQueryDto>> ordersV4() {
  List<OrderQueryDto> orders = orderQueryRepository.findOrderQueryDtos();
  return new Result<>(orders);
}
생성되는 SQL 로그
2023-10-27 18:20:38.752 DEBUG 95140 --- [nio-8080-exec-1] org.hibernate.SQL                        :
    select
        order0_.order_id as col_0_0_,
        member1_.name as col_1_0_,
        order0_.order_date as col_2_0_,
        order0_.status as col_3_0_,
        delivery2_.city as col_4_0_,
        delivery2_.street as col_4_1_,
        delivery2_.zipcode as col_4_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
...

2023-10-27 18:20:38.767 DEBUG 95140 --- [nio-8080-exec-1] org.hibernate.SQL                        :
    select
        orderitem0_.order_id as col_0_0_,
        item1_.name as col_1_0_,
        orderitem0_.order_price as col_2_0_,
        orderitem0_.count as col_3_0_
    from
        order_item orderitem0_
    inner join
        item item1_
            on orderitem0_.item_id=item1_.item_id
    where
        orderitem0_.order_id=?
...

2023-10-27 18:20:38.770 DEBUG 95140 --- [nio-8080-exec-1] org.hibernate.SQL                        :
    select
        orderitem0_.order_id as col_0_0_,
        item1_.name as col_1_0_,
        orderitem0_.order_price as col_2_0_,
        orderitem0_.count as col_3_0_
    from
        order_item orderitem0_
    inner join
        item item1_
            on orderitem0_.item_id=item1_.item_id
    where
        orderitem0_.order_id=?
...


  • Query : 루트 1번, 컬렉션 N번 실행
  • ToOne(N:1, 1:1) 관계들을 먼저 조회하고, ToMany(1:N) 관계는 각각 별도로 처리
    • ToOne 관계는 조인해도 데이터 row 수가 증가하지 않음
    • ToMany(1:N) 관계는 조인하면 row 수가 증가
  • row 수가 증가하지 안는 ToOne 관계는 조인으로 최적화 하기 쉬우므로 한번에 조회하고, ToMany 관계는 최적화하기 어려우므로 findOrderItems() 같은 별도의 메소드로 조회

주문조회 V5 - JPA메서 DTO 직접 조회후 컬렉션 조회 최적화

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
@Repository
@RequiredArgsConstructor
public class OrderQueryRepository {

  private final EntityManager entityManager;

  ...

  /**
   * 1:N 관계(컬렉션)를 제외한 나머지를 한번에 조회
   */
  private List<OrderQueryDto> findOrders() {
    String jpql =
      "select new jpabook.jpashop.repository.order.query.OrderQueryDto(o.id, m.name, o.orderDate, o.status, d.address) " +
      "from Order o " +
        "join o.member m " +
        "join o.delivery d";
    return entityManager.createQuery(jpql, OrderQueryDto.class).getResultList();
  }


  /**
   * 최적화
   * Query: 루트 1번, 컬렉션 1번
   * 데이터를 한꺼번에 처리할 때 많이 사용하는 방식
   *
   */
  public List<OrderQueryDto> findAllByDto_optimization() {
    // 루트 조회(toOne 코드를 모두 한번에 조회)
    List<OrderQueryDto> result = findOrders();

    // orderItem 컬렉션을 map 한번에 조회
    Map<Long, List<OrderItemQueryDto>> orderItemMap = findOrdeItemMap(toOrderIds(result));

    // 루프를 돌면서 컬렉션 추가(추가 쿼리 실행 x)
    result.forEach(
      o -> o.setOrderItems(orderItemMap.get(o.getOrderId())));

    return result;
  }

  private List<Long> toOrderIds(List<OrderQueryDto> result) {
    return result.stream()
                  .map(o -> o.getOrderId())
                  .collect(Collectors.toList());
  }

  private Map<Long, List<OrderItemQueryDto>> findOrdeItemMap(List<Long> orderIds) {
    String jpql =
      "select new jpabook.jpashop.repository.order.query.OrderItemQueryDto(oi.order.id, i.name, oi.orderPrice, oi.count) " +
      "from OrderItem oi " +
        "join oi.item i " +
      "where oi.order.id in :orderIds";

    List<OrderItemQueryDto> orderItems =
      entityManager.createQuery(jpql, OrderItemQueryDto.class)
                    .setParameter("orderIds", orderIds)
                    .getResultList();

    return orderItems.stream()
            .collect(Collectors.groupingBy(OrderItemQueryDto::getOrderId));
  }
}
1
2
3
4
5
6
7
8
9
/**
 * V5. JPA에서 DTO로 바로 조회, 컬렉션 1 조회 최적화 버전 (1 + 1 Query)
 * - 페이징 가능
 */
@GetMapping("/api/v5/orders")
public Result<List<OrderQueryDto>> ordersV5() {
  List<OrderQueryDto> orders = orderQueryRepository.findAllByDto_optimization();
  return new Result<>(orders);
}
생성되는 SQL 로그
2023-10-30 10:25:48.096 DEBUG 16091 --- [nio-8080-exec-1] org.hibernate.SQL                        :
    select
        order0_.order_id as col_0_0_,
        member1_.name as col_1_0_,
        order0_.order_date as col_2_0_,
        order0_.status as col_3_0_,
        delivery2_.city as col_4_0_,
        delivery2_.street as col_4_1_,
        delivery2_.zipcode as col_4_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
...

2023-10-30 10:25:48.116 DEBUG 16091 --- [nio-8080-exec-1] org.hibernate.SQL                        :
    select
        orderitem0_.order_id as col_0_0_,
        item1_.name as col_1_0_,
        orderitem0_.order_price as col_2_0_,
        orderitem0_.count as col_3_0_
    from
        order_item orderitem0_
    inner join
        item item1_
            on orderitem0_.item_id=item1_.item_id
    where
        orderitem0_.order_id in (
            ? , ?
        )
...


  • Query : 루트 1번, 컬렉션 1번
  • ToOne 관계들을 먼저 조회하고 여기서 얻은 실별자 orderId로 ToMany 관계인 OrderItem을 한번에 조회
  • Map을 이용하여 매칭 성능 향상(O(1))

주문조회 V6 - JPA메서 DTO 직접 조회후 플렛 데이터로 최적화

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
@Data
public class OrderFlatDto {

  private Long orderId;
  private String name;
  private LocalDateTime orderDate;
  private Address address;
  private OrderStatus orderStatus;

  private String itemName;
  private int orderPrice;
  private int count;

  public OrderFlatDto(Long orderId, String name, LocalDateTime orderDate, OrderStatus orderStatus,
                      Address address, String itemName, int orderPrice, int count) {
    this.orderId = orderId;
    this.name = name;
    this.orderDate = orderDate;
    this.address = address;
    this.orderStatus = orderStatus;
    this.itemName = itemName;
    this.orderPrice = orderPrice;
    this.count = count;
  }
}

@Repository
@RequiredArgsConstructor
public class OrderQueryRepository {

  private final EntityManager entityManager;

  ...

  public List<OrderFlatDto> findAllByDto_flat() {
    String jpql =
      "select new jpabook.jpashop.repository.order.query.OrderFlatDto(
        o.id, m.name, o.orderDate, o.status, d.address, i.name, oi.orderPrice, oi.count
      ) " +
      "from Order o " +
        "join o.member m " +
        "join o.delivery d " +
        "join o.orderItems oi " +
        "join oi.item i";

    return entityManager.createQuery(jpql, OrderFlatDto.class).getResultList();
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
  /**
   * V6. JPA에서 DTO로 바로 조회, 플랫 데이터(1Query) (1 Query)
   * - 페이징 불가능...
   */
  @GetMapping("/api/v6/orders")
  public Result<List<OrderQueryDto>> ordersV6() {
    List<OrderFlatDto> orders = orderQueryRepository.findAllByDto_flat();
    return new Result<>(orders.stream()
            .collect(Collectors.groupingBy(o -> new OrderQueryDto(o.getOrderId(), o.getName(),
                                                                  o.getOrderDate(), o.getOrderStatus(),
                                                                  o.getAddress()),
                    Collectors.mapping(o -> new OrderItemQueryDto(o.getOrderId(), o.getItemName(),
                                                                  o.getOrderPrice(), o.getCount()),
                                      Collectors.toList())
            )).entrySet().stream()
              .map(e -> new OrderQueryDto(e.getKey().getOrderId(), e.getKey().getName(),
                                          e.getKey().getOrderDate(), e.getKey().getOrderStatus(),
                                          e.getKey().getAddress(), e.getValue()))
            .collect(Collectors.toList()));
  }
생성되는 SQL 로그
2023-10-30 10:55:06.426 DEBUG 18016 --- [nio-8080-exec-1] org.hibernate.SQL                        :
    select
        order0_.order_id as col_0_0_,
        member1_.name as col_1_0_,
        order0_.order_date as col_2_0_,
        order0_.status as col_3_0_,
        delivery2_.city as col_4_0_,
        delivery2_.street as col_4_1_,
        delivery2_.zipcode as col_4_2_,
        item4_.name as col_5_0_,
        orderitems3_.order_price as col_6_0_,
        orderitems3_.count as col_7_0_
    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
...


  • Query : 1번
  • 단점
    • 쿼리는 한번이지만 조인으로 인하여 데이터베이스에서 어플리케이션에 전달하는 데이터에 중복데이터가 추가되어 상황에 따라 V5보다 느릴수 있음
    • 어플리케이션에 데이터 처리에 대한 추가 작업이 많음
    • 페이징 불가능

정리

  • 엔티티 조회
    • 엔티티를 조회해서 그대로 반환(V1)
    • 엔티티 조회후 DTO로 변환(V2)
    • 페치조인으로 쿼리 수 최적화(V3)
    • 컬렉션 페이징과 한계 돌파(V3.1)
      • 컬렉션은 페치조인시 페이징 불가능
      • ToOne 관계는 페치조인으로 쿼리수 최적화
      • 컬렉션은 페치조인 대신에 지연 로딩을 유지하고, hibernate.default_batch_fetch_size나 @BatchSize로 최적화
  • DTO 직접 조회
    • JPA에서 DTO를 직접 조회(V4)
    • 컬렉션조회 최적화(V5)
      • 일대다 관계인 컬렉션은 IN 절을 활용하여 메모리에 미리 조회해서 최적화 시도
    • 플렛 데이터 최적화(V6)
      • JOIN 결과를 그대로 조죄 후 어플리케이션에서 워하는 모양으로 직접 변환

권장 순서

  • 엔티티 조회 방식으로 우선접근
    • 페치조인으로 쿼리수를 최적화
    • 컬렉션 최적화
      • 페이징이 필요하다면 hibernate.default_batch_fetch_size나 @BatchSize로 최적화
      • 페이징이 불필요 하다면 단순히 패치조인 사용
    • 엔티티 조회 방식은 페치조인이나 hibernate.default_batch_fetch_size나 @BatchSize 같이 코드를 거의 수정하지 않고, 옵션만 약간 변경하여 다양한 성능 최적화 시도 가능
  • 엔티티 조회 방식으로 해결이 안되면 DTO 조회 방식 사용
    • 성능을 최적화 하거나 성능 최적화 방식을 변경할 떄 많은 코드 수정 필요
  • DTO 조회 방식으로도 해결이 안되면 NativeSQL이나 스프링 JdbcTemplate 사용하거나, 캐시를 이용해서 성능 최적화 시도
    • 캐시를 이용할 시, 영속성 컨텍스트에서 관리되고 있는 엔티티를 또 다른 캐시에 저장하면 안됨
      • 또 다른 내부 캐시저장소에 저장하게 되면 관리 포인트가 2곳이 되므로 객체의 관리가 원활히 되지 않음
    • DTO를 만들어 DTO를 캐시에 저장해야 함

DTO 조회 방식의 선택지

DTO로 조회하는 방법도 각각 장단이 있음

  • V4 방식은 코드가 단순함
    • 특정 주문 한건만 조회 핟다면 해당 방식도 성능이 잘 나옴
    • 예를 들어 조회한 Order 데이터가 1건이면 OrderItem을 찾기 위한 쿼리도 1번만 실행됨
  • V5 방식은 코드가 복잡하지만 실행되는 쿼리가 적음
    • 여러 주문을 한번에 조회하는 경우 V4의 방식대신 V5 방식을 사용해야함
    • 예를 들어 조회한 Order 데이터가 1000건일때 V4 방식을 그대로 사용하면, 쿼리가 총 1 + 1000번 실행됨(1은 Order를 조회한 쿼리이고 1000은 조회된 Order의 row 수) 그러나 V5 방식으로 최적화 하면 쿼리가 총 1 + 1번만 실행 됨
  • V6 방식은 완전히 다른 접근방식이며 쿼리가 한번만 실행 됨
    • 쿼리가 한번만 실행되어 좋은 방식인 것 같으나, Order를 기준으로 페이징이 불가능 함
    • 실무에서는 수백이나, 수천건 단위로 페이징 처리가 필요할 떄가 많으므로 선택하기엔 어려운 방식
    • 데이터가 많으면 중복 전송으로 인하여 V5 방식과 비교했을떄 성능차이도 미비 함

참고

  • 실전! 스프링 부트와 JPA 활용2 - 웹 애플리케이션 개발(김영한)
This post is licensed under CC BY 4.0 by the author.