본문 바로가기

IT

JPA N+1 문제 응용 사례

728x90

JPA N+1문제에 대한 기본은 이전에 포스팅한 글을 참고하도록 하자.

https://sh970901.tistory.com/126

 

JPA N+1 문제 1분 이해하기

JPA로 애플리케이션을 개발할 때 성능상 가장 주의해야 하는 것이 N+1 문제이다. JPA를 사용하는 다양한 개발자분들이 이를 사용하다보면 한번 쯤은 꼭 겪게 되는 문제일 것이다. 간단한 예제를 통

sh970901.tistory.com

이번에는 실제 사례를 들어 설명해보려고 한다. 

ERD

현재 엔티티는 위 그림의 다이어그램을 참고한다. 간단히 설명하자면 회원은 여러 주문을 할 수 있으며 주문은 여러 주문 아이템, 배송 주소, 회원 정보 등으로 구성되어있다. 주로 이 부분에서 JPA N+1 문제를 파악해보려고 한다.

기본적으로 Order Entity에 member, orderItems, delivery는 Lazy 로딩으로 설정 되어있다.

@GetMapping("/api/v1/orders")
 public List<OrderDto> orderV1){
        List<Order> orders = orderRepository.findAllByString(new OrderSearch());
        List<OrderDto> collect = orders.stream()
                .map(o -> new OrderDto(o))
                .collect(toList());

        return collect;
  }
  
  @Getter
  static class OrderDto{

        private Long orderId;
        private String name;
        private LocalDateTime orderData;
        private OrderStatus orderStatus;
        private Address address;
        private List<OrderItemDto> orderItems;
        public OrderDto(Order order){
            orderId = order.getId();
            name = order.getMember().getName();
            orderData = order.getOrderDate();
            orderStatus = order.getStatus();
            address = order.getDelivery().getAddress();
            orderItems = order.getOrderItems().stream()
                    .map(orderItem -> new OrderItemDto(orderItem))
                    .collect(toList());
        }
    }
    
    @Getter
    static class OrderItemDto{
        private String itemName; //상품명
        private int orderPrice; //주문 가격
        private int count; //주문 수량

        public OrderItemDto(OrderItem orderItem) {
            itemName = orderItem.getItem().getName();
            count = orderItem.getCount();
            orderPrice = orderItem.getOrderPrice();
        }
    }

예시를 보면 DTO를 생성하여 주문에 대한 정보를 맵핑하여 전달하고 있다. 이 경우 LAZY 로딩으로 인하여 해당 필드 값이 조회되는 순간 쿼리를 생성하여 값을 받아온다. 그렇다면 SELECT 쿼리는 findAllByString()에서 한번, stream을 돌면서 OrderDto가 생성됨에 따라 생성자에서 getMember() 에서 한번, getDelivery()에서 한번, 또 getOrderItems()를 돌면서 Order에 포함되는 OrderItems 수 만큼 Select 쿼리가 생성될 것이다. 끝이 아니다. OrderItemDto를 만들어 getItem()이 호출될 때 또 Select 쿼리가 만들어질 것이다. 

2023-06-11T20:40:51.420+09:00 DEBUG 31908 --- [io-8080-exec-10] org.hibernate.SQL                        : 
    select
       	o1_0.order_id,
        o1_0.delivery_id,
        o1_0.member_id,
        o1_0.order_date,
        o1_0.status 
    from
        orders o1_0 
    join
        member m1_0 
            on m1_0.member_id=o1_0.member_id fetch first ? rows only
2023-06-11T20:40:51.422+09:00 DEBUG 31908 --- [io-8080-exec-10] org.hibernate.SQL                        : 
    select
        m1_0.member_id,
        m1_0.city,
        m1_0.street,
        m1_0.zipcode,
        m1_0.name 
    from
        member m1_0 
    where
        m1_0.member_id=?
2023-06-11T20:40:51.423+09:00 DEBUG 31908 --- [io-8080-exec-10] org.hibernate.SQL                        : 
    select
        d1_0.delivery_id,
        d1_0.status,
        d1_0.city,
        d1_0.street,
        d1_0.zipcode 
    from
        delivery d1_0 
    where
        d1_0.delivery_id=?
