JPA 성능 최적화 (지연 로딩, 조회 성능 최적화)

지연 로딩 때문에 발생하는 성능 문제를 단계적으로 해결

Version 1 (Entity를 직접 노출) (실무 사용 X)

  • order 와 member 는 many to one 관계
  • order와 deliver는 one to one 관계
@Entity
@Table(name = "orders")
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Order {

    @Id
    @GeneratedValue
    @Column(name = "order_id")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id")
    private Member member;

    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
    private List<OrderItem> orderItems = new ArrayList<>();

    @OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
    @JoinColumn(name = "delivery_id")
    private Delivery delivery;

    private LocalDateTime orderDate;
...
에러 1
  • 모든 order를 조회하는 findAll을 하였을 경우 member와 order에서 양방향 관계에서 무한 루프에 빠질 수 있다.
    해결1
  • 따라서 양방향 관계에서 한쪽은 @jsonIgnore처리를 해서 하나를 끊어준다.
에러2
  • order를 조회할때 member는 지연로딩 전략을 사용했을 때 결과를 가져올때 member는 프록시 객체(임시 객체)를 생성하여 대신 주입한다. 그때 json생성 라이브러리가 순수한 member객체가 아니기 때문에 에러가 발생할 수 있다.
해결2
  • jackson dataType hibernate 5module을 gradle 또는 maven에 의존을 설정하고 @Bean으로 Hibernate5Module을 생성하여 사용하면 에러 해결

※ 위와 같이 Entity를 그대로 사용하여 반환할 경우 api 의 스펙이 변경될 때 구조를 전부 바꿔야 하는 상황이 올 수 있으며, 스펙이 노출되어 좋지 않다. 또한, 성능적으로도 좋지 않다.

Version2 (Dto 사용)

  • DTO 클래스를 생성하여 생성자에 entity를 받아서 dto로 전환하여 사용하게끔 구성
  • 실제 Entity가 노출되지 않고, Json 어노테이션을 사용하지 않고 원하는 키값으로 리턴 가능

문제점

  • lazy 호출에 의한 쿼리가 너무 많이 호출됨
  • 영속성 컨텍스트에 값이 있더라도 최악의 경우 n + 1의 쿼리 호출함
  • 많은 결과값(조회 결과)가 있을 수록 쿼리 호출의 수가 감당이 안된다.
  • 심지어 EAGER로 설정했을 경우 예상치 못한 쿼리 호출(양방향일 경우 더 예측 불가능)

Version3 (DTO + 성능 최적화)

  • lazy 호출에 대한 쿼리가 너무 많이 처리되기 때문에 이것을 최적화
  • fetch join을 활용하여 객체 그래프를 한번에 가져온다.
  • 기존의 n + 1 쿼리 호출을 한번에 가져온다.
@Repository
public class OrderRepository {
    private final EntityManager em;

    public OrderRepository(EntityManager em) {
        this.em = em;
    }
    
	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();
	}
}

Version4 (결과를 바로 DTO로 매핑)

  • repository의 createQuery에 반환 타입을 바로 dto로 받는다.
  • 내가 원하는 select의 컬럼 값 들을 선택해서 받을 수 있다.
  • version3와 효율에 대한 우열 가리기 어렵다. (select의 속도가 빠르다. 다만, 재사용성이 매우 떨어진다. 화면에 의존적인 조회)

해결법

  • 패키지를 repository 하위에 별도 생성하여 쿼리용을 별도로 추출하여 관리
  • 이유 : 원래의 repository는 Entity를 조회하여 재사용을 할 수 있고 Entity로 가공을 하도록 하고, 별도로 추출한 repository는 화면에 의존적이지만 속도는 빠르게 할 수 있도록 하게하여 유지보수성을 크게 향상.
  • 그래도 성능이 안나올 경우 JPA가 제공하는 네이티브 SQL이나 스프링 JDBC Template를 사용하여 SQL을 직접 사용한다.

  • 매핑 받을 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;
      }
    }
    
  • Repository의 createQuery에서 바로 DTO로 맵핑하여 반환
    @Repository
    @RequiredArgsConstructor
    public class OrderSimpleQueryRepository {
    
      private final EntityManager em;
    
      public List<OrderSimpleQueryDto> findOrderDtos() {
          return em.createQuery(
                  "select new jpabook.jpashop.repository.order.simplequery.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();
      }
    }