0+ 스프링/0 + 스프링 ORM(JPA)

[JPA] 조건절을 포함한 일대다 페이징 최적화

힘들면힘을내는쿼카 2023. 6. 7. 17:09
728x90
반응형

[JPA] 조건절을 포함한 일대다 페이징 최적화 방법

 

 

일대다 조인에서 페치조인을 사용하면 페이징을 할 수 없습니다.

 

 

우리는 일대다에서 일(1)을 기준으로 페이징하는 것이 목적입니다.

그런데 데이터는 다(N)를 기준으로 row를 생성합니다.

그렇다면 페이징을 하기 위해서는 지연로딩으로 1+N 문제가 발생하는 것을 모르는척 해야할까요? 🤔

 

일대다 페치조인의 문제

일대다 페치조인을 하면 다(N)를 기준으로 데이터가 생성됩니다.

 

Order <—(1:N)—> OrderItem라고 할 때 다음과 같습니다.

 

우리는 Order를 기준으로 페이징을 하고 싶습니다….! 😭

 

조건절을 포함한 일대다 페이징 최적화 방법

목표

 

Item의 상품명을 조건으로 OrderDelivery, OrderItem, Item 데이터를 함께 조회하여 페이징 되도록하는 것이 목표입니다…!!!

 

쉽게 말하면 다음과 같습니다.

상품명으로 주문 내역(주문상품, 배송지 포함)을 조회할 수 있는 API를 만들어 주세요~

 

전략

 

  • Order 조회와 OrderItem 조회를 분리한다.
  • Order를 조회하는 쿼리를 작성 -(결과1)
  • OrderItem을 조회하는 쿼리를 작성 -(결과2)
  • 결과1결과2를 합친다.
  • 합친 결과를 Page로 변환한다.

 

엔티티

Delivery

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Delivery {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String address;

    @Builder
    private Delivery(String address) {
        this.address = address;
    }
}

 

Order

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "orders")
public class Order {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String orderUid;
    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
    private List<OrderItem> orderItems = new ArrayList<>();
    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "delivery_id")
    private Delivery delivery;

    @Builder
    private Order(String orderUid, List<OrderItem> orderItems, Delivery delivery) {
        this.orderUid = orderUid;
        this.delivery = delivery;

        for(OrderItem oi : orderItems) {
            oi.setOrder(this);
            this.orderItems.add(oi);
        }
    }

    public void setOrderItems(List<OrderItem> orderItems) {
        this.orderItems = orderItems;
    }
}

 

OrderItem

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class OrderItem {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private int count;
    private int orderPrice;
    @JsonIgnore
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "order_id")
    private Order order;
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "item_id")
    private Item item;

    @Builder
    private OrderItem(int count, int orderPrice, Order order, Item item) {
        this.count = count;
        this.orderPrice = orderPrice;
        this.order = order;
        this.item = item;
    }

    public void setOrder(Order order) {
        this.order = order;
    }
}

 

Item

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Item {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;
    private int stock;
    private int price;

    @Builder
    private Item(String name, int stock, int price) {
        this.name = name;
        this.stock = stock;
        this.price = price;
    }
}

 

ToOne 관계는 페치조인!!!!!!!!!!!

ToOne 관계는 페치조인 합니다.
엔티티를 지연로딩으로 설정했기 때문에 페치조인을 사용하지 않으면 1+N 문제가 발생하게 됩니다.

 

OrderRepository
OrderDeliveryToOne 관계 입니다.

public interface OrderRepository extends JpaRepository<Order, Long> {
    @EntityGraph(attributePaths = {"delivery"})
    List<Order> findOrdersBy(Pageable pageable);
}

 

OrderItemRepository
OrderItemItemToOne 관계 입니다.

public interface OrderItemRepository extends JpaRepository<OrderItem, Long> {
    @Query("select oi from OrderItem oi" +
            " left join fetch oi.item i" +
            " where i.name like %:itemName%" +
            " and oi.order.id in (:orderIds)")
    List<OrderItem> findOrderItemsByItemName(@Param("itemName") String itemName, @Param("orderIds") List<Long> orderIds);
}

 

  • itemName의 조건으로 조회해야 하기 때문에 like 쿼리문을 추가합니다.
  • Order를 기준으로 조회해야하기 때문에 in 쿼리문을 추가합니다.

 

서비스 레이어

OrderService

