[실전! 스프링 부트와 JPA 활용2 - API 개발과 성능 최적화] 컬렉션 조회 최적화

이재표·2023년 11월 19일
0

이전 게시글에서 ToOne관계를 최적화 했다면, 이번 게시글에서 ToMany 조회를 최적화하는 내용을 적어보겠습니다.

첫번째 방법(V1)

엔티티를 직접 노출하는 경우 입니다.

@GetMapping("/api/v1/orders")
    public List<Order> ordersV1() {
        List<Order> all = orderRepository.findAllByString(new OrderSearch());
        for (Order order : all) {
            order.getMember().getName();
            order.getDelivery().getAddress();
            List<OrderItem> orderItems = order.getOrderItems();
            orderItems.stream().forEach(o -> o.getItem().getName());
        }
        return all;
    }

이전 포스트와 같이 엔티티를 직접 반환하는것은 응답값이 재귀적으로 무한대로 반환되고, 엔티티 변경시 api스펙이 변경되기 때문에 바람직한 방법이 아닙니다.

두번째 방법(V2)

이전 방법을 개선하여 DTO로 변환하여 반환하는 방법을 알아보겠습니다.

//=> Controller
@GetMapping("/api/v2/orders")
    public List<OrderDto> orderV2() {
        List<Order> orders = orderRepository.findAllByString(new OrderSearch());
        List<OrderDto> collect = orders.stream()
                .map(o -> new OrderDto(o))
                .collect(Collectors.toList());
        return collect;
    }

//=> DTO
    @Data
    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){
            orderId = order.getId();
            name = order.getMember().getName();
            orderDate = order.getOrderDate();
            address = order.getDelivery().getAddress();
            orderItems = order.getOrderItems();
        }
    }

이대로 출력하면 orderItems는 ToMany관계의 엔티티이고 Lazy설정이 되어있어 객체가 프록시 이기 때문에 null이 나오게 된다. 따라서 다음과 같이 객체의 값을 이용하는 코드를 통해 db에서 값을 불러와줘야한다.

public OrderDto(Order order){
            orderId = order.getId();
            name = order.getMember().getName();
            orderDate = order.getOrderDate();
            address = order.getDelivery().getAddress();
            order.getOrderItems().stream().forEach(o -> o.getItem().getName());
            orderItems = order.getOrderItems();
        }

하지만 이렇게 하면 결국 orderItems의 엔티티가 외부에 드러나게 된다. 따라서 orderItems도 dto로 변환해줘야합니다.

결국 DTO에 데이터를 넣어주기 위해 지연로딩되어있는 객체들을 모두 불러와야하는데, 이때 반복문으로 각 객체를 하나하나 돌기때문에 N+1문제가 발생하게 됩니다.

세번째 방법(V3)

이번에도 페치조인으로 N+1문제를 해결해봅시다!

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

페치조인하게 되면 다음과 같이 jpql을 짜게됩니다. 이때 order와 orderItem을 페치조인하게 되는데, order는 1개당 2개의 orderItem과 연관되어있습니다. 데이터베이스입장에서는 결국 order를 orderItem과 한줄씩 join하게 되어 order가 기대했던 2개가 아닌 4개가 나오게됩니다.(orderItem이 더 많았다면 더 많이 나오게 된다.)

따라서 값을 살펴보면 중복인것을 볼수 있는데, jpql에는 중복을 없애기 위한 distinct 연산자가 존재합니다.

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는 완전히 모든 값이 같아야 중복제거가 되는데 쿼리의 값을 보면 다른 값이 있는것을 볼수 있습니다. 하지만 jpql은 distinct에서 id값이 같다면 중복을 그냥 제거해줍니다.

하지만 컬렉션의 페치조인은 페이징 불가라는 한계가 있습니다.

