JPA(Java Persistence API)

[JPA] 페치 조인의 한계와 한계 돌파

cwchoiit 2023. 11. 15. 14:06
728x90
반응형
SMALL

페치조인의 한계

JPA에서 정말 정말 중요한 페치 조인도 역시 만능은 아니고 컬렉션 데이터를 페치 조인할 때 페이징이 불가능하다.

ToMany 관계를 페치 조인할 때라고 말하는 것과 동일한데, 결론은 페치 조인을 사용할 때 페이징이 불가능하다.

 

정확히 말하면 페이징이 불가능한것보단 하이버네이트가 메모리 상에서 페이징을 해주는데 이는 절대로 사용해선 안된다. 그냥 그러니까 ToMany 관계를 페치 조인할 땐 페이징이 불가능하다고 생각하면 된다.

 

그래서 이를 해결해야 하는데, 우선 다음과 같은 쿼리가 있다.

public List<Order> findAllWithItem() {
    return em.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();
}

 

여기서 Order 입장에서 Member, Delivery는 ToOne 관계다. 그러므로 페치 조인을 하고 페이징을 하고 뭘해도 상관이 없다. 그러나 OrderItem은 ToMany 관계다. 즉, 페치 조인으로 페이징을 할 수 없다. 이 때 페이징을 만약 한다면, 다음과 같은 경고문구가 노출된다.

2023-11-15T13:36:10.167+09:00  WARN 72378 --- [nio-8080-exec-2] org.hibernate.orm.query                  : HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory

 

절대로 이렇게는 페이징을 하면 안되고, 왜 안되냐면 일대다 관계일때 조인을 하면 일을 기준으로 데이터가 뻥튀기가 된다. 만약 오더A, 오더B가 있을 때, 오더A의 오더아이템A-1, 오더아이템A-2가 있고 오더B의 오더아이템B-1, 오더아이템B-2가 있으면 이거를 조인하면 오더는 두개뿐이지만 레코드는 4개가 나온다. (오더A-오더아이템A-1, 오더A-오더아이템A-2, 오더B-오더아이템B-1, 오더B-오더아이템B-2)

 

이렇게 뻥튀기가 된 데이터를 메모리에 올려놓고 페이징을 한다는건 .. X 제대로 페이징이 될 리가 없을뿐더러, 데이터가 많아서 메모리에 만약 한 20000개가 한번에 올라가면? 메모리 누수가 생겨서 애플리케이션이 장애가 발생할 수 있다.

 

이를 해결해보자.

728x90
반응형
SMALL

한계 돌파

우선 우리가 원하는 데이터는 주문에 대한 페이징이다. 그럼 주문 데이터에 대한 페이징이 수행되어야 한다. 첫번째로, 페치 조인을 사용하는 쿼리에서 주 테이블(Order)을 기준으로 ToMany 관계에 있는 테이블(OrderItem)을 모두 제외시킨다. 

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

 

위 쿼리를 이렇게 변경하자. 그리고 이 상태에선 ToMany 관계를 페치 조인하지 않기 때문에 얼마든지 페이징을 할 수 있다.

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

 

다음과 같이 페이징까지 적용한 쿼리로 완성시켰고 이것을 호출해서 사용하면 아무런 문제없이 페이징이 잘 수행되는데 여기서 끝내면 당연히 안되겠지. 왜냐하면 내가 원하는 데이터 OrderItem에 대한 데이터는 이 쿼리가 가져오지 않기 때문에 Order의 OrderItem을 조회하는 과정에서 계속 지연 로딩을 강제 초기화하면서 쿼리를 마구잡이로 잔뜩 날릴거다. 한번 보자.

 

컨트롤러를 하나 만들고 저 쿼리를 사용해서 Order 데이터를 가져와보자.

Controller

@GetMapping("orders/paging")
public List<OrderDto> ordersPaging(@RequestParam(value = "offset", defaultValue = "0") int offset,
                                   @RequestParam(value = "limit", defaultValue = "100") int limit) {
    List<Order> orders = orderRepository.findAllWithMemberDelivery(offset, limit);

    return orders.stream().map(OrderDto::new).toList();
}

OrderDto

@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) {
        orderId = order.getId();
        name = order.getMember().getUsername();
        orderDate = order.getOrderDate();
        orderStatus = order.getOrderStatus();
        address = order.getDelivery().getAddress();

        orderItems = order.getOrderItems().stream().map(OrderItemDto::new).toList();
    }
}

OrderItemDto

@Data
static class OrderItemDto {