@Service
@Transactional
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository orderRepository;
    private final OrderItemRepository orderItemRepository;

    public Page<Order> findOrderByItemName(String itemName, Pageable pageable) {

        // ToOne 관계는 fetch join 한다.
        List<Order> orders = orderRepository.findOrdersBy();

        // orderIds 리스트 생성
        List<Long> orderIds = orders.stream()
                .map(Order::getId)
                .collect(Collectors.toList());

        // orderIds와 동일한 OrderItem를 기준으로 조회
        List<OrderItem> orderItems = orderItemRepository.findOrderItemsByItemName(itemName, orderIds);

        // key: OrderId, value: List<OrderItem>
        // orderId를 기준으로 List<OrderItem>을 생성한다.
        Map<Long, List<OrderItem>> orderItemMap = orderItems.stream()
                .collect(Collectors.groupingBy(orderItem -> orderItem.getOrder().getId()));

        // orders에 orderItems 저장
        orders.forEach(o -> o.setOrderItems(orderItemMap.get(o.getId())));

        // orderItems 가 null 인 경우는 제외
        List<Order> result = orders.stream()
                .filter(o -> o.getOrderItems() != null)
                .collect(Collectors.toList());

        // Page로 변환
        return getCutomPageImpl(pageable, result);
    }

    @NotNull
    private <T> PageImpl<T> getCutomPageImpl(Pageable pageable, List<T> resultContent) {

        PageRequest pageRequest = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize());
        int start = (int) pageRequest.getOffset();
        int end = Math.min(start + pageRequest.getPageSize(), resultContent.size());

        if(start > end) {
            throw new IllegalArgumentException("데이터가 없습니다. 관리자에게 문의하세요.");
        }

        return new PageImpl<>(resultContent.subList(start, end), pageRequest, resultContent.size());
    }
}

 

하나씩 코드를 살펴 보겠습니다.

 

먼저 Order를 조회 합니다.

// ToOne 관계는 fetch join 한다.
List<Order> orders = orderRepository.findOrdersBy();

 

orderId와 일치하는 orderItem을 조회해야하기 때문에
orderIdList 형태로 생성 합니다.

// orderIds 리스트 생성
List<Long> orderIds = orders.stream()
        .map(Order::getId)
        .collect(Collectors.toList());

 

앞에서 생성한 orderIds를 이용하여 orderItem을 조회 합니다.

// orderIds와 동일한 OrderItem를 기준으로 조회
List<OrderItem> orderItems = orderItemRepository.findOrderItemsByItemName(itemName, orderIds);

 

orderItems는 현재 orderItem을 기준으로 조회되었습니다.
orderItemorder를 기준로 변경하기 위해서 orderId를 기준으로 Map으로 변환 합니다.

// key: OrderId, value: List<OrderItem>
// orderId를 기준으로 List<OrderItem>을 생성한다.
Map<Long, List<OrderItem>> orderItemMap = orderItems.stream()
        .collect(Collectors.groupingBy(orderItem -> orderItem.getOrder().getId()));

여기서 이해가 안될 수 있는데 그림으로 설명하면 아래와 같습니다.

 

orderIdkey 값으로 하여 orderItem를 앞에서 조회한 order에 저장 합니다.

// orders에 orderItems 저장
orders.forEach(o -> o.setOrderItems(orderItemMap.get(o.getId())));

 

새롭게 생성된 orderorderItem의 값이 null 일수 있기 때문에
orderItemnull 인것은 제외 합니다.
(Order는 존재하지만 orderItem의 조건 itemName과 일치하지 않으면 OrderItem의 값은 null 이 되기 때문입니다.)

// orderItems 가 null 인 경우는 제외
List<Order> result = orders.stream()
        .filter(o -> o.getOrderItems() != null)
        .collect(Collectors.toList());

 

컨트롤러 레이어

OrderController
itemName을 이용하여 상품명을 추가하고, 페이징 사이즈는 2로 설정했습니다.

@RestController
@RequiredArgsConstructor
public class OrderController {


    private final OrderService orderService;

    @GetMapping("/")
    public Page<Order> findOrdersByItemName(@RequestParam(name = "itemName", required = false) String itemName,
                                            @PageableDefault(page = 0, size = 2) Pageable pageable) {

        Page<Order> orders = orderService.findOrderByItemName(itemName, pageable);

        return orders;
    }
}

 

결과

DB에 다음과 같은 데이터가 저장되어 있다고 했을 때 아이패드 주문 이력을 조회 해보겠습니다.^^

 

http://localhost:8080/?itemName=아이패드&page=0

// 20230607165358
// http://localhost:8080/?itemName=%EC%95%84%EC%9D%B4%ED%8C%A8%EB%93%9C&page=0

