4. API 개발 고급 - 컬렉션 조회 최적화(1)

shin·2024년 3월 31일
post-thumbnail
  • 주문내역에서 추가로 주문한 상품 정보를 조회
  • Order 기준으로 컬렉션인 OrderItemItem이 필요함

Order 엔티티

package jpabook.jpashop.domain;
...
@Entity
@Table(name = "orders")
@Getter @Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Order {
    ...
    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
    private List<OrderItem> orderItems = new ArrayList<>();
	...
}

OrderItem 엔티티

package jpabook.jpashop.domain;
...
@Entity
@Table(name = "order_item")
@Getter @Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class OrderItem {
	...
    @JsonIgnore
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "order_id")
    private Order order;
    ...
}
  • 이전 섹션3의 경우 toOne(OneToOne, ManyToOne) 관계만 있었고, 이번 섹션4에는 컬렉션인 일대다 관계(OneToMany)를 조회하고 최적화하는 방법에 대해 알아봄


1. 주문 조회 V1 : 엔티티 직접 노출

package jpabook.jpashop.api;

import jpabook.jpashop.domain.Order;
import jpabook.jpashop.domain.OrderItem;
import jpabook.jpashop.repository.OrderRepository;
import jpabook.jpashop.repository.OrderSearch;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;

import java.util.List;

@Controller
@RequiredArgsConstructor
public class OrderApiController {

    private final OrderRepository orderRepository;

    /**
     * V1. 엔티티 직접 노출
     * - 엔티티가 변하면 API 스펙이 변함
     * - 트랜잭션 안에서 지연 로딩 필요
     * - 양방향 연관관계 문제
     * @return List<Order> 
     */
    @GetMapping("/api/v1/orders")
    public List<Order> ordersV1() {

        List<Order> all = orderRepository.findAll(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;

    }

}
  • OrderItem, item 관계를 직접 초기화하면 Hibernate5Module 설정에 의해 엔티티를 JSON으로 생성함

  • 양방향 연관관계면 무한루프에 걸리지 않도록 한곳에 @JsonIgnore를 추가해야 함

    • OrderItem 엔티티에서 order@JsonIgnore이 추가되어 있음
  • 엔티티가 변하면 API 스펙이 변하고, 엔티티를 직접 노출하므로 좋은 방법은 아님


API 실행 결과

  • OrderItem까지 추가로 조회 완료


2. 주문 조회 V2 : 엔티티를 DTO로 변환

public class OrderApiController {
...
    /**
     * V2. 엔티티를 조회해서 DTO로 변환
     * - 트랜잭션 안에서 지연 로딩 필요
     * @return List<OrderDto>
     */
    @GetMapping("/api/v2/orders")
    public List<OrderDto> orderV2() {

        List<Order> orders = orderRepository.findAll(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<OrderItem> orderItems;

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

    }

}

API 실행 결과

  • 위 코드 작성 후 v2 api도 실행을 해보면 v1과 동일한 결과가 출력되지만, 해당 방식으로 코드를 작성해서는 안됨

  • 왜냐하면 OrderItem 엔티티가 그대로 외부에 노출이 되어버림

    • DTO로 반환을 할 때에 DTO 안에도 엔티티가 존재해서는 안됨
    • 엔티티를 외부에 노출하지 않아야 한다는 것의 의미는, 단순하게 DTO로 감싸라는 의미가 아니라 엔티티와의 의존 관계를 완전히 끊어야 하는 것을 의미함
  • 따라서 OrderItem도 DTO로 변환한 후에 반환하도록 수정해야 함


v2 수정

package jpabook.jpashop.api;
...
@RestController
@RequiredArgsConstructor
public class OrderApiController {

    private final OrderRepository orderRepository;
	...
    /**
     * V2. 엔티티를 조회해서 DTO로 변환
     * - 트랜잭션 안에서 지연 로딩 필요
     * @return List<OrderDto>
     */
    @GetMapping("/api/v2/orders")
    public List<OrderDto> orderV2() {

        List<Order> orders = orderRepository.findAll(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();
        }

    }

}

API 실행 결과

