-
컬렉션 조회 최적화이커머스 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 보다 더 느릴 수도 있다
- 애플리케이션에 추가 작업이 많다
- 페이징 불가능
결론
- 엔티티 조회 방식으로 우선 접근
- 컬렉션 최적화
- 페이징 필요, @Batchsize로 최적화
- 페이징 필요 x, 페치 조인 사용
- 엔티티 조회 방식으로 해결 안 되면 DTO 조회 방식 사용
- 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