Back-End/JPA

JPA 활용 - 지연 로딩과 조회 성능 최적화

LightSource 2023. 7. 20. 20:19

실전! 스프링 부트와 JPA 활용2 - API 개발과 성능 최적화 - 인프런 | 강의

 

실전! 스프링 부트와 JPA 활용2 - API 개발과 성능 최적화 - 인프런 | 강의

스프링 부트와 JPA를 활용해서 API를 개발합니다. 그리고 JPA 극한의 성능 최적화 방법을 학습할 수 있습니다., 스프링 부트, 실무에서 잘 쓰고 싶다면? 복잡한 문제까지 해결하는 힘을 길러보세요

www.inflearn.com

간단한 주문 조회 V1 - 엔티티 직접 노출

@GetMapping("/api/v1/simple-orders")
    public List<Order> ordersV1() {
        List<Order> all = orderRepository.findAllByString(new OrderSearch());
        return all;
    }

문제점 - 무한루프 발생

위와 같이 엔티티를 직접 노출할 시, Order ↔ Member사이, Order ↔ Delivery 간의 엔티티 조회시 무한루프에 빠지면서 에러를 발생시킨다.

@JsonIgnore 사용

해당 무한루프 문제를 해결하기 위해서 @JsonIgnore를 양방향 연관관계중 하나에 추가해 주면 무한루프 문제를 해결할 수 있다. 하지만 다음과 같은 에러가 발생한다.

ByteBuddyInterceptor에서 Type defnition error가 발생했다. 왜 그럴까?

→ 해당 코드의 로딩 전략은 지연 로딩(@ManyToOne(fetch = FetchType.LAZY))전략을 사용하는데 이는 Member나 Delivery등의 엔티티를 조회하는 시점에 가짜 프록시 객체를 생성해서 넣어놓게 되는데, Jackson 라이브러리에서는 프록시 객체를 처리하지 못하면서 해당 에러가 발생하게 된다.

Hibernate5Module 사용

위의 오류는 Hibernate5Module 을 스프링 Bean으로 등록하여 해결한다.

// build.gradle
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-hibernate5'

//jpashopApplication.java
@Bean
    Hibernate5Module hibernate5Module() {
        return new Hibernate5Module();
    }

엔티티를 API에 직접 노출하는 것은 좋지 않은 방법이므로, Hibernate5Module을 이용하는 방법은 알아만 두고 실무에서는DTO Response를 만들어서 사용하도록 하자!

간단한 주문 조회 V2 - 엔티티를 DTO로 변환

@GetMapping("/api/v2/simple-orders")
    public List<SimpleOrderDto> ordersV2() {
        List<Order> orders = orderRepository.findAllByString(new OrderSearch());
        return orders.stream().map(SimpleOrderDto::new)
                     .collect(Collectors.toList());
    }

    @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(); //LAZY 초기화
            orderDate = order.getOrderDate();
            orderStatus = order.getStatus();
            address = order.getDelivery().getAddress(); //LAZY 초기화
        }
    }

Controller 추가 및 Dto 클래스를 추가하였다.

N + 1 의 문제

해당 쿼리에서는 쿼리가 총 1 + N + N 번 실행된다.

  • order 조회 1번
  • order → member 지연 로딩 조회 N 번
  • order → delivery 지연 로딩 조회 N 번

현재 order의 결과가 2개이므로 1 + 2 + 2 번 실행된다.(최악의 경우)

간단한 주문 조회 V3 - DTO 페치 조인 최적화

OrderRepository.java

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

OrderSimpleApiController.java

@GetMapping("/api/v3/simple-orders")
    public List<SimpleOrderDto> ordersV3() {
        List<Order> orders = orderRepository.findAllWithMemberDelivery();
        return orders.stream()
                .map(SimpleOrderDto::new)
                .collect(Collectors.toList());
    }
  • 엔티티를 페치 조인(fetch join)을 이용해서 쿼리 1번에 조회한다.
  • 페치 조인으로 order → member → delivery 는 이미 조회 되었으므로 지연로딩 X

다음과 같은 SQL 문으로 한번에 조회한다.

    select
        order0_.order_id as order_id1_6_0_,
        member1_.member_id as member_i1_4_1_,
        delivery2_.delivery_id as delivery1_2_2_,
        order0_.delivery_id as delivery4_6_0_,
        order0_.member_id as member_i5_6_0_,
        order0_.order_date as order_da2_6_0_,
        order0_.status as status3_6_0_,
        member1_.city as city2_4_1_,
        member1_.street as street3_4_1_,
        member1_.zipcode as zipcode4_4_1_,
        member1_.name as name5_4_1_,
        delivery2_.city as city2_2_2_,
        delivery2_.street as street3_2_2_,
        delivery2_.zipcode as zipcode4_2_2_,
        delivery2_.status as status5_2_2_ 
    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

간단한 주문 조회 V4 - JPA에서 DTO로 바로 조회

OrderSimpleApiController.java

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

따로 쿼리용 리포지토리를 만들면 구분이 쉽다.

OrderSimpleQueryRepository.java

@Repository
@RequiredArgsConstructor
public class OrderSimpleQueryRepository {
    private final EntityManager em;

    public List<OrderSimpleQueryDto> findOrderDtos() {
        return em.createQuery(
                         "select new jpabook.jpashop.repository.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();
    }
}

OrderSimpleQueryDto.java

@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; //LAZY 초기화
        this.orderDate = orderDate;
        this.orderStatus = orderStatus;
        this.address = address; //LAZY 초기화
    }

  • 일반 적인 SQL 사용할 때 처럼 원하는 값을 선택해서 조회
  • new 명령어를 사용해서 JPQL의 결과를 DTO로 즉시 변환
  • SELECT 절에서 원하는 데이터를 직접 선택하므로 DB → 애플리케이션 네트웍 용량 최적화(생각보다 효과가 크지는 않다.)
  • 리포지토리 재사용성 떨어짐, API 스펙에 맞춘 코드가 리포지토리에 들어가는 단점

정리

엔티티를 DTO로 변환하고너, DTO로 조회하는 방법은 각각 장단점이 있다. 상황에 따라 더 좋은 방법을 선택할 것. 엔티티로 조회하면 피로치토리 재사용성도 좋고, 개발도 단순해진다.

쿼리 방식 선택 권장 순서

  1. 우선 엔티티를 DTO로 변환하는 방법을 선택한다.
  2. 필요하면 페치 조인으로 성능을 최적화 한다. → 대부분의 성능 이유가 해결됨
  3. 그래도 안되면 DTO로 직접 조회하는 방법을 사용해 본다.
  4. 최후의 방법은 JPA가 제공하는 네이티브 SQL이나 스프링 JDBC Template을 사용해서 SQL을 직접 사용한다.
반응형