-
JPA(2) - 컬렉션 조회 최적화카테고리 없음 2024. 1. 22. 12:41
- OneToMany 관계에서 조회를 최적화 하는 방법을 생각해보자
- 앞에서는 Order 조회시 OrderItem컬렉션은 그냥 무시하고 DTO만들어서 리턴했음 (처음부터 지연로딩으로 두고 초기화 안함)
1. 엔티티 직접 노출
/** * V1. 엔티티 직접 노출 * - Hibernate5Module 모듈 등록, LAZY=null 처리 * - 양방향 관계 문제 발생 -> @JsonIgnore */ @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; }
- 양방향 연관관계에서 무한 루프를 피하기 위해 @JsonIgnore를 추가했다 (양방향 연관관계면 지연로딩 풀어도 문제 발생하나봄? -> 추후에 알아보자)
- 엔티티 직접 노출 방식으로 좋지 못하다.
- 엔티티 변화시 API 스펙 변화, 양방향 연관관계 문제
2. 엔티티를 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(); } }
- 단순한 DTO변환방식 (패치조인 x)
- N+1문제 발생
- 패치 조인이 필요
*엔티티 dto로 변환시 dto안에서 포함되는 엔티티도 dto로 변환해줘야함!
3. 엔티티 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; }
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 증가를 막기 위함이다.
(jpa는 sql에도 distict 추가 + 애플리케이션 레벨에서 같은 식별자에 포함된 list면 자동으로 결과를 줄여준다)
- 단점은 페이징이 불가하다! (페이징은 sql 결과 단에서 부터 중복된 row가 없어야하는데 oneToMany관계는 그게 어렵다)
*컬렉션 패치 조인시 페이징 불가능!! -> 사용하면 메모리 페이징함 (절대 사용 금지)
* 둘 이상의 패치조인 불가능 (데이터 부정합하게 조회 될 수 있음) [fetch oneToMany, fetch oneToMany 금지]
-> 따로따로 jqpl을 날리던 해야한다.
3.1 엔티티 DTO 변환 - 패치조인 + 벌크 (페이징 가능하게 만들기)
- 컬렉션 패치 조인시 페이징 불가하다.
- 여기서 1을 기준으로 페이징 하는 것이 목적 (방향 바꾸면 페이징 삽가능)
- 대부분의 페이징 + 컬렉션 엔티티 조회문제는 다음 방식으로 해결해보자.
> 먼저 ToOne 관계를 모두 페치조인한다. (row증가 문제 발생하지 않는다)
> 컬렉션은 지연 로딩으로 조회한다. (패치조인말고 그냥 조회)
> 지연 로딩 성능 최적화를 위해 @BatchSize를 적용한다. -> 해당 옵션은 컬렉션이나 프록시 객체 한번에 조회시 in쿼리 날려줌 size만큼
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; }
public OrderItemDto(OrderItem orderItem){ itemName = orderItem.getItem().getName(); orderPrice = orderItem.getTotalPrice(); count = orderItem.getCount(); }
- 지연로딩을 초기화하려고 할때 (프록시) @BatchSize 옵션이 적용된다. (jpql 따로 날릴때 사용되는게아니라 지연 강제로딩시 적용)
- orderItemDto에도 배치사이즈가 적용되어 있음 getItem().getName()등을 날릴때 OrderItem의 item_id 기준으로 in쿼리를 날린다.
- mappedby의 중요성이 들어난다. 어디에 fk를 둘 것 인가! @BatchSize같은 옵션을 사용할때 어느테이블에서 fk찾을지 중요해진다..!
> 쿼리 호출수가 1+1로 최적화됨
> 조인보다 DB 데이터 전송량이 최적화된다. Order와 OrderItem을 조인하면, ORder가 OrderItem만큼 뻥튀기 문제 해결
결론적으로 ToOne관계는 페지조인, 나머지 컬렉션은 @Batchsize옵션을 통해 지연 로딩 실행해서 최적화하자
* 배치사이즈의 적다안 크기는 100~1000사이이다. in절을 사용하는데, 너무 많은 in절은 DB부하를 증가시킬 수 있다.
*하이버네이트 6.2부터는 where in 대신 array_contains를 사용한다고한다. (성능 최적화를 위해)
4. DTO 직접 조회
@GetMapping("/api/v4/orders") public List<OrderQueryDto> ordersV4() { return orderQueryRepository.findOrderQueryDtos(); }
/** * 컬렉션은 별도로 조회 * 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; } }
- oneToMany와 XToOne관계 따로 조회하는 건 동일함
- 다만 List를 초기화할때 배치사이즈를 사용하는게 아니라, xToOne결과에 식별자를 사용해서 각 order에 orderItem을 조회함과 동시에 dto로 변환하여 리스트를 초기화해줌
- 그냥 sql날리는 것과 매우 유사함!
(one관계에서 딱히 fetch를 사용하지 않은 이유는 dto바로 변환 + 컬럼안에서 join된 엔티티 전부를 가져오는게 아니라 몇몇 값만 조회하는 것이므로 패치 필요없음)
- row증가하지않은 ToOne관계 한번에 조회하고, ToMany관계는 ToOne관계의 결과에 따라 따로 조회
- 루프를 돌면서 컬렉션을 조회하는 추가쿼리가 작동함! 메서드가 따로 분리되어 있어서 그러하다 (최적화 필요)
4.1 DTO 변환시 컬렉션 조회 최적화
@GetMapping("/api/v5/orders") public List<OrderQueryDto> ordersV5() { return orderQueryRepository.findAllByDto_optimization(); }
//Repository 메서드 추가 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) { //orderId List뽑기 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)); }
- ToOne관계 먼저 조회하고, 여기서 얻은 식별자를 ToMany관계에서 in을 사용해서 한번에 조회
- Map을 사용해서 성능을 향상 (식별자 중심으로 오더와 오더아이템DTO List묶음 -> 추후 조회시 성능 좋다)
- 패치조인보다 확실히 데이터를 덜 가져오지만, 과정이 매우 복잡해짐 (진짜진짜 성능안나오면 생각해볼만하다!)
*OrderITemDto -> Map으로 변환하는 코드 잘 봐두자
결론 : 엔티티 조회방식으로 접근하자
-> Lazy로딩이 있으면, 패치 조인 사용해서 가져오자
-> 컬렉션이 있으면
페이징 필요시 -> 배치사이즈를 활용해서 지연로딩 초기화를 사용하자
페이징 필요x -> 패치조인 사용
엔티티 조회 방식으로도 해결이 안되면 DTO 조회방식을 사용하자
그래도 해결이 안되면 SQL을 사용하자
* DTO조회방식은 성능 최적화시 너무 많은 코드를 바꿔야한다. -> 상충관계 존재
* 영속성 컨텍스트에서 캐시를 관리하므로, 엔티티는 다른 캐싱을 발생시키지말고, DTO를 캐싱하자 (타 캐시 서버 도입시)
* 여러 건의 Order를 DTO 조회 방식으로 사용하고 싶다면, V5와 같은 방식을 차용하도록 하자
(One관계 먼저 조회 -> 조회 결과 식별자 list로 변환 -> 식별자 list를 결과로 in 쿼리를 통해 필요한 컬렉션 조회 -> Map에 식별자 , List로 변환 -> 다시 첫 조회 결과에 List 삽입 (Map탐색)