본문 바로가기
Spring 이해하기

LazyInitializationException 를 벗어나기 위해서는 어떻게 해야될까? JPA에서 최적의 쿼리를 보내는 방법은 무엇일까?

by simplify-len 2020. 10. 3.

Photo by Jan Antonin Kolar on Unsplash

들어가기

 JPA의 연관관계를 사용하다 사용하지 않으니까, 금방 까먹고, 또다시 공부하는 제 모습을 보면서, 이 부분은 어느 정도 정리해야겠다는 마음으로 포스팅을 작성합니다.

 이 글은 실전! 스프링 부트와 JPA 활용2 - API 개발과 성능 최적화 학습한 뒤, 일부 내용을 편집했습니다.

리뷰

 전체적으로 김영한 강사님의 노하우가 '녹아내려있다' 라는 생각은 강하게 들었습니다.
당연하다고 여겨지는 부분이라도 직접 동작하는 코드로 이해하지 않는다면, 이해하지 않은 것이라고 했습니다. 그런 측면에서 이번 강의는 당연하지만 실행해보지 않았기에 몰랐던 부분을 잘 집어주는 부분이 많았습니다.

LazyInitializationException 는 왜 발생할까?

지연로딩일 경우, 초기화 되지 않는 객체의 경우에는 LazyInitializationException 가 발생합니다.

LazyInitializationException 가 발생하는 걸까요? 주로 트랜잭션이 시작될 때, 세션이 존재하는데, 세션이 닫힌 이 후에 객체의 특정 부분을 접근할 때 발생합니다.

다양한 이유에서 LazyInitializationException 가 발생할 수 있는데, 인터넷으로 찾아본 바로는 3가지 정도가 있었습니다.

1. 세션의 콘테스트가 Controller까지 존재하지 않아, Controller에서 데이터를 접근하려고 할 때, LazyInitializationException 가 발생
: 자세한 내용은 OpenInView 를 살펴보는게 좋습니다. 참조 링크

2. 무분별한 롬복의 @Date 사용, Proxy 객체의 toString()을 사용하려는 시도 중에 발생
: 강남언니 기술 블로그에서 발췌한 아래 내용을 보자.

https://blog.gangnamunni.com/post/hibernate_lombok_lazy/

3. 다중 스레드에서 데이터를 공유하려는 행위를 했을 경우에 발생

https://coding-start.tistory.com/179

아마도 더 많은 경우가 있을 수 있지만, 명확하게는 Proxy에 의해 초기화되지 않은 객체에 접근시 LazyInitializationException 발생한다는 것 입니다.

N+1 문제은 무엇이고, 왜 발생할까?

N+1 문제는 JPA를 사용하다가 컬렉션을 하는 대목에서 대부분의 개발자가 겪게되는 부분입니다. N+1 도 언제 발생하는지 코드로 확인해보겠습니다.

// 모든 Order 의 객체를 반환하는 API 입니다.
  @GetMapping("/api/v2/orders")
  public List<OrderDto> ordersV2() {

    List<Order> orders = orderRepository.findAllByCriteria(new OrderSearch());

    List<OrderDto> result = orders.stream()
        .map(OrderDto::new).collect(toList());
    return result;

  }

Member 엔티티의 코드는 아래와 같습니다.

@Entity
@Table(name = "orders")
@Getter
@Setter
public class Order {

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

    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "member_id") // FK가 member_id가 된다. 주인으로 정한다.
    private Member member;

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

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

    private LocalDateTime orderDate; // 주문시간.

    @Enumerated(value = EnumType.STRING)
    private OrderStatus status; // 주문 상태
    
 }

초기 데이터베이스에 들어있는 데이터

복수의 ORDER 에는 하나의 고객 ID가 존재할 수 있고, 하나의 ORDER에는 다양한 ORDERITEM이 존재할 수 있는 연관관계를 갖고 있습니다.

 그러므로, 모든 Order를 조회할 시,

일단 Order 데이터베이스에 존재하는 모든 것을 조회합니다. ( +1)

    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 
    where
        1=1 limit ?

이 후, 

  @Data
  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(OrderItemDto::new)
          .collect(toList());

    }
  }

OrderDto 에 존재하는 getMember(), getDelivery(), getOrderitems() 에 의하여, 위 쿼리 후 각각을 구하기 위해 또 다른 쿼리가 발생합니다.

// 단 한명의 Member 를 구하는 쿼리
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=?
 // 단 하나의 Delivery 를 구하는 쿼리
   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=?
   // Order_id 에 의해 하나의 Orderitem을 구하는 쿼리
   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=?

단 하나의 객체를 조회하기 위해서 총 7번의 쿼리가 발생합니다.

