이번 게시글에서는 엔티티 내부에 있는 컬렉션 필드의 조회를 최적화하는 방법에 대해서 알아본다.
컬렉션 필드를 쓰면 쿼리가 많이 나가서 최적화를 상당히 신경써야 한다.
v1 - 엔티티를 직접 노출
엔티티를 그대로 반환하기 때문에 API에서 사용하기에는 좋지 않은 방법
지연 로딩에 의해 프록시로 존재하는 부분을 강제로 초기화해줘야 한다.
//주문 조회 v1 @GetMapping("/api/v1/orders")publicList<Order>ordersV1(){List<Order>all=orderRepository.findAll();for(Orderorder:all){order.getMember().getName();//Lazy 강제 초기화order.getDelivery().getAddress();//Lazy 강제 초기환List<OrderItem>orderItems=order.getOrderItems();orderItems.stream().forEach(o->o.getItem().getName());//Lazy 강제 초기화}returnall;}
publicList<Order>findAll(){returnem.createQuery("select o from Order o",Order.class).getResultList();}
Hibernate5Module
JPA 사용 시 일반적으로 fetch 전략을 LAZY로 잡기 때문에 실제 엔티티 객체 대신에 프록시 객체를 갖고 있다.
jackson 라이브러리는 기본적으로 이 프록시 객체를 json으로 어떻게 생성해야 하는지 모르기 때문에 예외가 발생한다.
이를 해결하기 위해 Hibernate5Module 또는 Hibernate5JakartaModule를 스프링 빈으로 등록해준다.
publicList<Order>findAll(){returnem.createQuery("select o from Order o",Order.class).getResultList();}
@DatapublicclassOrderDto{privateLongorderId;privateStringname;privateLocalDateTimeorderDate;privateOrderStatusorderStatus;privateAddressaddress;privateList<OrderItemDto>orderItems;publicOrderDto(Orderorder){orderId=order.getId();name=order.getMember().getName();//Lazy 강제 초기화 (Member 엔티티)orderDate=order.getOrderDate();orderStatus=order.getStatus();address=order.getDelivery().getAddress();//Lazy 강제 초기화 (Member 엔티티)orderItems=order.getOrderItems().stream()//Lazy 강제 초기화 (Member 엔티티).map(orderItem->newOrderItemDto(orderItem)).collect(toList());}}@DatapublicclassOrderItemDto{privateStringitemName;//상품 명privateintorderPrice;//주문 가격privateintcount;//주문 수량publicOrderItemDto(OrderItemorderItem){itemName=orderItem.getItem().getName();//Lazy 강제 초기화 (Member 엔티티)orderPrice=orderItem.getOrderPrice();count=orderItem.getCount();}}
v3 - 페치 조인 최적화
페치 조인(fetch join)으로 데이터를 한꺼번에 가져온다.
엔티티로 조회했기 때문에 DB와의 작업이 수월하다. (ex : 변경 감지)
유연도가 높은 방식이다. (v4에 비해서 비교적 높은 편)
컬렉션 페치 조인은 하나만 가능하다.
억지로 2건 이상이 가능은 한데 하이버네이트가 제대로 쿼리나 쿼리의 결과를 인식 못 할 가능성이 크다.
distinct 키워드를 사용하면 JPA가 중복 데이터를 제거할 수 있게 설정할 수 있다.
하이버네이트 6.0에서는 distinct 키워드를 명시하지 않아도 JPA가 자동으로 중복 제거를 시도한다.
문제점
컬렉션 조회 & 페치 조인을 할 때는 페이징이 안 된다.
정확하게는 페이징이 되는건 맞는데 모든 데이터를 가져와서 메모리 내부에서 페이징 작업을 한다.
//distinct 키워드로 중복 제거//하이버네이트 6.0부터는 distinct 키워드 안 써도 JPA가 알아서 중복을 제거해줌publicList<Order>findAllWithItem(){returnem.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();}
v3.1 - 페이징과 한계 돌파
사실 v3에서 페이징을 사용할 수 있는 방법이 있다.
v3에서 설명했다시피 한꺼번에 가져와서 정렬하는 것이 문제다.
한꺼번에 가져오는 데이터의 개수에 제한을 설정하면 된다.
환경설정 방식
hibernate.default_batch_fetch_size
어노테이션 방식
@BatchSize
컬렉션 조회때문에 1대N대M였던 것을 1대1대1로 바꿀 수 있다.
“사이즈를 설정한다.”라는 것은 “쿼리 내부에서 사용되는 IN 절에서 포함되는 요소의 개수를 설정한다는 것을 의미한다.
publicList<Order>findAllWithMemberDelivery(intoffset,intlimit){returnem.createQuery("select o from Order o "+"join fetch o.member m "+"join fetch o.delivery d",Order.class).setFirstResult(offset).setMaxResults(limit).getResultList();}
//컬렉션은 별도로 조회한다.//쿼리가 1 + N만큼 실행된다.//단건 조회에서 많이 사용하는 방식publicList<OrderQueryDto>findOrderQueryDtos(){//1대N이 관게가 아닌 항목들을 한꺼번에 조회한다.List<OrderQueryDto>result=findOrders();//쿼리 1번 실행//반복문을 돌면서 1대N 관계인 컬렉션을 조회한다.result.forEach(o->{List<OrderItemQueryDto>orderItems=findOrderItems(o.getOrderId());//쿼리 N번 실행o.setOrderItems(orderItems);});returnresult;}//컬렉션 아닌 항목들을 한꺼번에 조회//1대N 관계가 아닌 항목들을 한꺼번에 조회privateList<OrderQueryDto>findOrders(){returnem.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을 조회한다.privateList<OrderItemQueryDto>findOrderItems(LongorderId){returnem.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();}
//데이터를 한꺼번에 처리할 때 많이 사용하는 방식publicList<OrderQueryDto>findAllByDto_optimization(){//1대1이나 N대1인 관계의 엔티티를 먼저 조회한다.List<OrderQueryDto>result=findOrders();//orderItem 컬렉션을 하나의 Map 안에 한꺼번에 저장한다.Map<Long,List<OrderItemQueryDto>>orderItemMap=findOrderItemMap(toOrderIds(result));//반복문을 통해서 컬렉션을 추가한다.result.forEach(o->o.setOrderItems(orderItemMap.get(o.getOrderId())));returnresult;}//컬렉션 아닌 항목들을 한꺼번에 조회//1대N 관계가 아닌 항목들을 한꺼번에 조회privateList<OrderQueryDto>findOrders(){returnem.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();}//Id 값만 별개의 List로 추출한다.privateList<Long>toOrderIds(List<OrderQueryDto>result){returnresult.stream().map(o->o.getOrderId()).collect(Collectors.toList());}//하나의 Map 안에 한꺼번에 저장한다.//":orderIds" 부분에 orderIds가 바인딩된다.privateMap<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)//이 부분에 있는 IN절이 N번 조회할 것을 1번만 조회하게 해준다..setParameter("orderIds",orderIds).getResultList();returnorderItems.stream().collect(Collectors.groupingBy(OrderItemQueryDto::getOrderId));//람다 레퍼런스 사용}
v6 - 플랫 데이터 최적화
Inner Join으로 쿼리 한 번으로 모든 데이터를 가져오는 방법
groupingBy()를 위해 ` @EqualsAndHashCode(of = “xxx”)`를 지정해야 한다