ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 컬렉션 조회 최적화
    이커머스 devops 2025. 11. 21. 15:53

    주문 조회 V1: 엔티티 직접 노출

    @RestController
    @RequiredArgsConstructor
    public class OrderApiController {
        private final OrderRepository orderRepository;
    
        @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;
        }
    }
    • 엔티티가 변하면 API 스펙이 변한다
    • 트랜잭션 안에서 지연 로딩 필요
    • 양방향 연관관계 문제
      • 양방향 연관관계면 무한 루프에 걸리지 않게 한 곳에 @JsonIgnore 추가

     

     

    주문 조회 V2: 엔티티를 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();
        }
    }
    • 지연 로딩으로 너무 많은 SQL 실행
    • SQL 실행 수
      • order 1번
      • member, address N번 (order 조회 수 만큼)
      • orderItem N번 (order 조회 수만큼)
      • item N번 (orderItem 조회 수만큼)

     

     

    주문 조회 V3: 엔티티를 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;
    }
    //  OrderRepository
    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가 증가하기 때문에 사용한다 
    • 컬렉션 페치 조인을 사용하면 페이징 불가능

     

     

    주문 조회 V3.1: 엔티티를 DTO로 변환 - 페이징과 한계 돌파

    [ 페이징 + 컬렉션 엔티티를 함께 조회 ]

    • 먼저 ToOne(OneToOne, ManyToOne) 관계를 모두 페치조인 한다
    • 컬렉션은 지연 로딩으로 조회한다
    • 지연 로딩 성능 최적화를 위해 hibernate.default_batch_fetch_size, @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();
    }
    @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;
    }
    • 쿼리 호출 수가 1+N -> 1 + 1 로 최적화
    • 조인보다 DB 데이터 전송량이 최적화된다 
    • 페치 조인 방식과 비교해 쿼리 호출 수가 증가하지만 DB 데이터 전송량은 감소한다
    • 페이징 가능
    • 하이버네이트 6.2 부터는 where in 대신 array_contains를 사용한다

     

     

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

    private final OrderQueryRepository orderQueryRepository;
    
    @GetMapping("/api/v4/orders")
    public List<OrderQueryDto> ordersV4() {
        return orderQueryRepository.findOrderQueryDtos();
    }
    @Repository
    @RequiredArgsConstructor
    public class OrderQueryRepository {
        private final EntityManager em;
        /**
         * 컬렉션은 별도로 조회
         * 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;
        }
    }
    • Query: 루트 1번, 컬렉션 N 번 실행
    • ToOne(N:1, 1:1) 관계들을 먼저 조회하고, ToMany(1:N) 관계는 각각 별도로 처리

     

     

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

    @GetMapping("/api/v5/orders")
    public List<OrderQueryDto> ordersV5() {
        return orderQueryRepository.findAllByDto_optimization();
    }
    // OrderQueryRepository
    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(OrderItemQueryDto::getOrderId));
    }
    • Query: 루트 1번, 컬렉션 1번
    • ToOne 관계들을 먼저 조회하고, 여기서 얻은 식별자 orderId로 ToMany 관계인 OrderItem을 한 번에 조회
    • MAP 사용으로 매칭 성능 향상(O(1))

     

     

    주문 조회 V6: JPA에서 DTO로 직접 조회, 플랫 데이터 최적화

    @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());
    }
    public OrderQueryDto(Long orderId, String name, LocalDateTime orderDate, 
        // dto에 생성자 추가
        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;
    }
    // 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();
    }
    @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;
        }
    }
    • Query: 1번
    • 쿼리는 한 번이지만 조인으로 인해 DB에서 애플리케이션에 전달하는 데이터에 중복 데이터가 추가되므로 상황에 따라 V5 보다 더 느릴 수도 있다
    • 애플리케이션에 추가 작업이 많다
    • 페이징 불가능

     

     

    결론

    1. 엔티티 조회 방식으로 우선 접근
    2. 컬렉션 최적화
      1. 페이징 필요, @Batchsize로 최적화
      2. 페이징 필요 x, 페치 조인 사용
    3. 엔티티 조회 방식으로 해결 안 되면 DTO 조회 방식 사용
    4. DTO 조회 방식으로 해결 안되면 NativeSQL, 스프링 Jdbc Template 사용
    728x90

    '이커머스 devops' 카테고리의 다른 글

    Distributed Relational Database  (0) 2025.11.25
    OSIV와 성능 최적화  (0) 2025.11.21
    지연 로딩과 조회 성능 최적화  (0) 2025.11.20
    Jenkins - CI/CD (1)  (0) 2025.11.03
    kafka (3)  (0) 2025.10.17
Designed by Tistory.