[JPA] xToMany 관계의 컬렉션 조회 최적화

olsohee·2023년 8월 14일
0

JPA

목록 보기
13/21
post-thumbnail

주문 정보(order)를 조회할 때 연관된 회원 정보(member), 배송 정보(delivery), 주문 상품 정보(orderItem), 상품 정보(item)를 조회해보자. 이때 엔티티 연관관계는 다음과 같고, 특히 일대다 관계인 컬렉션 조회에 주목하여 컬렉션 조회시 성능 최적화를 알아보자.

  • order - member: 다대일

  • order - delivery: 일대일

  • order - orderItem: 일대다

  • orderItem - item: 다대일

1. N+1 문제

1.1. 컬렉션 조회 결과를 DTO에 담을 때 주의사항

OrderApiController
@RestController
@RequiredArgsConstructor
public class OrderApiController {

    private final OrderRepository orderRepository;

    @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(Collectors.toList());
        return result;
    }

    @Getter
    static class OrderDto {

        private Long orderId;
        private String name;
        private LocalDateTime orderDate;
        private OrderStatus orderStatus;
        private Address address;
        private List<OrderItem> orderItems; 
        
        public OrderDto(Order order) {
            this.orderId = order.getId();
            this.name = order.getMember().getName();
            this.orderDate = order.getOrderDate();
            this.orderStatus = order.getStatus();
            this.address = order.getDelivery().getAddress();
            order.getOrderItems().stream().forEach(oi -> oi.getItem().getName());
            this.orderItems = order.getOrderItems();
        }
    }
}

위 예제에서는 order를 조회해와서 OrderDto로 변환한다. 그런데 일대다 조회이기 때문에 order 내에는 컬렉션 형태로 orderItem이 있다. 따라서 OrderDto로 order를 감싸더라도 그 내부에는 orderItem 엔티티가 컬렉션 형태로 여전히 존재한다(List<OrderItem> orderItems). 따라서 엔티티가 그대로 노출되는 문제가 발생한다.

컬렉션 조회시 조회된 컬렉션 데이터까지도 DTO에 담아서 노출해야 한다.

OrderApiController
@RestController
@RequiredArgsConstructor
public class OrderApiController {

    private final OrderRepository orderRepository;

    @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(Collectors.toList());
        return result;
    }

    @Getter
    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) {
            this.orderId = order.getId();
            this.name = order.getMember().getName();
            this.orderDate = order.getOrderDate();
            this.orderStatus = order.getStatus();
            this.address = order.getDelivery().getAddress();
            orderItems = order.getOrderItems().stream()
                    .map(oi -> new OrderItemDto(oi))
                    .collect(Collectors.toList());
        }
    }

    @Getter
    static class OrderItemDto {

		// api 스펙에 맞게 필요한 데이터만 노출
        private String itemName; // 상품 명
        private int orderPrice; // 주문 가격
        private int count; // 주문 수량

        public OrderItemDto(OrderItem orderItem) {
            this.itemName = orderItem.getItem().getName();
            this.orderPrice = orderItem.getItem().getPrice();
            this.count = orderItem.getCount();
        }
    }
}

위 예제에서는 order를 OrderDto로 변환한다. 그리고 order 내부에 있는 컬렉션 형태의 orderItem 또한 OrderItemDto로 변환한다(List<OrderItemDto> orderItems). 이렇게 컬렉션에 담긴 엔티티까지도 DTO로 변환해야 엔티티가 그대로 노출되지 않고, api 스펙에 맞게 필요한 데이터만 노출시킬 수 있다.

1.2. N+1 문제 발생

그런데 여전히 문제가 존재한다. order -> member, order -> delivery, order -> orderItem, orderItem -> item 조회가 지연 로딩으로 설정되어 있기 때문에 N+1 문제가 발생한다. 즉 SQL이 다음과 같이 실행된다. (참고로 한 번 조회한 후 영속성 컨텍스트에 엔티티가 있으면, 그 이후에는 DB에서 조회하지 않고 영속성 컨텍스트의 엔티티를 사용하기 때문에 sql이 실행되지 않는다.)

  • order: 1번

  • member, delivery: N번(order 조회 수 만큼)

  • orderItem: N번(order 조회 수 만큼)

  • item: N번(orderItem 조회 수 만큼)

