Dev/JPA

JPA 에서 Collection 조회시 주의점 및 성능 튜닝 방법

린네의 2024. 3. 15. 16:07

 

Entity -> Dto 변환 시 Collection 도 잊지 말고 변환해야 한다

Collection을  dto 로 변환하여 리턴할 경우에도 엔티티에 대한 의존도를 끊어야 한다. 

 

예시 코드를 보자.

 

 

  • 흐름도

 

 

 

 

  • Order 엔티티 정보
@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;

    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL) /* cascade 옵션을 써서 order 만 저장하면 orderitems 가 함께 저장 */
    private List<OrderItem> orderItems = new ArrayList<>();

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

    private LocalDateTime orderDate; // 주문시간

    private  OrderStatus status; // 주문 상태
    
    ...
    
   }

 

  • repository 에서 데이터 조회, entity로 반환 
// repository
public List<OrderDto> orderV3() {
    List<Order> orders = orderRepository.findAllWithItem();

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

 

 

  • collection 내 데이터에 대하여 dto 로 변환 
@Data
@AllArgsConstructor(access = AccessLevel.PROTECTED)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class OrderDto {

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

    public Long getOrderId() {
        return orderId;
    }

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


}

 

 

위 코드에서 핵심인 부분은 

 

orderItems = order.getOrderItems().stream()
                .map(orderItem -> new OrderItemDto(orderItem))
                .collect(toList());

 

 

이다. 단순하게  Order -> OrderDTO 로 처리하는 것이 아니라 내부에 있는 Collection 도 모두 Dto로 바꿔줘야 한다.

 

    

 

일대다 ( OneToMany )를 페치조인하는 경우 페이징은 문제가 발생한다

일대다 조인의 문제점은 출력되는 데이터의 결과가 증가하는 것이다. ( 1 + N 문제 발생 ) 따라서 데이터 결과 개수가 정확하게 측정되지 않는다.

그렇기 때문에 페이징이 불가능하다. 따라서 컬렉션에서 페치조인은 여러개 사용하기보다 하나만 사용하는 것이 권장된다. 

 

현재 spring 3.x 에서 사용하는 hibernate 6 이 사용되면서 자동으로 중복제거를 해주긴 한다.

 

이 내용은 2024.02.17 - [개발/jpa] - JPQL의 개념과 간단한 사용 방법 ( 2 / 2 )에서 보다 자세하게 설명했다.

 

 

 

컬렉션을 조회할 때  페치조인과 성능 최적화  -  페이징을 위한 주의점 

 

이 문제는 컬렉션을 페치조인할 때 주의해 아하는 내용이다.

페치조인은 즉시로딩이기 때문에 강제 Lazy초기화가 되어 한꺼번에 조회할 수 있어서 성능이 향상이점을 얻을 수 있다. xToOne 관계에서는 페치조인을 하더라도 페이징에 영향을 주지 않으므로  페치조인을 사용해서 쿼리 수를 줄이는 최적화를 진행하면 된다.

 

 

하지만 xToMany 에서는 출력 데이터의 증가 문제가 발생하기 때문에 hibernate.default_batch_fetch_size를 통해 최적화를 진행해야 한다.

 

hibernate.default_batch_fetch_size를 사용하면 지정한 개수만큼 in을 걸어서 한 번에 모든 데이터를 가져올 수 있다.  예를들어, 아이템에 lazy 초기화가 되어 있어 하나하나 조회하던 내용에 대해 in으로 묶어서 한 번에 가져올 수 있다.

 

default_batch_fetch_size의 적당한 사이즈는 100~ 1000 사이가 권장된다.

 

 

 

JPQL에서 new 생성자를 통해 dto 형식으로 반환할 때 컬렉션 항목은 포함될 수 없다

 

 em.createQuery("select new jpabook.jpashop.repository.order.query.OrderQueryDto(o.id, o.name, o.orderDate) 
 		from Order o" +
        " join o.member m"  +
        " join o.delivery d", OrderItemQueryDto.class)
        .getResultList();

 

Order 에는 OrderItems라는 Collection 항목이 있지만, 그 필드는 제외하고 가져와야 한다.  new

    public List<OrderQueryDto> findOrderQueryDtos() {
        List<OrderQueryDto> result = findOrders();
        
        // 직접 컬렉션을 다시 넣어준다 
        result.forEach(o -> {
            List<OrderItemQueryDto> orderItems = findOrderItems(o.getOrderId());
            o.setOrderItems(orderItems);
        });
        
        return result;
    }
    
    
    
public List<OrderQueryDto> findOrders() {
    return em.createQuery("select new jpabook.jpashop.repository.order.query.OrderQueryDto(o.id, o.name, o.orderDate)
    		from Order o" +
            " join o.member m"  +
            " join o.delivery d", OrderQueryDto.class)
            .getResultList();
}



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

}

 

