이번 포스팅에는 JPA를 통해 컬렉션을 조회할 때 최적화하는 방법을 설명한다. ToMany 관계에 있는 데이터를 가져올때 문제가 되는 부분들과 그 문제를 해결하는 방법을 설명한다.
우선, OneToMany 관계에 있는 Order와 OrderItems를 API로 반환해보자.
1. 모든 엔티티를 제거하고 DTO로 변환하라
@GetMapping("/api/v2/orders")
public List<OrderDto> ordersV2() {
List<Order> orders = orderRepository.findAllByCriteria(new OrderSearch());
return orders.stream().map(OrderDto::new).toList();
}
- 일단 @GetMapping으로 REST API 하나를 매핑시키고, 반환하는 데이터는 반드시 DTO로 변환하자.
- 아 물론, 반환 데이터는 응답 공통 객체로 감싸는게 더 좋다. 그래야 전체 개수나 페이지 수 같은 메타데이터도 포함시킬 수 있기 때문이다. 여기서는 단순화를 위해 데이터만 반환하도록 했다.
@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) {
this.orderId = order.getId();
this.name = order.getMember().getName();
this.orderDate = order.getOrderDate();
this.orderStatus = order.getStatus();
this.address = order.getDelivery().getAddress();
this.orderItems = order.getOrderItems().stream().map(OrderItemDto::new).toList();
}
}
@Data
static class OrderItemDto {
private String itemName;
private int orderPrice;
private int count;
public OrderItemDto(OrderItem orderItem) {
this.itemName = orderItem.getItem().getName();
this.orderPrice = orderItem.getOrderPrice();
this.count = orderItem.getCount();
}
}
- 반환하려는 OrderDto는 이렇게 생겼다. 여기서 중요한 점은, OrderItems에 해당하는 데이터 역시 DTO로 변환해야 한다는 점이다. 엔티티를 DTO로 변환하라는 말은 껍데기만 DTO를 사용하라는 말이 아니라 모든 엔티티가 없어야 한다.
- 그래서, orderItems 타입도 역시 DTO로 만들기 위해 OrderItemDto도 존재한다.
이 상태에서 REST API를 호출해보자. 아래와 같은 결과가 나온다.
[
{
"orderId": 1,
"name": "userA",
"orderDate": "2024-11-17T13:40:39.935598",
"orderStatus": "ORDER",
"address": {
"city": "서울",
"street": "1",
"zipCode": "1111"
},
"orderItems": [
{
"itemName": "JPA BOOK 1",
"orderPrice": 10000,
"count": 1
},
{
"itemName": "JPA BOOK 2",
"orderPrice": 20000,
"count": 2
}
]
},
{
"orderId": 2,
"name": "userB",
"orderDate": "2024-11-17T13:40:39.940763",
"orderStatus": "ORDER",
"address": {
"city": "경기",
"street": "2",
"zipCode": "2222"
},
"orderItems": [
{
"itemName": "SPRING BOOK 1",
"orderPrice": 10000,
"count": 1
},
{
"itemName": "SPRING BOOK 2",
"orderPrice": 20000,
"count": 2
}
]
}
]
- 결과는 원하는대로 잘 나왔다
여기서 끝낼 수 없다. 우선, 엔티티는 모두가 다 지연로딩이기 때문에 이 API를 호출하는 순간, 정말 많은 쿼리가 나갈것이다. 왜냐하면 아직 페치 조인으로 쿼리 최적화를 한 상태가 아니기 때문에. 그래서 엔티티를 DTO로 변환하는 것까진 좋지만 이제 쿼리 최적화를 위해 페치 조인을 사용해보자.
2. 쿼리 최적화를 위해 페치 조인을 사용하라
이제 페치 조인을 사용해서, 조인을 함과 동시에 셀렉트 절에 필드에 값을 채워넣는 작업까지 한번의 쿼리로 끝내보자. JPA가 제공하는 막강한 기능이다.
OrderRepository 일부분
public List<Order> findAllWithItem() {
return entityManager.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();
}
- 다음 코드와 같이 관련된 모든 엔티티에 대해 페치 조인을 사용했다. 이러면 SQL문으로 조인을 하는것까진 똑같은데 조인을 한 다음 실제 그 데이터를 메모리에 올려서 필드에 퍼올려주게 된다.
- 그런데 여기서 문제는 바로, o.orderItems를 페치 조인하는 것이다. 이 녀석은 OneToMany 관계로 설정된 컬렉션이다. 컬렉션을 페치 조인하게 되면 데이터가 뻥튀기가 된다. 결과를 보자.
OrderController 일부분
@GetMapping("/api/v3/orders")
public List<OrderDto> ordersV3() {
return orderRepository.findAllWithItem().stream().map(OrderDto::new).toList();
}
- 페치 조인을 사용하는 메서드를 호출한다. 이 녀석을 API로 호출해보자. 호출을 해보면 실제로 쿼리가 어떻게 나가는지 볼 수 있는데, 다음과 같이 나간다.
select
o1_0.order_id,
d1_0.delivery_id,
d1_0.city,
d1_0.street,
d1_0.zip_code,
d1_0.status,
m1_0.member_id,
m1_0.city,
m1_0.street,
m1_0.zip_code,
m1_0.name,
o1_0.order_date,
oi1_0.order_id,
oi1_0.order_item_id,
oi1_0.count,
i1_0.item_id,
case
when i1_1.item_id is not null
then 1
when i1_2.item_id is not null
then 2
when i1_3.item_id is not null
then 3
end,
i1_0.name,
i1_0.price,
i1_0.stock_quantity,
i1_1.artist,
i1_1.etc,
i1_2.author,
i1_2.isbn,
i1_3.actor,
i1_3.director,
oi1_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 oi1_0
on o1_0.order_id=oi1_0.order_id
join
(item i1_0
left join
album i1_1
on i1_0.item_id=i1_1.item_id
left join
book i1_2
on i1_0.item_id=i1_2.item_id
left join
movie i1_3
on i1_0.item_id=i1_3.item_id)
on i1_0.item_id=oi1_0.item_id
- 쿼리는 매우 행복하게 딱 한개만 나간다. 이게 페치 조인의 힘이다. 근데 이 쿼리를 실제로 데이터베이스에서 날려보면 어떻게 될까? 결과는 다음과 같다.
- 보다시피 Order 레코드는 ID가 1, 2로 2개뿐이지만, OneToMany 관계에 있는 OrderItems와 조인을 해버리니까 데이터가 4개로 뻥튀기 됐다. 왜냐하면 1번 주문이 가지고 있는 OrderItem이 2개다 보니까 1번 주문의 레코드가 2개가 나올수밖에 없다. 2번도 마찬가지 이유다.
이게 바로 OneToMany 관계에 있는 엔티티와 조인할 때 즉, 컬렉션을 조인할때 생기는 데이터 뻥튀기 현상이다. 이걸 가지고 페이징 처리를 한다면? 뭔가 문제가 생길 수 밖에 없다. 그래서 이를 해결하기 위해 JPA에서 무엇을 제공하냐? `DISTINCT` 라는 키워드를 제공한다.
"어? 데이터베이스에서 그냥 제공하는 키워드 아닌가요?" → 맞다. 맞는데, JPA가 추가적인 기능을 제공한다. 일단 사용을 해보자.
public List<Order> findAllWithItem() {
return entityManager.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();
}
- 다음과 같이 JPQL에 DISTINCT만 추가했다.
- 실행한 다음 나간 쿼리를 한번 보자.
select
distinct o1_0.order_id,
d1_0.delivery_id,
d1_0.city,
d1_0.street,
d1_0.zip_code,
d1_0.status,
m1_0.member_id,
m1_0.city,
m1_0.street,
m1_0.zip_code,
m1_0.name,
o1_0.order_date,
oi1_0.order_id,
oi1_0.order_item_id,
oi1_0.count,
i1_0.item_id,
case
when i1_1.item_id is not null
then 1
when i1_2.item_id is not null
then 2
when i1_3.item_id is not null
then 3
end,
i1_0.name,
i1_0.price,
i1_0.stock_quantity,
i1_1.artist,
i1_1.etc,
i1_2.author,
i1_2.isbn,
i1_3.actor,
i1_3.director,
oi1_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 oi1_0
on o1_0.order_id=oi1_0.order_id
join
(item i1_0
left join
album i1_1
on i1_0.item_id=i1_1.item_id
left join
book i1_2
on i1_0.item_id=i1_2.item_id
left join
movie i1_3
on i1_0.item_id=i1_3.item_id)
on i1_0.item_id=oi1_0.item_id
- 이 나간 쿼리에도 DISTINCT가 붙어있다. 근데 이 쿼리 그대로 복사해서 데이터베이스에 실제로 날려보면 결과 어떻게 나올까? 안타깝게도 전혀 달라지지 않는다.
- 이런 현상의 이유는 데이터베이스의 DISTINCT는 정말 레코드의 모든 값이 다 똑같아야 중복으로 판단하고 제거해준다. 그런데 위 결과를 보면, 1번 주문의 2개의 레코드는 Order 관련 데이터만 동일하고, 나머지 값은 다르다. OrderItem이 다르니까.
그런데 API 날리고 받은 응답을 보면, 데이터는 두개만 보여진다.
[
{
"orderId": 1,
"name": "userA",
"orderDate": "2024-11-17T14:24:10.567046",
"orderStatus": "ORDER",
"address": {
"city": "서울",
"street": "1",
"zipCode": "1111"
},
"orderItems": [
{
"itemName": "JPA BOOK 1",
"orderPrice": 10000,
"count": 1
},
{
"itemName": "JPA BOOK 2",
"orderPrice": 20000,
"count": 2
}
]
},
{
"orderId": 2,
"name": "userB",
"orderDate": "2024-11-17T14:24:10.571629",
"orderStatus": "ORDER",
"address": {
"city": "경기",
"street": "2",
"zipCode": "2222"
},
"orderItems": [
{
"itemName": "SPRING BOOK 1",
"orderPrice": 10000,
"count": 1
},
{
"itemName": "SPRING BOOK 2",
"orderPrice": 20000,
"count": 2
}
]
}
]
- 이게 JPA가 추가적으로 해주는 기능이다. JPA는 DISTINCT를 사용하면, 우선 SQL에도 그 키워드를 붙여준다. 그래서 만약, 레코드의 모든 값이 동일한 레코드가 있다면 하나만 보여주게 해준다.
- 두번째로는, JPA가 추가적으로 제공하는 기능인데 가져온 데이터를 메모리에 퍼 올릴때, 반환 엔티티에 대한 ID가 동일하다면 (여기서는 Order) 그 값은 중복된 것으로 판단하고 메모리에 올리지 않는다. 이게 JPA가 해주는 추가적인 기능이다.
그래서 결론적으로 깔끔하게 DTO와 페치조인을 사용해서 일대다 조인을 사용할때도 깔끔하게 데이터를 가져올 수 있게 됐다. 그런데, 이때 정말 치명적인 문제가 하나 발생한다. 일대다 페치 조인을 하는 순간 페이징 처리가 불가능하다.
2-1. 일대다 페치 조인을 할 때 페이징이 불가능한 문제
바로 위에서 일대다 페치 조인을 할 때 치명적인 문제를 말했다. 즉, 페이징 처리가 불가능해진다. 바로 코드로 보자.
페이징 처리라는 게 별게 아니라 다음과 같이 사용하면 된다.
public List<Order> findAllWithItem() {
return entityManager.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(1000)
.getResultList();
}
- setFirstResult(1), setMaxResults(1000) 을 사용해서 0번은 건너뛰고 1번부터 1000개를 가져오는 쿼리를 작성했다. 그러면 여기서 드는 생각은, 아 지금 현재 데이터는 2개니까 앞에꺼 하나를 건너뛰고 뒤에꺼 한 개만 가져오겠군?! 이라고 생각이 든다.
근데 이거 실제로 실행해보자. 실행해보면, 날라가는 쿼리가 다음과 같다.
select
distinct o1_0.order_id,
d1_0.delivery_id,
d1_0.city,
d1_0.street,
d1_0.zip_code,
d1_0.status,
m1_0.member_id,
m1_0.city,
m1_0.street,
m1_0.zip_code,
m1_0.name,
o1_0.order_date,
oi1_0.order_id,
oi1_0.order_item_id,
oi1_0.count,
i1_0.item_id,
case
when i1_1.item_id is not null
then 1
when i1_2.item_id is not null
then 2
when i1_3.item_id is not null
then 3
end,
i1_0.name,
i1_0.price,
i1_0.stock_quantity,
i1_1.artist,
i1_1.etc,
i1_2.author,
i1_2.isbn,
i1_3.actor,
i1_3.director,
oi1_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 oi1_0
on o1_0.order_id=oi1_0.order_id
join
(item i1_0
left join
album i1_1
on i1_0.item_id=i1_1.item_id
left join
book i1_2
on i1_0.item_id=i1_2.item_id
left join
movie i1_3
on i1_0.item_id=i1_3.item_id)
on i1_0.item_id=oi1_0.item_id
- ..? limit, offset은 어디있는거지?
페이징을 추가했는데 날라가는 쿼리에는 페이징 처리를 위한 limit, offset이 없다. 굉장히 심각한거다. 그리고 로그를 보면 이러한 로그가 보인다. 컬렉션 페치조인과 같이 firstResult, maxResults가 사용됐다고 말해주고, 이 데이터를 메모리에 올려서 페이징 처리를 하겠다는 무시무시한 로그가 찍힌다.
2024-11-17T14:34:19.126+09:00 WARN 42800 --- [ShoppingMall] [nio-8080-exec-1] org.hibernate.orm.query : HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory
- 이 로그가 왜 무시무시하냐? 데이터가 만개면? 십만개면? 메모리에 올리는 순간 OOM이 터질 수 있다. 실제 운영중인 서비스라면..? 끔찍하다.
- 아니 그럼 도대체 왜..? 메모리에 올려서 페이징 처리를 하지..? 왜 Hibernate는 이런 극단적인 선택을 했을까?
2-2. 일대다 페치 조인일 때 페이징 시도 시 Hibernate가 극단적 선택을 한 이유
일대다 페치 조인을 했을 때, 왜 도대체 메모리에 올려서 페이징을 할까?
아까 일대다 페치 조인을 했을 때 날라가는 쿼리를 직접 데이터베이스에서 실행해봤을 때 이런 결과를 얻었다.
- 실제 데이터베이스에 있는 주문 데이터는 딱 2개다. ID가 (1, 2)
- 그렇지만, 그 주문 데이터는 주문 아이템 데이터를 두개씩 가지고 있기 때문에 위 결과처럼 데이터가 뻥튀기 된다.
- 이 상태에서 limit, offset을 적용하면 정확한 데이터가 나올까? 아니다. 실제로는 주문은 2개뿐인데, 지금 상태에서 offset 1 limit 1000을 쿼리에 붙여 실행하면 위 결과에서 ORDER_ID 컬럼 기준으로 맨 위에 데이터를 제외하고 (1, 2, 2) 세 개의 데이터가 나올 것이다. 그 말은 잘못된 페이징 처리가 된다는 뜻이다.
- 그런데, JPA가 어떤 도움을 주냐? 이 상태의 데이터를 메모리에 올릴때 같은 아이디는 제거해버린다. 그래서 메모리에 올라간 시점은 뻥튀기 데이터가 사라진 상태가 된다. 그렇기 때문에 메모리에 올린 상태에서 페이징 처리를 꾸역꾸역 해주는 것이다.
이러한 이유때문에 결국 일대다 페치 조인을 사용할때 페이징 처리를 하면 메모리에 올려서 페이징 처리를 할 수 밖에 없게 되는 것이다.
그리고 또 한가지 주의할 점이 있다. 일대다 페치 조인은 딱 한개만 사용해야 한다. 그러니까 위 예시에서는 Order입장에서 일대다 관계가 OrderItems였는데, 여기에 만약에 추가적으로 일대다 관계가 있는 녀석(A)이 있다고 가정하면, 페치 조인 시 OrderItems, A 이 두개를 같이 페치조인하면 안된다는 뜻이다. 이유는 위에서 이미 다 봤다. 데이터가 뻥튀기가 되는데, 이건 배로 뻥튀기가 된다. 데이터 정합성의 문제가 생길 소지가 너무 많고, 이렇게 굳이 하지 않아도 충분히 원하는 데이터를 뽑아낼 수 있는데 리스크는 크고 리턴은 적다.
3. 일대다 페치조인 시 페이징과 한계 돌파
일대다 페치조인시 생기는 페이징 처리의 문제를 해결하는 방법을 소개한다. 결론부터 말하면 페이징이 필요한 쿼리에서 일대다 관계에 있는 녀석들은 다 지워버리면 된다. 그럼 페이징에 아무런 문제가 발생하지 않는다. 그럼 여기서 남은 문제는 페치 조인을 하지 않으면 일대다 관계에 있는 녀석들도 API 응답을 반환할때 반환 데이터로 이 컬렉션이 필요한 경우엔 프록시 초기화를 해야하니까 쿼리가 여러번 나갈텐데 이걸 어떻게 해결할까?
큰 그림으로 이렇게 정리할 수 있겠다. 다음 단계를 따르면 된다.
- ToMany 관계에 있는, 즉, 컬렉션은 모두 페치 조인에서 제거한다.
- ToOne 관계에 있는 엔티티들만 몇개가 됐든 상관없이 모두 페치조인한다.
- `default_batch_fetch_size` 옵션을 적절하게 부여해서 한번에 이 속성에 적용한 개수만큼 데이터를 가져오게 한다.
@GetMapping("/api/v3.1/orders")
public List<OrderDto> ordersV3_1(@RequestParam(value = "offset", defaultValue = "0") int offset,
@RequestParam(value = "limit", defaultValue = "100") int limit) {
return orderRepository.findAllWithMemberDelivery(offset, limit).stream()
.map(OrderDto::new)
.toList();
}
- ToOne 관계에 있는 엔티티는 몇개가 됐든 페치 조인을 해도 상관없고 페이징도 아주 잘 작동한다. 데이터 뻥튀기란 존재할 수 없기 때문에 그렇다.
- 그렇기 때문에 위 코드처럼 offset, limit을 쿼리 파라미터로 받아서 페이징을 처리할수 있는 메서드를 하나 만들자.
public List<Order> findAllWithMemberDelivery(int offset, int limit) {
return entityManager.createQuery(
"SELECT o " +
"FROM Order o " +
"JOIN FETCH o.member m " +
"JOIN FETCH o.delivery d", Order.class)
.setFirstResult(offset)
.setMaxResults(limit)
.getResultList();
}
- 위에서 말한대로, ToOne 관계에 있는 엔티티들(Member, Delivery)는 모두 페치 조인을 했고 컬렉션 관계에 있는 엔티티(OrderItems)는 페치 조인에서 제거했다.
- 이 상태로 실행해보자. 결과는 다음과 같다.
[
{
"orderId": 2,
"name": "userB",
"orderDate": "2024-11-17T15:26:12.224888",
"orderStatus": "ORDER",
"address": {
"city": "경기",
"street": "2",
"zipCode": "2222"
},
"orderItems": [
{
"itemName": "SPRING BOOK 1",
"orderPrice": 10000,
"count": 1
},
{
"itemName": "SPRING BOOK 2",
"orderPrice": 20000,
"count": 2
}
]
}
]
- 아주 깔끔하게 딱 페이징이 된 모습이다.
- 그런데 이걸 실행했을 때 날라가는 쿼리를 보면 굉장히 많이 나갈 수 밖에 없다. 왜냐하면 OrderItems는 지연로딩이고 이 값을 초기화하기 위한 쿼리가 모두 나가야하기 때문이다.
그럼 여기서 일대다 관계에 있는 데이터인 OrderItems는 페치 조인으로 적용하지 않았기 때문에 반환값으로 이 데이터를 보여주려면 프록시 초기화가 진행된다. 초기화하기 위해 추가적으로 날라가는 많은 쿼리들을 어떻게 최적화하면 될까? 다음과 같이 적용하면 된다.
application.yaml
...
jpa:
properties:
hibernate:
default_batch_fetch_size: 100
...
- jpa.properties.hibernate 하위에 다음과 같은 프로퍼티가 있다: `default_batch_fetch_size`.
- 이 값을 적절하게 넣어주면 프록시 초기화를 할 때, 한번에 저 개수만큼 초기화하는 쿼리가 날라간다.
- 바로 쿼리로 확인해보자.
우선, 가장 먼저 Order와 Member, Delivery를 조인하는 쿼리가 나간다.
select
o1_0.order_id,
d1_0.delivery_id,
d1_0.city,
d1_0.street,
d1_0.zip_code,
d1_0.status,
m1_0.member_id,
m1_0.city,
m1_0.street,
m1_0.zip_code,
m1_0.name,
o1_0.order_date,
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
offset
? rows
fetch
first ? rows only
그 다음은, OrderItems의 값을 가져와야 하므로 프록시 초기화를 하고 초기화 하기 위해 쿼리를 날리는데, 이렇게 날라간다.
select
oi1_0.order_id,
oi1_0.order_item_id,
oi1_0.count,
oi1_0.item_id,
oi1_0.order_price
from
order_item oi1_0
where
oi1_0.order_id=?
ㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡ
select
i1_0.item_id,
case
when i1_1.item_id is not null
then 1
when i1_2.item_id is not null
then 2
when i1_3.item_id is not null
then 3
end,
i1_0.name,
i1_0.price,
i1_0.stock_quantity,
i1_1.artist,
i1_1.etc,
i1_2.author,
i1_2.isbn,
i1_3.actor,
i1_3.director
from
item i1_0
left join
album i1_1
on i1_0.item_id=i1_1.item_id
left join
book i1_2
on i1_0.item_id=i1_2.item_id
left join
movie i1_3
on i1_0.item_id=i1_3.item_id
where
i1_0.item_id in (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
- 참고로, 나는 Item과 Album, Book, Movie를 조인 전략을 사용해서 상속 관계 매핑을 정의했기 때문에 OrderItem을 가져오고 OrderItem에 있는 Item의 실제 데이터를 가져오기 위해 Album, Book, Movie와 조인하는 쿼리 한번이 더 나간다. 만약, Item과 Album, Book, Movie를 싱글 테이블 전략을 사용했다면 조인하는 쿼리가 나가지 않을 것이다. 이건 테이블 매핑 전략을 어떻게 사용하느냐에 따라 달라진다.
- 그래서, OrderItem을 가져오는 쿼리를 한번 날린다. 근데 OrderItem에는 Item이 있고 이 Item은 Album, Book, Movie 중 하나이기 때문에 조인 쿼리를 하나 더 날리는데 그때, IN 키워드로 100개의 ID가 한번에 날라간다.
- 이게 바로 default_batch_fetch_size가 적용된 결과다. 만약, 이 값이 적용되지 않았다면, OrderItem에 있는 Item 마다마다 그 값을 가져오기 위한 쿼리가 나갔을 것이다.
일대다 페치조인의 한계돌파 결론
결론적으로, 이 일대다 관계를 가지고 있는 엔티티(OrderItems)가 있는 엔티티(Order)를 페치 조인으로 페이징 할 때는 1. 일대다 관계의 엔티티를 페치 조인에서 전부다 제거하고, 2. ToOne 관계에 있는 모든 엔티티는 페치조인을 사용해서 최대한 여러번의 쿼리가 나가는 것을 방지한 다음, 페이징을 처리해서 원하는 데이터를 원하는 수만큼 가져온다. 그런데 여기서 반환해야 하는 데이터에 컬렉션 데이터가 필요한 경우 그 값들을 채우기 위해 프록시 초기화가 발생하기에 추가적으로 나가는 쿼리를 최대한 최적화하기 위해, 3. default_batch_fetch_size 옵션을 설정해서 한번에 설정한 값만큼 데이터를 가져오도록 최적화한다.
그리고 이 default_batch_fetch_size 옵션은 글로벌 옵션이다. 그래서 전체에 적용이 되는데, 만약에 특정 부분만 적용하고 싶은 경우 이렇게 하면 된다.
Order 엔티티 일부분
package cwchoiit.shoppingmall.domain;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.BatchSize;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
@Entity
@Getter
@Table(name = "orders")
@NoArgsConstructor(access = AccessLevel.PROTECTED) // 생성 메서드를 만들었으면 그 메서드로만 인스턴스를 생성할 수 있도록 막아야 함
public class Order {
...
@BatchSize(size = 100)
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY, cascade = CascadeType.ALL)
private List<OrderItem> orderItems = new ArrayList<>();
...
}
- 이런식으로 필드에 @BatchSize 애노테이션을 붙여주면 된다. 근데 이건 컬렉션 필드에는 이렇게 적용하면 되는데 ToOne에는 이렇게 적용하면 안되고 다음과 같이 적용해야 한다.
OrderItem 일부분
package cwchoiit.shoppingmall.domain;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.BatchSize;
@BatchSize(size = 100)
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class OrderItem {
...
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "order_id")
private Order order;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "item_id")
private Item item;
...
}
- 이렇게 OrderItem에 ToOne 관계로 Order, Item이 있는데 이 녀석들에도 배치 사이즈를 부여하고 싶으면 클래스 레벨에 @BatchSize를 붙여주면 된다.
근데 보통은 그냥, 글로벌로 적용해두면 좋다. 그냥 기본으로 깔고 간다고 생각하면 좋다.
참고로, 이 default_batch_fetch_size값의 최대값은 1000이다.
자, 이렇게 일대다 페치 조인의 한계까지 해결해보고 지연로딩도 어떻게 해결해야 하는지 Part.1부터 걸쳐서 알아봤다. 여기까지 완벽히 이해하면 JPA로 성능 문제의 90%는 해결할 수 있다.
번외: 컬렉션도 DTO로 바로 조회하기
이 컬렉션을 데이터베이스에서 가져와야 할 때도 SELECT절에 DTO로 직접 조회가 가능하다. 한번 이 부분도 다뤄보자.
Part.1 에서 만들었었던 OrderQueryRepository를 기억하는가? 어떤 특정 화면에 종속적인 그러니까 순수한 Repository가 아니라 어딘가에 핏하게 종속되는데 사용되는 쿼리만 따로 모아두는 레포지토리를 만들었었다. 여기서 DTO로 직접 받아서 딱 필요한 필드들만 메모리에 올리게끔 최적화를 해본 적이 있는데 이걸 그대로 사용해보자.
우선, DTO부터 만들어야 한다.
package cwchoiit.shoppingmall.repository.order;
import cwchoiit.shoppingmall.domain.Address;
import cwchoiit.shoppingmall.domain.OrderStatus;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
@Data
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;
}
}
package cwchoiit.shoppingmall.repository.order;
import lombok.Data;
@Data
public class OrderItemQueryDto {
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;
}
}
- DTO를 두개 만들었다. 왜냐하면 Order를 위한 DTO와 OrderItems를 위한 DTO.
OrderQueryRepository
package cwchoiit.shoppingmall.repository.order;
import cwchoiit.shoppingmall.dto.SimpleOrderQuery;
import jakarta.persistence.EntityManager;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
@RequiredArgsConstructor
public class OrderQueryRepository {
private final EntityManager entityManager;
public List<SimpleOrderQuery> findOrderByDTO() {
return entityManager.createQuery(
"SELECT new cwchoiit.shoppingmall.dto.SimpleOrderQuery(o.id, m.name, o.orderDate, o.status, d.address) " +
"FROM Order o " +
"JOIN o.member m " +
"JOIN o.delivery d", SimpleOrderQuery.class)
.getResultList();
}
public List<OrderQueryDto> findOrderQueries() {
List<OrderQueryDto> results = entityManager.createQuery(
"SELECT new cwchoiit.shoppingmall.repository.order.OrderQueryDto(o.id, m.name, o.orderDate, o.status, o.delivery.address) " +
"FROM Order o " +
"JOIN o.member m " +
"JOIN o.delivery d", OrderQueryDto.class)
.getResultList();
results.forEach(r -> {
List<OrderItemQueryDto> orderItems = findOrderItems(r.getOrderId());
r.setOrderItems(orderItems);
});
return results;
}
private List<OrderItemQueryDto> findOrderItems(Long orderId) {
return entityManager.createQuery(
"SELECT new cwchoiit.shoppingmall.repository.order.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();
}
}
- 지금 추가한 부분은 딱 두 부분이다. 하나씩 살펴보자.
public List<OrderQueryDto> findOrderQueries() {
List<OrderQueryDto> results = entityManager.createQuery(
"SELECT new cwchoiit.shoppingmall.repository.order.OrderQueryDto(o.id, m.name, o.orderDate, o.status, o.delivery.address) " +
"FROM Order o " +
"JOIN o.member m " +
"JOIN o.delivery d", OrderQueryDto.class)
.getResultList();
results.forEach(r -> {
List<OrderItemQueryDto> orderItems = findOrderItems(r.getOrderId());
r.setOrderItems(orderItems);
});
return results;
}
- 먼저, DTO로 직접 필요한 필드들만 받기 위해 SELECT절에 new 키워드를 사용해서 DTO로 직접 받고 있다. 그리고 이때도 마찬가지로 컬렉션 데이터는 조인해서 가져오지 않는다. 애시당초에 DTO로 조회를 할 때 컬렉션 데이터는 데이터베이스에서 바로 붙일수도 없다. 이건 위에서 말한 일대다 페치 조인을 할 때 페이징에 대한 한계를 돌파하는 방법이랑은 또 다른 얘기다. 그냥 DTO로 받아올때 필드에 컬렉션이 있으면 그 컬렉션에 데이터베이스에서 값을 한번에 넣어줄 수가 없다.
- 그래서, 우선 컬렉션을 제외하고 DTO로 데이터를 다 받아오면 이 데이터를 가지고 루프를 돌아야 한다. 그 부분이 바로 다음 results.forEach(...) 이 부분이다.
private List<OrderItemQueryDto> findOrderItems(Long orderId) {
return entityManager.createQuery(
"SELECT new cwchoiit.shoppingmall.repository.order.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();
}
- 그래서 이제 순회하면서 Order 레코드 하나하나가 이 메서드를 하나씩 실행한다. 이 메서드는 OrderItem으로부터 데이터를 받아오는데 이 역시 DTO로 직접 받아 온다.
- 그런데, WHERE절에서 특정 Order의 ID 조건을 넣어서 해당하는 데이터만 가져온다.
- 그래서 아래 코드를 보면, 순회하면서 실행한 후 이렇게 받아온 데이터를 r.setOrderItems(...)를 호출해서 넣어준다. 조금 귀찮지만 DTO로 직접 받아올때 컬렉션 데이터를 받을 수 있는 유일한 방법이다.
results.forEach(r -> {
List<OrderItemQueryDto> orderItems = findOrderItems(r.getOrderId());
r.setOrderItems(orderItems);
});
그리고 컨트롤러에서 이 메서드를 이제 실행한다.
OrderController 일부분
@GetMapping("/api/v4/orders")
public List<OrderQueryDto> ordersV4() {
return orderQueryRepository.findOrderQueries();
}
실행 결과는 다음과 같다. 결과는 똑같이 나오겠지만 쿼리를 보자.
select
o1_0.order_id,
m1_0.name,
o1_0.order_date,
o1_0.status,
d1_0.city,
d1_0.street,
d1_0.zip_code
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
ㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡ
select
oi1_0.order_id,
i1_0.name,
oi1_0.order_price,
oi1_0.count
from
order_item oi1_0
join
item i1_0
on i1_0.item_id=oi1_0.item_id
where
oi1_0.order_id=?
ㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡ
select
oi1_0.order_id,
i1_0.name,
oi1_0.order_price,
oi1_0.count
from
order_item oi1_0
join
item i1_0
on i1_0.item_id=oi1_0.item_id
where
oi1_0.order_id=?
- 최초에 Order를 가져오는 쿼리는 당연히 나간다.
- 그런데, 그 Order의 Item들을 가져오는 쿼리가 개수만큼 나가고 있다. 어찌보면 당연한게 가져온 Order 데이터들로 순회하면서 OrderItem을 DTO로 받아오기 위해 호출하는 메서드가 있기 때문에 당연한건데 이것 역시 N + 1 문제이다.
- 이 부분을 해결해야 한다.
다음 코드는 N + 1 문제를 해결하는 방법이다. 하나씩 보면서 살펴보자.
public List<OrderQueryDto> findOrderQueries2() {
List<OrderQueryDto> results = entityManager.createQuery(
"SELECT new cwchoiit.shoppingmall.repository.order.OrderQueryDto(o.id, m.name, o.orderDate, o.status, o.delivery.address) " +
"FROM Order o " +
"JOIN o.member m " +
"JOIN o.delivery d", OrderQueryDto.class)
.getResultList();
List<Long> orderIds = results.stream().map(OrderQueryDto::getOrderId).toList();
List<OrderItemQueryDto> orderItems = entityManager.createQuery(
"SELECT new cwchoiit.shoppingmall.repository.order.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();
/**
* orderItem: OrderItemQueryDto(orderId=1, itemName=JPA BOOK 1, orderPrice=10000, count=1)
* orderItem: OrderItemQueryDto(orderId=1, itemName=JPA BOOK 2, orderPrice=20000, count=2)
* orderItem: OrderItemQueryDto(orderId=2, itemName=SPRING BOOK 1, orderPrice=10000, count=1)
* orderItem: OrderItemQueryDto(orderId=2, itemName=SPRING BOOK 2, orderPrice=20000, count=2)
*
* 이렇게 orderItems 가 있으면, 이걸 orderId 로 groupingBy를 하면
* <1, [OrderItemQueryDto(orderId=1, itemName=JPA BOOK 1, orderPrice=10000, count=1), OrderItemQueryDto(orderId=1, itemName=JPA BOOK 2, orderPrice=20000, count=2)]>
* <2, [OrderItemQueryDto(orderId=2, itemName=SPRING BOOK 1, orderPrice=10000, count=1), OrderItemQueryDto(orderId=2, itemName=SPRING BOOK 2, orderPrice=20000, count=2)]>
* 이렇게 된다.
*/
Map<Long, List<OrderItemQueryDto>> orderItemMap = orderItems.stream()
.collect(Collectors.groupingBy(OrderItemQueryDto::getOrderId));
results.forEach(r -> r.setOrderItems(orderItemMap.get(r.getOrderId())));
return results;
}
List<OrderQueryDto> results = entityManager.createQuery(
"SELECT new cwchoiit.shoppingmall.repository.order.OrderQueryDto(o.id, m.name, o.orderDate, o.status, o.delivery.address) " +
"FROM Order o " +
"JOIN o.member m " +
"JOIN o.delivery d", OrderQueryDto.class)
.getResultList();
- 우선, findOrderQueries()와 동일하게 처음은 Order 관련 데이터를 DTO로 바로 받아온다. 이때, Member, Delivery와 같은 ToOne 관계만 조인하는 것 역시 동일하다.
List<Long> orderIds = results.stream().map(OrderQueryDto::getOrderId).toList();
List<OrderItemQueryDto> orderItems = entityManager.createQuery(
"SELECT new cwchoiit.shoppingmall.repository.order.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();
- 이제 여기가 변하는 지점인데, 기존에는 가져온 Order 데이터들로 순회하면서 본인의 OrderItems를 하나씩 DTO로 받아왔다. 그러다보니 N + 1 문제가 발생한 것인데, 여기서는 그 부분을 해결하기 위해 가져온 Order 데이터들의 ID값을 모두 추출한 후 WHERE절에 IN 키워드로 한번에 모두 해당하는 데이터를 가져온다. 이렇게 N + 1 문제를 해결할 수 있다.
Map<Long, List<OrderItemQueryDto>> orderItemMap = orderItems.stream()
.collect(Collectors.groupingBy(OrderItemQueryDto::getOrderId));
results.forEach(r -> r.setOrderItems(orderItemMap.get(r.getOrderId())));
- 이제 OrderItems를 모두 받아왔으면, OrderQueryDto에 OrderItems에 값을 넣어줘야 한다. 이때, 자기의 OrderItems을 컬렉션으로 넣어주기 위해 가져온 orderItems를 가지고 Collectors.groupingBy(OrderItemQueryDto::getOrderId)를 사용한다. 이건뭐냐면, 리스트를 맵으로 변경하는데 이때 그룹핑을 할 수가 있다. 각 리스트의 요소인 orderId로.
- 이 부분의 자세한 동작은 주석으로 설명을 추가해놨다. 다시 한번 주석을 보여주면 이렇게 생각하면 된다.
/**
* orderItem: OrderItemQueryDto(orderId=1, itemName=JPA BOOK 1, orderPrice=10000, count=1)
* orderItem: OrderItemQueryDto(orderId=1, itemName=JPA BOOK 2, orderPrice=20000, count=2)
* orderItem: OrderItemQueryDto(orderId=2, itemName=SPRING BOOK 1, orderPrice=10000, count=1)
* orderItem: OrderItemQueryDto(orderId=2, itemName=SPRING BOOK 2, orderPrice=20000, count=2)
*
* 이렇게 orderItems 가 있으면, 이걸 orderId 로 groupingBy를 하면
* <1, [OrderItemQueryDto(orderId=1, itemName=JPA BOOK 1, orderPrice=10000, count=1), OrderItemQueryDto(orderId=1, itemName=JPA BOOK 2, orderPrice=20000, count=2)]>
* <2, [OrderItemQueryDto(orderId=2, itemName=SPRING BOOK 1, orderPrice=10000, count=1), OrderItemQueryDto(orderId=2, itemName=SPRING BOOK 2, orderPrice=20000, count=2)]>
* 이렇게 된다.
*/
이러면, 이제 OrderItems가 몇개가 됐건, 딱 2번의 쿼리(Order 데이터를 가져오는 쿼리, 각 Order에 있는 OrderItems를 한번에 다 가져오는 쿼리)만 나가게 된다. 이렇게 컬렉션도 DTO로 직접 조회를 할 수 있고 N + 1 문제도 해결할 수 있게 된 것이다.
근데 지금 보면 SELECT절에 DTO로 직접 받아오는게 생각보다 만만치 않다. 귀찮기도 하고, 무엇보다 그냥 페치 조인을 사용해서 전체 필드에 값을 다 박을때보다 코드양이 너무 길어진다. 그래서 SELECT절에 DTO로 딱 필요한 것만 받아오면 아무래도 모든 데이터를 퍼올리는 페치 조인보다는 성능적인 부분에서 장점이 있지만, 코드의 가시성이나 더 많은 코드를 수행하는 부분에서는 성능적인 단점이 또 있다. 즉, 트레이드 오프가 있다는 말이다.
정리를 하자면
컬렉션이 있는 엔티티를 조회할 때 여러 난관이 있다. 대표적으로 페치 조인을 할 때 페이징이 불가능하다는 점이다. 그리고 이 문제를 해결하기 위해 페치 조인에서 컬렉션은 페치 조인에서 제외한 후 페이징을 하고 컬렉션 데이터를 초기화할 때 N + 1 문제를 BatchSize로 해결했다. 이게 가장 최적의 방법이고 사실 이 방법을 제외하고 방법도 없다.
그리고, 마지막에는 DTO로 바로 조회하는 방법도 알아봤다. 이제 조회에 대해서는 다 배운것이다.
그럼 권장하는 방식을 정리해보면,
- SELECT절에 엔티티 조회? DTO 직접 조회? → 엔티티 조회로 우선 접근
- 컬렉션 최적화
- 페이징 필요 → 컬렉션 데이터를 페치 조인에서 제외하고, default_batch_fetch_size를 사용해서 최적화
- 페이징 필요 X → 페치 조인 사용
- SELECT절에 엔티티로 조회하는 방식으로 해결이 안되면 DTO 조회 방식을 사용
- 이도 저도 안되면 NativeSQL을 사용
'JPA(Java Persistence API)' 카테고리의 다른 글
[우아하게 JPA 사용] Part.3 OSIV와 성능 최적화 (0) | 2024.11.13 |
---|---|
[우아하게 JPA 사용] Part.1 지연로딩과 조회 성능 최적화 (0) | 2024.11.11 |
[JPA] 엔티티와 인덱스 (0) | 2024.11.10 |
[JPA] Dirty Checking, Merge (8) | 2024.11.10 |
[JPA] OSIV (Open Session In View) (0) | 2023.11.16 |