1.3. 페치 조인으로 문제 해결

OrderApiController
@RestController
@RequiredArgsConstructor
public class OrderApiController {

    private final OrderRepository orderRepository;

    @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(Collectors.toList());
        return result;
    }

    @Getter
    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) {
            this.orderId = order.getId();
            this.name = order.getMember().getName();
            this.orderDate = order.getOrderDate();
            this.orderStatus = order.getStatus();
            this.address = order.getDelivery().getAddress();
            orderItems = order.getOrderItems().stream()
                    .map(oi -> new OrderItemDto(oi))
                    .collect(Collectors.toList());
        }
    }

    @Getter
    static class OrderItemDto {

        private String itemName;
        private int orderPrice; 
        private int count;

        public OrderItemDto(OrderItem orderItem) {
            this.itemName = orderItem.getItem().getName();
            this.orderPrice = orderItem.getItem().getPrice();
            this.count = orderItem.getCount();
        }
    }
}
OrderRepository
@Repository
@RequiredArgsConstructor
public class OrderRepository {

    private final EntityManager em;

    public List<Order> findAllWithItem() {

        return em.createQuery(
                "select 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();
    }
}

사실상 컨트롤러의 코드는 이전 예제와 같고, 리포지토리에서 createQuery() 부분에 페치 조인을 적용한 점만 다르다. 이렇게 페치 조인을 적용하여 sql이 딱 한 번만 실행된다. 즉 sql 한 번으로 order, member, delivery, orderItem, item을 한 번에 조회해온다.


2. 컬렉션 페치 조인시 데이터 수 증가 문제

2.1. 컬렉션 페치 조인으로 데이터 수 증가 문제 발생

일대다 관계인 컬렉션을 페치 조인하면 데이터 수가 증가한다는 문제가 발생한다.

예를 들어 order 조회 결과가 2개이고, 첫번째 order와 두번째 order 각각 orderItem이 2개씩 있다고 해보자. 페치 조인은 join을 하면서 select절에서 order 뿐만 아니라 페치 조인한 모든 데이터를 함께 조회한다. 즉 JPA를 통해 페치 조인을 하면 DB에서 조인되는데, 이때 2개의 order가 4개로 데이터 수가 증가하게 된다. 그리고 이 4개의 order가 어플리케이션으로 리스트 형태로 반환된다(리스트에는 order1, order1, order2, order2가 들어있다). 따라서 데이터가 중복된다.

DB에서 조인

DB에서 직접 조인 쿼리를 통해 조회해보면 일대다 관계의 페치 조인으로 인해 2개의 order 데이터가 조인으로 인해 4개로 증가한 것을 확인할 수 있다.

select * from orders;

select * from order_item;

select * from orders o 
join order_item oi on o.order_id = oi.order_id;

결과가 담긴 리스트

따라서 쿼리 결과가 담긴 리스트에는 하나의 order 엔티티 참조 값이 두개 중복으로 들어있고(order1의 참조 값, order1의 참조 값, order2의 참조 값, order2의 참조 값), 이 하나의 order 엔티티는 두개의 orderItem을 참조한다. 다음 사진을 보고 구조를 참고하자. (다음 사진은 team -> member 일대다 관계인 경우이다.)

쿼리 결과가 담긴 리스트에는 각각의 order가 중복으로 두 개씩 들어있고(orderItem 수만큼 뻥튀기 됨), 이 각각의 order는 동일한 orderItems들을 가리키고 있다.

2.2. distinct로 문제 해결

이런 데이터 중복 문제를 해결하기 위해 페치 조인시에 distinct를 넣어주면 된다.

OrderRepository
@Repository
@RequiredArgsConstructor
public class OrderRepository {

