API 개발 고급 - 컬렉션 조회 최적화

LeeKyoungChang·2022년 4월 15일
0
post-thumbnail

인프런 수업 강의를 듣고 정리한 내용입니다.

 

이전까지 xxxtoOne(OneToOne, ManyToOne) 관계만 있었다.
이번에는 컬렉션인 일대다 관계(OneToMany)를 조회하고, 최적화하는 방법을 알아보자!

 

📚 1. 주문 조회 V1: 엔티티 직접 노출

OrderApiController

@RestController
@RequiredArgsConstructor
public class OrderApiController {

    private final OrderRepository orderRepository;

    /**
     * V1. 엔티티 직접 노출
     * - Hibernate5Module 모듈 등록, LAZY=null 처리
     * - 양방향 관계 문제 발생 -> @JsonIgnore
     */
    @GetMapping("/api/v1/orders")
    public List<Order> ordersV1() {
        List<Order> all = orderRepository.findAllByString(new OrderSearch());

        for (Order order : all) {
            order.getMember().getName();    //Lazy 강제 초기화
            order.getDelivery().getAddress();   //Lazy 강제 초기화

            List<OrderItem> orderItems = order.getOrderItems();
            orderItems.stream().forEach(o -> o.getItem().getName());    //Lazy 강제 초기화
        }
        return all;
    }

}
            // 초기화
            List<OrderItem> orderItems = order.getOrderItems();
            orderItems.stream().forEach(o -> o.getItem().getName());

가 추가되었는데
order.getOrderItems()를 실행하여

스크린샷 2022-04-19 오후 4 48 45 스크린샷 2022-04-19 오후 4 49 45

강제 초기화가 실행된다.

  • orderItem, item 관계를 직접 초기화하면 Hibernate5Module 설정에 의해 엔티티를 JSON으로 생성한다.
    • Hibernate5Module 기본 설정 자체가 지연 로딩 할 때 프록시인 것은 데이터를 뿌리지 않지만, 프록시들을 미리 강제 초기화할 시 데이터가 있다고 판단하여 뿌려준다.
    • 코드에서 프록시를 강제 초기화할 시 데이터를 뿌려준다.
  • 양방향 관계에서는 무한 루프에 걸리지 않기 위해 한 곳에 @JsonIgnore을 꼭 붙여줘야한다.
  • 엔티티를 직접 노출하므로 좋은 코드는 아니다.

 

실행 결과

[
    {
        "id": 4,
        "member": {
            "id": 1,
            "name": "userA",
            "address": {
                "city": "서울",
                "street": "1",
                "zipcode": "1111"
            }
        },
        "orderItems": [
            {
                "id": 6,
                "item": {
                    "id": 2,
                    "name": "JPA1 BOOK",
                    "price": 10000,
                    "stockQuantity": 99,
                    "categories": null,
                    "author": null,
                    "isbn": null
                },
                "orderPrice": 10000,
                "count": 1,
                "totalPrice": 10000
            },
            {
                "id": 7,
                "item": {
                    "id": 3,
                    "name": "JPA2 BOOK",
                    "price": 20000,
                    "stockQuantity": 98,
                    "categories": null,
                    "author": null,
                    "isbn": null
                },
                "orderPrice": 20000,
                "count": 2,
                "totalPrice": 40000
            }
        ],
        "delivery": {
            "id": 5,
            "address": {
                "city": "서울",
                "street": "1",
                "zipcode": "1111"
            },
            "status": null
        },
        "orderDate": "2022-04-13T15:17:07.10899",
        "status": "ORDER",
        "totalPrice": 50000
    },
    {
        "id": 11,
        "member": {
            "id": 8,
            "name": "userB",
            "address": {
                "city": "진주",
                "street": "2",
                "zipcode": "2222"
            }
        },
        "orderItems": [
            {
                "id": 13,
                "item": {
                    "id": 9,
                    "name": "SPRING1 BOOK",
                    "price": 20000,
                    "stockQuantity": 197,
                    "categories": null,
                    "author": null,
                    "isbn": null
                },
                "orderPrice": 20000,
                "count": 3,
                "totalPrice": 60000
            },
            {
                "id": 14,
                "item": {
                    "id": 10,
                    "name": "SPRING2 BOOK",
                    "price": 40000,
                    "stockQuantity": 296,
                    "categories": null,
                    "author": null,
                    "isbn": null
                },
                "orderPrice": 40000,
                "count": 4,
                "totalPrice": 160000
            }
        ],
        "delivery": {
            "id": 12,
            "address": {
                "city": "진주",
                "street": "2",
                "zipcode": "2222"
            },
            "status": null
        },
        "orderDate": "2022-04-13T15:17:07.247352",
        "status": "ORDER",
        "totalPrice": 220000
    }
]

 

