스프링데이터 + JPA/API 개발

13. 컬렉션 페이징 한계돌파

sdafdq 2023. 11. 15. 07:43

컬렉션 패치조인은 결국 JPA는 데이터를 일단 다 가지고 와 메모리에서 페이징을 하기 때문에,

데이터가 많을 경우 out of memory도 날 수 있고 생각보다 굉장히 위험하다.

 

지금부터 페이징 + 컬렉션 엔티티 함께 조회하는 효율적인 방법을 알아보겠다.

 

먼저, ToOne관계는(즉 나에게 1인) 모두 join fetch한다. row수를 증가시키지 않는다. 그래서 페이징 해도 상관이 없다.

그 다음 컬렉션은 그냥 LAZY 로딩인 상태로 둔다. 여기서 뭔가 작업을 해 둘거다.

 

일단은, ToOne 관계는 그냥 join fetch해서 가져온다.

@GetMapping("/api/v3.1/orders")
public List<OrderDto> ordersV3_page(){
    List<Order> orders = orderRepository.findAllWithMemberDelivery();
    return orders.stream()
            .map(o-> new OrderDto(o))
            .collect(Collectors.toList());
}

 

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

 

이 상태면 현재까지는 일단은 ToOne 관계인 member, delivery는 join fetch로 가져오는 데, 나머지는 LAZY 로딩 상태라 접근했을 때 가져온다. 즉, N + 1 문제가 터진다.

이번 같은 경우는 저기 OrderDto로 변환하면서 개개별 접근하면서 쿼리를 날린다.

    select
        o1_0.order_id,
        d1_0.delivery_id,
        d1_0.city,
        d1_0.street,
        d1_0.zipcode,
        d1_0.status,
        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