    private final EntityManager em;

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

distinct를 넣으면 다음과 같은 일이 발생한다.

1. SQL에 distinct를 추가한다.

SQL의 distinct는 중복되는 row의 중복을 제거해준다. 이때, 모든 칼럼이 중복되어야 중복으로 인정한다. 예제의 경우, DB에서는 중복되는 row가 모든 컬럼이 완전히 중복되지는 않는다. 따라서 SQL에 distinct가 적용되었지만, 중복이 제거되지는 않는다.

2. 엔티티가 조회되면, 애플리케이션에서 중복을 걸러준다.

예제의 경우, distinct 적용 전에는 리스트에 order1의 참조 값이 2개, order2의 참조 값이 2개 저장된다. 그러나 distinct를 적용하면 order의 id 값이 같으면 같다고 간주하여 중복을 제거한다. 따라서 리스트에 order1의 참조 값 1개, order2의 참조 값 1개만 저장된다.


3. 컬렉션 페치 조인시 페이징 처리 불가

3.1. 컬렉션 페치 조인시 페이징 처리 불가 문제 발생

페치 조인을 사용하면 SQL 한 번으로 연관된 엔티티들을 한 번에 조회할 수 있어서 SQL 호출 횟수를 줄여 성능을 최적화할 수 있다. 그러나 컬렉션을 페치 조인하면 페이징 API를 사용할 수 없다. 자세한 내용은 다음을 참고하자.

https://velog.io/@dlthgml0108/JPA-%ED%8E%98%EC%B9%98-%EC%A1%B0%EC%9D%B8%EC%9D%98-%ED%95%9C%EA%B3%84

3.2. 지연 로딩+배치 사이즈 설정으로 문제 해결

컬렉션 페치 조인의 경우 페이징 API를 사용할 수 없다. 그렇다면 컬렉션 엔티티를 페이징 처리하여 조회하려면 어떻게 해야 할까?

  1. 먼저 ToOne(OneToOne, ManyToOne) 관계는 모두 페치 조인한다. (ToOne 관계는 페치 조인을 하더라도 row 수를 증가시키기 않기 때문에 페이징 처리가 정상 동작한다.)

  2. ToMany(OneToMany, ManyToMany) 관계 같은 컬렉션은 지연 로딩으로 조회한다.

  3. 컬렉션을 지연 로딩으로 조회하면 N+1 문제가 발생한다. 따라서 지연 로딩 성능 최적화를 위해 배치 사이즈를 설정한다. 다음 옵션을 사용하면 컬렉션이나 프록시 객체를 설정한 size만큼 한 번에 in 쿼리로 조회한다.

    • jpa.properties.hibernate.default_batch_fetch_size: 글로벌 설정

    • @BatchSize: 개별 최적화

      • 참고로 order -> orderItem과 같이 컬렉션 조회의 경우에는 Order 클래스의 List orderItems 필드 위에 어노테이션을 붙이고,

      • orderItem -> item과 같이 하나의 엔티티 조회의 경우에는 orderItem 클래스가 아닌 item 클래스의 클래스명 위에 어노테이션을 붙여주어야 한다.

OrderApiController
@RestController
@RequiredArgsConstructor
@Slf4j
public class OrderApiController {

    private final OrderRepository orderRepository;

    @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(Collectors.toList());
        return result;
    }

    @Getter
    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) {
            this.orderId = order.getId();
            this.name = order.getMember().getName();
            this.orderDate = order.getOrderDate();
            this.orderStatus = order.getStatus();
            this.address = order.getDelivery().getAddress();
            orderItems = order.getOrderItems().stream()
                    .map(oi -> new OrderItemDto(oi))
                    .collect(Collectors.toList());
        }
    }

    @Getter
    static class OrderItemDto {

        private String itemName;
        private int orderPrice;
        private int count;

        public OrderItemDto(OrderItem orderItem) {
            this.itemName = orderItem.getItem().getName();
            this.orderPrice = orderItem.getItem().getPrice();
            this.count = orderItem.getCount();
        }
    }
}
OrderRepository
@Repository
@RequiredArgsConstructor
public class OrderRepository {

    private final EntityManager em;

    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();
    }
}
Order
/*
 * order -> orderItem 배치 사이즈 설정 
 */
public class Order {

    @Id @GeneratedValue
    @Column(name = "order_id")
    private Long id;

    @BatchSize(size = 100)
    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
    private List<OrderItem> orderItems = new ArrayList<>();
    
    ...
}
Item
/*
 * orderItem -> item 배치 사이즈 설정 
 */
@BatchSize(size = 100)
public abstract class Item {

    @Id @GeneratedValue
    @Column(name = "item_id")
    private Long id;
    
    ...
}

