BackEnd/JPA

[JPA] 컬렉션 조회 최적화 - 4

샤아이인 2022. 4. 24.

내가 공부한 것을 올리며, 중요한 단원은 저 자신도 곱씹어 볼 겸 상세히 기록하고 얕은 부분들은 가볍게 포스팅하겠습니다.

 

7. 주문 조회 V6: JPA에서 DTO로 직접 조회, 플랫 데이터 최적화

이번시간에는 지난 V5 버전을 더욱 최적화 하여 쿼리1 번으로 끝내보자!

 

우선 컨트롤러는 다음과 같다.

@GetMapping("/api/v6/orders")
public List<OrderQueryDto> ordersV6() {
    return orderQueryRepository.findAllByDtoFlat();
}

 

이번에는 데이터를 한번에 가져오기 위해 Order, OrderItems, Item을 전부 Join할 것 이다.

다만 그냥 Join을 하게 되면 데이터 뻥튀기 현상이 발생함을 이전에 많이 언급했었다.

이를 해결하기 위해 전용 DTO를 만들어 flat하게 각각 단건의 데이터로 만들것 이다.

 

따라서 사용될 DTO는 다음과 같다.

@Data
public class OrderFlatDto {
    private Long orderId;
    private String name;
    private LocalDateTime orderDate;
    private OrderStatus orderStatus;
    private Address address;

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

    public OrderFlatDto(Long orderId, String name, LocalDateTime orderDate, OrderStatus orderStatus, Address address, String itemName, int orderPrice, int count) {
        this.orderId = orderId;
        this.name = name;
        this.orderDate = orderDate;
        this.orderStatus = orderStatus;
        this.address = address;
        this.itemName = itemName;
        this.orderPrice = orderPrice;
        this.count = count;
    }
}

이를 사용하는 repository의 코드는 다음과 같다.

public List<OrderFlatDto> findAllByDtoFlat() {
    return em.createQuery(
                    "select new jpabook.jpashop.repository.order.query.OrderFlatDto(o.id, m.name, o.orderDate, o.status, d.address, i.name, oi.orderPrice, oi.count)" +
                            " from Order o" +
                            " join o.member m" +
                            " join o.delivery d" +
                            " join o.orderItems oi" + // 데이터 뻥튀기됨
                            " join oi.item i", OrderFlatDto.class)
            .getResultList();
}

실행해보면 예상했던것 처럼 중복 데이터가 발생하였다.

데이터 뻥튀기 문제 때문이다. 1 : N join을 했기 때문에 Order 데이터가 중복되어 버린다.

 

SQL문 같은 경우, 단 1건의 쿼리문이 생성되었다.

이렇게되면 페이징 처리는 할수 없게 된다.

 

만약 API spec을 OrderQueryDto에 맞추고 싶다면 어떻게 해야 할까? 지금은 OrderFlatDto로 반환하고 있다.

Order 안에 OrderItems와 관련된 정보까지 Json으로 전송하고 데이터 또한 중복되면 안된다.

 

따라서 Data를 flat화 시켜주는 작업이 필요하다.

DTO를 사용하는 방식은 일반적인 SQL 같은 방식으로 조회하기 때문에, 데이터를 한줄로 flat하게 조회해야 한다.

그래서 이것을 분리할 때 수동으로 한땀한땀 분리해야 한다.

 

이때 복잡한 과정이 한번 필요하다...  개발자가 직접 중복을 걸러내면 된다...

@GetMapping("/api/v6/orders")
public List<OrderQueryDto> ordersV6() {
    List<OrderFlatDto> flats = orderQueryRepository.findAllByDtoFlat();

    return flats.stream()
            .collect(groupingBy(o -> new OrderQueryDto(o.getOrderId(), o.getName(), o.getOrderDate(), o.getOrderStatus(), o.getAddress()),
                    mapping(o -> new OrderItemQueryDto(o.getOrderId(), o.getItemName(), o.getOrderPrice(), o.getCount()), toList())
            )).entrySet().stream()
            .map(e -> new OrderQueryDto(e.getKey().getOrderId(), e.getKey().getName(), e.getKey().getOrderDate(), e.getKey().getOrderStatus(), e.getKey().getAddress(), e.getValue()))
            .collect(toList());
}

loop를 돌면서 직접 중복을 제거한다. 메모리에서 진행되는 것 이다.

 

추가로 groupingBy를 적용할때는 group 을 만들 기준이 필요하기 때문에 EqualAndHashCode를 구현해줘야 한다.

@Data
@EqualsAndHashCode(of = "orderId")
public class OrderQueryDto {
    // 생략...
}

위와같이 OrderQueryDto위에 지정 해 주었다. id를 기준으로 grouping을 하게될 것 이다.

 

postman으로 조회해보면 다음과 같다.

