Web/JPA

EP2. 지연 로딩과 조회 성능 최적화

 

주문을 조회하는 API를 설계해보았다.

package jpabook.jpashop.api;

import jpabook.jpashop.domain.Order;
import jpabook.jpashop.repository.OrderRepository;
import jpabook.jpashop.repository.OrderSearch;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

/**
 * XToOne (ManyToOne, OneToOne)
 * Order
 * Order -> Member
 * Order -> Delivery
 */
@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());
        return all;
    }
}

 

Order 컬렉션을 그대로 반환하려 했었는데 오류가 발생했다.

com.fasterxml.jackson.databind.exc.InvalidDefinitionException: No serializer found for class org.hibernate.proxy.pojo.bytebuddy.ByteBuddyInterceptor and no properties discovered to create BeanSerializer (to avoid exception, disable SerializationFeature.FAIL_ON_EMPTY_BEANS) (through reference chain: java.util.ArrayList[0]->jpabook.jpashop.domain.Order["member"]->jpabook.jpashop.domain.Member$HibernateProxy$Thl8gx2a["hibernateLazyInitializer"])

 

 

 

오류가 발생한 이유

해당 오류는 Json이 Lazy로딩의 프록시 객체를 읽어올 수 없기 때문에 발생하는 오류이다.

 

 

지난번 JPA 기초 강의에서 @XToOne 연관관계는 LAZY 설정했었다.

    @OneToMany(mappedBy = "member")
    private List<Order> orders = new ArrayList<>();

 

2021.02.05 - [Web/JPA] - EP5. 연관관계 매핑

 

EP5. 연관관계 매핑

예제로 연관관계 매핑 해보기 단방향 연관관계 시나리오 회원과 팀이 있다. 회원은 하나의 팀에만 소속될 수 있다. 회원과 팀은 다대일 관계다. 객체지향 모델링 Member class 의 teamId는 멤버가 어

ksabs.tistory.com

2021.02.10 - [Web/JPA] - EP9. 프록시와 연관관계 관리

 

EP9. 프록시와 연관관계 관리

프록시 em.getReference() : 데이터베이스 조회를 미루는 가짜(프록시) 엔티티 객체 조회 실제 클래스를 상속받아 만들어져서 모양이 같다. 그래서 사용자입장에선 그냥 진짜인지 프록시인지 구분하

ksabs.tistory.com

 

 

@XToOne 연관관계가 있는 것을 fetch = LAZY로 하게되면 쿼리를 당장 보내서 가져오는 것이 아닌 프록시 객체를 넣어두게 된다.

 

JSON은 프록시객체를 읽을 수 없기 때문에 오류가 발생한 것이다.

 

 

 

 

 

해결방법 1

Hibernate5Module 을 @Bean으로 등록해서 Lazy 로딩은 당장 읽어오지 않게 설정한다.

package jpabook.jpashop;

import com.fasterxml.jackson.datatype.hibernate5.Hibernate5Module;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;

@SpringBootApplication
public class JpashopApplication {

	public static void main(String[] args) {

		SpringApplication.run(JpashopApplication.class, args);
	}

	@Bean
	Hibernate5Module hibernate5Module() {
		return new Hibernate5Module();
	}

}

 

 

Json 라이브러리는 Hibernate5Module을 사용하는데, Hibernate5Module 는 Lazy로딩은 무시하고 넘어가게 한다.

 

 

하지만 이 방법에는 문제가 있다.

 

전과 마찬가지로 엔티티를 JSON에 그대로 노출하게 되면 엔티티를 수정했을때 API 스펙에 맞지 않게된다.

또한 엔티티를 그대로 노출할때는 양방향 연관관계중 한쪽에 @JsonIgnore를 설정해주어야 한다.

 

그렇다고 Lazy로딩을 무시하려고 Eager로 설정하는 것은 더더욱 안된다!

 

2021.02.10 - [Web/JPA] - EP9. 프록시와 연관관계 관리

 