    private String itemName;
    private int orderPrice;
    private int count;

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

 

실행을 해보면 다음 결과를 받는다.

[
    {
        "orderId": 1,
        "name": "userA",
        "orderDate": "2023-11-15T13:45:49.61074",
        "orderStatus": "ORDER",
        "address": {
            "city": "city",
            "street": "street",
            "zipcode": "zipcode"
        },
        "orderItems": [
            {
                "itemName": "JPA1 BOOK",
                "orderPrice": 10000,
                "count": 1
            },
            {
                "itemName": "JPA2 BOOK",
                "orderPrice": 20000,
                "count": 2
            }
        ]
    },
    {
        "orderId": 2,
        "name": "userB",
        "orderDate": "2023-11-15T13:45:49.660323",
        "orderStatus": "ORDER",
        "address": {
            "city": "city2",
            "street": "street2",
            "zipcode": "zipcode2"
        },
        "orderItems": [
            {
                "itemName": "SPRING1 BOOK",
                "orderPrice": 20000,
                "count": 2
            },
            {
                "itemName": "SPRING2 BOOK",
                "orderPrice": 40000,
                "count": 3
            }
        ]
    }
]

 

Order가 총 2개, 각 Order의 OrderItem도 2개씩 있다. 이럴 때 쿼리를 보면 지연 로딩을 초기화하는 쿼리가 개수만큼 쭉쭉 나간다.

Hibernate: 
    select
        o1_0.order_id,
        d1_0.delivery_id,
        d1_0.city,
        d1_0.street,
        d1_0.zipcode,
        d1_0.delivery_status,
        m1_0.member_id,
        m1_0.city,
        m1_0.street,
        m1_0.zipcode,
        m1_0.username,
        o1_0.order_date,
        o1_0.order_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
Hibernate: 
    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=?
Hibernate: 
    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=?
Hibernate: 
    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=?
Hibernate: 
    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=?
Hibernate: 
    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=?
Hibernate: 
    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=?

 

 이건 당연히 좋지 못하다. 이제 이럴 때 해결하는 방법은 default_batch_fetch_size를 사용하는 것.

application.yml 파일에서 다음 라인을 추가하면 한번에 최대 500개를 가져올 수 있다는 뜻이다.

spring.jpa.properties.hibernate.default_batch_fetch_size = 500

 

이걸 추가하고 나가는 쿼리를 위 쿼리와 비교해보자. 위에는 지금 오더아이템에 포함된 아이템을 하나씩 하나씩 꺼내온다. 이것을 한번에 500개까지 가져오게 할 수 있는것이다. 다음이 그 결과다. 

Hibernate: 
    select
        o1_0.order_id,
        d1_0.delivery_id,
        d1_0.city,
        d1_0.street,
        d1_0.zipcode,
        d1_0.delivery_status,
        m1_0.member_id,
        m1_0.city,
        m1_0.street,
        m1_0.zipcode,
        m1_0.username,
        o1_0.order_date,
        o1_0.order_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
Hibernate: 
    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 (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
Hibernate: 
    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 (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)

 

우선 페치조인을 사용해서 Member, Delivery를 Order와 같이 페이징을 하여 가져오는 쿼리 1개, 그리고 그 Order의 OrderItem을 가져오는데 위에서는 OrderItem 개수만큼 쿼리를 날렸다. 두개가 있었으니 두개가 날라갔는데 지금은 최대 500개를 한번에 가져오는 'IN' 키워드를 사용한다. 그래서 하나의 쿼리로 가져올 수 있다. 그리고 OrderItem에 포함된 아이템들을 가져오는 쿼리 역시 위에는 각 Item을 조회할 때마다 쿼리를 날렸는데 지금은 아이템을 최대 500개를 한번에 가져와서 영속성 컨텍스트에 영속시키니까 단 한번의 쿼리로 우리가 원하는 모든 아이템을 다 가져왔다. 즉, 지금 총 3개의 쿼리가 나갔다.

 

batch_fetch_size를 적용하지 않은 쿼리는 7개가 나갔다. 아마 데이터가 많으면 200개 300개도 나갈것이다. 그 200개를 3개의 쿼리로 다 해결할 수 있는것. 이렇게 페치 조인을 사용해서 ToMany 관계를 가질 때 페이징을 하는 방법을 알아보았다.

 

 

결론

결론은, 나는 그렇게 생각한다. 사실 ToMany는 없으면 없을수록 좋다. 내가 진행하는 프로젝트는 단 한개도 없이 아무런 문제없이 애플리케이션을 동작시키고 있다. 왜냐하면 ManyToOne, OneToOne 만으로 관계를 가져도 결국 다 데이터베이스에선 외래키로 참조하여 데이터를 가져올 수 있기 때문이다. 그러나, 편의를 위해 ToMany 관계를 만들수도 있겠지. 만들었다면 페치 조인으로 페이징은 불가능하다. 그래서 어떤 엔티티를 페이징을 사용해서 데이터베이스에서 조회할 때 ToMany 관계가 있으면 페이징을 하는 방법을 알아보았다. 생각보다 간단하게 default_batch_fetch_size를 이용해서 해결할 수 있었다.

 

728x90
반응형
LIST

'JPA(Java Persistence API)' 카테고리의 다른 글

[JPA] OSIV (Open Session In View)  (0) 2023.11.16
[JPA] 변경감지와 Merge  (0) 2023.11.12
[JPA] Part 18. 벌크 연산  (0) 2023.10.30
[JPA] Part 17. Named Query  (2) 2023.10.30
[JPA] Part 16. Fetch JOIN (JPQL)  (0) 2023.10.30