생성자를 통해 바로 가져올 수 없기 때문에  아래 코드처럼 직접 컬렉션을 넣어주는 작업이 필요하다.

 

그런데 이렇게 하면 문제가 발생한다.

Orders에 포함된 OrderItems 개수만큼 N 번의 쿼리 조회가 발생하게 된다 따라서 아래처럼 합칠 수 있다.

 

public List<OrderQueryDto> findAllByDto_optimization() {
        
        // 주문을 모두 가져옴 
        List<OrderQueryDto> result = findOrders();

        // orderId 에 대한 리스트를 생성
        List<Long> orderIds = result.stream()
                .map(o -> o.getOrderId())
                .collect(Collectors.toList());

        // orderIds 를 in 을 통해서 한번에 가져오기 
        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();
        
        
        // map 으로 바꾸는 최적화 진행  (  쿼리는 한번 날리고 메모리에서 값을 세팅하는 방법 ) 
        Map<Long, List<OrderItemQueryDto>> orderItemMap = orderItems.stream()
                .collect((Collectors.groupingBy(OrderItemQueryDto -> OrderItemQueryDto.getOrderId())));
        
        result.forEach(o -> o.setOrderItems(orderItemMap.get(o.getOrderId())));
        
        return result;
    }

 

위 코드를 실행하면 Order를 조회할 때 한번,  Orderitems를 in으로 묶어서 가져올 때 한번 해서 총 두 번으로 쿼리 조회가 줄게 된다.

 

select 
	o1_0.order_id,m1_0.name,o1_0.order_date,o1_0.status,d1_0.city,d1_0.street,d1_0.zipcode 
from orders o1_0 
join member m1_0 
	on m1_0.member_id=o1_0.member_id 
join delivery d1_0 
	on d1_0.delivery_id=o1_0.delivery_id.

select 
	oi1_0.order_id,i1_0.name,oi1_0.order_price,oi1_0.count 
from order_item oi1_0 
	join item i1_0 
    	on i1_0.item_id=oi1_0.item_id 
where oi1_0.order_id in (1,2);

 

 

 

만약 페이징이 필요 없는 경우라면 한꺼번에 조회해서 가져오는 방법도 있다.

 

 public List<OrderFlatDto> findAllByDto_flat() {
    return em.createQuery(
            "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", OrderFlatDto.class )
            .getResultList();
}

 

 select o1_0.order_id,m1_0.name,o1_0.order_date,o1_0.status,d1_0.city,d1_0.street,d1_0.zipcode,i1_0.name,oi1_0.order_price,oi1_0.count 
 from orders o1_0 
 join member m1_0 
 	on m1_0.member_id=o1_0.member_id 
 join delivery d1_0 
 	on d1_0.delivery_id=o1_0.delivery_id 
 join order_item oi1_0 
 	on o1_0.order_id=oi1_0.order_id 
 join item i1_0 
 	on i1_0.item_id=oi1_0.item_id;

 

 

이렇게 가져올 경우 OrderQueryDto로 반환하기 위해 서버단에서 다음과 같이 수정하는 작업이 필요하다

 

// 반환을 OrderQueryDto 로 하기 위해 변환
return flats.stream()
        .collect(groupingBy(o -> new OrderQueryDto(o.getOrderId(), o.getName(), o.getOrderDate(), o.getOrderStatus(), o.getAddress()),
                mapping(o -> new OrderItemQueryDto(o.getOrderId(), o.getItemName(), o.getOrderPrice(), o.getCount()), 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(toList());

 

 

 

  • OrderQueryDto

@Data
@EqualsAndHashCode(of = "orderId")
public class OrderQueryDto {
    public OrderQueryDto(Long orderId, String name, LocalDateTime orderDate, OrderStatus orderStatus, Address address, List<OrderItemQueryDto> orderItems) {
        this.orderId = orderId;
        this.name = name;
        this.orderDate = orderDate;
        this.orderStatus = orderStatus;
        this.address = address;
        this.orderItems = orderItems;
    }

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


}

 

 

@EqualsAndHashCode(of = "orderId")가 있어야 orderId를 기준으로 묶인다.  (  equals 랑 hashcode는 자주 나오기 때문에...@EqualsAndHashCode 관련해서는 곧 게시글을 작성해야겠다. )