ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • JPA - API 개발 고급(OneToMany 컬렉션 조회 최적화)
    Spring/JPA 2022. 3. 23. 17:31
    728x90
    반응형

     

     

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

     

     

     대부분의 성능 문제는 조회에서 발생하므로 컬렉션인 OneToMany 관계에서의 조회에 대한 성능 최적화에 대해 알아보자.


     

    엔티티 직접 노출

     항상 강조하지만 엔티티를 직접 전달하는 방법은 여러 문제가 발생할 뿐만 아니라 유지보수에도 문제가 생긴다. 따라서 참고만 하고 DTO를 사용하자.


    지연로딩으로 인한 문제

     

     지연 로딩으로 인해 JSON은 Order 엔티티가 갖고 있는 OrderItem를 proxy 객체로 가지고 있게 된다. proxy 객체로 가지고 있다는 것은 실제로 OrderItem 엔티티의 정보가 없고 OrderItem 엔티티의 정보를 접근할 시점에 DB에서 정보를 가져온다는 뜻이다. 때문에 JSON으로 변환되는 과정에서 proxy 객체를 변환하려고 할 때 정보를 가진 객체가 아니기 때문에 JSON 라이브러리에서 문제가 발생하여 다음과 같은 오류가 나타난다.

     

    {
        "timestamp""2022-03-22T08:26:18.934+00:00",
        "status"500,
        "error""Internal Server Error",
        "path""/api/v1/orders"
    }

     

     

     이 문제를 해결하기 위해서는 Hibernate5Module을 등록한 후에 proxy 객체를 초기화시켜주면 해당 문제를 해결할 수 있게 된다.

     

    xxxApplication.class

    @SpringBootApplication
    public class Practice2Application {
    
    	public static void main(String[] args) {
    		SpringApplication.run(Practice2Application.class, args);
    	}
    
    	@Bean
    	Hibernate5Module hibernate5Module() {
    		return new Hibernate5Module();
    	}
    }

     

    @RestController
    @RequiredArgsConstructor
    public class OrderApiController {
    
        private final OrderRepository orderRepository;
    
        @GetMapping("/api/v1/orders")
        public List<Order> orderV1() {
            List<Order> all = orderRepository.findAllByString(new OrderSearch(null, null));
            for (Order order : all) {
                order.getMember().getName();	// (1)
                order.getDelivery().getAddress();	// (2)
                List<OrderItem> orderItems = order.getOrderItems();	// (3)
                orderItems.stream().forEach(o ->o.getItem());	// (4)
            }
            return all;
        }
    }
    • (1), (2), (3), (4) : Member, Delivery, OrderItem, Item의 proxy 객체 초기화

     

     다시 한번 강조하지만 굳이 Hibernate5Module을 이용하여 Entity를 JSON으로 직접 반환하는 것보단 DTO를 반환하는 방법을 사용하는 것이 좋다.


     

    엔티티를 DTO로 변환 - 페치 조인 최적화

     엔티티를 DTO를 변환하여 사용해야 한다고 강조했다. 근데 DTO로 반환하여 사용하면 성능 문제가 발생하게 되는데 어떤 문제가 발생되며 어떻게 해결해야 하는지 알아보자

        @GetMapping("/api/v2/orders")
        public List<OrderDto> orderV2() {
            List<Order> orders = orderRepository.findAllByString(new OrderSearch(null, null));
            List<OrderDto> result = orders.stream()
                    .map(o -> new OrderDto(o))	// (1)
                    .collect(Collectors.toList());
            return result;
        }
    
    
        @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();	// (2)
                orderDate = order.getOrderDate();	
                orderStatus = order.getStatus();
                address = order.getDelivery().getAddress();	// (3)
                orderItems = order.getOrderItems().stream()
                        .map(orderItem -> new OrderItemDto(orderItem))	// (4)
                        .collect(Collectors.toList());
            }
        }
    
        @Getter
        static class OrderItemDto {
    
            private String itemName;
            private int orderPrice;
            private int count;
    
            public OrderItemDto(OrderItem orderItem) {
                itemName = orderItem.getItem().getName();	// (5)
                orderPrice = orderItem.getOrderPrice();
                count = orderItem.getCount();
            }
    
        }
    • (1) : Order 엔티티를 Dto로 변환
    • (2) : Member proxy 객체 초기화
    • (3) : Delivery proxy 객체 초기화
    • (4) : OrderItem 엔티티를 Dto로 변환(Dto안에 엔티티가 있으면 해당 엔티티도 Dto로 변환해줘야 한다)
    • (5) : Item proxy 객체 초기화

     

    성능 문제

     

     위 코드를 보면 Order 엔티티를 전체 조회해 오고, Dto로 변환하는 작업에서 각 Order 엔티티에 지연 로딩으로 proxy 객체를 가지고 있는 Member, Address, OrderItem를 조회하여 가져오고 있다. 또한 OrderItem의 지연 로딩으로 proxy 객체인 Item도 조회하여 가져오고 있다. 즉, 쿼리가 1 + N + N + N + N번 실행되는 문제가 발생하게 된다.

     

    1번 쿼리 실행 : Order 전체 조회

    N번 쿼리 실행 : 각 Order의 Member 조회(최악의 경우 Order 개수만큼 조회)

    N번 쿼리 실행 : 각 Order의 Address 조회(최악의 경우 Order 개수만큼 조회)

    N번 쿼리 실행 : 각 Order의 OrderItem 조회(최악의 경우 Order 개수만큼 조회)

    N번 쿼리 실행 : 각 OrderItem의 Item 조회(최악의 경우 OrderItem 개수만큼 조회)

     

     성능 문제로 지연로딩을 설정하였는데 이 지연로딩 때문에 성능이 더 안 좋아지는 아이러니한 상황이 발생하였다.


     

    문제 1 - OneToMany 관계로 인한 데이터 중복

     

     성능 문제를 해결하기 위해 페치 조인을 사용해야 하는데 여기서 페치 조인 시 문제점이 있다. OneToMany 관계(Order와 OrderItem)인 경우는 Order 데이터가 OrderItem의 수만큼 증가가 된다. 무슨 말인지 DB에서 직접 조회하여 확인해보자.

     

    select * from orders;

     

    select * from order_item;

     

    select * from orders o join order_item oi on(o.order_id = oi.order_id);

     

     

     원래 Order는 2개였지만 Order와 OrderItem의 관계 때문에 조인 시 Order의 개수가 OrderItem의 개수만큼 늘어난 것을 볼 수 있다. 따라서 페치 조인을 사용하면 데이터 중복의 문제가 발생한다.

     

    데이터 중복


     

    페치 조인과 distinct으로 성능 최적화

     

    • distinct : OneToMany의 관계에서 발생하는 데이터 중복을 제거해준다.
       (DB에서의 distinct는 모든 데이터 값이 같아야 제거되지만
        JPQL에서는 주 엔티티의 PK가 중복되면 제거해준다)            
                   단점 - 페이징이 불가능하다.
    • 컬렉션 페치 조인은 1개만 가능하다. 둘 이상의 페치 조인을 사용하면 데이터가
      부정합 하게 조회될 수 있다.

     

    @Repository
    @RequiredArgsConstructor
    public class OrderRepository {
    
        private final EntityManager em;
        
        ...
    
        public List<Order> findAllWithItem() {	// (1)
            return em.createQuery(
                    "select distinct o from Order o" +	// (2)
                            " join fetch o.member m" +
                            " join fetch o.delivery d" +
                            " join fetch o.orderItems oi" +
                            " join fetch oi.item", Order.class)
                    .getResultList();
        }
    }
    • (1) : OrderRepository에 페치 조인을 하는 메서드 생성
    • (2) : distinct을 사용하여 컬렉션 페치 조인으로 발생하는 중복 제거

     

        @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;
        }

     

     

     위와 같이 distinct를 이용하면 중복이 제거되어 사용이 가능하다. 하지만 문제는 페이징 처리가 안된다는 점을 명심해야 한다. 

     


     

    페이징과 한계 돌파(batch size)

     

     위에서는 컬렉션을 페치 조인 시 페이징 처리가 안된다고 했지만 정확히 말해서는 페이징 처리가 된다. 하지만 안된다고 했던 이유는 컬렉션을 페치 조인한 상태에서 페이징을 사용하면 하이버네이트는 경고 로그를 남기고 페치 조인한 모든 DB 데이터를 읽어와 메모리에서 페이징을 시도한다. 이때 읽어오는 데이터가 많으면 최악의 경우 서버 장애가 발생하게 된다. 

     

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

     

    정답은 지연 로딩 시 proxy 객체를 설정한 size 만큼 한번에 IN 쿼리로 조회할 수 있는 batch size를 사용하면 된다.

     

    • 글로벌 설정 : spring.jpa.properties.hibernate.default_batch_fetch_size
    • @BatchSize : 개별 배치 사이즈 설정,
                        xxToMany(컬렉션) - 해당 필드에 적으면 된다.
                        xxToOne - 해당 엔티티에 적으면 된다.

     

     

     

    1. 우선 xxToOne(OneToOne, ManyToOne) 관계를 모두 페치 조인한다.
              - xxToOne 관계는 row 수를 증가시키지 않으므로 페이징 쿼리에 영향을 주지 않기 때문

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

     

    2. 컬렉션은 지연 로딩으로 조회
             - 여기서 설정한 배치 사이즈만큼 IN 쿼리로 한 번에 조회해온다.

    @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;
    }

     

    3. 배치 사이즈 설정

    application.yml

    spring:
      jpa:
        properties:
          hibernate:
            default_batch_fetch_size: 100	// 조회 시 한번에 100개를 조회해온다.

     

     

     쿼리 로그를 보면 다음과 같이 IN절이 포함된 것을 볼 수 있다. 따라서 여러 번 쿼리 보냈던 작업을 배치 사이즈 설정을 하여 IN 절을 이용해 한번에 데이터들을 가져온 것을 볼 수 있다.

     

        select
            orderitems0_.order_id as order_id5_5_1_,
            orderitems0_.order_item_id as order_item_id1_5_1_,
            orderitems0_.order_item_id as order_item_id1_5_0_,
            orderitems0_.count as count2_5_0_,
            orderitems0_.item_id as item_id4_5_0_,
            orderitems0_.order_id as order_id5_5_0_,
            orderitems0_.order_price as order_price3_5_0_ 
        from
            order_item orderitems0_ 
        where
            orderitems0_.order_id in (
                ?, ?                     // OrderItem을 한번에 불러온다
            )
            
            ...
            
        select
            item0_.item_id as item_id2_3_0_,
            item0_.name as name3_3_0_,
            item0_.price as price4_3_0_,
            item0_.stock_quantity as stock_quantity5_3_0_,
            item0_.artist as artist6_3_0_,
            item0_.etc as etc7_3_0_,
            item0_.author as author8_3_0_,
            item0_.isbn as isbn9_3_0_,
            item0_.actor as actor10_3_0_,
            item0_.director as director11_3_0_,
            item0_.dtype as dtype1_3_0_ 
        from
            item item0_ 
        where
            item0_.item_id in (
                ?, ?, ?, ?
            )

     

     

     

    배치 사이즈의 장점

    • 쿼리 호출 수가 1 + N → 1 + 1로 최적화
    • 조인보다 DB 데이터 전송량이 최적화된다.
    • 페치 조인 방식과 비교해서 쿼리 호출 수가 약간 증가하지만, DB 전송량은 감소한다.
    • 컬렉션 페치 조인은 페이징이 불가능하지만 이 방법은 페이징이 가능하다.

     

    JPA에서 DTO 직접 조회 - 컬렉션 조회 최적화

     우선 핵심 비즈니스 로직이 아닌 특정 화면에 맞춘 로직을 구현하는 것이므로 패키지를 따로 해서 관리하는 게 좋다. 따라서 특정 화면을 위한 패키지를 생성하여 Repository, Dto를 관리하여 사용하면 역할이 뚜렷해지기 때문에 나중에 유지 보수하기 좋아진다. 다음과 같은 query 패키지 만들어 Repository와 Dto를 생성하였다.

     

     

    JPA에서 DTO 직접 조회

     

     우선 OrderQueryDto와 OrderItemQueryDto 생성해준다.

     

    @Data
    public class OrderQueryDto {
    
        private Long orderId;
        private String name;
        private LocalDateTime orderDate;
        private OrderStatus orderStatus;
        private Address address;
        private List<OrderItemQueryDto> orderItems = new ArrayList<>();
    
        public OrderQueryDto(Long orderId, String name, LocalDateTime orderDate, OrderStatus orderStatus, Address address) {
            this.orderId = orderId;
            this.name = name;
            this.orderDate = orderDate;
            this.orderStatus = orderStatus;
            this.address = address;
    
        }
    }
    @Data
    public class OrderItemQueryDto {
    
        private Long orderId;
        private String itemName;
        private int orderPrice;
        private int count;
    
        public OrderItemQueryDto(Long orderId, String itemName, int orderPrice, int count) {
            this.orderId = orderId;
            this.itemName = itemName;
            this.orderPrice = orderPrice;
            this.count = count;
        }
    }

     

     

     이제 생성한 Dto를 이용하여 JPQL에서 직접 조회하는 메서드를 구현하면 된다.

    1. xxToOne 관계들을 먼저 조회
    2. xxToMany 관계는 별도로 조회

     

    @Repository
    @RequiredArgsConstructor
    public class OrderQueryRepository {
    
        private final EntityManager em;
    
        public List<OrderQueryDto> findOrderQueryDtos() {	// (1)
            List<OrderQueryDto> result = findOrders();  // (4)
    
            result.forEach(o ->{
                List<OrderItemQueryDto> orderItems = findOrderItems(o.getOrderId());    // (5)
                o.setOrderItems(orderItems);
            });
            return result;
        }
        
        private List<OrderQueryDto> findOrders() {	// (2)
            return em.createQuery(
                            "select new jpa.practice2.repository.order.query.OrderQueryDto(o.id, m.name, o.orderDate, o.status, d.address)" +
                                    " from Order o" +
                                    " join o.member m" +
                                    " join o.delivery d", OrderQueryDto.class)
                    .getResultList();
        }
    
        private List<OrderItemQueryDto> findOrderItems(Long orderId) {	// (3)
            return em.createQuery(
                    "select new jpa.practice2.repository.order.query.OrderItemQueryDto(oi.order.id, i.name, oi.orderPrice, oi.count)" +
                            " from OrderItem oi" +
                            " join oi.item i" +
                            " where oi.order.id = :orderId", OrderItemQueryDto.class)
                    .setParameter("orderId", orderId)
                    .getResultList();
    
        }
    
    
    }
    • (1) : 직접 호출하는 메서드, 내부에서 순차적으로 Dto를 이용한 쿼리들을 실행
    • (2) : 먼저 Order와 xxToOne 관계인 Member, Delivery를 조회해온다.
    • (3) : Order와 xxToMany 관계인 OrderItem를 조회해온다.
    • (4) : (2) 메서드 호출, 쿼리는 1번 실행된다. (1번)
    • (5) : (3) 메서드 호출, 쿼리는 Order의 개수만큼 실행된다. (N번)

     

        @GetMapping("/api/v4/orders")
        public List<OrderQueryDto> ordersV4() {
            return orderQueryRepository.findOrderQueryDtos();
        }

     

     

     위처럼 하는 방법은 역시나 컬렉션 때문에 N + 1 문제가 발생한다.  


     

    JPA에서 DTO 직접 조회 - 컬렉션 조회 최적화

     

    • 위에서 컬렉션 때문에 발생한 N + 1 문제는 OrderItem을 한 번에 조회하여 해결
    • Order의 식별키들을 List 형태로 변환하여 IN 절을 사용한다.
    • 쿼리는 총 1 + 1 이 발생하여 N + 1 문제가 해결된다.

     

        public List<OrderQueryDto> findAllByDto_optimization() {
            List<OrderQueryDto> result = findOrders();  // (1)
    
            List<Long> orderIds = result.stream()
                    .map(o -> o.getOrderId())
                    .collect(Collectors.toList());	// (2)
    
    
            List<OrderItemQueryDto> orderItems = em.createQuery(
                            "select new jpa.practice2.repository.order.query.OrderItemQueryDto(oi.order.id, i.name, oi.orderPrice, oi.count)" +
                                    " from OrderItem oi" +
                                    " join oi.item i" +
                                    " where oi.order.id in :orderIds", OrderItemQueryDto.class)
                    .setParameter("orderIds", orderIds)
                    .getResultList();	// (3)
    
            // (4)
            Map<Long, List<OrderItemQueryDto>> orderItemMap = orderItems.stream()
                    .collect(Collectors.groupingBy(orderItemQueryDto -> orderItemQueryDto.getOrderId()));
    
            // (5)
            result.forEach(o -> o.setOrderItems(orderItemMap.get(o.getOrderId())));
    
            return result;
        }
    • (1) : Order들을 조회
    • (2) : 조회한 Order들의 식별키들을 List형태로 변환
    • (3) : in 절을 활용하여 한번에 컬렉션들을 조회
    • (4) : 조회한 OrderItem들을 람다식을 이용하여 Map으로 변환
            key = orderId, value = 키 값으로 분류된 OrderItemQueryDto
    • (5) : 각 Order들은 자신의 orderId값을 이용하여 컬렉션을 찾아 매핑

     

     

     코드 최적화

    @Repository
    @RequiredArgsConstructor
    public class OrderQueryRepository {
    
        private final EntityManager em;
        
        ...
    
        public List<OrderQueryDto> findAllByDto_optimization() {
            List<OrderQueryDto> result = findOrders();
    
            List<Long> orderIds = toOrderIds(result);
            Map<Long, List<OrderItemQueryDto>> orderItemMap = findOrderItemMap(orderIds);
            result.forEach(o -> o.setOrderItems(orderItemMap.get(o.getOrderId())));
    
            return result;
        }
    
        private List<Long> toOrderIds(List<OrderQueryDto> result) {
            List<Long> orderIds = result.stream()
                    .map(o -> o.getOrderId())
                    .collect(Collectors.toList());
            return orderIds;
        }
    
        private Map<Long, List<OrderItemQueryDto>> findOrderItemMap(List<Long> orderIds) {
            List<OrderItemQueryDto> orderItems = em.createQuery(
                            "select new jpa.practice2.repository.order.query.OrderItemQueryDto(oi.order.id, i.name, oi.orderPrice, oi.count)" +
                                    " from OrderItem oi" +
                                    " join oi.item i" +
                                    " where oi.order.id in :orderIds", OrderItemQueryDto.class)
                    .setParameter("orderIds", orderIds)
                    .getResultList();
    
            // key = orderId, value = 키 값에 따라 분류된 OrderItemQueryDto
            Map<Long, List<OrderItemQueryDto>> orderItemMap = orderItems.stream()
                    .collect(Collectors.groupingBy(orderItemQueryDto -> orderItemQueryDto.getOrderId()));
            return orderItemMap;
        }
    
        private List<OrderQueryDto> findOrders() {
            return em.createQuery(
                            "select new jpa.practice2.repository.order.query.OrderQueryDto(o.id, m.name, o.orderDate, o.status, d.address)" +
                                    " from Order o" +
                                    " join o.member m" +
                                    " join o.delivery d", OrderQueryDto.class)
                    .getResultList();
        }
    }

     

     

     

     

     

     

     

     

    <참고>
    DTO로 조회 시 fetch join이 아닌 join을 사용하는 이유?

     

    fetch join은 객체 그래프를 조회하는 것이기 때문에 엔티티를 select 하는 경우에만 사용할 수 있다.

    Dto로 직접 조회하는 경우는 엔티티의 특정 필드 정보들만 가져오기 때문에 join을 사용해야 한다.

    728x90
    반응형

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

    JPA - OSIV와 성능 최적화  (0) 2022.03.24
    JPA - API 개발 고급(OneToOne, ManyToOne 지연 로딩과 조회 성능 최적화)  (0) 2022.03.22
    JPA - API 기본  (0) 2022.03.21
    JPA - Entity 설계시 주의점  (0) 2022.03.16
    JPA - 설계 순서  (0) 2022.03.16

    댓글

Designed by Tistory.