📚 2. 주문 조회 V2: 엔티티를 DTO로 변환

OrderApiController

@RestController
@RequiredArgsConstructor
public class OrderApiController {

    private final OrderRepository orderRepository;

    ...

    /**
     * V2. 엔티티를 DTO로 변환
     */
    @GetMapping("/api/v2/orders")
    public List<OrderDto> ordersV2() {
        List<Order> orders = orderRepository.findAllByString(new OrderSearch());
        List<OrderDto> result = orders.stream()
                .map(o -> new OrderDto(o))
                .collect(toList());

        return result;
    }

    @Getter
    static class OrderDto {

        private Long orderId;
        private String name;
        private LocalDateTime orderDate;    //주문시간
        private OrderStatus orderStatus;
        private Address address;
        private List<OrderItemDto> orderItems;  //Dto 변환 필수!!

        public OrderDto(Order order) {
            orderId = order.getId();
            name = order.getMember().getName();
            orderDate = 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();
            orderPrice = orderItem.getOrderPrice();
            count = orderItem.getCount();
        }
    }
}

 

✔️ DTO에 엔티티가 있으면 안된다.

    @Data
    static class OrderDto {

        private Long orderId;
        private String name;
        private LocalDateTime orderDate;
        private OrderStatus orderStatus;
        private Address address;
        private List<OrderItem> orderItems;

        public OrderDto(Order order) {
            orderId = order.getId();
            name = order.getMember().getName();
            orderDate = order.getOrderDate();
            orderStatus = order.getStatus();
            address = order.getDelivery().getAddress();
            order.getOrderItems().stream().forEach(o->o.getItem().getName());
            orderItems = order.getOrderItems();
        }
    }

이와 같이 DTO안에 엔티티가 있으면 안된다. (매핑하는 것도 안된다.)

스크린샷 2022-04-19 오후 5 13 30
  • 왜냐하면, 실행 결과와 같이 엔티티가 외부에 다 노출되기 때문이다.
  • 이와 같이 DTO에 감싸서 보내면 엔티티가 외부에 다 노출되기 때문에, 엔티티 의존을 끊는 코드로 변경해야 한다.
  • OrderItem 자체를 DTO로 변경해야 한다.

 

✔️ 엔티티 의존을 끊는 다른 방법으로 OrderItemDto를 생성한다.

    @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().getName();
            orderDate = 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();
            orderPrice = orderItem.getOrderPrice();
            count = orderItem.getCount();
        }
    }
  • 클라이언트 입장에서 필요한 요구조건 데이터들만 (OrderItemDto)클래스에 추가한다.
  • OrderDtoOrderItemDto
스크린샷 2022-04-19 오후 5 26 14

클라이언트가 원하는 요구조건 데이터들만 출력된다.
(address : ValueObject 변경될 일이 없어 화면에 출력해도 된다. OrderItemDto 같은 클래스들은 DTO를 주의해야한다.)

 

  • 지연 로딩으로 너무 많은 SQL을 실행한다.
  • SQL 실행 수
    • order 1번
    • member, address N번 (order 조회 수 만큼)
    • orderItem N번 (order 조회 수 만큼)
    • item N번 (orderItem 조회 수 만큼)

 

