ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • JPA(2) - 컬렉션 조회 최적화
    카테고리 없음 2024. 1. 22. 12:41

     

    - OneToMany 관계에서 조회를 최적화 하는 방법을 생각해보자

    - 앞에서는 Order 조회시 OrderItem컬렉션은 그냥 무시하고 DTO만들어서 리턴했음 (처음부터 지연로딩으로 두고 초기화 안함)

     

    1. 엔티티 직접 노출 

     

    /**
     * V1. 엔티티 직접 노출
     * - Hibernate5Module 모듈 등록, LAZY=null 처리
     * - 양방향 관계 문제 발생 -> @JsonIgnore
     */
     @GetMapping("/api/v1/orders")
     public List<Order> ordersV1() {
     List<Order> all = orderRepository.findAllByString(new OrderSearch());
     for (Order order : all) {
     order.getMember().getName(); //Lazy 강제 초기화
     order.getDelivery().getAddress(); //Lazy 강제 초기환
     List<OrderItem> orderItems = order.getOrderItems();
     orderItems.stream().forEach(o -> o.getItem().getName()); //Lazy 강제
    초기화
     }
     return all;
     }

     

    - 양방향 연관관계에서 무한 루프를 피하기 위해 @JsonIgnore를 추가했다 (양방향 연관관계면 지연로딩 풀어도 문제 발생하나봄? -> 추후에 알아보자)

    - 엔티티 직접 노출 방식으로 좋지 못하다.

    - 엔티티 변화시 API 스펙 변화, 양방향 연관관계 문제 

     

    2. 엔티티를 DTO로 변환 

     

    @GetMapping("/api/v2/orders")
    public List<OrderDto> ordersV2() {
     List<Order> orders = orderRepository.findAllByString(new OrderSearch());
     List<OrderDto> result = orders.stream()
     .map(o -> new OrderDto(o))
     .collect(toList());
     return result;
    }
    
    @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(orderItem -> new OrderItemDto(orderItem))
     .collect(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();
     }
    }

     

     - 단순한 DTO변환방식 (패치조인 x) 

     - N+1문제 발생 

     - 패치 조인이 필요

     

    *엔티티 dto로 변환시 dto안에서 포함되는 엔티티도 dto로 변환해줘야함! 

     

    3. 엔티티 DTO 변환 + 페치 조인 

     

    @GetMapping("/api/v3/orders")
    public List<OrderDto> ordersV3() {
     List<Order> orders = orderRepository.findAllWithItem();
     List<OrderDto> result = orders.stream()
     .map(o -> new OrderDto(o))
     .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();
    }

     

    - 지연로딩 모두 패치조인으로 엮어서 리턴한다. (sql 1번 실행)

    - distinct는 1대다 조인에서 데이터베이스 row 증가를 막기 위함이다.

      (jpa는 sql에도 distict 추가 + 애플리케이션 레벨에서 같은 식별자에 포함된 list면 자동으로 결과를 줄여준다)

    - 단점은 페이징이 불가하다! (페이징은 sql 결과 단에서 부터 중복된 row가 없어야하는데 oneToMany관계는 그게 어렵다)

     

    *컬렉션 패치 조인시 페이징 불가능!! -> 사용하면 메모리 페이징함 (절대 사용 금지)

    * 둘 이상의 패치조인 불가능 (데이터 부정합하게 조회 될 수 있음) [fetch oneToMany, fetch oneToMany 금지]

       -> 따로따로 jqpl을 날리던 해야한다. 

     

    3.1 엔티티 DTO 변환 - 패치조인 + 벌크 (페이징 가능하게 만들기)

     

    - 컬렉션 패치 조인시 페이징 불가하다.

    - 여기서 1을 기준으로 페이징 하는 것이 목적 (방향 바꾸면 페이징 삽가능)

    - 대부분의 페이징 + 컬렉션 엔티티 조회문제는 다음 방식으로 해결해보자.

     

     > 먼저 ToOne 관계를 모두 페치조인한다. (row증가 문제 발생하지 않는다)

     > 컬렉션은 지연 로딩으로 조회한다. (패치조인말고 그냥 조회)

     > 지연 로딩 성능 최적화를 위해 @BatchSize를 적용한다. -> 해당 옵션은 컬렉션이나 프록시 객체 한번에 조회시 in쿼리 날려줌 size만큼

     

    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();
    }
    @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);
     List<OrderDto> result = orders.stream()
     .map(o -> new OrderDto(o))
     .collect(toList());
     return result;
    }

     

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

     

    - 지연로딩을 초기화하려고 할때 (프록시) @BatchSize 옵션이 적용된다. (jpql 따로 날릴때 사용되는게아니라 지연 강제로딩시 적용)

    - orderItemDto에도 배치사이즈가 적용되어 있음 getItem().getName()등을 날릴때 OrderItem의 item_id 기준으로 in쿼리를 날린다.

    - mappedby의 중요성이 들어난다. 어디에 fk를 둘 것 인가! @BatchSize같은 옵션을 사용할때 어느테이블에서 fk찾을지 중요해진다..! 

     

    > 쿼리 호출수가 1+1로 최적화됨

    > 조인보다 DB 데이터 전송량이 최적화된다. Order와 OrderItem을 조인하면, ORder가 OrderItem만큼 뻥튀기 문제 해결

     

    결론적으로 ToOne관계는 페지조인, 나머지 컬렉션은 @Batchsize옵션을 통해 지연 로딩 실행해서 최적화하자

     

    * 배치사이즈의 적다안 크기는 100~1000사이이다. in절을 사용하는데, 너무 많은 in절은 DB부하를 증가시킬 수 있다.

    *하이버네이트 6.2부터는 where in 대신 array_contains를 사용한다고한다. (성능 최적화를 위해)

     

    4. 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;
     }
     /**
     * 1:N 관계(컬렉션)를 제외한 나머지를 한번에 조회
     */
     private List<OrderQueryDto> findOrders() {
     return em.createQuery(
     "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", OrderQueryDto.class)
     .getResultList();
     }
     /**
     * 1:N 관계인 orderItems 조회
     */
     private List<OrderItemQueryDto> findOrderItems(Long orderId) {
     return em.createQuery(
     "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", 
    OrderItemQueryDto.class)
     .setParameter("orderId", orderId)
     .getResultList();
     }

     

    @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;
     }
    }
    
    @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;
     }
    }

     

    - oneToMany와 XToOne관계 따로 조회하는 건 동일함

    - 다만 List를 초기화할때 배치사이즈를 사용하는게 아니라, xToOne결과에 식별자를 사용해서 각 order에 orderItem을 조회함과 동시에 dto로 변환하여 리스트를 초기화해줌 

    - 그냥 sql날리는 것과 매우 유사함!

    (one관계에서 딱히 fetch를 사용하지 않은 이유는 dto바로 변환 + 컬럼안에서 join된 엔티티 전부를 가져오는게 아니라 몇몇 값만 조회하는 것이므로 패치 필요없음)

    - row증가하지않은 ToOne관계 한번에 조회하고, ToMany관계는 ToOne관계의 결과에 따라 따로 조회

    - 루프를 돌면서 컬렉션을 조회하는 추가쿼리가 작동함! 메서드가 따로 분리되어 있어서 그러하다 (최적화 필요)

     

    4.1 DTO 변환시 컬렉션 조회 최적화

     

    @GetMapping("/api/v5/orders")
    public List<OrderQueryDto> ordersV5() {
     return orderQueryRepository.findAllByDto_optimization();
    }

     

    //Repository 메서드 추가 
    
    public List<OrderQueryDto> findAllByDto_optimization() {
    //루트 조회(toOne 코드를 모두 한번에 조회)
     List<OrderQueryDto> result = findOrders();
     //orderItem 컬렉션을 MAP 한방에 조회
     Map<Long, List<OrderItemQueryDto>> orderItemMap =
    findOrderItemMap(toOrderIds(result));
     //루프를 돌면서 컬렉션 추가(추가 쿼리 실행X)
     result.forEach(o -> o.setOrderItems(orderItemMap.get(o.getOrderId())));
     return result;
    }
    
    private List<Long> toOrderIds(List<OrderQueryDto> result) { //orderId List뽑기
     return result.stream()
     .map(o -> o.getOrderId())
     .collect(Collectors.toList());
    }
    
    private Map<Long, List<OrderItemQueryDto>> findOrderItemMap(List<Long> orderIds) 
    {
     List<OrderItemQueryDto> orderItems = em.createQuery(
     "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", OrderItemQueryDto.class)
     .setParameter("orderIds", orderIds)
     .getResultList();
     return orderItems.stream()
     .collect(Collectors.groupingBy(OrderItemQueryDto::getOrderId));
    }

     

    - ToOne관계 먼저 조회하고, 여기서 얻은 식별자를 ToMany관계에서 in을 사용해서 한번에 조회 

    - Map을 사용해서 성능을 향상 (식별자 중심으로 오더와 오더아이템DTO List묶음 -> 추후 조회시 성능 좋다)

    - 패치조인보다 확실히 데이터를 덜 가져오지만, 과정이 매우 복잡해짐 (진짜진짜 성능안나오면 생각해볼만하다!)

     

    *OrderITemDto -> Map으로 변환하는 코드 잘 봐두자 

     

    결론 : 엔티티 조회방식으로 접근하자 

                -> Lazy로딩이 있으면, 패치 조인 사용해서 가져오자

                -> 컬렉션이 있으면

                        페이징 필요시 -> 배치사이즈를 활용해서 지연로딩 초기화를 사용하자  

                         페이징 필요x -> 패치조인 사용 

               엔티티 조회 방식으로도 해결이 안되면 DTO 조회방식을 사용하자

               그래도 해결이 안되면 SQL을 사용하자

     

    * DTO조회방식은 성능 최적화시 너무 많은 코드를 바꿔야한다. -> 상충관계 존재 

    * 영속성 컨텍스트에서 캐시를 관리하므로, 엔티티는 다른 캐싱을 발생시키지말고, DTO를 캐싱하자 (타 캐시 서버 도입시)

    * 여러 건의 Order를 DTO 조회 방식으로 사용하고 싶다면, V5와 같은 방식을 차용하도록 하자 

       (One관계 먼저 조회 -> 조회 결과 식별자 list로 변환 -> 식별자 list를 결과로 in 쿼리를 통해 필요한 컬렉션 조회 -> Map에 식별자 , List로 변환 -> 다시 첫 조회 결과에 List 삽입 (Map탐색) 

     

Designed by Tistory.