페치조인을 하고 페이징을 하게되면 메모리에서 페이징처리를 한다는 경고가 나오는 것을 볼수 있는데, 만약 지금과 달리 데이터가 많다면 많은 데이터를 어플리케이션에 올리게되고, 메모리에러로 인해 장애가 발생하게 됩니다.

일대다 하는 순간 order는 기대한 2개가 아닌 4개의 값이 나오게 됩니다.이때 페이징을 하게되면 4개를 페이징처리를 하게되는데, 원하는 방향으로 페이징이 되지 않습니다.

즉 order가 아닌 orderItem을 기준으로 페이징하게 되므로 메모리에 올리고, 경고를 던지게 만들어버렸다.

또한 일대다 페치조인은 1개만 사용할수 있다. 두개이상 사용하게 되면 일대다의다가 되기 때문에 n*n이 되어 데이터를 아예 못맞추게 될수 있다.

네번째 방법(V3.1)
페치조인을 하면 페이징이 불가능하기 때문에 이를 극복하기 위해 다음과 같은 과정을 진행할수 있습니다.

  1. ToMany는 손대지 않고, ToOne관계만을 모두 패치조인한다. ToOne관계는 row수를 증가시키지 않는다.(데이터 뻥튀기가 되지않는다),
  2. 지연로딩성능 최적화를 위해 hibernate.default_batch_fetch_size(전역) 또는 @BatchSize(특정)를 적용한다.
  3. 컬렉션은 지연로딩으로 조회한다.

우선 ToOne관계는 페이징이 가능하기 때문에 다음 쿼리에 대해서는 페이징처리가 되게 된다.

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에 jpa.properties.hibernate. default_batch_fetch_size 를 설정해준다. 이것은 지연로딩으로 발생되는 쿼리를 설정된 값만큼 IN절로 한번에 모아서 가져오는 설정이다.
물론 설정된 size를 넘어가게 된다면 한번 더 반복문을 돌겠지만, size를 늘리면 된다. 만약 전역적인 아닌 디테일하게 정하고싶다면 엔티티 컬럼에 @Batch를 적용해주면 된다

이때 ToMany관계일때는 컬럼위에 적어주면 된다.

@BatchSize(size = 1000)
    @OneToMany(mappedBy = "order",cascade = CascadeType.ALL)
    private List<OrderItem> orderItems = new ArrayList<>();

ToOne관계일때는 엔티티 클래스 위에 적어주면 된다.

@BatchSize(size = 1000)
public abstract class Item {
...
}

김영한 강사님은 대체로 설정파일에 적어 전역적으로 사용한다고 한다.
이때 사이즈는 100~1000까지만 하는게 좋다. 물론 1000개를 위해 100개 쿼리가 10번 돌아 성능상 떨어질수 있지만, db에서 애플리케이션으로 순간적으로 데이터를 가져오기 때문에 데이터베이스와 애플리케이션에 순간적으로 부하가 오게되기에 was와 db의 부하를 잘 조절하여 사용해야 합니다.

데이터베이스에 따라 IN절 파라미터를 제한하기도 하니 잘 보고 사용해야한다.

메모리의 경우 적은 개수를 가져오면 메모리가 더 최적화되지 않을까 생각할수 있지만 결국 데이터베이스에서 긁어와야하는 개수가 똑같기때문에 was입장의 메모리 사용량은 같다.

다섯번째 방법(V4)

이번에는 컬렉션까지 페치 조인으로 처리한 상황에서 직접 DTO에 직접 값을 넣는 방법을 알아보자!

  1. ToOne관계는 join하기 쉽기 때문에 조인을 해온다.
  2. ToMany관계는 지연로딩으로 값을 가져오는 쿼리를 짠다,
  3. 2번에서 가져온 객체를 1번으로 만들어진 객체에 직접 넣어준다.
