본문 바로가기
Back-end/Spring

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

by 안녕주 2022. 7. 25.

안녕하세요 안녕주입니다.

오늘은 인프런의 JPA 활용2 강의를 들으면서 복습 겸 정리한 블로그를 올리려고 합니다.

 

 

주문 + 배송정보 + 회원을 조회하는 API를 만듭니다. 지연로딩 때문에 발생하는 성능 문제를 단계적으로 해결해볼 예정!

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

OrderSimpleApiController

package jpabook.jpashop.api;

import com.fasterxml.jackson.annotation.JsonIgnore;
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;

    /**
     * V1. 엔티티 직접 노출(Order)
     * - Hibernate5Module 모듈 등록, LAZY=null 처리 * - 양방향 관계 문제 발생 -> @JsonIgnore
     */
    @GetMapping("/api/v1/simple-orders")
    public List<Order> ordersV1() {
        List<Order> all = orderRepository.findAllByString(new OrderSearch());
        for (Order order : all) {
            order.getMember().getName(); //Lazy 강제 초기화
            order.getDelivery().getAddress(); //Lazy 강제 초기환 }
        }
        return all;
    }
}
  • 엔티티를 직접 노출하는 것은 좋지 않다. (앞에서 계속 설명)
  • order → member와 order → address는 지연 로딩이다. 따라서 실제 엔티티 대신에 프록시 존재
  • jackson 라이브러리는 기본적으로 이 프록시 객체를 json으로 어떻게 생성해야하는지 모름 → 예외 발생
  • Hibernate5Module을 스프링 빈으로 등록하면 해결(스프링 부트 사용중)
    • OrderItem의 Order, Member의 Order, Delivery의 Order에 @JsonIgnore추가 해서 양방향 관계문제 해결
    • fetch = LAZY(지연로딩: DB에서 안 끌고옴), 사실 Order의 Memberdp ProxyMember는 ByteBuddyInterceptor가 대신 들어있음 (?)

 

Hibernate5Module 등록

JpashopApplication

@Bean
    Hibernate5Module hibernate5Module() {
        return new Hibernate5Module();
    }
  • 기본적으로 초기화된 프록시 객체만 노출, 초기화 되지 않은 프록시 객체는 노출 안함

참고 : build.gradle에 다음 라이브러리를 추가 implementation 'com.fasterxml.jackson.datatype:jackson-datatype-hibernate5’

 

다음과 같이 설정하면 강제로 지연 로딩 가능

@Bean
Hibernate5Module hibernate5Module() {
Hibernate5Module hibernate5Module = new Hibernate5Module();
//강제 지연 로딩 설정 hibernate5Module.configure(Hibernate5Module.Feature.FORCE_LAZY_LOADING,
    true);
        return hibernate5Module;
}
  • 해당 옵션을 키면 order → member, member → orders 양방향 연관관계를 계속 로딩하게 된다. 따라서 @JsonIgnore 옵션을 한곳에 주어야한다.

주의 : 엔티티를 직접 노출할 때는 양방향 연관관계가 걸린 곳은 꼭! 한 곳을 @JsonIgnore 처리 해야한다. 안그러면 양쪽을 서로 호출하면서 무한 루프가 걸린다.

참고 : 앞에서 이야기 했듯이 API 응답을 외부로 노출하는 것은 좋지 않다. 따라서 Hibernate5Module를 사용하기 보다는 DTO로 변환해서 반환하는 것이 더 좋은 방법이다.

주의 : 지연로딩(LAZY)을 피하기 위해 즉시 로딩(EARGR)으로 설정하면 안된다! 즉시 로딩 때문에 연관관계가 필요 없는 경우에도 데이터를 항상 조회해서 성능 문제가 발생할 수 있다. 즉시로딩으로 설정하면 성능 튜닝이 매우 어려워진다. 항상 지연로딩을 기본으로 하고 성능 최적화가 필요한 경우에는 페치 조인(fetch join)을 사용해라(V3)

 

 


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

yml파일에 org.hibernate.type : trace를 키고 확인 후 주석처리해서 결과를 또 확인해보자

OrderRepository

public List<Order> findAll() {
        return em.createQuery("select o from Order o", Order.class)
                .getResultList();
    }