실행 결과

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

 

💡 참고
지연 로딩은 영속성 컨텍스트에 있으면 영속성 컨텍스트에 있는 엔티티를 사용하고 없으면 SQL을 실행한다. 따라서 같은 영속성 컨텍스트에서 이미 로딩한 회원 엔티티를 추가로 조회하면 SQL을 실행하지 않는다.

 

💡 참고
Dto 안에는 엔티티가 있으면 안된다.
OrderDto 안에 있는 orderItems 조차도 Dto로 변환해주어야 한다.

 

📚 3. 주문 조회 V3: 엔티티를 DTO로 변환 - 페치 조인 최적화

OrderApiController에 추가

    @GetMapping("/api/v3/orders")
    public List<OrderDto> ordersV3() {
        List<Order> orders = orderRepository.findAllWithItem();

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

        return result;
    }

 

OrderRepository에 추가 (distinct 없이)

    public List<Order> findAllWithItem() {
        return em.createQuery(
                        "select 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();
    }
스크린샷 2022-04-19 오후 6 09 10
  • ORDER_ID 4번인 경우, order_item으로 JPA1, JPA2를 가지고 있다.
  • orders 테이블은 행이 하나, order_item 테이블은 행이 두개 이다.
  • RDS에서는 join을 할시, (행이 많은)다 쪽에 맞추어 결과가 출력된다. (뻥튀기 발생)

 

실행 결과

[
    {
        "orderId": 4,
        "name": "userA",
        "orderDate": "2022-04-13T16:32:18.909982",
        "orderStatus": "ORDER",
        "address": {
            "city": "서울",
            "street": "1",
            "zipcode": "1111"
        },
        "orderItems": [
            {
                "itemName": "JPA1 BOOK",
                "orderPrice": 10000,
                "count": 1
            },
            {
                "itemName": "JPA2 BOOK",
                "orderPrice": 20000,
                "count": 2
            }
        ]
    },
    {
        "orderId": 4,
        "name": "userA",
        "orderDate": "2022-04-13T16:32:18.909982",
        "orderStatus": "ORDER",
        "address": {
            "city": "서울",
            "street": "1",
            "zipcode": "1111"
        },
        "orderItems": [
            {
                "itemName": "JPA1 BOOK",
                "orderPrice": 10000,
                "count": 1
            },
            {
                "itemName": "JPA2 BOOK",
                "orderPrice": 20000,
                "count": 2
            }
        ]
    },
    {
        "orderId": 11,
        "name": "userB",
        "orderDate": "2022-04-13T16:32:19.083073",
        "orderStatus": "ORDER",
        "address": {
            "city": "진주",
            "street": "2",
            "zipcode": "2222"
        },
        "orderItems": [
            {
                "itemName": "SPRING1 BOOK",
                "orderPrice": 20000,
                "count": 3
            },
            {
                "itemName": "SPRING2 BOOK",
                "orderPrice": 40000,
                "count": 4
            }
        ]
    },
    {
        "orderId": 11,
        "name": "userB",
        "orderDate": "2022-04-13T16:32:19.083073",
        "orderStatus": "ORDER",
        "address": {
            "city": "진주",
            "street": "2",
            "zipcode": "2222"
        },
        "orderItems": [
            {
                "itemName": "SPRING1 BOOK",
                "orderPrice": 20000,
                "count": 3
            },
            {
                "itemName": "SPRING2 BOOK",
                "orderPrice": 40000,
                "count": 4
            }
        ]
    }
]
스크린샷 2022-04-13 오후 4 33 32 스크린샷 2022-04-13 오후 4 37 45
  • 실행 결과를 보면 데이터가 뻥튀기가 되어 있다.

 

✔️ distinct 추가

    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();
    }
