-
컬렉션 조회 최적화 3 - 페이징과 한계점 돌파JPA/JPA 활용 2023. 8. 10. 21:30
컬렉션을 페치 조인하면 일대 다 조인이기 때문에 페이징이 불가능하다.
하이버네이트 6에서부터 중복을 제거해주긴 해지만 일대다 매핑을 하면 기대했던 것보다 데이터 양이 배로 늘어날 수 있다.
일대 다에서 1을 기준으로 페이징을 해야하는데 다수 쪽을 기준으로 페이징을 하면 문제인 것이다.
Order를 기준으로 페이징을 하고 싶어도 다수쪽인 OrderItem을 조인해버리면 OrderItem이 기준이 될 수 있다.
하이버네이트는 모든 데이터를 읽어온 다음 메모리에서 페이징을 하는데, 실제로 많은 데이터를 페이징한다면 메모리 오류가 날 것이다. 장애가 생길 수 있으므로 되도록 사용하지 않는 것이 좋다.
최적화 방법
1. 먼저 일대일, 다대일 관계에서는 페치 조인을 적용해도 상관이 없으므로 페치 조인을 한다. @ToOne 관계에서는 row 수를 증가시키지 않으므로 페이징 쿼리에 영향을 주지 않는다.
2. 컬렉션은 지연 로딩으로 조회한다.
3. 지연 로딩 성능 최적화를 위해 'hibernate.defualt_batch_fetch_size', @BatchSize'를 적용한다.
컨트롤러는 항상 동일 -> repository만 수정
@GetMapping("/api/v3.1/orders") public List<OrderDto> ordersV3_page() { List<Order> orders = orderRepository.findAllWithMemberDelivery(); List<OrderDto> collect = orders.stream() .map(o -> new OrderDto(o)) .collect(Collectors.toList()); return collect; }
1. @ToOne 관계만 페치조인
public List<Order> findAllWithMemberDelivery() { return em.createQuery( "select o from Order o" + " join fetch o.member m" + // @ToOne 관계인 엔티티만 페치 조인으로 가져오기 " join fetch o.delivery d", Order.class ).getResultList(); }
위에서도 설명했듯이 @ToOne 관계는 row 수에 영향을 주지 않기 때문에 페치조인을 진행한다. 이 때 컬렉션인 orderItems는 Lazy 로딩 상태이기 때문에 추가 쿼리가 나갈 것이고, 이걸 다음 단계에서 해결해줘야 한다.
첫 번째 쿼리는 member랑 delivery를 fetch join으로 가져오기 때문에 같이 쿼리가 나간다. 이후에 orderItems가 2개여서 orderItems를 가져오고 orderItems안에도 item이 2개기 때문에 orderItem 하나 당 item 쿼리 2개가 나가게 된다.
첫번째 통합 쿼리 -> 첫 번째 orderItem 쿼리 -> 첫 번째 orderitem의 item 2개 -> 두 번째 orderItem 쿼리 -> 두 번째 orderitem의 item 2개
첫 번째 fetch join 쿼리 이외에도 루프를 돌면서 6개의 추가 쿼리가 나가는 상황에서 컬렉션을 해결해줘야 한다.
public List<Order> findAllWithMemberDelivery(int offset, int limit) { return em.createQuery( "select o from Order o" + " join fetch o.member m" + // @ToOne 관계인 엔티티만 페치 조인으로 가져오기 " join fetch o.delivery d", Order.class) .setFirstResult(offset) .setMaxResults(limit) .getResultList(); }
물론 이 경우에는 컬렉션이 없기 때문에 페이징을 사용해도 된다. (컨트롤러에서 offset, limit을 입력한다 가정)
application.yml에 설정 추가
spring: jpa: properties: hibernate: default_batch_fetch_size: 100
default_batch_fetch_size를 통해 최적화 옵션을 넣어준다.
api 호출 시 쿼리를 살펴보면 처음에 order를 찾아오는 쿼리 fetch join으로 member와 delivery를 포함해서 하나 날린다. 이후에 orderItems가 2번 쿼리가 나가는 것이 아니라 in query를 이용해서 orderItems를 찾아온다.
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(?,?) // 여기서 한 번에 가져오는 것
batch_fetch_size의 크기가 한 번에 IN query를 몇 번 날리는지 알려주는 것이다. 만약에 size가 10이라면 쿼리로 10개를 가져와서 컨트롤러에서 10개만큼 루프를 돌린 후에 그 다음에 10개를 찾아오는 쿼리를 또 날리는 것이다.
orderItems뿐만 아니라 item도 마찬가지다.
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(?,?,?,?)
쿼리를 보면 4개를 쿼리 한번에 가져왔다. size가 100이기 때문에 최대 100개까지 가져올 수 있는 것이다.
betch_fetch_size의 설정으로 1 + N + N이 아니라 1 + 1+ 1이 된 것이다. 이전에 컬렉션까지 전부를 fetch join 했을 때보다는 쿼리가 많이 나간다. 하지만 이런 방식은 원하는 데이터를 확실하게 가져와준다는 장점이 있다. 데이터의 중복이 없이 필요한 데이터를 가져오는 것이다.
상황에 맞아서 사용해야하는데 데이터가 너무 많을 때에는 지금의 방법이 컬렉션을 fetch join 하는 것보다 좋다. 또한, 페이징을 해야할 때에는 무조건 지금의 방식을 따라야한다.
public List<Order> findAllWithMemberDelivery(int offset, int limit) { return em.createQuery( "select o from Order o", Order.class) .setFirstResult(offset) .setMaxResults(limit) .getResultList(); }
컬렉션이 아닌 엔티티도 default_batch_size의 영향을 받기 때문에 fetch join에서 삭제해도 되긴 하는데, 그래도 엔티티는 fetch join을 이용하는 것이 좋다.
정리
- 이 방식을 활용하면 N + 1 문제가 1 + 1로 최적화 된다.
- join보다 db 데이터 전송량이 최적화 된다. (Order와 OrderItem을 조인하면 Order가 OrderItem 만큼 중복해서 조회한다. 이 방법은 각각 조회하므로 전송해야할 중복 데이터가 없다.)
- 페치 조인 방식과 비교해서 쿼리 호출 수가 약간 증가하지만, DB 데이터 전송량이 감소한다.
- 컬렉션 페치 조인은 페이징이 불가능 하지만 이 방법은 페이징이 가능하다.
결론
ToOne 관계는 페치 조인해도 페이징에 영향을 주지 않는다. 이러한 관계는 fetch join으로 쿼리 수를 줄이고, 나머지(컬렉션)은 'hibernate.default_batch_fetch_size'로 최적화 하는 것이 좋다.
batch_fetch의 size 범위는?
- 100~1000개가 중요하다.
max 값은 보통 1000개인데 데이터베이스에 따라 IN 절 파라미터를 1000으로 제한하기도 하기 때문이다.
하지만 너무 작으면 쿼리가 많이 나가게 될 것이다. 따라서 적당한 사이즈를 설정하는 것이 중요하다. 만약에 데이터가 1000인데 size를 100으로 설정하면 쿼리를 10번 날려야한다.
하지만 size를 너무 높게 설정하면 DB에 순간 부하가 걸릴 수도 있고, 애플리케이션에도 부하가 올 수 있다.
WAS랑 DB가 버틸 수 있을 정도면 1000이 가장 커서 좋지만 그렇지 않으면 100으로 설정한 다음에 조금씩 늘려 가나는 것이 좋다.
애매하면 500으로 두고 쓰자
'JPA > JPA 활용' 카테고리의 다른 글
OSIV와 성능 최적화 (0) 2023.08.11 컬렉션 조회 최적화 2 - fetch join으로 최적화 (0) 2023.08.10 컬렉션 조회 최적화 1 - 페치 조인을 안 했을 시 (0) 2023.08.10 간단한 주문조회 API - 지연 로딩과 조회 성능 최적화(2) (0) 2023.07.11 간단한 주문조회 API - 지연 로딩과 조회 성능 최적화 (0) 2023.07.11