이렇게 컬렉션 조회를 지연 로딩으로 설정하고 배치 사이즈를 설정하면 다음과 같은 장점이 있다.

  • 쿼리 호출 수가 1+N에서 1+1로 최적화된다.
    • 배치 사이즈를 설정하지 않고, 컬렉션 조회를 지연 로딩으로 설정하는 경우

      • order 조회 1번

      • orderItem 조회 N번(order 조회 수 만큼)

      • item 조회 N번(orderItem 조회 수 만큼)

    • 배치 사이즈를 설정하고, 컬렉션 조회를 지연 로딩으로 설정하는 경우

      • order 조회 1번

      • orderItem 조회 1번(배치 사이즈로 한 번에 조회 가능한 범위 내에서)

      • item 조회 1번(배치 사이즈로 한 번에 조회 가능한 범위 내에서)

  • xToOne, xToMany 관계 모두 페치 조인을 했을 때(3.1 예제)보다 쿼리 호출 수가 약간 증가하지만, DB 데이터 전송량이 최적화된다(3.1 예제의 경우, xToMany 페치 조인은 증가된 데이터 row 수 만큼 조회해와서 메모리 내에서 페이징 처리가 되기 때문에 DB에서 가져오는 데이터 전송량이 크다).

  • 컬렉션 페치 조인은 페이징이 불가능하지만 이 방법은 페이징이 가능하다.

결론: ToOne 관계는 페치 조인을 사용해도 페이징에 영향을 주지 않는다. 따라서 ToOne 관계는 페치 조인을 사용하여 쿼리 수를 줄이고, ToMany 관계는 지연 로딩 + 배치 사이즈 설정 방법을 사용하자.

적절한 default_batch_fetch_size의 크기

default_batch_fetch_size의 크기는 100~1000 사이를 선택하는 것이 권장된다. 이 전략은 sql의 in절을 사용하는데, 데이터베이스에 따라 in절 파라미터를 1000으로 제한하기도 한다.

1000으로 잡으면 한 번에 1000개를 DB에서 애플리케이션으로 불러오기 때문에 DB에 순간 부하가 증가할 수 있다. 반면 100으로 잡으면 1000에 비해 짧게 끊어서 가져오기 때문에 부하 측면에선 안전하지만 쿼리 실행 수가 늘어나며 시간이 더 오래 걸린다.

그러나 결국 100이든 1000이든 애플리케이션은 전체 데이터를 로딩하기 때문에 메모리 사용량은 같다.

결론적으로, 쿼리 수를 줄이고 실행시간을 줄이기 위해 100보다는 1000으로 하는 것이 성능상 가장 좋지만, DB와 애플리케이션이 순간 부하를 얼마나 견딜 수 있는가를 보고 결정해야 한다.


4. JPA에서 DTO로 바로 조회시 N+1 문제

4.1. N+1 문제 발생

OrderApiController
@RestController
@RequiredArgsConstructor
@Slf4j
public class OrderApiController {

    private final OrderRepository orderRepository;
    private final OrderQueryRepository orderQueryRepository;

    @GetMapping("/api/v4/orders")
    public List<OrderQueryDto> ordersV4() {

        return orderQueryRepository.findOrderQueryDtos();
    }
    
    @Getter
    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) {
            this.orderId = order.getId();
            this.name = order.getMember().getName();
            this.orderDate = order.getOrderDate();
            this.orderStatus = order.getStatus();
            this.address = order.getDelivery().getAddress();
            orderItems = order.getOrderItems().stream()
                    .map(oi -> new OrderItemDto(oi))
                    .collect(Collectors.toList());
        }
    }

    @Getter
    static class OrderItemDto {

        private String itemName;
        private int orderPrice;
        private int count;

        public OrderItemDto(OrderItem orderItem) {
            this.itemName = orderItem.getItem().getName();
            this.orderPrice = orderItem.getItem().getPrice();
            this.count = orderItem.getCount();
        }
    }
}
OrderQueryRepository
@Repository
@RequiredArgsConstructor
public class OrderQueryRepository {

    private final EntityManager em;

    public List<OrderQueryDto> findOrderQueryDtos() {

        // 루트 조회(toMany(컬렉션) 관계를 제외한 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" + // orderItem -> item은 toOne 관계이기 때문에 함께 조회
                        " where oi.order.id = :orderId", OrderItemQueryDto.class)
                .setParameter("orderId", orderId)
                .getResultList();
    }
}
OrderQueryDto
@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
@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;
    }
}