2023-11-15T07:33:08.521+09:00  INFO 17112 --- [nio-8080-exec-1] p6spy                                    : #1700001188521 | took 0ms | statement | connection 7| url jdbc:h2:tcp://localhost/./jpashop
select o1_0.order_id,d1_0.delivery_id,d1_0.city,d1_0.street,d1_0.zipcode,d1_0.status,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
select o1_0.order_id,d1_0.delivery_id,d1_0.city,d1_0.street,d1_0.zipcode,d1_0.status,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;
2023-11-15T07:33:08.538+09:00 DEBUG 17112 --- [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=?
2023-11-15T07:33:08.539+09:00 TRACE 17112 --- [nio-8080-exec-1] org.hibernate.orm.jdbc.bind              : binding parameter [1] as [BIGINT] - [1]
2023-11-15T07:33:08.540+09:00  INFO 17112 --- [nio-8080-exec-1] p6spy                                    : #1700001188540 | took 0ms | statement | connection 7| url jdbc:h2:tcp://localhost/./jpashop
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=?
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=1;
2023-11-15T07:33:08.610+09:00 DEBUG 17112 --- [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=?
2023-11-15T07:33:08.610+09:00 TRACE 17112 --- [nio-8080-exec-1] org.hibernate.orm.jdbc.bind              : binding parameter [1] as [BIGINT] - [1]
2023-11-15T07:33:08.611+09:00  INFO 17112 --- [nio-8080-exec-1] p6spy                                    : #1700001188611 | took 0ms | statement | connection 7| url jdbc:h2:tcp://localhost/./jpashop
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=?
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=1;
2023-11-15T07:33:08.612+09:00 DEBUG 17112 --- [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=?
2023-11-15T07:33:08.612+09:00 TRACE 17112 --- [nio-8080-exec-1] org.hibernate.orm.jdbc.bind              : binding parameter [1] as [BIGINT] - [2]
2023-11-15T07:33:08.613+09:00  INFO 17112 --- [nio-8080-exec-1] p6spy                                    : #1700001188613 | took 0ms | statement | connection 7| url jdbc:h2:tcp://localhost/./jpashop
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=?
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=2;
2023-11-15T07:33:08.614+09:00 DEBUG 17112 --- [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=?
2023-11-15T07:33:08.614+09:00 TRACE 17112 --- [nio-8080-exec-1] org.hibernate.orm.jdbc.bind              : binding parameter [1] as [BIGINT] - [2]
2023-11-15T07:33:08.615+09:00  INFO 17112 --- [nio-8080-exec-1] p6spy                                    : #1700001188615 | took 0ms | statement | connection 7| url jdbc:h2:tcp://localhost/./jpashop
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=?
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=2;
2023-11-15T07:33:08.615+09:00 DEBUG 17112 --- [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=?
2023-11-15T07:33:08.616+09:00 TRACE 17112 --- [nio-8080-exec-1] org.hibernate.orm.jdbc.bind              : binding parameter [1] as [BIGINT] - [3]
2023-11-15T07:33:08.616+09:00  INFO 17112 --- [nio-8080-exec-1] p6spy                                    : #1700001188616 | took 0ms | statement | connection 7| url jdbc:h2:tcp://localhost/./jpashop
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=?
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=3;
2023-11-15T07:33:08.617+09:00 DEBUG 17112 --- [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=?

이렇게 처음엔 member, delivery, order 합쳐서 가져오고,

그 다음은 order_item과 item을 가져오면서 n + 1 문제가 생긴다.

 

나가는 쿼리 예상은 생각을 좀 해야 한다.

먼저 join fetch로 order는 한번에 쫙 가져오니까 한번,

그 다음 order_item 가져오는데 order의 order_item_id 가져오니까 또 한번, 그러면 2개 가져 오겠지.

그러면 그렇게 가져온 것의 item을 터치하니까 order_item이 2개니까 2번

이렇게 order마다 반복하는데, order가 2개니까,

 

일단 위까지가 

1(order)

1(order_item)

2(item)

 

해서, 1은 이미 가져왔으니까 넘기고,

똑같이 1, 2해서 총 7개

 

일단은 where, 보통 엔티티 자체는 id를 기준으로 함.

where나 on을 뭘로 가져오는지 생각하면 답 나옴.

 

보통 기본은 id를 통해 가져오니, 별 다른 기준을 where나 on에 주지 않으면 id를 기준으로 생각하셈.

 

 

여기서 무언가 LAZY 로딩에 관해 처리를 해 줘야 한다.

application.yml에서

spring:
  datasource:
    url: jdbc:h2:tcp://localhost/./jpashop
    username: sa
    password: 1234
    driver-class-name: org.h2.Driver

  jpa:
    hibernate:
      ddl-auto: create
    properties:
      hibernate:
#        show_sql: true
        format_sql: true
        default_batch_fetch_size: 100

  thymeleaf:
    cache: false


logging:
  level:
    org.hibernate.SQL: debug
    org.hibernate.orm.jdbc.bind: trace

저렇게 default_batch_fetch_size를 넣어준다.

저게 뭐냐면

 

lazy 로딩인 쿼리들을 같은 종류면(batch) 한번에 100개까지 땡겨오는 것이다.

쿼리 보면 안다.

 

접근하여, LAZY 로딩이 발동되는 순간에,

같은 종류를 쭉 땡겨오는 것이다. 일단 나는 10개만 해 놨다

    select
        o1_0.order_id,
        d1_0.delivery_id,
        d1_0.city,
        d1_0.street,
        d1_0.zipcode,
        d1_0.status,
        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

 

처음은 이렇게 ToOne인 것들을 join fetch 한것들을 가져오고, 

 

LAZY 로딩설정인 것들을 접근 하여 LAZY로딩이 발동되는 순간,

    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-11-15T13:14:36.411+09:00  INFO 11456 --- [nio-8080-exec-1] p6spy                                    : #1700021676411 | took 0ms | statement | connection 7| url jdbc:h2:tcp://localhost/./jpashop
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 (1,2,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL);

이렇게 쭉 땡겨온다.

10개로 설정해놔서 10개 쭉 땡겨온다.

 

이게 10개에 다 들어가고, 나머지 null로 들어가서 비효율적이지 않나? 생각했더니,

null값이 추가되는 건 아마도 특정 DB에 따라 배열의 데이터 수가 같아야 최적화 되기 때문에 그런 것으로 추정된다.

라고 한다.

아무래도 batch_size는 아예 딱 설정을 해버리니까.

물론 개개별로 할 수 있기도 하지만, 보통 저렇게 아예 기본으로 뭐 100개 1000개 설정하는 듯 하다.

저렇게 하고, ToOne인 것들은 join fetch로 가져오고.

 

이렇게 이 두가지로 보통 쿼리 나가는 부분은 최적화를 하는 모양이다.

    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 (?,?,?,?,?,?,?,?,?,?)

그 다음 Dto로 변하면서 order_item의 item도 만지는 순간에 쿼리가 in으로 한번에 나간다.

만약 배치 사이즈를 100개로 해놨는데 조회해오는 데이터가 350개면 

100개 조회해오고

100개 조회해오고

100개 조회해오고

100개 조회해오고 (나머지 50개는 in null)

 

이렇게 4번 나갈꺼임.

 

 

물론 fetch join은 한방쿼리지만,

각자 장단점이 다 있다.

 

네트워크를 호출하는 횟수랑 데이터 전송하는 양의 트레이드 오프가 있다.

 

 

여튼, 컬렉션인데 페이징을 하고 싶다면, 이런 식으로..

지연로딩에 batch로

 

세세하게 적용하고 싶다면 엔티티에다

@BatchSize(size = 20)
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
private List<OrderItem> orderItems = new ArrayList<>();

이런 식으로.

 

아니면 또 다른 것이 나를 컬렉션으로 갖고 있는 경우

@Entity
@BatchSize(size = 100)
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "dtype")
@Getter @Setter
public abstract class Item {
.....

이런 식으로 엔티티에다 직접

 

둘 다 가능함.

 

근데 보통, default_batch_fetch_size 하고 씀. 사이즈는 보통 100~ 1000. 

1000이하로 하셈. DB에 따라 1000개. in 개수를 1000개로 제한하는 db도 있음.

 

 

 

보통

먼저 ToOne은 모두 join fetch 함.

컬렉션은 지연 로딩으로 함.

지연로딩 성능 최적화를 위해 batch를 씀.

 

 

이거의 장점은.페이징이 가능하게 됨.

 

LAZY 로딩을 쓰니까.

ToOne인건 join fetch로 가져오니까 rows수가 뻥튀기 되지도 않고.

 

당연하게도 메모리 페이징한다고 warn 안뜸.

 

걍 Order 가져오는 거고 ToOne인것만 join fetch 하는거니 rows도 뻥튀기 안되고.