  • OrderItem 출력 시, 원하는 필드만 출력이 가능함

  • 하지만 지연 로딩으로 너무 많은 SQL이 실행됨

    • SQL 실행 수
      • order 1번
      • member, address N번(order 조회 수 만큼)
      • orderItem N번(order 조회 수 만큼)
      • item N번(orderItem 조회 수 만큼)
  • 지연로딩은 영속성 컨텍스트에 있으면서 영속성 컨텍스트에 있는 엔티티를 사용하고 없으면 SQL을 실행함

  • 같은 영속성 컨텍스트에서 이미 로딩한 회원 엔티티를 추가로 조회하면 SQL을 실행하지 않음

  • 따라서 위 방식을 활용할 수 있는 fetch join으로 성능 최적화가 필요함




3. 주문 조회 V3 : 엔티티를 DTO로 변환 - fetch join 최적화

OrderApiController

package jpabook.jpashop.api;
...
@RestController
@RequiredArgsConstructor
public class OrderApiController {

    private final OrderRepository orderRepository;
...
    /**
     * V3. 엔티티를 조회해서 DTO로 변환 
     * - fetch join 최적화
     * @return List<OrderDto>
     */
    @GetMapping("/api/v3/orders")
    public List<OrderDto> orderV3() {

        List<Order> orders = orderRepository.findAllWithItem();
        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();
        }

    }

}

OrderRepository

ToOne 조인 코드

    public List<Order> findAllWithMemberDelivery() {

        return em.createQuery(
                "select o from Order o" +
                        " join fetch o.member m" +
                        " join fetch o.delivery d", Order.class)
                .getResultList();

    }

ToMany 조인 코드

    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();
        
    }
  • memberdelivery 조인은 이전에 작성한 ToOne과 동일하게 작동이 됨
  • 문제는 orderItemsitem의 fetch join

