컬렉션 조회 최적화 - 엔티티를 DTO로 변환 + 최적화

HotFried·2023년 11월 24일
0

DTO 변환 코드

@RestController
@RequiredArgsConstructor
public class OrderApiController {
    @GetMapping("/api/v2/orders")
    public List<OrderDto> ordersV2() {
        return orderRepository.findAllByString(new OrderSearch()).stream()
                .map(OrderDto::new)
                .collect(Collectors.toList());
    }
    
    @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(OrderItemDto::new)
                    .collect(Collectors.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();
        }
    }
}

DTO로 한번 감싼다고 끝나는 것이 아니다.

 @GetMapping("/api/v2/orders")
    public List<OrderDto> ordersV2() {
        return orderRepository.findAllByString(new OrderSearch()).stream()
                .map(OrderDto::new)
                .collect(Collectors.toList());
    }

우리는 OrderDto의 List를 반환한다고 문제가 끝나는 것이 아니다.

orderItems = order.getOrderItems().stream()
                    .map(OrderItemDto::new)
                    .collect(Collectors.toList());

OrderDto 내에는 OrderItem의 List도 존재하기 때문에, OrderItem의 DTO도 만들어 주어야 한다.

그렇지 않으면, 결국 API 호출 시 OrderItem 엔티티가 노출되는 셈이니까 말이다.


문제점

해당 코드를 실행하면 기하급수적으로 많은 쿼리가 나갈 것임을 확인할 수 있다.

  • order : 1번
  • member, address : N번
  • orderItem : N번
  • item : N번

지연 로딩은 영속성 컨텍스트에 있으면 영속성 컨텍스트에 있는 엔티티를 사용하고 없으면 SQL을 실행하기 때문에, 이미 로딩한 회원 엔티티를 추가로 조회하면 SQL이 실행되지는 않는다.

하지만 항상 최악의 경우는 있는 법, 반드시 최적화를 해주어야 한다.


Fetch Join 최적화

코드

OrderApiController

@Repository
@RequiredArgsConstructor
public class OrderApiController {

    @GetMapping("/api/v3/orders")
    public List<OrderDto> ordersV3() {
        return orderRepository.findAllWithItem().stream()
                .map(OrderDto::new)
                .collect(Collectors.toList());
    }
}

OrderRepository

@Repository
@RequiredArgsConstructor
public class OrderRepository {
    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();
    }
}

문제점

페치 조인을 이용해 최적화를 시도했다.

[
  {
    "orderId": 4,
    "name": "userA",
    "orderDate": "2022-05-01T12:07:06.612872",
    "orderStatus": "ORDER",
    "address": {
      "city": "서울",
      "street": "1",
      "zipcode": "1111"
    },
    "orderItems": [
      {
        "itemName": "JPA1 BOOK",
        "orderPrice": 10000,
        "count": 1
      },
      {
        "itemName": "JPA2 BOOK",
        "orderPrice": 20000,
        "count": 2
      }
    ]
  },
  {
    "orderId": 4,
    "name": "userA",
    "orderDate": "2022-05-01T12:07:06.612872",
    "orderStatus": "ORDER",
    "address": {
      "city": "서울",
      "street": "1",
      "zipcode": "1111"
    },
    "orderItems": [
      {
        "itemName": "JPA1 BOOK",
        "orderPrice": 10000,
        "count": 1
      },
      {
        "itemName": "JPA2 BOOK",
        "orderPrice": 20000,
        "count": 2
      }
    ]
  },
  {
    "orderId": 11,
    "name": "userB",
    "orderDate": "2022-05-01T12:07:06.643625",
    "orderStatus": "ORDER",
    "address": {
      "city": "진주",
      "street": "2",
      "zipcode": "2222"
    },
    "orderItems": [
      {
        "itemName": "SPRING1 BOOK",
        "orderPrice": 20000,
        "count": 3
      },
      {
        "itemName": "SPRING2 BOOK",
        "orderPrice": 40000,
        "count": 4
      }
    ]
  },
  {
    "orderId": 11,
    "name": "userB",
    "orderDate": "2022-05-01T12:07:06.643625",
    "orderStatus": "ORDER",
    "address": {
      "city": "진주",
      "street": "2",
      "zipcode": "2222"
    },
    "orderItems": [
      {
        "itemName": "SPRING1 BOOK",
        "orderPrice": 20000,
        "count": 3
      },
      {
        "itemName": "SPRING2 BOOK",
        "orderPrice": 40000,
        "count": 4
      }
    ]
  }
]

우리는 order 결과값 2개, orderItem 4개를 기대했지만,
SQL을 실행하니 order가 4개로 증가했다.

-> DB에서 inner join을 하기 때문에 order가 4개로 증가한 것이다.


해결책 (Distinct)

반드시 보세요 : 객체지향 쿼리 언어 - 중급 문법 (fetch join) [매우 중요]

DB의 Distinct는 Row가 완전히 같아야 중복이 제거가 된다.
하지만 우리는 ID가 같은 경우에 중복을 제거하고 싶은 것이다.

-> JPA는 SQL에 DISTINCT를 추가, ID가 같을 경우 엔티티 중복 제거
총 두가지의 기능을 제공한다.

@Repository
@RequiredArgsConstructor
public class 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();
    }
}

JPQL에 distinct를 추가해서 문제를 해결할 수 있다.


단, 위 링크 내용에서 확인할 수 있듯이

  • distinct이용 시 페이징이 불가능하다는 단점이 있고,
  • 컬렉션 페치 조인은 1개만 사용할 수 있다.
    (1 -> N -> N * N ... 기하 급수적으로 데이터가 늘어나서 OutofMemory 예외가 발생한다.)

참고 :

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

profile
꾸준하게

0개의 댓글