[
    {
        "orderId": 4,
        "name": "userA",
        "orderDate": "2022-04-13T16:38:12.067828",
        "orderStatus": "ORDER",
        "address": {
            "city": "서울",
            "street": "1",
            "zipcode": "1111"
        },
        "orderItems": [
            {
                "itemName": "JPA1 BOOK",
                "orderPrice": 10000,
                "count": 1
            },
            {
                "itemName": "JPA2 BOOK",
                "orderPrice": 20000,
                "count": 2
            }
        ]
    },
    {
        "orderId": 11,
        "name": "userB",
        "orderDate": "2022-04-13T16:38:12.206272",
        "orderStatus": "ORDER",
        "address": {
            "city": "진주",
            "street": "2",
            "zipcode": "2222"
        },
        "orderItems": [
            {
                "itemName": "SPRING1 BOOK",
                "orderPrice": 20000,
                "count": 3
            },
            {
                "itemName": "SPRING2 BOOK",
                "orderPrice": 40000,
                "count": 4
            }
        ]
    }
]
스크린샷 2022-04-13 오후 4 40 11 스크린샷 2022-04-13 오후 4 39 54
  • db 쿼리에서는 distinct가 되지 않는다. (중복 데이터가 너무 많다.)
  • jpabook1, jpabook2, spring1, spring2가 각각 이름이 다르기 때문이다.
스크린샷 2022-04-13 오후 4 38 26
  • 그러나, jpa에서는 distinct가 있으면 order를 가져올 때, id가 같을 시 중복을 제거해준다. (현재는 orderId 4와 11)

 

스크린샷 2022-04-13 오후 4 43 34
  • 그리고 v2v3코드가 같다.
  • 단지, v2와 다르게 v3에서는 페치 조인만 소스를 추가하여 최적의 결과를 얻게 되었다.
  • 페치 조인으로 SQL이 1번만 실행된다.

 

🔔 distinct를 사용하는 이유는?

  • 1대다 조인이 있으므로 데이터베이스 row가 증가한다. 그 결과 같은 order 엔티티의 조회 수도 증가하게 된다.
  • JPA의 distinct는 SQL에 distinct를 추가하고, 더해서 같은 엔티티가 조회되면, 애플리케이션에서 중복을 걸러준다.
    • 예제에서는 order가 컬렉션 페치 조인 때문에 중복 조회 되는 것을 막아준다.

 

⚠️ 단점 : 컬렉션 페치 조인을 사용하면 페이징이 불가능하다.

  • 조회할 데이터가 적을 때는 문제가 없지만, 데이터가 커지게 되면 out of memory 예외가 발생할 수도 있어 매우 치명적이다. (메모리에서 수 많은 페이징 처리하게 된다. 이럴 경우 out of memory가 발생할 수 있다.)
  • 일대다를 페치 조인 하는 순간, 페이징이 불가능하다. (setFirstResult, setMaxResults 불가능)
  • 하이버네이트는 경고 로그를 남기면서 모든 데이터를 (뻥튀기가 일어난)DB에서 읽어오고, 메모리에서 페이징 해버린다. (굉장히 위험하다.)

 

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

 

📚 4. 주문 조회 V3.1: 엔티티를 DTO로 변환 - 페이징과 한계 돌파 (이 방식을 선호, 중요)

✔️ 컬렉션을 페치 조인하면 페이징이 불가능하다.

  • 컬렉션을 페치 조인하면 일대다 조인이 발생하므로 데이터가 예측할 수 없이 증가한다.
  • 일다대에서 일(1)을 기준으로 페이징을 하는 것이 목적이다. 그런데 데이터는 다(N)를 기준으로 row 가 생성된다.
  • Order를 기준으로 페이징 하고 싶은데, 다(N)인 OrderItem을 조인하면 OrderItem이 기준이 되어버린다.

➡️ 이 경우 하이버네이트는 경고 로그를 남기고 모든 DB 데이터를 읽어서 메모리에서 페이징을 시도한다. 최악의 경우 장애로 이어질 수 있다.

 

