jiny

[JPA] API 개발 - 컬렉션 조회 최적화 본문

서버/JPA

[JPA] API 개발 - 컬렉션 조회 최적화

ongjiny 2023. 4. 3. 17:05

특정 엔티티 조회시, toOne 관계는 페이조인을 사용하면 한번에 불러올 수 있었다.

Order와 Member,Delivery 는 각각 OnetoOne 관계

Order와 OrderItem은 OnetoMany 관계

모두 Lazy 로딩이라고 가정한다.

 

엔티티를 그대로 반환

@GetMapping("/api/v1/orders")
    public List<Order> ordersV1() {
        List<Order> all = orderRepository.findAll();
 
        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;
    }

[문제점]

1. Member,Delivery 등 toOne 관계를 json에 보내려면 강제 초기화를 해야한다( 추가 쿼리가 나가게된다. )

2. all은 List 이다. 엔티티를 그대로 보내면 무한 루프가 발생하기에 JsonIgnore를 해주어야 한다.

3. 또한 Lazy로딩 관계인 엔티티들을 json에 태우지 않으려고 Lazy로딩을 강제 초기화 하지않으면 프록시 객체를 사용하기 때문에 직렬화 과정에서 No serializer found Exception이 발생하는 것으로 보임. Hibernate5Modeule 라이브러리 추가로 사용하여 해결해야한다. (연관 관계 엔티티에 jsonIgnore을 했을때 발생) 
(관련 이슈는 해당 링크로 직접 질문하여 다른분의 도움을 받을 수 있었습니다 https://www.inflearn.com/questions/51726/6%EB%B6%8421%EC%B4%88-%EC%A7%88%EB%AC%B8-%EC%9E%88%EC%8A%B5%EB%8B%88%EB%8B%A4 )

 

4. 1:N 관계인 OrderItem 을 조회하고 OrderItem의 toOne 관계인 Item을 초기화 하면서 추가 쿼리가 나간다.

5. 엔티티가 변경되면 해당 엔티티를 사용하는 많은 API 스펙들이 변경되어 버린다

 

 

Dto 를 만들어 엔티티에서 dto로 변경

@GetMapping("/api/v2/orders")
    public List<OrderDto> ordersV2() {
        List<Order> orders = orderRepository.findAll();
        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());
    }
}

OrderDto 내부에서 OrderItem 을 OrderItemDto로 변경하여 가져온다

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

[해결된 점]

엔티티가 그대로 반환 되지 않아 

[문제점]

order를 대상으로 쿼리 한번 

+ order 조회 수가 2라면, member,address 대상으로 2번씩 조회 쿼리가 발생

+ orderItem도 2번씩 조회발생 + orderItem 수만큼 item 에 대한 조회 쿼리 발생

-> 말도 안되는 수의 쿼리가 발생한다.

 

 

페치 조인 사용

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

JPQL에 DISTINCT 를 사용하였다.

OrderItems를 조회하는 것처럼 OneToMany 조인이 있으면 아래처럼 DB의 ROW수가 증가한다 

OrderItem이 2개이면 Order의 개수와 상관없이 데이터를 보여주기 위해 row가 증가한다.

이때 JPA의 distinct는 sql에 distinct를 추가하고 (위에 데이터에서는 사실 의미는 없다.)

1차적으로 row의 중복을 제거하고

애플리케이션에서 추가적으로 같은 엔티티가 있는지 중복을 걸러준다.

여기서는 application 차원에서 order가 중복되는 것을 막아준다. 그래서 위의 row를 참고하면

List<Order> 은 한개만 들어가게 된다

 

[해결된 점]

페치 조인으로 SQL이 한번만 실행된다

 

[문제점]

페이징이 불가능하다.

Order기준으로 페이징 해야하는데 OrderItem 의 기준으로 row가 증가한다.

 

 

페이징 한계 돌파 (추천1)

ToOne 관계는 페치조인을 한다.

컬렉션은 지연 로딩으로 조회한다.

이때 지연 로딩 성능 최적화를 위해 @BatchSize 기능을 사용한다.

이 옵션은 컬렉션,프록시 객체를 한번에 설정한 size 만큼 IN 쿼리로 조회한다.

 

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

옵션 설정

application.yml

 

해당 옵션 설정 후

OrderController

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

OrderItem 은 따로 강초기화 하지 않아도  IN 쿼리로 한번에 가져온다

쿼리가 1+1으로 최적화 된다

 

ToOne 관계는 페치 조인으로 가져오고, 나머지는 BatchSize 옵션을 사용하여 최적화하자

 

 

 

 

DTO 직접 조회 (단건 주문이면 이것도 괜찮다.)

Controller

@GetMapping("/api/v4/orders")
    public List<OrderQueryDto> ordersV4() {
        return orderQueryRepository.findOrderQueryDtos();
    }