{
  "content": [
    {
      "id": 1,
      "orderUid": "b9d33ddf-3b78-469b-a35d-4b35e4496259",
      "orderItems": [
        {
          "id": 1,
          "count": 1,
          "orderPrice": 100,
          "item": {
            "id": 1,
            "name": "아이패드 프로1",
            "stock": 100,
            "price": 100
          }
        },
        {
          "id": 2,
          "count": 1,
          "orderPrice": 100,
          "item": {
            "id": 2,
            "name": "아이패드 프로2",
            "stock": 100,
            "price": 100
          }
        }
      ],
      "delivery": {
        "id": 1,
        "address": "서울특별시 서초구 역삼1동"
      }
    },
    {
      "id": 2,
      "orderUid": "f21355c8-e0a8-48dc-8d8e-6bc5b86b3c71",
      "orderItems": [
        {
          "id": 3,
          "count": 1,
          "orderPrice": 100,
          "item": {
            "id": 2,
            "name": "아이패드 프로2",
            "stock": 100,
            "price": 100
          }
        }
      ],
      "delivery": {
        "id": 2,
        "address": "서울특별시 서초구 역삼2동"
      }
    }
  ],
  "pageable": {
    "sort": {
      "empty": true,
      "sorted": false,
      "unsorted": true
    },
    "offset": 0,
    "pageNumber": 0,
    "pageSize": 2,
    "paged": true,
    "unpaged": false
  },
  "last": false,
  "totalPages": 2,
  "totalElements": 3,
  "first": true,
  "size": 2,
  "number": 0,
  "sort": {
    "empty": true,
    "sorted": false,
    "unsorted": true
  },
  "numberOfElements": 2,
  "empty": false
}

 

http://localhost:8080/?itemName=아이패드&page=1

// 20230607165414
// http://localhost:8080/?itemName=%EC%95%84%EC%9D%B4%ED%8C%A8%EB%93%9C&page=1

{
  "content": [
    {
      "id": 3,
      "orderUid": "5218fd31-ccdd-44cc-8e1c-d133e13937e9",
      "orderItems": [
        {
          "id": 4,
          "count": 1,
          "orderPrice": 100,
          "item": {
            "id": 3,
            "name": "아이패드 프로3",
            "stock": 100,
            "price": 100
          }
        }
      ],
      "delivery": {
        "id": 3,
        "address": "서울특별시 서초구 역삼3동"
      }
    }
  ],
  "pageable": {
    "sort": {
      "empty": true,
      "sorted": false,
      "unsorted": true
    },
    "offset": 2,
    "pageNumber": 1,
    "pageSize": 2,
    "paged": true,
    "unpaged": false
  },
  "last": true,
  "totalPages": 2,
  "totalElements": 3,
  "first": false,
  "size": 2,
  "number": 1,
  "sort": {
    "empty": true,
    "sorted": false,
    "unsorted": true
  },
  "numberOfElements": 1,
  "empty": false
}

목적에 맞게 잘 조회된 것을 확인할 수 있습니다.

 

SQL

쿼리를 확인해보면 의도에 맞게 2개 발생한 것을 확인할 수 있습니다.

-- Order 조회
select
        order0_.id as id1_3_0_,
        delivery1_.id as id1_0_1_,
        order0_.delivery_id as delivery3_3_0_,
        order0_.order_uid as order_ui2_3_0_,
        delivery1_.address as address2_0_1_ 
    from
        orders order0_ 
    left outer join
        delivery delivery1_ 
            on order0_.delivery_id=delivery1_.id


-- OrderItem 조회
select
        orderitem0_.id as id1_2_0_,
        item1_.id as id1_1_1_,
        orderitem0_.count as count2_2_0_,
        orderitem0_.item_id as item_id4_2_0_,
        orderitem0_.order_id as order_id5_2_0_,
        orderitem0_.order_price as order_pr3_2_0_,
        item1_.name as name2_1_1_,
        item1_.price as price3_1_1_,
        item1_.stock as stock4_1_1_ 
    from
        order_item orderitem0_ 
    left outer join
        item item1_ 
            on orderitem0_.item_id=item1_.id 
    where
        (
            item1_.name like ?
        ) 
        and (
            orderitem0_.order_id in (
                ? , ? , ? , ? , ?
            )
        )

 

조건 없는 일대다 페이징

그러면 조건이 없을 때는 일대다 페이징을 하려면 어떻게 해야할까요?
대부분의 페이징 + 컬렉션 엔티티 조회는 아래 방법을 사용하여 해결할 수 있습니다.

 

  • ToOne 관계는 모두 페치조인
    • ToOne 관계는 row를 증가시키지 않습니다.

 

  • 컬렉션은 지연 로딩을 조회
    • 지연 로딩 성능 최적화를 위해 @BatchSize 또는 hibernate.default_batch_fetch_size를 적용
      • 이 옵션을 사용하면 컬렉션이나 프록시 객체를 한꺼번에 설정한 size 만큼 IN 쿼리로 조회할 수 있습니다.^^

 

 

 

 

728x90
반응형