📖 A. 한계 돌파(해결 방법)

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

  • 지금부터 코드도 단순하고, 성능 최적화도 보장하는 매우 강력한 방법을 공부하겠다!
  • 대부분의 페이징 + 컬렉션 엔티티 조회 문제는 이 방법으로 해결할 수 있다.
  • 먼저 xxxToOne(OneToOne, ManyToOne) 관계를 모두 페치조인 한다.
    • xxxToOne 관계는 row수를 증가시키지 않으므로 페이징 쿼리에 영향을 주지 않는다.
  • 컬렉션은 지연 로딩으로 조회한다.
  • 지연 로딩 성능 최적화를 위해 hibernate.default_batch_fetch_size, @BatchSize 를 적용한다.
    • hibernate.default_batch_fetch_size: 글로벌 설정
    • @BatchSize: 개별 최적화
    • 이 옵션을 사용하면 컬렉션이나, 프록시 객체를 한꺼번에 설정한 size 만큼 IN 쿼리로 조회한다.

 

(1) 현재는

OrderRepository

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

 

기존 v3 방식으로 소스를 작성할 시

    @GetMapping("/api/v3.1/orders")
    public List<OrderDto> ordersV3_page(){
        List<Order> orders = orderRepository.findAllWithMemberDelivery();

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

        return result;
    }
스크린샷 2022-04-20 오후 1 03 15 스크린샷 2022-04-20 오후 1 06 03
  • memberdelivery는 페치 조인으로 가져왔기 때문에, 한 번에 다 가져온다. (ToOne 관계로 한 번에 가져온다.)

 

첫 번째 orderItem이 실행된다.
스크린샷 2022-04-20 오후 1 03 39

스크린샷 2022-04-20 오후 1 03 57 스크린샷 2022-04-20 오후 1 06 22
  • orderitems를 꺼내온다.
  • 지연 로딩 발생한 것이 프록시 초기화가 되면서 데이터를 가져온다.
  • orderitem에는 item이 2개가 들어있다.

 

스크린샷 2022-04-20 오후 1 04 12 스크린샷 2022-04-20 오후 1 15 09 스크린샷 2022-04-20 오후 1 06 39 스크린샷 2022-04-20 오후 1 06 43
  • item 데이터 2개가 각각 지연로딩이 일어난다.

 

두 번째 orderitem이 실행된다.

  • 이미 deliverymember가 조회된 상태라, orderItems부터 조회한다.
스크린샷 2022-04-20 오후 1 06 47 스크린샷 2022-04-20 오후 1 06 50 스크린샷 2022-04-20 오후 1 06 54

 

(2) default_batch_fetch_size 추가로 1 N N -> 1 1 1로 변경되었다. (최적화 옵션)

  • 페이징 및 성능이 최적화된다.
  • ***ToOne는 바로 전에 '지연 로딩과 조회 성능 최적화'에서 알아본 페치 조인 최적화를 사용하고, ***ToMany에서 이와 같이 사용한다.

OrderApiController에 offset과 limit 추가

    /**
     * V3.1 엔티티를 조회해서 DTO로 변환 페이징 고려
     * - ToOne 관계만 우선 모두 페치 조인으로 최적화
     * - 컬렉션 관계는 hibernate.default_batch_fetch_size, @BatchSize로 최적화
     * */
    @GetMapping("/api/v3.1/orders")
    public List<OrderDto> ordersV3_page(
            @RequestParam(value = "offset", defaultValue = "0") int offset,
            @RequestParam(value = "limit", defaultValue = "100") int limit)
    {
        List<Order> orders = orderRepository.findAllWithMemberDelivery(offset, limit);

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

        return result;
    }

 

OrderRepository에 추가

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

 

application.yml에 추가