문제점 : ORDER와 ORDER_ITEM 조인 - 데이터 중복

  • 먼저 DB 쿼리를 임의로 작성하여 ORDERORDER_ITEM을 조인해보면 똑같은 ORDER_ID의 값이 2번 중복되어 조회되는 것을 확인할 수 있음
    • ORDER_ID를 기준으로 조인을 수행함
    • ORDER_ITEM 테이블에 1번 ORDER_ID가 다른 ROW에 2번 적재되어 있기 때문에

 select
        o1_0.order_id,
        d1_0.delivery_id,
        d1_0.city,
        d1_0.street,
        d1_0.zipcode,
        d1_0.status,
        m1_0.member_id,
        m1_0.city,
        m1_0.street,
        m1_0.zipcode,
        m1_0.name,
        o1_0.order_date,
        o2_0.order_id,
        o2_0.order_item_id,
        o2_0.count,
        i1_0.item_id,
        i1_0.dtype,
        i1_0.name,
        i1_0.price,
        i1_0.stock_quantity,
        i1_0.artist,
        i1_0.etc,
        i1_0.author,
        i1_0.isbn,
        i1_0.actor,
        i1_0.director,
        o2_0.order_price,
        o1_0.status 
    from
        orders o1_0 
    join
        member m1_0 
            on m1_0.member_id=o1_0.member_id 
    join
        delivery d1_0 
            on d1_0.delivery_id=o1_0.delivery_id 
    join
        order_item o2_0 
            on o1_0.order_id=o2_0.order_id 
    join
        item i1_0 
            on i1_0.item_id=o2_0.item_id

  • 중복된 데이터가 조회가 되는 문제점이 발생함
  • 1대다 조인이 있기 때문에 데이터베이스의 row가 증가함
  • 그 결과 같은 order 엔티티의 조회 수도 증가를 하게 됨
	for(Order order : orders) {
    	System.out.println("order ref=" + order + " id=" + order.getId();
     }

  • 레퍼런스 id를 조회해보기 위해서 위 코드를 작성한 후에 실행을 해보면 레퍼런스 id가 같은 식별자가 두번씩 중복되어 조회되는 것을 알 수 있음
  • 같은 PK에 대해 SQL이 2번 중복되어 실행이 됨

해결방안 - 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 키워드를 사용하여 쿼리 작성
  • 같은 PK에 대해서 SQL이 1번만 실행됨
  • SQL 실행 중복은 제거됨

    select
        distinct o1_0.order_id,
        d1_0.delivery_id,
        d1_0.city,
        d1_0.street,
        d1_0.zipcode,
        d1_0.status,
        m1_0.member_id,
        m1_0.city,
        m1_0.street,
        m1_0.zipcode,
        m1_0.name,
        o1_0.order_date,
        o2_0.order_id,
        o2_0.order_item_id,
        o2_0.count,
        i1_0.item_id,
        i1_0.dtype,
        i1_0.name,
        i1_0.price,
        i1_0.stock_quantity,
        i1_0.artist,
        i1_0.etc,
        i1_0.author,
        i1_0.isbn,
        i1_0.actor,
        i1_0.director,
        o2_0.order_price,
        o1_0.status 
    from
        orders o1_0 
    join
        member m1_0 
            on m1_0.member_id=o1_0.member_id 
    join
        delivery d1_0 
            on d1_0.delivery_id=o1_0.delivery_id 
    join
        order_item o2_0 
            on o1_0.order_id=o2_0.order_id 
    join
        item i1_0 
            on i1_0.item_id=o2_0.item_id

  • 실행되는 쿼리에도 distinct 키워드가 추가되었지만, 여전히 데이터는 중복되어 출력이 되고 있음
    • 왜냐하면 모든 컬럼의 값이 동일해야 중복된 데이터로 인식되어 중복이 제거가 될 수 있음
    • 현재 데이터 상으로는 똑같은 ORDER_ID에 대해 데이터가 다른 컬럼이 존재함

  • 하지만 API 실행 결과를 보면 서로 다른 ORDER_ID에 대해서만 각각 값들이 출력되는 것을 볼 수 있음
  • JPA에서 자체적으로 ORDER를 가지고 올 때, 쿼리에 distinct라는 키워드가 있는 상태에서 같은 ORDER_ID 값이 있으면 중복을 제거하여 출력함
    • 중복인 값을 제거한 상태에서 리스트에 값을 담아서 출력
    • order가 컬렉션 페치 조인 때문에 중복 조회되는 것을 막아줌

JPA의 distinct

  1. DB SQL에 distinct를 추가
  2. root인 entity가 중복으로 조회되는 경우, 애플리케이션에서 중복을 걸러서 컬렉션에 담아줌
    -> 2번이 일반적인 DB 쿼리에서 사용하는 distinct과 다른 추가적인 기능

🚨 컬렉션 페치 조인 단점

1) 페이징 불가능

    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)
                .setFirstResult(1)
                .setMaxResults(100)
                .getResultList();

    }

  • 하이버네이트는 경고 로그를 남기면서 모든 데이터를 DB에서 읽어오고, 메모리에서 페이징 해버림
    • 매우 위험함
  • 만약 데이터의 양이 많다면, 모든 데이터를 애플리케이션에 올린 다음에 메모리에서 페이징 처리를 수행하는 것이 되어버림
    • Out of memory 문제가 발생할 수 있음
  • 따라서 컬렉션 페치 조인을 사용하면 페이징이 불가능함

2) 컬렉션 페치 조인은 1개만 사용할 수 있음

  • 컬렉션 둘 이상에 페치 조인을 사용하면 안됨
  • 데이터가 부정합하게 조회가 될 수 있음


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

profile
Backend development

0개의 댓글