페치조인의 한계
JPA에서 정말 정말 중요한 페치 조인도 역시 만능은 아니고 컬렉션 데이터를 페치 조인할 때 페이징이 불가능하다.
ToMany 관계를 페치 조인할 때라고 말하는 것과 동일한데, 결론은 페치 조인을 사용할 때 페이징이 불가능하다.
정확히 말하면 페이징이 불가능한것보단 하이버네이트가 메모리 상에서 페이징을 해주는데 이는 절대로 사용해선 안된다. 그냥 그러니까 ToMany 관계를 페치 조인할 땐 페이징이 불가능하다고 생각하면 된다.
그래서 이를 해결해야 하는데, 우선 다음과 같은 쿼리가 있다.
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();
}
여기서 Order 입장에서 Member, Delivery는 ToOne 관계다. 그러므로 페치 조인을 하고 페이징을 하고 뭘해도 상관이 없다. 그러나 OrderItem은 ToMany 관계다. 즉, 페치 조인으로 페이징을 할 수 없다. 이 때 페이징을 만약 한다면, 다음과 같은 경고문구가 노출된다.
2023-11-15T13:36:10.167+09:00 WARN 72378 --- [nio-8080-exec-2] org.hibernate.orm.query : HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory
절대로 이렇게는 페이징을 하면 안되고, 왜 안되냐면 일대다 관계일때 조인을 하면 일을 기준으로 데이터가 뻥튀기가 된다. 만약 오더A, 오더B가 있을 때, 오더A의 오더아이템A-1, 오더아이템A-2가 있고 오더B의 오더아이템B-1, 오더아이템B-2가 있으면 이거를 조인하면 오더는 두개뿐이지만 레코드는 4개가 나온다. (오더A-오더아이템A-1, 오더A-오더아이템A-2, 오더B-오더아이템B-1, 오더B-오더아이템B-2)
이렇게 뻥튀기가 된 데이터를 메모리에 올려놓고 페이징을 한다는건 .. X 제대로 페이징이 될 리가 없을뿐더러, 데이터가 많아서 메모리에 만약 한 20000개가 한번에 올라가면? 메모리 누수가 생겨서 애플리케이션이 장애가 발생할 수 있다.
이를 해결해보자.
한계 돌파
우선 우리가 원하는 데이터는 주문에 대한 페이징이다. 그럼 주문 데이터에 대한 페이징이 수행되어야 한다. 첫번째로, 페치 조인을 사용하는 쿼리에서 주 테이블(Order)을 기준으로 ToMany 관계에 있는 테이블(OrderItem)을 모두 제외시킨다.
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> 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();
}
다음과 같이 페이징까지 적용한 쿼리로 완성시켰고 이것을 호출해서 사용하면 아무런 문제없이 페이징이 잘 수행되는데 여기서 끝내면 당연히 안되겠지. 왜냐하면 내가 원하는 데이터 OrderItem에 대한 데이터는 이 쿼리가 가져오지 않기 때문에 Order의 OrderItem을 조회하는 과정에서 계속 지연 로딩을 강제 초기화하면서 쿼리를 마구잡이로 잔뜩 날릴거다. 한번 보자.
컨트롤러를 하나 만들고 저 쿼리를 사용해서 Order 데이터를 가져와보자.
Controller
@GetMapping("orders/paging")
public List<OrderDto> ordersPaging(@RequestParam(value = "offset", defaultValue = "0") int offset,
@RequestParam(value = "limit", defaultValue = "100") int limit) {
List<Order> orders = orderRepository.findAllWithMemberDelivery(offset, limit);
return orders.stream().map(OrderDto::new).toList();
}
OrderDto
@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().getUsername();
orderDate = order.getOrderDate();
orderStatus = order.getOrderStatus();
address = order.getDelivery().getAddress();
orderItems = order.getOrderItems().stream().map(OrderItemDto::new).toList();
}
}
OrderItemDto
@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();
}
}
실행을 해보면 다음 결과를 받는다.
[
{
"orderId": 1,
"name": "userA",
"orderDate": "2023-11-15T13:45:49.61074",
"orderStatus": "ORDER",
"address": {
"city": "city",
"street": "street",
"zipcode": "zipcode"
},
"orderItems": [
{
"itemName": "JPA1 BOOK",
"orderPrice": 10000,
"count": 1
},
{
"itemName": "JPA2 BOOK",
"orderPrice": 20000,
"count": 2
}
]
},
{
"orderId": 2,
"name": "userB",
"orderDate": "2023-11-15T13:45:49.660323",
"orderStatus": "ORDER",
"address": {
"city": "city2",
"street": "street2",
"zipcode": "zipcode2"
},
"orderItems": [
{
"itemName": "SPRING1 BOOK",
"orderPrice": 20000,
"count": 2
},
{
"itemName": "SPRING2 BOOK",
"orderPrice": 40000,
"count": 3
}
]
}
]
Order가 총 2개, 각 Order의 OrderItem도 2개씩 있다. 이럴 때 쿼리를 보면 지연 로딩을 초기화하는 쿼리가 개수만큼 쭉쭉 나간다.
Hibernate:
select
o1_0.order_id,
d1_0.delivery_id,
d1_0.city,
d1_0.street,
d1_0.zipcode,
d1_0.delivery_status,
m1_0.member_id,
m1_0.city,
m1_0.street,
m1_0.zipcode,
m1_0.username,
o1_0.order_date,
o1_0.order_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
offset
? rows
fetch
first ? rows only
Hibernate:
select
o1_0.order_id,
o1_0.order_item_id,
o1_0.count,
o1_0.item_id,
o1_0.order_price
from
order_item o1_0
where
o1_0.order_id=?
Hibernate:
select
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
from
item i1_0
where
i1_0.item_id=?
Hibernate:
select
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
from
item i1_0
where
i1_0.item_id=?
Hibernate:
select
o1_0.order_id,
o1_0.order_item_id,
o1_0.count,
o1_0.item_id,
o1_0.order_price
from
order_item o1_0
where
o1_0.order_id=?
Hibernate:
select
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
from
item i1_0
where
i1_0.item_id=?
Hibernate:
select
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
from
item i1_0
where
i1_0.item_id=?
이건 당연히 좋지 못하다. 이제 이럴 때 해결하는 방법은 default_batch_fetch_size를 사용하는 것.
application.yml 파일에서 다음 라인을 추가하면 한번에 최대 500개를 가져올 수 있다는 뜻이다.
spring.jpa.properties.hibernate.default_batch_fetch_size = 500
이걸 추가하고 나가는 쿼리를 위 쿼리와 비교해보자. 위에는 지금 오더아이템에 포함된 아이템을 하나씩 하나씩 꺼내온다. 이것을 한번에 500개까지 가져오게 할 수 있는것이다. 다음이 그 결과다.
Hibernate:
select
o1_0.order_id,
d1_0.delivery_id,
d1_0.city,
d1_0.street,
d1_0.zipcode,
d1_0.delivery_status,
m1_0.member_id,
m1_0.city,
m1_0.street,
m1_0.zipcode,
m1_0.username,
o1_0.order_date,
o1_0.order_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
offset
? rows
fetch
first ? rows only
Hibernate:
select
o1_0.order_id,
o1_0.order_item_id,
o1_0.count,
o1_0.item_id,
o1_0.order_price
from
order_item o1_0
where
o1_0.order_id in (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
Hibernate:
select
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
from
item i1_0
where
i1_0.item_id in (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
우선 페치조인을 사용해서 Member, Delivery를 Order와 같이 페이징을 하여 가져오는 쿼리 1개, 그리고 그 Order의 OrderItem을 가져오는데 위에서는 OrderItem 개수만큼 쿼리를 날렸다. 두개가 있었으니 두개가 날라갔는데 지금은 최대 500개를 한번에 가져오는 'IN' 키워드를 사용한다. 그래서 하나의 쿼리로 가져올 수 있다. 그리고 OrderItem에 포함된 아이템들을 가져오는 쿼리 역시 위에는 각 Item을 조회할 때마다 쿼리를 날렸는데 지금은 아이템을 최대 500개를 한번에 가져와서 영속성 컨텍스트에 영속시키니까 단 한번의 쿼리로 우리가 원하는 모든 아이템을 다 가져왔다. 즉, 지금 총 3개의 쿼리가 나갔다.
batch_fetch_size를 적용하지 않은 쿼리는 7개가 나갔다. 아마 데이터가 많으면 200개 300개도 나갈것이다. 그 200개를 3개의 쿼리로 다 해결할 수 있는것. 이렇게 페치 조인을 사용해서 ToMany 관계를 가질 때 페이징을 하는 방법을 알아보았다.
결론
결론은, 나는 그렇게 생각한다. 사실 ToMany는 없으면 없을수록 좋다. 내가 진행하는 프로젝트는 단 한개도 없이 아무런 문제없이 애플리케이션을 동작시키고 있다. 왜냐하면 ManyToOne, OneToOne 만으로 관계를 가져도 결국 다 데이터베이스에선 외래키로 참조하여 데이터를 가져올 수 있기 때문이다. 그러나, 편의를 위해 ToMany 관계를 만들수도 있겠지. 만들었다면 페치 조인으로 페이징은 불가능하다. 그래서 어떤 엔티티를 페이징을 사용해서 데이터베이스에서 조회할 때 ToMany 관계가 있으면 페이징을 하는 방법을 알아보았다. 생각보다 간단하게 default_batch_fetch_size를 이용해서 해결할 수 있었다.
'JPA(Java Persistence API)' 카테고리의 다른 글
[JPA] OSIV (Open Session In View) (0) | 2023.11.16 |
---|---|
[JPA] 변경감지와 Merge (0) | 2023.11.12 |
[JPA] Part 18. 벌크 연산 (0) | 2023.10.30 |
[JPA] Part 17. Named Query (2) | 2023.10.30 |
[JPA] Part 16. Fetch JOIN (JPQL) (0) | 2023.10.30 |