본문 바로가기
JAVA/JPA

[JPA] JPA 컬렉션 조회 성능 최적화 #2 - @BatchSize

by 개미가되고싶은사람 2024. 12. 14.

xxToMany 관계인 컬렉션을 조회할 때 성능을 최적화 및 N+1 문제를 해결하기 위해 fetch join을 사용한 경우 다음과 같은 두 가지 단점이 있습니다. 이 단점을 @BatchSize를 사용하면 한 번에 해결할 수 있습니다.

 

1. 컬렉션을 페치 조인하면 메모리에서 페이징을 시도합니다. 만약 데이터 수가 많은 경우 메모리 부족으로 인해 오류가 발생할 수 있습니다.

2. 컬렉션 2개 이상 페치 조인하면 MultipleBagFetchException 예외가 발생

 

엔티티 코드

더보기
@Entity
@Getter
@Setter
public class Order {
    @Id
    @GeneratedValue
    @Column(name = "order_id")
    private Long id;

    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "member_id")
    private Member member; 

    @OneToOne(cascade = CascadeType.ALL, fetch = LAZY)
    @JoinColumn(name = "delivery_id")
    private Delivery delivery; 

    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
    private List<OrderItem> orderItems = new ArrayList<>(); // 1:N

    private LocalDateTime orderDate; 

    @Enumerated(EnumType.STRING)
    private OrderStatus status; 
}
@Entity
@Getter
@Setter
public class OrderItem {

    @Id
    @GeneratedValue
    @Column(name = "order_item_id")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "item_id")
    private Item item; 
    
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "order_id")
    @JsonIgnore
    private Order order; 

    private int orderPrice; 
    private int count; 
}

 

@BatchSize란?

Hibernate에서 지연 로딩을 사용할 때 특정 엔티티나 컬렉션을 가져올 때 한 번에 로드할 수 있는 엔티티의 수를 지정하는 기능입니다. 예를 들어, @BatchSize(size = 10)과 같이 설정하면 해당 엔티티나 컬렉션을 로드할 때 최대 10개의 엔티티를 한 번에 가져오도록 쿼리가 생성됩니다.

 

BatchSize 설정 방법

1. 글로벌 지정

application.properties / yml 파일에서 BatchSize를 글로벌 지정할 수 있습니다.

spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 100 // 사이즈 지정

 

2. 개별 지정 - 필드

@OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
@BatchSize(size = 10)
private List<OrderItem> orderItems = new ArrayList<>();
@Entity
@Getter
@Setter
@BatchSize(size = 10)
public abstract class Item {
	....
}

@BatchSize는 개별적으로 적용할 수 있는데 컬렉션 필드, 엔티티에 적용할 수 있습니다. 일반 필드는 안됩니다.

 

그럼 앞에서 말한 문제를 해결해보겠습니다.

 

 

1. xxToOne 관계 먼저 조회

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();
}

xxToOne 관계은 fetch join을 사용합니다. xxToOne 관계는 fetch join을 사용해도 레코드 수를 증가시키지 않습니다. 왜냐하면 xxToOne 관계의 관련된 값이 항상 하나이기 때문입니다. 따라서 쿼리를 실행해도 결과로 나오는 레코드 수는 변하지 않습니다. 그리고 만약 페이징 기능이 필요없다면 setFirstResult(), setMaxResults() 메소드 관련 코드를 지우고 사용하시면 됩니다.

 

2. Controller

@GetMapping("/api/page/orders")
public List<OrderDto> ordersPage(
            @RequestParam(value = "offset", defaultValue = "0") int offset,
            @RequestParam(value = "limit", defaultValue = "10") int limit) {

        List<Order> orders = orderRepository.findAllWithMemberDelivery(offset, limit);

        List<OrderDto> result = orders.stream()
                .map(o -> new OrderDto(o))
                .collect(toList());

        return result;
}

컨트롤러에서는 별거 없습니다. 엔티티를 반환하면 안되므로 따로 생성해둔 DTO를 이용해 JSON 데이터로 보냅니다. 그럼 orderItem 컬렉션에는 null 값이 들어가지 않나요 라고 생각할 수 있습니다. 현재 코드상으로 null 값이 들어갑니다. 여기서 지연로딩 전략을 이용해 orderItem 컬렉션에 값을 넣어주겠습니다.

 

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(orderItem -> new OrderItemDto(orderItem))
                    .collect(toList());
        }
}

지연 로딩 전략을 사용하면 this.orderItems = (생략).collect(toList())에서도 orderItems 데이터가 필요해 orderItems에 대한 쿼리가 나가게 되며, 이때 BatchSize가 적용되어 여러 개의 조건을 한 번에 처리할 수 있도록 IN 절을 사용하여 BatchSize만큼의 조건이 함께 전달되어 N+1 문제를 해결됩니다. 

참고로 Hibernate의 버전마다 WHERE 절에 포함되는 조건이 다를 수 있습니다.

 

쿼리 결과

 

 

참고자료

https://kyu-nahc.tistory.com/entry/Spring-boot-JPA-fetch-join-2%EA%B0%9C-%EC%9D%B4%EC%83%81%EC%9D%98-Collection-Join-%ED%95%B4%EA%B2%B0%EB%B2%95

 

[Spring boot] JPA fetch join 2개 이상의 Collection Join 해결법

JPA fetch Join이전 포스트에서는 JPA의 N+1 문제 및 해결방안에 대해 정리하였다. [Spring boot] JPA N+1 발생 케이스 및 해결 방법JPA FetchType저번 포스트에서 Jpa의 FetchType인 지연 로딩과 즉시 로딩에 대해

kyu-nahc.tistory.com