3 분 소요


주문 + 배송 정보 + 회원을 조회하는 API를 만들면서 지연 로딩 때문에 발생하는 성능 문제를 단계적으로 해결.


주문 조회 V1

엔티티를 직접 노출

@RestController
@RequiredArgsConstructor
public class OrderSimpleApiController {
    
    private final OrderRepository orderRepository;

    @GetMapping("/api/v1/simple-orders")
    public List<Order> ordersV1() {
        List<Order> all = orderRepository.findAll(new OrderSearch());
        for (Order order : all) {
            order.getMember().getName(); // LAZY 강제 초기화
            order.getDelivery().getAddress(); // LAZY 강제 초기화
        }
        return all;
    }
}
  • order -> memberorder -> address는 지연 로딩이다. 따라서 실제 엔티티 대신에 프록시가 존재
  • jackson 라이브러리는 기본적으로 이 프록시 객체를 json으로 어떻게 생성해야 하는지 모른다. -> 예외 발생
  • Hibernate5Module을 스프링 빈으로 등록하면 해결(스프링 부트 사용)

Hibernate5Module 등록

build.gradle에 라이브러리 추가
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-hibernate5'

JpashopApplicaion에 코드 추가

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

강제 지연 로딩

@Bean
Hibernate5Module hibernate5Module() {
    Hibernate5Module hibernate5Module = new Hibernate5Module();
    // 강제 지연 로딩 설정
    hibernate5Module.configure(Hibernate5Module.Feature.FORCE_LAZY_LOADING, true);
    
    return hibernate5Module;
}
  • 이 옵션을 설정하면 order -> member, member -> orders 양방향 연관관계를 계속 로딩하게 된다.

[주의]
엔티티를 직접 노출할 때는 양방향 연관관계가 걸린 곳은 반드시 한곳을 @JsonIgnore처리 해야 한다. 그렇지 않으면 양쪽을 서로 호출하면서 무한 루프가 걸린다.

[참고]
정말 간단한 애플리케이션이 아닌 이상 엔티티를 API 응답으로 노출하는 것은 좋지 않다. 따라서, Hibernate5Module을 사용하기 보다는 DTO로 변환해서 반환하는 것이 더 좋은 방법이다.


주문 조회 V2

엔티티를 DTO로 변환

OrderSimpleApiController - 추가

@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(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(); // LAZY 초기화
        orderDate = order.getOrderDate();
        orderStatus = order.getStatus();
        address = order.getDelivery().getAddress(); // LAZY 초기화
    }
}
  • 엔티티를 DTO로 변환하는 일반적인 방법.
  • 쿼리가 총 1 + N + N번 실행된다.
    • order 조회 1번
    • order -> member 지연 로딩 조회 N번
    • order -> delivery 지연 로딩 조회 N번
    • Ex) order의 결과가 4개면 1 + 4 + 4번 실행될 수 있다.(최악의 경우)
      • 지연 로딩은 영속성 컨텍스트에서 조회하므로 이미 조회된 경우 쿼리를 생략한다.


주문 조회 V3

엔티티를 DTO로 변환 - 패치 조인

OrderSimpleApiController - 추가

@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(Collector.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

JPA에서 DTO로 바로 조회

OrderSimpleApiController - 추가

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

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

OrderSimpleQueryRepository

조회 전용 Repository

@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

Repositroy에서 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;
    }
}
  • 일반적인 SQL을 사용할 때 처럼 원하는 값을 선택해서 조회
  • new 명령어를 사용해서 JPQL의 결과를 DTO로 즉시 전환
  • SELECT 절에서 원하는 데이터를 직접 선택하므로 DB -> 애플리케이션 네트워크 용량 최적화(생각보다 미미하다.)
  • Repository 재사용성 떨어짐. API 스펙에 맞춘 코드가 Repository에 들어가는 단점


정리

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

쿼리 방식 선택 권장 순서

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


<출처 : 인프런 - 실전! 스프링 부트와 JPA 활용2 (김영한)>

댓글남기기