[
    {
        "orderId": 11,
        "name": "userB",
        "orderDate": "2022-04-24T16:09:45.469959",
        "orderStatus": "ORDER",
        "address": {
            "city": "진주",
            "street": "2",
            "zipcode": "2222"
        },
        "orderItems": [
            {
                "orderId": 11,
                "itemName": "SPRING1 BOOK",
                "orderPrice": 20000,
                "count": 3
            },
            {
                "orderId": 11,
                "itemName": "SPRING2 BOOK",
                "orderPrice": 40000,
                "count": 4
            }
        ]
    },
    {
        "orderId": 4,
        "name": "userA",
        "orderDate": "2022-04-24T16:09:45.452588",
        "orderStatus": "ORDER",
        "address": {
            "city": "서울",
            "street": "1",
            "zipcode": "1111"
        },
        "orderItems": [
            {
                "orderId": 4,
                "itemName": "JPA1 BOOK",
                "orderPrice": 10000,
                "count": 1
            },
            {
                "orderId": 4,
                "itemName": "JPA2 BOOK",
                "orderPrice": 20000,
                "count": 2
            }
        ]
    }
]

 

또한 실행된 쿼리는 다음과 같다. 단 1건의 쿼리가 발생하였다.

select
    order0_.order_id as col_0_0_,
    member1_.name as col_1_0_,
    order0_.order_date as col_2_0_,
    order0_.status as col_3_0_,
    delivery2_.city as col_4_0_,
    delivery2_.street as col_4_1_,
    delivery2_.zipcode as col_4_2_,
    item4_.name as col_5_0_,
    orderitems3_.order_price as col_6_0_,
    orderitems3_.count as col_7_0_ 
from
    orders order0_ 
inner join
    member member1_ 
        on order0_.member_id=member1_.member_id 
inner join
    delivery delivery2_ 
        on order0_.delivery_id=delivery2_.delivery_id 
inner join
    order_item orderitems3_ 
        on order0_.order_id=orderitems3_.order_id 
inner join
    item item4_ 
        on orderitems3_.item_id=item4_.item_id

위 코드를 통해 API의 spec을 변경하는 것을 확인하였다.

핵심은 위 groupby하고 어쩌구 저쩌구 하는 코드가 아닌, 개발자가 노력하여 변경시켜줄 수 있다는 점 이다!

하지만 코드가 너무 복잡해지는 점은 어쩔수가 없다.

 

- 단점

쿼리는 한번이지만 조인으로 인해 DB에서 애플리케이션에 전달하는 데이터에 중복 데이터가 추가되므로 상황에 따라 V5 보다 더 느릴 수 도 있다. 또한 Order를 기준으로 하는 페이징 처리가 불가능하다.

 

 

8. API 개발 고급 정리

권장 순서

1. 엔티티 조회 방식으로 우선접근
    1. 페치조인으로 쿼리 수를 최적화

    2. 컬렉션 최적화

        1. 페이징 필요 hibernate.default_batch_fetch_size , @BatchSize 로 최적화

        2. 페이징 필요X 페치 조인 사용
2. 엔티티조회방식으로해결이안되면DTO조회방식사용
3. DTO 조회 방식으로 해결이 안되면 NativeSQL or 스프링 JdbcTemplate
Entity 조회 방식이 성능 최적화를 할때도 추가적인 코드 변경이 적다.

 

Entity 조회를 우선적으로 사용하자!

엔티티 조회 방식은 Fetch Join이나, hibernate.default_batch_fetch_size , @BatchSize 같이 코드를 거의 수정하지 않고, 옵션만 약간 변경해서, 다양한 성능 최적화를 시도할 수 있다.

 

이전 글 들에서 DTO를 사용하던 V4, V5, V6를 생각해보자.

V4, V5, V6에서 단순하게 쿼리가 1번 실행된다고 V6이 항상 좋은 방법인 것은 아니다.

- V4는 코드가 단순하다.

특정 주문 한건만 조회하면 이 방식을 사용해도 성능이 잘 나온다.

예를 들어서 조회한 Order 데이터가 1건이면 OrderItem을 찾기 위한 쿼리도 1번만 실행하면 된다.

하지만 N+1 문제가 발생하기도 한다.


- V5는 코드가 복잡하다.
여러 주문을 한꺼번에 조회하는 경우에는 V4 대신에 이것을 최적화한 V5 방식을 사용해야 한다.

V5 방식으로 최적화 하면 쿼리가 총 1 + 1번만 실행된다. 상황에 따라 다르겠지만 운영 환경에서 100배 이상의 성능 차이가 날 수 있다.


- V6는 완전히 다른 접근방식이다.

쿼리 한번으로 최적화 되어서 상당히 좋아보이지만, Order를 기준으로 페이징이 불가능하다.

실무에서는 이정도 데이터면 수백이나, 수천건 단위로 페이징 처리가 꼭 필요하므로, 이 경우 선택하기 어려운 방법이다.

그리고 데이터가 많으면 중복 전송이 증가해서 V5와 비교해서 성능 차이도 미비하다.

'BackEnd > JPA' 카테고리의 다른 글

[JPA] 예제 도메인 모델  (0) 2022.05.02
[JPA] Open Session In View (OSIV)  (0) 2022.04.24
[JPA] 컬렉션 조회 최적화 - 3  (0) 2022.04.24
[JPA] 컬렉션 조회 최적화 - 2  (0) 2022.04.23
[JPA] 컬렉션 조회 최적화 - 1  (0) 2022.04.22

댓글