스크린샷 2022-04-20 오후 1 42 18
  • 미리 쿼리 개수 100개를 가지고 오겠다.
    • 원래는 소스 중간에 쿼리를 호출할 때마다, 쿼리가 실행됬지만 default_batch_fetch_size를 추가할 시 지정한 갯수만큼 미리 쿼리 문을 실행한다. (작성한 쿼리문들을 미리 실행함)
  • 이 값을 잘 설정하는 것이 중요하다.

 

실행 결과

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

 

콘솔 실행 결과
(1)

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

이부분 실행 결과는

스크린샷 2022-04-20 오후 1 45 26

 

스크린샷 2022-04-13 오후 5 52 57
  • 데이터 중복이 없어졌다. (밑에 있는 2, 3번에서도 데이터 중복이 없어졌다.)
    • 이전 v3에서는 데이터 중복이 있었다.

 

(2)

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



        public OrderDto(Order order) {
            orderId = order.getId();
            name = order.getMember().getName();
            orderDate = order.getOrderDate();
            orderStatus = order.getStatus();
            address = order.getDelivery().getAddress();


            orderItems = order.getOrderItems().stream().map(orderItem -> new OrderItemDto(orderItem))
                    .collect(Collectors.toList());
        }

이부분 실행 결과는

스크린샷 2022-04-20 오후 1 45 56 스크린샷 2022-04-13 오후 5 38 57
  • db에 있는 userA orderItems, userB orderItems를 in 쿼리 한번으로 다 가져온 것이다.
  • default_batch_fetch_size: 100 설정 없이 실행할 때는, userA, userB 각각 가져왔지만, 추가했을 때는 한 번에 가져온다.

 

(3)
스크린샷 2022-04-13 오후 5 43 44

  • default_batch_fetch_size: 100 설정을 추가했을 때는 한 번에 item 4개를 다 가져온다.

 

✏️ default_batch_fetch_size

  • JPA와 db가 버틸수 있따면 큰 숫자를 선택해도 된다.
  • 큰 숫자가 걱정되면 default_batch_fetch_size=100부터 점차 늘려가보기
  • 개별로 설정하려면 @BatchSize를 적용하면 된다. (컬렉션은 컬렉션 필드에, 엔티티는 엔티티 클래스에 적용)

 

🍀 장점

  • 쿼리 호출 수가 1 + N1 + 1로 최적화 된다.
  • 조인보다 DB 데이터 전송량이 최적화 된다.
    • OrderOrderItem을 조인하면 OrderOrderItem 만큼 중복해서 조회된다.
    • 이 방법은 각각 조회하므로 전송해야할 중복 데이터가 없다.
  • 페치 조인 방식과 비교해서 쿼리 호출 수가 약간 증가하지만, DB 데이터 전송량이 감소한다.
  • 컬렉션 페치 조인은 페이징이 불가능 하지만 이 방법은 페이징이 가능하다.

 

🌼 결론

  • xxxToOne 관계는 페치 조인해도 페이징에 영향을 주지 않는다.
  • 따라서 xxxToOne 관계는 페치조인으로 쿼리 수를 줄이고 해결하고, 나머지는 hibernate.default_batch_fetch_size 로 최적화 하자.

 

💡 참고

  • default_batch_fetch_size 의 크기는 적당한 사이즈를 골라야 하는데, 100~1000 사이를 선택하는 것을 권장한다.
  • 이 전략을 SQL IN 절을 사용하는데, 데이터베이스에 따라 IN 절 파라미터를 1000으로 제한하기도 한다.
  • 1000으로 잡으면 한번에 1000개를 DB에서 애플리케이션에 불러오므로 DB 에 순간 부하가 증가할 수 있다.
  • 하지만 애플리케이션은 100이든 1000이든 결국 전체 데이터를 로딩해야 하므로 메모리 사용량이 같다.
  • 1000으로 설정하는 것이 성능상 가장 좋지만, 결국 DB든 애플리케이션이든 순간 부하를 어디까지 견딜 수 있는지로 결정하면 된다.

 

profile
"야, (오류 만났어?) 너두 (해결) 할 수 있어"

0개의 댓글