위 예제는 new 키워드를 사용하며 DTO로 바로 조회해오는 방법이다. 이때 toMany 관계인 컬렉션은 new 키워드를 통해 조회할 수 없다. 따라서 toOne 관계만 조회해오고, 그 결과를 루프를 타면서 orderItem을 조회하고(List<OrderItemQueryDto> orderItems = findOrderItems(o.getOrderId())), OrderQueryDto의 orderItems(List<OrderItemQueryDto> orderItems)에 넣어주었다(o.setOrderItems(orderItems)).

이때 실행되는 SQL은 루트 조회 1번, 컬렉션 조회 N번(루트 조회 수 만큼)이다. 즉 N+1 문제가 발생한다.

new 키워드를 통해 DTO로 바로 조회하는 경우, 지연 로딩/즉시 로딩 설정

DTO로 조회할 때는 fetch type(lazy, eager)과 별개로 필요한 데이터를 하나씩 다 찝어서 가져오기 때문에 쿼리가 한 번만 나간다. 즉 위 예제에서 findOrders() 실행 결과 쿼리는 한 번만 나간다.

DTO로 바로 조회할 경우, 리포지토리가 api에 의존히는 문제 발생

DTO로 바로 조회하는 경우, 리포지토리가 특정 api에 의존한다는 문제가 있다. 리포지토리는 가급적 엔티티만을 조회하고 재사용성이 뛰어나야 한다. 따라서 DTO로 바로 조회하는 경우, 해당 코드를 별개의 리포지토리로 분리하여, 엔티티만 조회하는 순수하고 재사용성이 높은 리포지토리(ex, OrderRepository)와 특정 api에 의존하는 리포지토리(OrderQueryRepository)를 분리하는 것이 좋다.

4.2. 컬렉션 조회시 in절 사용으로 N+1 문제 해결

OrderApiController
@RestController
@RequiredArgsConstructor
@Slf4j
public class OrderApiController {

    private final OrderRepository orderRepository;
    private final OrderQueryRepository orderQueryRepository;

    @GetMapping("/api/v5/orders")
    public List<OrderQueryDto> ordersV5() {

        return orderQueryRepository.findAllByDto_optimization();
    }
    
    @Getter
    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) {
            this.orderId = order.getId();
            this.name = order.getMember().getName();
            this.orderDate = order.getOrderDate();
            this.orderStatus = order.getStatus();
            this.address = order.getDelivery().getAddress();
            orderItems = order.getOrderItems().stream()
                    .map(oi -> new OrderItemDto(oi))
                    .collect(Collectors.toList());
        }
    }

    @Getter
    static class OrderItemDto {

        private String itemName;
        private int orderPrice;
        private int count;

        public OrderItemDto(OrderItem orderItem) {
            this.itemName = orderItem.getItem().getName();
            this.orderPrice = orderItem.getItem().getPrice();
            this.count = orderItem.getCount();
        }
    }
}
OrderQueryRepository
@Repository
@RequiredArgsConstructor
public class OrderQueryRepository {

    private final EntityManager em;
    
    public List<OrderQueryDto> findAllByDto_optimization() {

        // 루트 조회(toMany(컬렉션) 관계를 제외한 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));
    }

    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번, 컬렉션 조회 1번이다. 컬렉션을 조회할 때 in을 사용하여 각 order의 orderItems를 한 번에 조회한다. 그리고 조회 결과를 orderId를 키로, 조회 결과를 값으로 두어 Map을 통해 메모리에서 관리하고, 각 order의 orderItems로 넣어주었다(o.setOrderItems(orderItemMap.get(o.getOrderId()))). 결과적으로 루프를 타면서 컬렉션(각 order의 orderItems)을 조회하면서 생긴 N+1문제를 해결하였다.

4.3. 조인 결과를 그대로 조회하여 쿼리 한 번으로 최적화

OrderApiController
@RestController
@RequiredArgsConstructor
@Slf4j
public class OrderApiController {

    private final OrderRepository orderRepository;
    private final OrderQueryRepository orderQueryRepository;

    @GetMapping("/api/v6/orders")
    public List<OrderFlatDto> ordersV6() {

        return orderQueryRepository.findAllByDto_flat();
    }
}
OrderQueryRepository
@Repository
@RequiredArgsConstructor
public class OrderQueryRepository {

