[JPA] 조건절을 포함한 일대다 페이징 최적화 방법
일대다 조인에서 페치조인을 사용하면 페이징을 할 수 없습니다.
우리는 일대다에서 일(1)을 기준으로 페이징하는 것이 목적입니다.
그런데 데이터는 다(N)를 기준으로 row를 생성합니다.
그렇다면 페이징을 하기 위해서는 지연로딩으로 1+N 문제가 발생하는 것을 모르는척 해야할까요? 🤔
일대다 페치조인의 문제
일대다 페치조인을 하면 다(N)를 기준으로 데이터가 생성됩니다.
Order <—(1:N)—> OrderItem
라고 할 때 다음과 같습니다.
우리는 Order
를 기준으로 페이징을 하고 싶습니다….! 😭
조건절을 포함한 일대다 페이징 최적화 방법
목표
Item
의 상품명을 조건으로 Order
를 Delivery
, 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 문제가 발생하게 됩니다.
OrderRepositoryOrder
와 Delivery
는 ToOne
관계 입니다.
public interface OrderRepository extends JpaRepository<Order, Long> {
@EntityGraph(attributePaths = {"delivery"})
List<Order> findOrdersBy(Pageable pageable);
}
OrderItemRepositoryOrderItem
과 Item
은 ToOne
관계 입니다.
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
을 조회해야하기 때문에orderId
를 List
형태로 생성 합니다.
// 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
을 기준으로 조회되었습니다.orderItem
을 order
를 기준로 변경하기 위해서 orderId
를 기준으로 Map
으로 변환 합니다.
// key: OrderId, value: List<OrderItem>
// orderId를 기준으로 List<OrderItem>을 생성한다.
Map<Long, List<OrderItem>> orderItemMap = orderItems.stream()
.collect(Collectors.groupingBy(orderItem -> orderItem.getOrder().getId()));
여기서 이해가 안될 수 있는데 그림으로 설명하면 아래와 같습니다.
orderId
를 key
값으로 하여 orderItem
를 앞에서 조회한 order
에 저장 합니다.
// orders에 orderItems 저장
orders.forEach(o -> o.setOrderItems(orderItemMap.get(o.getId())));
새롭게 생성된 order
에 orderItem
의 값이 null
일수 있기 때문에orderItem
이 null
인것은 제외 합니다.
(Order
는 존재하지만 orderItem
의 조건 itemName
과 일치하지 않으면 OrderItem
의 값은 null
이 되기 때문입니다.)
// orderItems 가 null 인 경우는 제외
List<Order> result = orders.stream()
.filter(o -> o.getOrderItems() != null)
.collect(Collectors.toList());
컨트롤러 레이어
OrderControlleritemName
을 이용하여 상품명을 추가하고, 페이징 사이즈는 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 쿼리
로 조회할 수 있습니다.^^
- 이 옵션을 사용하면 컬렉션이나 프록시 객체를 한꺼번에 설정한
- 지연 로딩 성능 최적화를 위해
'0+ 스프링 > 0 + 스프링 ORM(JPA)' 카테고리의 다른 글
[JPA] JPQL 기본 개념과 예제 (0) | 2023.02.28 |
---|---|
[JPA] 잘 설계한 ORM 애플리케이션은 매핑한 테이블의 수보다 클래스의 수가 더 많다.(값 타입, 엔티티 타입) (0) | 2023.02.27 |
[JPA] CASCADE(영속성 전이)와 고아객체 (0) | 2023.02.24 |
[JPA] JPA가 Entity를 판별하는 방법과 save()의 비밀(entityInformation.isNew(entity)) (0) | 2023.02.22 |
[JPA] 하이버네이트 프록시와 지연로딩(Lazy), 즉시로딩(Eager) (0) | 2023.02.22 |