2023-06-11T20:40:51.424+09:00 DEBUG 31908 --- [io-8080-exec-10] org.hibernate.SQL                        : 
    select
        o1_0.order_id,
        o1_0.delivery_id,
        o1_0.member_id,
        o1_0.order_date,
        o1_0.status 
    from
        orders o1_0 
    where
        o1_0.delivery_id=?
2023-06-11T20:40:51.426+09:00 DEBUG 31908 --- [io-8080-exec-10] org.hibernate.SQL                        : 
    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=?

Order가 하나이고 OrderItem도 하나인 경우에도 5번의 쿼리면 최악의 상황이라고 볼 수 있다. 이를 어떻게 개선할 수 있을까?

이전 포스팅에서 다양한 방법과 예시를 들었는데 필요한 상황에 맞춰 선택 해야한다. 필자는 일대다의 경우 fetch join은 데이터가 뻥튀기되기 때문에 distinct로 중복을 제거하거나 BatchSize를 활용하고 다대일, 일대일의 경우에는 무조건 fetch join을 활용한다. 

필자가 말하는 데이터 뻥튀기란 예시를 들자면 Order가 두개, OrderItems가 각각 두개씩이라고 가정하면 총 네개의 데이터가 조회된다.

따라서 이 중복을 제거하기 위해 distinct 를 활용하는 것이다.

모두 fetch join으로 묶고 distinct 로 중복을 제거하였을 경우 쿼리를 확인해보자.

public List<Order> findAllWithItem() {
        return em.createQuery(
                "select distinct o from Order o" +
                        " join fetch o.member" +
                        " join fetch o.delivery" +
                        " join fetch o.orderItems oi" +
                        " join fetch oi.item i", Order.class
        ).getResultList();
 }
2023-06-11T21:00:48.231+09:00 DEBUG 31908 --- [nio-8080-exec-4] org.hibernate.SQL                        : 
    select
        distinct o1_0.order_id,
        d1_0.delivery_id,
        d1_0.status,
        d1_0.city,
        d1_0.street,
        d1_0.zipcode,
        m1_0.member_id,
        m1_0.city,
        m1_0.street,
        m1_0.zipcode,
        m1_0.name,
        o1_0.order_date,
        o2_0.order_id,
        o2_0.order_item_id,
        o2_0.count,
        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,
        o2_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 o2_0 
            on o1_0.order_id=o2_0.order_id 
    join
        item i1_0 
            on i1_0.item_id=o2_0.item_id

정말 쿼리 한번으로 원하는 데이터를 join해 받아온다. 결과도 이상적인 데이터로 받아올 수 있다. 여기서 의심해볼건 distinct를 사용하면 모든 데이터가 같아야 중복이 제거되지 않을까 라는 생각을 할 수 있다(해야한다).  JPA에서는 id 값이 같으면 애플리케이션 레벨에서 중복을 제거해서 컬렉션에 담아준다고 한다. 따라서 네개가 아닌 두개의 Order만 받아올 수 있는 것이다. 이렇게 하면 모든 문제를 fetch join으로 해결할 수 있을 것 같다. 하지만 문제는 페이징(paging)에 있다. 

public List<Order> findAllWithItem() {
        return em.createQuery(
                "select distinct o from Order o" +
                        " join fetch o.member" +
                        " join fetch o.delivery" +
                        " join fetch o.orderItems oi" +
                        " join fetch oi.item i", Order.class
        )
                .setFirstResult(1)
                .setMaxResults(100)
                .getResultList();
    }

setFirstResult

setFirstResult는 start가 0부터 시작이라고 되어있으니 참고하도록 하자.

문제는 경고 로그를 확인해보니 하이버네이트는 DB에서 읽어오고 메모리에서 페이징을 돌린다. 데이터가 만개가 넘어가고 이 메서드가 여러번 실행되면 매우 위험한 상황이 될 수 있다. 더 큰 문제는 DB에서 데이터를 읽어올 때 원하는 두개의 ORDER 데이터를 페이징하는 것이 아닌 네개의 ORDER 데이터를 읽어와 메모리에서 페이징을 실행한다. 따라서 일대다의 경우 페이징을 사용하지 못한다는 단점이 있다. 

 