public List<OrderQueryDto>findOrderQueryDtos(){ --- 3번
        List<OrderQueryDto> result = findOrders();
        result.forEach(o->{
            List<OrderItemQueryDto> orderItems = findOrderItems(o.getOrderId());
            o.setOrderItems(orderItems);
        });
        return result;
    }

    private List<OrderItemQueryDto> findOrderItems(Long orderId) { --- 2번
        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();
    }

    public List<OrderQueryDto> findOrders() { --- 1번
        return em.createQuery(
                //컬렉션을 바로 넣을수는 없기때문에(일대다여서 데이터 뻥튀기 됨) orderItem을 바로 넣을수는 없음 flat하게 한줄로 넣어야함
                "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();
    }

하지만 쿼리를 보면 Order에 의해 1개의 쿼리가 만들어지고 해당되는 Order에 의해 OrderItem(ToOne관계)의 쿼리가 2개 나왔으니 N+1문제가 발생하게 된다.

여섯번째 방법(V5)

그렇다면 위의 쿼리를 최적화해보자!

우선 이전과 같이 ToOne관계의 객체들을 Dto에 맞춰 가져온다. 다음 해당 DTO에 존재하는 Order객체의 Id값을 where in 문을 작성하여 orderItem DTO의 값을 가져온다. 이때 Map을 통해 Dto와 Id값을 만들어 사용성을 더 좋게 만들어 준다. 그리고 이전과 같이 반복문을 통해 Dto에 값을 넣어준다.
즉 해당 방법은 where in 절로 값을 한번에 가져오고, 애플리케이션 단에서 DTO를 조작하는 방법으로 N+1이 해결되게 된다. 페이징도 가능하다.

public List<OrderQueryDto> findAllByDto_optimization() {
        List<OrderQueryDto> result = findOrders();
        Map<Long, List<OrderItemQueryDto>> orderItemMap = findOrderItemMap(toOrderIds(result));
        result.forEach(o->o.setOrderItems(orderItemMap.get(o.getOrderId())));
        return result;
    }
    
    
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();
        Map<Long, List<OrderItemQueryDto>> orderItemMap = orderItems.stream()
                .collect(Collectors.groupingBy(orderItemQueryDto -> orderItemQueryDto.getOrderId()));
        return orderItemMap;
    }

일곱번째 방법(V6)

위의 쿼리를 더 최적화 시켜 2개의 쿼리를 1개의 쿼리만 나가도록 만들어보자!
모든 값을 join해서 한번에 가져온후 값들을 애플리케이션단에서 개발자가 직접 조작해주는 방법이다.

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

다음과 같이 모든 값을 가져오면 ToMany관계의 값들은 컬렉션이기 때문에 원래 개수보다 많은값을 가져오게 된다. 따라서 페이징이 불가하게 된다.

데이터를 가져온후 스트림을 통해 값을 직접 원하는 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());

    }

해당 방법은 대체로 속도가 빠르지만 중복 데이터로 인해 데이터가 커서 오히려 속도가 느려질수 있으며, 페이징이 불가하다

그렇다면 어떤순서로 개발을 진행하면 좋을까?
1. 엔티티 조회방식으로 우선 접근한다

  • ToOne이라면 페치조인으로 쿼리 수를 최적화
  • ToMany라면 컬렉션 최적화
    페이징 필요하면 @BatchSize 또는 hibernate.default_batch_fetch_size로 최적화
    페이징 필요없으면 페치조인을 그냥 사용
  1. 엔티티 조회방식으로 해결이 안된다면 DTO조회 방식 사용
  2. DTO조회 방식으로 해결이 안된다면 NativeSQL 또는 스프링 JdbcTemplate로 해결

    엔티티 조회방식은 옵션변경으로 성능최적화를 시도할수 있어 권장된다.

이때 주의해야 할점은 엔티티는 캐시쓰면 안된다는 점이다. 영속성 컨텍스트가 관리하고 있는데, 캐시하면 영속성 컨텍스트가 지워지지 않으니까 복잡해진다. 캐시를 쓸때는 엔티티가 아닌 dto를 캐싱해야한다

0개의 댓글