    private final EntityManager em;

    public List<OrderQueryDto> findOrderQueryDtos() {

    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();
    }
}
OrderFlatDto
@Data
public class OrderFlatDto {

    private Long orderId;
    private String name;
    private LocalDateTime orderDate;
    private OrderStatus orderStatus;
    private Address address;

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

위 예제에서는 order, member, delivery, orderItem, item과 관련한 모든 정보를 담은 DTO인 OrderFlatDto를 정의하고, order, member, delivery 뿐만 아니라 조인을 통해 orderItem과 item까지 하나의 쿼리로 모두 조회한다. 따라서 4.2의 예제에서 루트 조회 1번, 컬렉션 조회 1번하던 것에서 쿼리 한 번으로 실행되는 쿼리 수가 더 줄었다.

그러나 이 방법은 컬렉션 조인으로 인해 데이터 수가 증가하게 된다는 치명적인 단점이 있다. 즉 애플리케이션에 중복된 데이터가 전달되고, 따라서 페이징이 불가하다. 만약 중복된 데이터를 제거하고 싶거나, 다른 형태의 DTO로 반환하고 싶다면 애플리케이션 내에서 추가 작업이 필요하다.


5. 정리

권장 순서

권장되는 컬렉션 조회 최적화 순서는 다음과 같다.

  1. 우선 엔티티 조회 방식을 사용한다.

    • 이때, toOne 관계는 페치 조인으로 쿼리 수를 최적화한다.

    • toMany 관계는 만약 페이징이 필요하면 지연 로딩 + 배치 사이즈 설정으로 최적화하고, 페이징이 필요 없으면 지연 로딩 + 배치 사이즈 설정 또는 페치 조인을 사용한다.

  2. 엔티티 조회 방식으로 해결이 안되면 DTO 조회 방식을 사용한다.

  3. DTO 조회 방식으로 해결이 안되면 네이티브 SQL이나 스프링 JDBC Template을 사용해서 SQL을 직접 작성한다.

엔티티 조회 방식이 우선

DTO 조회 방식보다 엔티티 조회 방식이 우선적으로 권장되는 이유는 다음과 같다. 엔티티 조회 방식은 페치 조인이나 지연 로딩 + 배치 사이즈 설정이나 코드를 약간만 변경하여 다양한 성능 최적화를 시도할 수 있다. 반면 DTO 조회 방식은 성능 최적화 방식을 변경할 때 많은 코드를 변경해야 한다.

DTO 조회 방식의 선택지

앞서 4.1, 4.2, 4.3 총 3가지의 DTO 조회 방식을 살펴보았다. 그러나 이들 간에서 각각 장단점이 있다.

  • 4.1 방식은 코드가 단순하다. 그리고 N+1 문제가 발생하지만, 조회 주문 건수가 적으면 N+1 문제가 발생하더라도 성능이 잘 나온다.

    예를 들어, 조회한 order가 1개이면, orderItem을 조회하는 쿼리도 1번 실행되어 1(order 조회)+1(orderItem 조회)=2개의 쿼리가 실행된다.

  • 4.2 방식은 코드가 복잡하다. 그러나 조회 주문 건수가 많으면 4.1 방식보다 4.2 방식을 사용하는 것이 성능이 훨씬 좋아진다.

    예를 들어, 4.1 방식은 조회한 order가 1000개이면, orderItem을 조회하는 쿼리가 1000개 실행되어 총 1+1000개의 쿼리가 실행된다. 그러나 4.2 방식은 1+1개의 쿼리만 실행된다.

  • 4.3 방식은 쿼리가 한 번만 실행된다. 그러나 이 방식은 컬렉션 조인으로 인해 데이터 수가 증가하여, 페이징 처리가 불가능하다(참고로 이때 불가능하다는 것은 일대다 관계에서 일(1)을 기준으로, 즉 order를 기준으로 페이징이 불가능하다는 의미이다. orderItem을 기준으로는 페이징이 가능하다). 실무에서는 수많은 데이터의 페이징 처리가 필요하므로, 이 방식을 선택하기는 쉽지 않다.

profile
공부한 것들을 기록합니다.

0개의 댓글