참고) 컬렉션 페치 조인은 1개만 사용할 수 있다. 컬렉션 둘 이상의 페치 조인을 사용하면 데이터가 부정합하게 조회될 수 있다.

 

그러면 페이징 + 컬렉션 엔티티를 함께 조회하려면 어떻게 해야할까?

1. 먼저 XToOne의 경우는 모두 페치조인을 한다.

2. ToOne 관계는 row수를 증가시키지 않으므로 페이징에 영향이 가지 않는다.

3. 컬렉션은 지연 로딩으로 조회한다.

4. 지연 로딩 최적화를 위해 @BatchSize를 적용한다. (BatchSize 글로벌 설정 or 개별 최적화)

5. 이 옵션을 사용하면 컬렉션이나, 프록시 객체를 한꺼번에 설정한 size 만큼 IN 쿼리로 조회한다.

 

ToOne의 경우는 모두 페치조인을 한다.

public List<Order> findAllWithMemberDelivery(int offset, int limit) {
        return em.createQuery(
                "select o from Order o" +
                        " join fetch o.member" +
                        " join fetch o.delivery d", Order.class)
                .setFirstResult(offset)
                .setMaxResults(limit)
                .getResultList();
    }

 

이렇게만 끝내면 OrderItems를 받아오는 과정에서 N+1이 터질 것이다. Member와 Delivery를 조인해서 쿼리 한번에 받아오겠지만 각각의 OrderItem, Item을 조회하는 쿼리가 지연 로딩으로 또 발생할 것이다. 

지연 로딩 최적화를 위한 batchSize를 설정해준다. 

 

1. 글로벌 설정

  jpa:
    hibernate:
      ddl-auto: create
    properties:
      hibernate:
        format_sql: true
        default_batch_fetch_size: 100 #IN(?,?) 을 사용, 미리 데이터 땡겨옴
    open-in-view: true

2. 개별 최적화

@Entity
@Table(name = "orders")
@Getter 
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Order {
	...
    @BatchSize(size = 100)
    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL) //persist 시 자동으로 딸려 영속성 저장됨
    private List<OrderItem> orderItems = new ArrayList<>();
	...

3. IN 절을 사용한 쿼리

2023-06-11T22:04:15.648+09:00 DEBUG 17812 --- [nio-8080-exec-1] org.hibernate.SQL                        : 
    select
        o1_0.order_id,
        d1_0.delivery_id,
        d1_0.status,
        d1_0.city,
        d1_0.street,
        d1_0.zipcode,
        m1_0.member_id,
        m1_0.city,
        m1_0.street,
        m1_0.zipcode,
        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
2023-06-11T22:04:15.666+09:00 DEBUG 17812 --- [nio-8080-exec-1] org.hibernate.SQL                        : 
    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(?,?)
2023-06-11T22:04:15.678+09:00 DEBUG 17812 --- [nio-8080-exec-1] org.hibernate.SQL                        : 
    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(?,?,?,?)

 

이 옵션을 사용하면 컬렉션이나, 프록시 객체를 한꺼번에 설정한 size 만큼 IN 쿼리로 조회한다.

pk 기반으로 IN 절을 날리기 때문에 DB 입장에서 최적화 된 쿼리이며 필요한 데이터만 받아와 전송률 효율이 좋다. 

batchSize를 100으로 지정하였기에 in 절은 최대 100개 값 까지 IN절에 포함된다. 따라서 지연로딩 관련된 N+1 문제를 batchSize를 활용하여 쿼리 한번에 여러 데이터들을 받아올 수 있다. 이 방법이라면 fetch join을 다대일의 경우에만 사용하였기 때문에 데이터가 뻥튀기 되는 일도 없어 페이징 처리하는 데 문제가 없다.  

'IT' 카테고리의 다른 글

[Java] ThreadLocal  (0) 2023.07.31
CQRS ? AbstractRoutingDatasource  (0) 2023.07.29
JAVA 불필요한 객체 생성  (1) 2023.06.11
JPA N+1 문제 1분 이해하기  (0) 2023.06.01
스프링 AOP(Aspect Oriented Programming) step3 : @Transactional  (0) 2023.05.12