ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • JPA - API 개발 고급(OneToOne, ManyToOne 지연 로딩과 조회 성능 최적화)
    Spring/JPA 2022. 3. 22. 20:11
    반응형

     

     

    API 개발 고급(OneToOne, ManyToOne 지연 로딩과 조회 성능 최적화)

     

     

      대부분의 성능 문제는 조회에서 발생하므로 OneToOne, ManyToOne 관계에서의 조회에 대한 성능 최적화에 대해 알아보자.

     


     

    엔티티를 직접 노출

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

     

     

    문제 1 - 양방향 연관관계에서의 문제

     

    @RestController
    @RequiredArgsConstructor
    public class OrderSimpleApiController {
    
        private final OrderRepository orderRepository;
    
        @GetMapping("/api/v1/simple-orders")
        public List<Order> orderV1() {
            List<Order> all = orderRepository.findAllByString(new OrderSearch(null, null));
            return all;
        }
    }

     

     /api/v1/simple-orders 로 요청을 하게 되면 무한루프에 빠지게 된다. 그 이유는 양방향 연관관계 때문인데, JSON이 Order 엔티티의 가져오는데 Order 엔티티에는 Member가 있고, Member 엔티티에는 List<Order>가 있으므로 서로 정보를 가져오는 무한루프에 빠지게 된다. 이건 양방향 연관관계 때문에 발생하는 문제인데, 이 경우에는 양방향 연관관계 중 한 곳에 @JsonIgnore 어노테이션을 붙여 해결할 수 있다. 

     

    @Entity
    @Getter
    @NoArgsConstructor(access = AccessLevel.PROTECTED)
    public class Member {
    
        ...
        
        @JsonIgnore     // (1)
        @OneToMany(mappedBy = "member")
        List<Order> orders = new ArrayList<>();
        
        ...
    }
    • (1) : @JsonIgnore 은 JSON이 로딩하지 않도록 하는 어노테이션

     

    문제 2 - 지연 로딩으로 인한 문제

     

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

     

    {
        "timestamp""2022-03-22T08:26:18.934+00:00",
        "status"500,
        "error""Internal Server Error",
        "path""/api/v1/simple-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 OrderSimpleApiController {
    
        private final OrderRepository orderRepository;
    
        @GetMapping("/api/v1/simple-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)
            }
            return all;
        }
    }
    • (1), (2) : Member, Delivery의 proxy 객체 초기화

     

     

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


     

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

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

     

        @GetMapping("/api/v2/simple-orders")
        public List<SimpleOrderDto> ordersV2() {
            List<Order> orders = orderRepository.findAllByString(new OrderSearch(null, null));
            List<SimpleOrderDto> results = orders.stream()
                    .map(o -> new SimpleOrderDto(o))
                    .collect(Collectors.toList());
            return results;
    
        }
    
        @Data
        static class SimpleOrderDto {
            private Long orderId;
            private String name;
            private LocalDateTime orderDate;
            private OrderStatus orderStatus;
            private Address address;
    
            public SimpleOrderDto(Order order) {
                orderId = order.getId();
                name = order.getMember().getName();	// (1)
                orderDate = order.getOrderDate();
                orderStatus = order.getStatus();
                address = order.getDelivery().getAddress();	// (2)
            }
        }
    • (1), (2) : Member, Delivery의 proxy 객체 초기화

     

    성능 문제

     

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

     

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

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

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

     

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

     


     

    페치 조인으로 해결

     

     주 엔티티에서 연관관계가 있는 엔티티의 정보가 필요할 경우 주 엔티티 조회 시 페치 조인을 이용하여 연관관계를 갖는 엔티티의 정보도 같이 조회해오면 된다. 즉, Order 엔티티와 연관관계가 있는 Member, Delivery의 정보도 필요할 경우 페치 조인을 이용하여 조회해오면 이전에 발생한 1+N+N번 쿼리가 실행되었던 문제가 해결되어 쿼리 1번으로 모든 정보를 조회할 수 있다.

     

    @Repository
    @RequiredArgsConstructor
    public class OrderRepository {
    
        private final EntityManager em;
        
        ...
        
        // 페치 조인을 이용한 메서드 추가
        public List<Order> findAllWithMemberDelivery() {
            List<Order> resultList = em.createQuery(
                            "select o from Order o" +
                                    " join fetch o.member m" +
                                    " join fetch o.delivery d", Order.class)
                    .getResultList();
            return resultList;
        }
    }

     

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

     

    JPA에서 DTO로 바로 조회

     

    • 일반적인 SQL을 사용할 때처럼 원하는 값을 선택해서 조회할 수 있다.
    • new 명령어를 사용하여 JPQL의 결과를 DTO로 즉시 변환
    • SELECT 절에서 원하는 데이터를 직접 선택하므로 DB → 네트워크 용량 최적화(생각보다 미비)
    • 리포지토리 재사용성이 떨어진다. API 스펙에 맞춘 코드이므로 다른 곳에서 재사용하기 어렵다.

     

    직접 조회하는 Dto 생성

    @Data
    public class OrderSimpleQueryDto {
    
        private Long orderId;
        private String name;
        private LocalDateTime orderDate;
        private OrderStatus orderStatus;
        private Address address;
        
        public OrderSimpleQueryDto(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;
        }
    }

     

     

    Dto로 조회하는 Repository 생성

    @Repository
    @RequiredArgsConstructor
    public class OrderSimpleQueryRepository {
    
        private final EntityManager em;
        public List<OrderSimpleQueryDto> findOrderDtos() {
            return em.createQuery(
                    "select new jpa.practice2.repository.order.OrderSimpleQueryDto(o.id, m.name, o.orderDate, o.status, d.address)" +
                            " from Order o" +
                            " join o.member m" +
                            " join o.delivery d", OrderSimpleQueryDto.class)
                    .getResultList();
        }
    }

     

        @GetMapping("/api/v4/simple-orders")
        public List<OrderSimpleQueryDto> ordersV4() {
            return orderSimpleQueryRepository.findOrderDtos();
        }

     


     

    엔티티, DTO 조회 두 가지 방법의 
    권장하는 순서

     

     

    1. 우선 엔티티를 DTO로 변환하는 방법을 선택
    2. 필요하면 패치 조인으로 성능을 최적화 => 대부분의 성능 이슈가 해결됨
    3. 그래도 성능에 대한 문제가 있으면 DTO로 직접 조회하는 방법을 사용
    4. 위 모든 방법으로도 성능 이슈가 발생하면 네이티브 SQL이나 JDBC Template을 사용하여
      직접 SQL 사용
    반응형

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

    JPA - OSIV와 성능 최적화  (0) 2022.03.24
    JPA - API 개발 고급(OneToMany 컬렉션 조회 최적화)  (0) 2022.03.23
    JPA - API 기본  (0) 2022.03.21
    JPA - Entity 설계시 주의점  (0) 2022.03.16
    JPA - 설계 순서  (0) 2022.03.16

    댓글

Designed by Tistory.