EP9. 프록시와 연관관계 관리

프록시 em.getReference() : 데이터베이스 조회를 미루는 가짜(프록시) 엔티티 객체 조회 실제 클래스를 상속받아 만들어져서 모양이 같다. 그래서 사용자입장에선 그냥 진짜인지 프록시인지 구분하

ksabs.tistory.com

 

 

 

해결방법2

엔티티를 DTO로 변환하여 반환하기.

    @GetMapping("/api/v2/simple-orders")
    public List<SimpleOrderDto> orderV2() {
        List<Order> orders = orderRepository.findAllByString(new OrderSearch());

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

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

 

 

이 방법에도 문제가 있다.

 

주문이 2개가 있을때 쿼리가 몇개가 나가는지 살펴보자.

 

먼저 orders를 가져올때 쿼리가 한번 나간다.

List<Order> orders = orderRepository.findAllByString(new OrderSearch());

 

 

 

 

order를 Dto로 변환하는 코드가 실행되고

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

 

 

 

Dto에서는, Order에서 Member와 Delivery가 Lazy 로딩이므로 한 주문당 2번씩 쿼리가 나간다.

 

총 5번의 쿼리가 나가게된다.

 

order가 2개밖에 없는데 5번의 쿼리가 나간다.

만약 order가 10개, 100라고 생각한다면 얼마의 쿼리가 나갈까?

 

이것이 바로 JPA의 유명한 N + 1 문제 이다.

 

 

 

 

 

해결방법 3

fetch join 을 사용해서 Member, Delivery를 한번에 가져오기

 

orderRepository에 findAllWithMemberDelivery를 만들어준다

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

join fetch을 사용해서 쿼리 한번에 Member와 delivery를 가져온다.

 

 

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

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

 

그럼 한번의 쿼리로 order와 member, delivery를 한번에 가져올 수 있다.

 

 

 

 

 

해결방법 4

 

dto를 repository에 만든다.

package jpabook.jpashop.repository;

import jpabook.jpashop.domain.Address;
import jpabook.jpashop.domain.Order;
import jpabook.jpashop.domain.OrderStatus;
import lombok.Data;

import java.time.LocalDateTime;

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

 

OrderRepository에 findOrderDtos를 만든다.

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

 

jpql로 쿼리를 짤 때 주의할 점은 반환을 OrderSimpleQueryDto.class로 반환해야 하기 때문에

jpql은 OrderSimpleQueryDto.class를 모르기 때문에 select 절에서 new 패키지경로 까지 해줘야한다.

또한,  Dto에 전달값으로 Order (o)를 넘기면 jpql은 식별자로 넣어버리기 때문에 값을 다 풀어서 넣어주어야 한다.

 

 

API Controller에서는

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

DTO를 직접 반환한다.

 

 

 

이 방법도 쿼리가 한번만 실행된다.

 

 

장점

  1. 쿼리가 한번만 나간다.
  2. 원하는 데이터만 직접 선택해 반환하므로 네트워크 용량을 최적화할 수 있다. (fetch join보다도)

 

단점

  1. 코드가 지저분하다.
  2. 생각보다 네트워크 용량 최적화가 적다.
  3. API 스펙에 너무 의존한다. (재사용X, 스펙 바뀌면 바꿔줘야함)
    repository에서는 순수한 엔티티 조회 로직만 들어가야 하는데 view와 관련된 로직이 들어가게 된다.
    -> 이럴경우 별도의 order.simpleQueryRepository 패키지를 생성해서 레벨을 분리해야한다.

 

 

 

 

 

fetch join vs jpa에 DTO 직접반환

 

select절에서 몇개 더 가져온다고 성능이 심각하게 저하되지는 않는다. 그래서 기본적으로 fetch join으로 사용한다. 하지만 만약에 엄청나게 많은 데이터가 오고가는 API라면 JPA에 DTO를 원하는 데이터만 선택해 반환하는 것을 고려할 만 하다.

 

 

 

쿼리 방식 선택 순서

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