4. API 개발 고급 - 컬렉션 조회 최적화(3)

shin·2024년 4월 21일

1. 주문 조회 V4 : JPA에서 DTO 직접 조회

단건 조회에서 많이 사용하는 방식

OrderApiController 코드 추가

package jpabook.jpashop.api;
...
@RestController
@RequiredArgsConstructor
public class OrderApiController {

    private final OrderQueryRepository orderQueryRepository;

    /**
     * V4. JPA에서 DTO 직접 조회
     * @return List<OrderQueryDto>
     */
    @GetMapping("/api/v4/orders")
    public List<OrderQueryDto> ordersV4() {

        return orderQueryRepository.findOrderQueryDtos();

    }

}

OrderQueryRepository 새로 생성

package jpabook.jpashop.repository.order.query;
...
@Repository
@RequiredArgsConstructor
public class OrderQueryRepository {

    private final EntityManager em;

    /**
     * 컬렉션은 별도로 조회
     * Query : 루트 1번, 컬렉션 N번
     * 단건 조회에서 많이 사용하는 방식
     * @return List<OrderQueryDto>
     */
    public List<OrderQueryDto> findOrderQueryDtos() {

        List<OrderQueryDto> result = findOrders();

        result.forEach(o -> {
            List<OrderItemQueryDto> orderItems = findOrderItems(o.getOrderId());
            o.setOrderItems(orderItems);
        });

        return result;

    }

    /**
     * 1 : N 관계(컬렉션)를 제외한 나머지를 한 번에 조회
     * @return List<OrderQueryDto>
     */
    public 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 조회
     * @param orderId
     * @return List<OrderItemQueryDto>
     */
    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();

    }

}
  • 특정 화면에 핏한 쿼리들은 해당 OrderQueryRepository에서 정의
  • 핵심 비즈니스 로직은 기존의 OrderRepository에 정의

OrderQueryDto

package jpabook.jpashop.repository.order.query;
...
@Data
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;
    }

}

OrderItemQueryDto

package jpabook.jpashop.repository.order.query;
...
@Data
public class OrderItemQueryDto {

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

}

👌 api 수행 테스트


  • 첫번째 쿼리 : findOrders 수행시 호출됨
    • Order, Member, Delivery 조인

  • 두번째 쿼리 : findOrderItems 수행시 호출됨

    • OrderItem과 item을 가져옴
    • OrderItem과 item은 ToOne 관계이기 때문에 서로 조인해도 무방함
    • 해당 쿼리가 2번 수행됨
  • 총 3번의 쿼리(1 + N번)가 수행됨

    • findOrders 쿼리 수행 1번으로 2개가 나왔고, findOrderItems 쿼리가 2번 수행됨
    • 첫번째 쿼리가 1번 수행되어 N개의 결과가 나오면, 두번째 컬렉션 쿼리는 N번 수행됨

😊 정리

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


2. 주문 조회 V5: JPA에서 DTO 직접 조회 - 컬렉션 조회 최적화

데이터를 한꺼번에 처리할 때 많이 사용하는 방식

OrderApiController에 추가

    /**
     * V5. JPA에서 DTO 직접 조회 - 컬렉션 조회 최적화
     * @return List<OrderQueryDto>
     */
    @GetMapping("/api/v5/orders")
    public List<OrderQueryDto> ordersV5() {

        return orderQueryRepository.findAllByDto_optimization();

    }

OrderQueryRepository에 추가

package jpabook.jpashop.repository.order.query;
...
@Repository
@RequiredArgsConstructor
public class OrderQueryRepository {

    private final EntityManager em;
...
    /**
     * 최적화
     * Query : 루트 1번, 컬렉션 1번
     * 데이터를 한꺼번에 처리할 때 많이 사용하는 방식
     * @return List<OrderQueryDto>
     */
    public List<OrderQueryDto> findAllByDto_optimization() {

		// 루트 조회(toOne 코드를 모두 한 번에 조회)
        List<OrderQueryDto> result = findOrders();
		
        // orderItem 컬렉션을 MAP 한방에 조회
        List<Long> orderIds = result.stream()
                .map(o -> o.getOrderId())
                .collect(Collectors.toList());

        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<Long, List<OrderItemQueryDto>> orderItemMap = orderItems.stream()
                .collect(Collectors.groupingBy(orderItemsQueryDto -> orderItemsQueryDto.getOrderId()));
        
        // 루프를 돌면서 컬렉션 추가(추가 쿼리 실행X)
        result.forEach(o -> o.setOrderItems(orderItemMap.get(o.getOrderId())));
        
        return result;

    }
...
}

코드 리팩토링

package jpabook.jpashop.repository.order.query;
...
@Repository
@RequiredArgsConstructor
public class OrderQueryRepository {

    private final EntityManager em;
...
    /**
     * 최적화
     * Query : 루트 1번, 컬렉션 1번
     * 데이터를 한꺼번에 처리할 때 많이 사용하는 방식
     * @return List<OrderQueryDto>
     */
    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) {
        
        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(orderItemsQueryDto -> orderItemsQueryDto.getOrderId()));

    }

}
  • V4는 루프를 돌때마다 쿼리를 날렸는데, V5는 쿼리를 한 번 날리고 메모리에서 MAP으로 전부 가져온 다음에 메모리에서 매칭하여 값을 세팅해줌
  • 쿼리는 총 2번 수행됨
    • findOrders 쿼리 1번 + OrderItems 가져오는 쿼리 1번

👌 api 수행 테스트


  • 첫번째 쿼리 findOrders 1번 수행

  • 두번째 쿼리 findOrderItems 1번 수행

  • 총 2번의 쿼리 수행