public List<OrderQueryDto> findOrderQueryDtos() {
        // 루트 조회(toOne 코드를 모두 한번에 조회)
        List<OrderQueryDto> result = findOrders();
        // 루프를 돌면서 컬렉션 추가(추가 쿼리 실행)
        result.forEach(o -> {
            List<OrderItemQueryDto> orderItems = findOrderItems(o.getOrderId());
            o.setOrderItems(orderItems);
        });
        return result;
    }

findOrders()를 실행하여 OrderQueryDto로 직접 가져오고

OrderQueryDto 의 id 값으로 OrderItemQueryDto 를 직접 가져온뒤 다시 OrderQueryDto의 내부 필드에 밀어넣는다

private List<OrderQueryDto> findOrders() {
        return em.createQuery(
                    "select new xxx.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();
    }
private List<OrderItemQueryDto> findOrderItems(Long orderId) {
        return em.createQuery(
            "select new xxx.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();
    }

Dto를 2개 만들어야 한다.

 

OrderQueryDto로 직접 가져올 때 쿼리 한번이 나가고

조회된 개수만큼 OrderItemQueryDto 를 순회하면서 추가로 쿼리가 발생한다.

(1+N) 발생

 

 

 

DTO 직접 조회 1+1 쿼리로 해결 (추천2)

@GetMapping("/api/v5/orders")
    public List<OrderQueryDto> ordersV5() {
        return orderQueryRepository.findAllByDto_optimization();
    }
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;
    }

toOne코드를 DTO로 한번에 직접 조회합니다.

그리고 OrderId를 List에 저장하고 findOrderItemMap에 넘겨줍니다.

 

private List<Long> toOrderIds(List<OrderQueryDto> result) {
        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 xxx.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));
    }

IN쿼리를 통해 OrderItemQueryDto로 한번에 가져오고

key,value가 orderId : OrderItemQueryDto 인 map을 반환합니다

 

orderId가 담긴 list를 순회하면서

toOne 코드를 조회해온 OrderQueryDto에 마저 OrderItemQueryDto를 넣어줍니다.

 

루트 쿼리 1번,

IN쿼리를 사용해서 컬렉션을 한번에 조회해오고  MAP을 사용해서 매칭 성능이 향상되었습니다.

총 2번의 쿼리가 발생하였습니다.

 

 

 

한번에 직접 조회, 플랫 데이터 최적화

@GetMapping("/api/v6/orders")
    public List<OrderQueryDto> ordersV6() {
        List<OrderFlatDto> flats = orderQueryRepository.findAllByDto_flat();
        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());
    }

OrderFlatDto 를 바로 조회하도록 Repository를 아래와 같이 조회하고

public List<OrderFlatDto> findAllByDto_flat() {
        return em.createQuery("select new xxx.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();
    }

아래와 같은 OrderFaltDto에 담습니다

@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.orderStatus = orderStatus;
        this.address = address;
        this.itemName = itemName;
        this.orderPrice = orderPrice;
        this.count = count;
    }
}

그럼 OrderItam기준으로 row는 늘어날 것이고

아래를 다시보면

@GetMapping("/api/v6/orders")
    public List<OrderQueryDto> ordersV6() {
        List<OrderFlatDto> flats = orderQueryRepository.findAllByDto_flat();
        return flats.stream()
                .collect(groupingBy(o -> new OrderQueryDto(o.getOrderId(),
                        o.getName(), o.getOrderDate(), o.getOrderStatus(), o.getAddress()),
                        mapping(o -> new OrderItemQueryDto(o.getOrderId(),   ////////////////////////////// (1)
                                o.getItemName(), o.getOrderPrice(), o.getCount()), toList())))
                .entrySet().stream()
                .map(e -> new OrderQueryDto(e.getKey().getOrderId(), //////// (2)
                        e.getKey().getName(), e.getKey().getOrderDate(), e.getKey().getOrderStatus(),
                        e.getKey().getAddress(), e.getValue()))
                .collect(toList());
    }

(1) 에서 OrderItem기준으로 조회된 데이터를 제외한

orderId~adress 값을 기준으로 grouping하고  mapping 을 이용하여

List<OrderItemQueryDto> 에 담아 OrderQueryDto를 생성하고 Map 에 담깁니다.

 

orderId~adress 값이 담긴 OrderQueyDto가 key가 되고,

OrderItem에 대한 데이터인 List<OtderItemQueryDto>는 value가 됩니다.

 

map을 다시 순회하여 새로운 OrderQueryDto 에 생성한 뒤 List로 반환합니다

 

 OrderQueryDto에는 orderItems도 받을 수 있도록 생성자를 만들어 e.getValue로 바로 받을 수 있게합니다.

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

 

Query는 한번이지만 처음에 쿼리는 OrderItam 기준으로 하기 때문에 row는 늘어나게 되었고

페이징이 결국 불가능하다.

그리고 조인으로 인해 DB에서 애플리케이션에 전달하는 데이터에 중복 데이터가 추가되므로 상황에 따라 위에서 설명한 DTO 직접 조회 1+1 쿼리로 해결 보다 느릴 수 있다.

 

 

추천1,추천2 방법으로 안되면 NativeSQL or 스프링 JdbcTemplate