즉, 맨 처음 전체를 조회하기 위한 1 + 각각의 데이터를 조회하기 위한 N 입니다. 총 7번이 호출됩니다.

앞으로 이 강의에서는 2가지의 문제점을 해결하기 위한 여러가지 조치를 취합니다.

 

어떻게 위 2개의 문제를 해결했을까?

해결방법의 방안으로 V2, V3, V4 등의 코드 버전으로 점진적으로 문제를 해결하는 방안을 모색합니다.

각각의 V2, V3, V4는 더 좋은 답변의 순서라고는 할 수는 없고, 각각의 상황에 맞쳐서 해야 한다고 합니다.

2가지 트랙으로 준비되어 있는데,

컬렉션 조회를 최적화하는 것(xxxToMany)과 컬렉션이 아닌 상황(xxxToOne)에서의 최적화로 나눕니다.

 

컬렉션이 아닌 상황(xxxToOne)에서 최적화를 알아보겠습니다.

V1 코드는 엔티티를 조회해서 그대로 반환하는 것으로 절대로 엔티티의 정보를 노출시키지 않아야 한다고 말하고 있습니다.

V2 코드는 DTO로 변환시킨 후 반환하는 방법을 말합니다.

바로 위에서 예시로 들었던 코드를 말합니다.

  /**
   * V2. 엔티티를 조회해서 DTO로 변환(fetch join 사용X) * - 단점: 지연로딩으로 쿼리 N번 호출
   */
  @GetMapping("/api/v2/simple-orders")
  public List<SimpleOrderDto> ordersV2() {

    List<Order> orders = orderRepository.findAllByCriteria(new OrderSearch());

    List<SimpleOrderDto> result = orders.stream()
        .map(o -> new SimpleOrderDto(o)).collect(toList());
    return result;
  }
  @Data
  static class SimpleOrderDto {

    private Long orderId;
    private String name;
    private LocalDateTime orderDate; //주문시간
    private OrderStatus orderStatus;
    private Address address;

    public SimpleOrderDto(Order order) {

      orderId = order.getId();
      name = order.getMember().getName();
      orderDate = order.getOrderDate();
      orderStatus = order.getStatus();
      address = order.getDelivery().getAddress();
    }
  }

DTO를 사용할 경우 String으로 표현시 발생되는 무한 반복 순환으로 인한 문제가 발생하지 않게 됩니다.

V3 코드는 폐치 조인을 활용한 방법입니다.

  @GetMapping("/api/v3/simple-orders")
  public List<SimpleOrderDto> ordersV3() {

    List<Order> orders = orderRepository.findAllWithMemberDelivery();
    return orders.stream()
        .map(SimpleOrderDto::new).collect(toList());
  }
  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();

  }
@Data
  static class SimpleOrderDto {

    private Long orderId;
    private String name;
    private LocalDateTime orderDate; //주문시간
    private OrderStatus orderStatus;
    private Address address;

    public SimpleOrderDto(Order order) {

      orderId = order.getId();
      name = order.getMember().getName();
      orderDate = order.getOrderDate();
      orderStatus = order.getStatus();
      address = order.getDelivery().getAddress();
    }
  }

이렇게 폐치 조인을 활용할 경우 쿼리가 최적화되어 나갑니다.

    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

단, 한번의 쿼리로 데이터를 모두 가져옵니다.

V4 로는 JPA에서 DTO로 바로 조회 하는 방법을 말합니다.

  /**
   * V4. JPA에서 DTO로 바로 조회
   * - 쿼리 1번 호출
   * - select 절에서 원하는 데이터만 선택해서 조회
   */
  @GetMapping("/api/v4/simple-orders")
  public List<OrderSimpleQueryDto> ordersV4() {
    return orderSimpleQueryRepository.findOrderDtos();
  }
@Repository
@RequiredArgsConstructor
public class OrderSimpleQueryRepository {

  private final EntityManager em;

  public List<OrderSimpleQueryDto> findOrderDtos() {
    return em.createQuery(
        "select new jpabook.jpashop.repository.order.simplequery."
            + "OrderSimpleQueryDto(o.id, m.name, o.orderDate, o.status,d.address)" + " from Order o"
            + " join o.member m"
            + " join o.delivery d", OrderSimpleQueryDto.class).getResultList();
  }
}
@Data
public class OrderSimpleQueryDto {

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

  public OrderSimpleQueryDto(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;
  }

}

V3, V4, V5 각각의 방법이 장단점을 가지고 있습니다.