OrderSimpleApiController - 추가

/**
     * V2. 엔티티를 조회해서 DTO로 변환(fetch join 사용X) * - 단점: 지연로딩으로 쿼리 N번 호출
     */
    @GetMapping("/api/v2/simple-orders")
    public List<SimpleOrderDto> ordersV2() {
        List<Order> orders = orderRepository.findAll();
        List<SimpleOrderDto> result = orders.stream()
                .map(o -> new SimpleOrderDto(o))
                .collect(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();
        } 
    }
  • 엔티티를 DTO로 변환하는 일반적인 방법
  • 쿼리가 총 1 + N + N 번 실행된다. (V1과 쿼리수 결과는 같다.)
    • order 조회 1번 (order 조회 결과 수가 N이 된다.)
    • order → member 지연 로딩 조회 N번
    • order → delivery 지연 로딩 조회 N번
    • 예) order의 결과가 4개면 최악의 경우 1 + 4 + 4번 실행된다.
      • 지연로딩은 영속성 컨텍스트에서 조회하므로, 이미 조회된 경우 쿼리를 생략한다.

 

 


간단한 주문 조회 V3 : 엔티티를 DTO로 변환 - 페치 조인 최적화

OrderSimpleApiController - 추가

/**
* V3. 엔티티를 조회해서 DTO로 변환(fetch join 사용O)
* - fetch join으로 쿼리 1번 호출
* 참고: fetch join에 대한 자세한 내용은 JPA 기본편 참고(정말 중요함) */
  @GetMapping("/api/v3/simple-orders")
  public List<SimpleOrderDto> ordersV3() {
      List<Order> orders = orderRepository.findAllWithMemberDelivery();
      List<SimpleOrderDto> result = orders.stream()
              .map(o -> new SimpleOrderDto(o))
              .collect(toList());
      return result;
}

OrderRepository - 추가코드

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();
    }
  • 엔티티를 페치조인(fetch join)을 사용해서 쿼리 1번에 조회
  • 페치 조인으로 order → member, order → delivery는 이미 조회된 상태이므로 지연로딩X

 

 


간단한 주문 조회 V4 : 엔티티를 DTO로 바로 조회

OrderSimpleApiController - 추가

private final OrderSimpleQueryRepository orderSimpleQueryRepository; //의존관계 주입

/**
     * V4. JPA에서 DTO로 바로 조회
     * -쿼리1번 호출
     * - select 절에서 원하는 데이터만 선택해서 조회
     */
    @GetMapping("/api/v4/simple-orders")
    public List<OrderSimpleQueryDto> ordersV4() {
        return orderSimpleQueryRepository.findOrderDtos();
    }

OrderSimpleQueryRepository 조회 전용 리포지토리

package jpabook.jpashop.repository.order.simplequery;

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;

import javax.persistence.EntityManager;
import java.util.List;

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

OrderSimpleQueryDto 리포지토리에서 DTO 직접 조회

package jpabook.jpashop.repository.order.simplequery;

import jpabook.jpashop.domain.Address;
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;
    } }
  • 일반적인 SQL을 사용할 때처럼 원하는 값을 선택해서 조회
  • new 명령어를 사용해서 JPQL의 결과를 DTO로 즉시 변환
  • SELECT 절에서 원하는 데이터를 직접 선택하므로 DB → 애플리케이션 네트워크 용량 최적화(생각보다 미비)
  • 리포지토리 재사용성 떨어짐, API 스펙에 맞춘 코드가 리포지토리에 들어가는 단점 ( 리포지토리는 원래 엔티티의 객체를 조회할때만 쓰는게 옳다)

 

정리

엔티티를 DTO로 변환하거나, DTO로 바로 조회하는 두가지 방법은 각각 장단점이 있다. 둘중 상황에 따라서 더 나은 방법을 선택하면 된다. 엔티티로 조회하면 리포지토리 재사용성도 좋고, 개발도 단순해 진다. 따라서 권장하는 방법은 다음과 같다.

 

쿼리 방식 선택 권장 순서 (가장 중요)

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

 


개인적으로 궁금한것

  1. fetch = LAZY
  2. Proxy
  3. 지연로딩
  4. 영속성 컨텍스트에서 조회
  5. fetch join

댓글