😊 정리

  • Query : 루트 1번, 컬렉션 1번
  • ToOne 관계들을 먼저 조회하고, 여기서 얻은 식별자 orderIdToMany 관계인 OrderItem을 한꺼번에 조회
  • MAP을 사용해서 매칭 성능 향상(O(1))


3. 주문 조회 V5 : JPA에서 DTO 직접 조회 - 플랫 데이터 최적화

return List<OrderFlatDto>

  • OrderOrderItem을 조인하고, OrderItemItem을 조인하여 한방쿼리를 만들어서 DB를 한방에 가져옴
  • 데이터를 한줄로 flat하게 SQL 조인의 결과를 가져올 수 있음

OrderApiController

package jpabook.jpashop.api;
...
@RestController
@RequiredArgsConstructor
public class OrderApiController {
...
    /**
     * V6. JPA에서 DTO 직접 조회 - 플랫 데이터 최적화
     * @return List<OrderQueryDto>
     */
    @GetMapping("/api/v6/orders")
    public List<OrderFlatDto> ordersV6() {

        return orderQueryRepository.findAllByDto_flat();

    }

}

OrderFlatDto

package jpabook.jpashop.repository.order.query;
...
@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;
    }

}

OrderQueryRepository

package jpabook.jpashop.repository.order.query;
...
@Repository
@RequiredArgsConstructor
public class OrderQueryRepository {
...
    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();

    }

}

[
    {
        "orderId": 1,
        "name": "userA",
        "orderDate": "2024-04-28T22:04:34.667737",
        "address": {
            "city": "서울",
            "street": "1",
            "zipcode": "1111"
        },
        "orderStatus": "ORDER",
        "itemName": "JPA1 BOOK",
        "orderPrice": 10000,
        "count": 1
    },
    {
        "orderId": 1,
        "name": "userA",
        "orderDate": "2024-04-28T22:04:34.667737",
        "address": {
            "city": "서울",
            "street": "1",
            "zipcode": "1111"
        },
        "orderStatus": "ORDER",
        "itemName": "JPA2 BOOK",
        "orderPrice": 20000,
        "count": 2
    },
    {
        "orderId": 2,
        "name": "userB",
        "orderDate": "2024-04-28T22:04:34.815809",
        "address": {
            "city": "진주",
            "street": "2",
            "zipcode": "2222"
        },
        "orderStatus": "ORDER",
        "itemName": "SPRING1 BOOK",
        "orderPrice": 20000,
        "count": 3
    },
    {
        "orderId": 2,
        "name": "userB",
        "orderDate": "2024-04-28T22:04:34.815809",
        "address": {
            "city": "진주",
            "street": "2",
            "zipcode": "2222"
        },
        "orderStatus": "ORDER",
        "itemName": "SPRING2 BOOK",
        "orderPrice": 40000,
        "count": 4
    }
]
  • API를 실행해보면 같은 값이 중복으로 호출됨

  • Query는 한 번 호출됨
  • 페이징이 되긴 하지만, 원했던 방식으로 페이징이 되는 것은 아님
  • OrderItems가 기준이 되어서 중복된 값들이 출력됨

return List<OrderQueryDto>

  • OrderFlatDto를 반환하는 것이 아니라 OrderQueryDto 스펙에 맞춰야하는 경우, stream으로 중복된 값들을 제외하여 호출해주면 됨
  • 직접 중복을 제거해주는 방법 사용
package jpabook.jpashop.api;
...
@RestController
@RequiredArgsConstructor
public class OrderApiController {
...
    /**
     * V6. JPA에서 DTO 직접 조회 - 플랫 데이터 최적화
     * @return List<OrderQueryDto>
     */
    @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());

    }

}
package jpabook.jpashop.repository.order.query;
...
@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;
    }

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

}

[
    {
        "orderId": 1,
        "name": "userA",
        "orderDate": "2024-04-28T22:37:54.959234",
        "orderStatus": "ORDER",
        "address": {
            "city": "서울",
            "street": "1",
            "zipcode": "1111"
        },
        "orderItems": [
            {
                "orderId": 1,
                "itemName": "JPA1 BOOK",
                "orderPrice": 10000,
                "count": 1
            },
            {
                "orderId": 1,
                "itemName": "JPA2 BOOK",
                "orderPrice": 20000,
                "count": 2
            }
        ]
    },
    {
        "orderId": 2,
        "name": "userB",
        "orderDate": "2024-04-28T22:37:55.076539",
        "orderStatus": "ORDER",
        "address": {
            "city": "진주",
            "street": "2",
            "zipcode": "2222"
        },
        "orderItems": [
            {
                "orderId": 2,
                "itemName": "SPRING1 BOOK",
                "orderPrice": 20000,
                "count": 3
            },
            {
                "orderId": 2,
                "itemName": "SPRING2 BOOK",
                "orderPrice": 40000,
                "count": 4
            }
        ]
    }
]

  • API를 수행해보면 쿼리도 1번만 수행되고 OrderQueryDto 스펙에 맞춰서 데이터가 중복없이 호출됨

단점

  • 쿼리는 한 번 수행되지만 조인으로 인해 DB에서 애플리케이션에 전달하는 데이터에 중복 데이터가 추가됨

  • 따라서 상황에 따라 V5보다 더 느릴 수 있음

  • 애플리케이션에서 추가 작업이 큼

  • 페이징이 불가능함

    • OrderItems 기준으로는 페이징을 할 수는 있지만 Order 기준으로는 페이징이 불가능함


강의 : 실전! 스프링 부트와 JPA 활용2 - API 개발과 성능 최적화

profile
Backend development

0개의 댓글