그래서 강의해서 추천하는 쿼리 방식 선택 권장 순서는 다음과 같습니다.

  1.  우선 엔티티를 DTO로 변환하는 방법을 선택합니다.
  2. 필요하면 폐치조인으로 성능을 최적화 합니다 -> 대부분의 성능 이슈가 해결됩니다.
  3. 그래도 안되면 DTO로 직접 조회하는 방법을 사용합니다.
  4. 최후의 방법은 JPA가 제공하는 네이티브 SQL이나 스프링 JDBC Template를 사용해서 SQL를 직접 사용합니다.

컬렉션 상황(xxxToMany) 에서 조회를 최적화 해보자.

맨 위 예시에서 Order 기준으로 컬렉션인 OrderItem와 Item을 조회하는 경우라고 할 수 있습니다.

  @GetMapping("/api/v1/orders")
  public List<Order> orderV1() {
    List<Order> allByCriteria = orderRepository.findAll(new OrderSearch());
    for (Order order : allByCriteria) {
      order.getMember().getName();
      order.getDelivery().getAddress();

      List<OrderItem> orderItems = order.getOrderItems();
      orderItems.forEach(o -> o.getItem().getName());
    }
    return allByCriteria;
  }

V1의 경우 쿼리를 살펴보자.

   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 ?

역시 Order에 대해서 쿼리를 보내고 난 뒤,

LazyLoading 이기 때문에 getMember(), getDelivery(), getOrderItems(), getItem() 등을 조회하기 위해서 쿼리를 보냅니다.

    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 ?

마찬가지로 Order 에 대한 정보를 획득하는 쿼리를 보내고,

Member 정보를 획득하기 위한 쿼리

    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=?

delivery 정보를 획득하기 위한 쿼리

    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=11
   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_.artiest as artiest10_3_0_,
        item0_.etc as etc11_3_0_,
        item0_.dtype as dtype1_3_0_ 
    from
        item item0_ 
    where
        item0_.item_id=9
   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_.artiest as artiest10_3_0_,
        item0_.etc as etc11_3_0_,
        item0_.dtype as dtype1_3_0_ 
    from
        item item0_ 
    where
        item0_.item_id=10

위 와같은 커리가 2번씩 날라간다는 사실

또 N + 1 문제가 발생한 것이다. 여기서 질문. fetch 타입을 바꾸면 최적화된 쿼리가 날라갈까?

사실 이미 OrderItems와 Item이 그러지 않았다라는 사실을 보면 최적화된 쿼리가 날아갈거라 생각하지 않지만 테스트를 해보겠습니다.

    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 
    where
        1=1 limit ?

똑같이 Order를 호출하는데,

다음 쿼리부터는 한꺼번에 가져오긴 하는데, 총 4번의 쿼리가 발생합니다.

같은 쿼리가 총 4번이 일어나는 걸 보면서, EAGER로 할 경우, 주변에 연관관계를 모두다 가져오는 것을 확인할 수 있었습니다.

V2에서는 DTO를 활용한 것입니다.

  @GetMapping("/api/v2/orders")
  public List<OrderDto> ordersV2() {

    List<Order> orders = orderRepository.findAllByCriteria(new OrderSearch());

    List<OrderDto> result = orders.stream()
        .map(OrderDto::new).collect(toList());
    return result;

  }

V3 폐치조인을 활용한 조회입니다.

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

    List<OrderDto> result = orders.stream()
        .map(OrderDto::new).collect(toList());
    return result;

  }
  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();
  }
    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_.author as author6_3_4_,
        item4_.isbn as isbn7_3_4_,
        item4_.actor as actor8_3_4_,
        item4_.director as director9_3_4_,
        item4_.artiest as artiest10_3_4_,
        item4_.etc as etc11_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

1+N 문제를 해결할 수 있는 한번 쿼리에 최적화된 조회를 할 수 있습니다.

그러나, 여기서 주의해야 될 점이 있습니다.

쿼리에 distinct 가 들어간 이유가 무엇일까요? distinct 를 빼고 하면 어떤 차이가 있을까요?

왼쪽 - distinct가 포함된 쿼리의 결과 / 오른쪽 - distinct가 포함되지 않는 결과

이 결과로 알 수 있는 것은 OneToMany 연관관계에서 조인을 할 때는 데이터베이스의 Row가 증가합니다. 그 결과 같은 Order 엔티티의 조회 수가 증가하게 되고, 이를 막기 위해 distinct 를 SQL에 추가했습니다. 

 이 같은 결과가 알려주는 사실은, 바로 페이징이 불가능 하다는 점을 알 수 있습니다.

또한 아래와 같은 워닝을 발생시킵니다. 

 

다시, 왜 페이징으로 불가한 이유를 이야기해보면, 컬렉션을 폐치조인하면 일대다 조인이 발생하는데, 이 때 데이터가 예측할 수 없이 증가합니다. OneToMany에서 One을 기준으로 페이징하는 것이 목적이지만, 조인시, Many를 기준으로 Row 가 생성되게 됩니다. 이 경우 하이버네이트가 경고 로그를 남기고 모든 DB데이터를 읽어서 메모리에서 페이징을 시도합니다.

그렇다면 어떻게 페이징을 할 수 없는 한계를 돌파하기 위한 행위를 할 수 있을까요?

 강의에서 설명하는 것은 먼저 xxxToOne 관계를 모두 폐치조인합니다. ToOne 관계는 row수를 증가시키지 않으므로 페이징 쿼리에 영향을 주지 않습니다. 이 후, 컬렉션은 지연 로딩으로 조회합니다. 

그리고 지연 로딩 성능 최적화를 위해 hibernate.default_batch_fetch_size, @BatchSize 를 적용합니다.

위 옵션을 사용하면, 컬렉션이나, 프록시 객체를 한꺼번에 설정한 size 만큼 IN 쿼리로 조회합니다.

코드로 이해해봅시다.

  @GetMapping("/api/v3.1.1/orders")
  public List<OrderDto> ordersV3__1_page() {
    List<Order> orders = orderRepository.findAllWithMemberDelivery();
    List<OrderDto> result = orders.stream()
        .map(OrderDto::new).collect(toList());
    return result;
  }
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();

  }

이렇게 되면, OrderItem과 Item은 폐치조인을 하지 않았기 때문에, 1+N 쿼리가 발생할 것입니다.

그러나, 실제 동작되는 쿼리를 확인해보면

    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_.author as author6_3_0_,
        item0_.isbn as isbn7_3_0_,
        item0_.actor as actor8_3_0_,
        item0_.director as director9_3_0_,
        item0_.artiest as artiest10_3_0_,
        item0_.etc as etc11_3_0_,
        item0_.dtype as dtype1_3_0_ 
    from
        item item0_ 
    where
        item0_.item_id in (
            ?, ?, ?, ?
        )

1+N 쿼리가 1+1 로 변경되는 것을 확인할 수 있습니다.

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

V4의 경우에는 JPA에서 DTO를 직접 조회하는 방식입니다.

  @GetMapping("/api/v4/orders")
  public List<OrderQueryDto> ordersV4() {
    return orderQueryRepository.findOrderQueryDtos();
  }
  /**
   * 컬렉션은 별도로 조회 * Query: 루트 1번, 컬렉션 N 번 * 단건 조회에서 많이 사용하는 방식
   */
  public List<OrderQueryDto> findOrderQueryDtos() {
    
    //루트 조회(toOne 코드를 모두 한번에 조회)
    List<OrderQueryDto> result = findOrders();
    
    //루프를 돌면서 컬렉션 추가(추가 쿼리 실행)
    result.forEach(o -> {
      List<OrderItemQueryDto> orderItems = findOrderItems(o.getOrderId());
      o.setOrderItems(orderItems);
    });
    return result;
  }

그렇게 될 경우, 쿼리는 아래와 같습니다.

    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

먼저, xxxToOne 관계들을 먼저 조회하고. 코드에서는 findOrders 입니다. ToMany관계는 각각 별도로 처리합니다.

    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_.author as author6_3_0_,
        item0_.isbn as isbn7_3_0_,
        item0_.actor as actor8_3_0_,
        item0_.director as director9_3_0_,
        item0_.artiest as artiest10_3_0_,
        item0_.etc as etc11_3_0_,
        item0_.dtype as dtype1_3_0_ 
    from
        item item0_ 
    where
        item0_.item_id in (
            ?, ?, ?, ?
        )

row 수가 증가하지 않는 ToOne 관계는 조인으로 최적화 하기 쉬우므로 한번에 조회하고, ToMany 관계 는 최적화 하기 어려우므로 findOrderItems() 같은 별도의 메서드로 조회합니다.

정리

지금까지 xxxToOne 관계, xxxToMany 관계에서의 최적화된 쿼리를 조회할 수 있는 방법에 대해서 알아보았습니다.

 강의에서 권장하는 순서는 아래와 같습니다.

  1. 엔티티 조회 방식으로 우선 접근을 합니다.
    1. 폐치조인으로 쿼리의 수를 최적화할 수 있다면 그렇게 하라.
    2. 컬렉션을 최적화하는 경우
      1. 페이징이 필요할 경우에는 "hibernate.default_batch_fetch_size", "@BatchSize" 로 최적화합니다.
      2. 페이징이 필요하지 않을 경우, 폐치 조인을 활용합니다.
  2. 엔티티 조회 방식으로 해결이 안된다면 DTO 조회 방식을 사용합니다.
  3. DTO 조회 방식으로 해결이 안되면 NativeSQL 또는 Spring JdbcTemplate 를 이용합니다.

댓글