package jpabook.jpashop.service;

import jpabook.jpashop.domain.Delivery;
import jpabook.jpashop.domain.Item.Item;
import jpabook.jpashop.domain.Member;
import jpabook.jpashop.domain.Order;
import jpabook.jpashop.domain.OrderItem;
import jpabook.jpashop.repository.*;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class OrderService {

    ...
    public List<OrderSimpleQueryDto> findOrderDtos() {
        return orderRepository.findOrderDtos();
    }
}

주문 + 배송정보 + 회원을 조회하는 API 구현.

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

 

참고

실무에서 JPA를 사용하려면 100% 이해해야 할 정도로 중요한 내용입니다. 시간을 날리고 싶지않다면 제대로 이해하라고 하십니다. (정신차리자)

 

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


api.OrderSimpleApiController 생성

 

package jpabook.jpashop.api;

import jpabook.jpashop.domain.Order;
import jpabook.jpashop.repository.OrderSearch;
import jpabook.jpashop.service.MemberService;
import jpabook.jpashop.service.OrderService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.*;

/**
 * XToOne (ManyToOne, OneToOne) 에서의 성능 최적화
 * Order
 * Order -> Member
 * Order -> Delivery
 */
@RestController
@RequiredArgsConstructor
public class OrderSimpleApiController {

    private final OrderService orderService;
    private final MemberService memberService;

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

현재 주문 내역을 조회하는 api를 생성했습니다. 돌려보면 결과가 어떨까요?? 감이 온다면 좋겠지만 전 감이 오지 않았습니다... 결론은 무한루프에 빠지게 됩니다. Member -> Order (ManyToOne)이고 Order -> Member (OneToMany)로 계속 무한루프에 빠지게 되는 것 입니다.

 

테스트

Entity를 직접 노출시키는 프로젝트라면, 해결책은 JsonIgnore를 해줘야 합니다.

Order와 연관된 엔티티에서 다시 Order로 오는 필드를 JsonIgnore한 뒤 돌려보면

500 에러와 함께 org.hibernate.proxy.pojo.bytebuddy.ByteBuddyInterceptor란 에러가 뜹니다. ByteBuddyInterceptor 클래스는 LAZY.Loading으로 했기때문에 진짜 객체가아닌 프록시 객체를 주는데, 실제로 프록시 객체가 ByteBuddy로 되어있다고 합니다. (프록시는 김영한님 강의 중 기본편에 있는 내용인데, 아래 링크에 정리했으니 잘 모르면 보도록합시다.)

 

[JPA 기본편] 8. 프록시와 연관관계 관리

목차 프록시 즉시 로딩과 지연 로딩 지연 로딩 활용 연속성 전이 : CASECADE 고아 객체 연속성 전이 + 고아 객체, 생명주기 실전 예제 - 5. 연관관계 관리 프록시 Member를 조회할 때 Team도 함께 조회해

qazyj.tistory.com

여기서 에러가 작동한 이유는 Order를 조회할 때, Member를 같이 뽑아오려고하는데 Member가 순수한 자바객체가 아닌 다른 ByteBuddy라는 라이브러리로 되어있기 때문에 발생하는 예외입니다.

 

해결책은 지연 로딩의 경우에 json라이브러리에게 아무런 데이터를 뽑아오지 않도록 해주면 됩니다. Hibernate5Module을 사용해서 해결해줄 수 있다고 합니다. 

 

build.gradle 추가 

implementation 'com.fasterxml.jackson.datatype:jackson-datatype-hibernate5'

후 Bean으로 hibernate5Module을 추가해주면 됩니다.

 

JpaShopApplication.java 코드 추가

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 {

	...

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

하지만, 이전에 말했듯 엔티티에 직접 접근하는 것은 지양해야 한다고 설명했습니다. 그렇기때문에 hibernate5Module을 사용하지 않아도 됩니다..!

 

테스트

이제야 정상적으로 데이터를 볼 수 있게 됐습니다..!

 

모든 Lazy.Loading 데이터를 보고 싶다면 아래와 같이 옵션을 바꿔주면됩니다. 

	@Bean
	Hibernate5Module hibernate5Module() {
		Hibernate5Module hibernate5Module = new Hibernate5Module();
		hibernate5Module.configure(Hibernate5Module.Feature.FORCE_LAZY_LOADING, true);
		return hibernate5Module;
	}

테스트

모든 Lazy.Loading 엔티티 데이터들이 나온 것을 볼 수 있습니다. 하지만, 이렇게하는 경우 Lazy로 되어있는 필요하지 않은 Entity도 끌고오게 됩니다. (즉, 쿼리가 한번 더 나갑니다.) 그렇기 때문에 필요한 데이터만 갖고 오는 방법도 있습니다.


OrderSimpleApiController 코드 변경

package jpabook.jpashop.api;

import jpabook.jpashop.domain.Order;
import jpabook.jpashop.repository.OrderSearch;
import jpabook.jpashop.service.MemberService;
import jpabook.jpashop.service.OrderService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.*;

/**
 * XToOne (ManyToOne, OneToOne) 에서의 성능 최적화
 * Order
 * Order -> Member
 * Order -> Delivery
 */
@RestController
@RequiredArgsConstructor
public class OrderSimpleApiController {

    ...

    @GetMapping("/api/v1/simple-orders")
    public List<Order> ordersV1() {
        List<Order> orders = orderService.findOrders(new OrderSearch());
        for(Order order : orders){
            // getMember까지는 프록시 객체지만, getName()을 가져오면 강제 초기화되서 데이터를 끌고 온다.
            // 이렇게 강제적으로 Entity를 가져오는 방법도 있다.
            order.getMember().getName();
            order.getDelivery().getAddress();
        }
        return orders;
    }
}

강제 초기화로 데이터를 끌고 오는 방법입니다.

 

테스트

설명한 이유는 이렇게 해라가 아니라 다음에 설명하는 방법이 왜 좋은지 알려주기 위한 설명입니다. 위와 같이 엔티티를 직접 노출시키지 말고 무조건 DTO로 변환합시다!

 

 

 

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


OrderSimpleApiController 코드 추가

package jpabook.jpashop.api;

import jpabook.jpashop.domain.Address;
import jpabook.jpashop.domain.Order;
import jpabook.jpashop.domain.OrderStatus;
import jpabook.jpashop.repository.OrderSearch;
import jpabook.jpashop.service.MemberService;
import jpabook.jpashop.service.OrderService;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import java.time.LocalDateTime;
import java.util.*;
import java.util.stream.Collectors;

/**
 * XToOne (ManyToOne, OneToOne) 에서의 성능 최적화
 * Order
 * Order -> Member
 * Order -> Delivery
 */
@RestController
@RequiredArgsConstructor
public class OrderSimpleApiController {

    ...

    @GetMapping("/api/v2/simple-orders")
    public List<SimpleOrderDTO> ordersV2() {
        List<Order> orders = orderService.findOrders(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();
        }
    }
}

테스트

 DTO로 조회했습니다. DTO로 조회해도 해결되지 않는 문제 1가지가 있습니다. 쿼리가 생각보다 많이 나가기 때문입니다. Order, Member, Delivery를 주문 1개당 3개의 쿼리문을 보내서 갖고오기 때문입니다. 

        public SimpleOrderDTO(Order order){
            orderId = order.getId();
            name = order.getMember().getName();     // LAZY 초기화
            orderDate = order.getOrderDate();
            orderStatus = order.getStatus();
            address = order.getDelivery().getAddress();         // LAZY 초기화
        }

 

이것을 N+1 문제라고 말합니다. 말이 N+1이지 1+N이라고 해도 이상하지 않습니다. 주문(1) 당 N(2)개만큼 쿼리가 추가실행되기 때문입니다. 여기선 1(주문) + N(회원) + 배송(N)이 실행되어 총 5번의 쿼리문이 나갑니다.

 

 

 

 

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


OrderSimpleApiController 코드 추가

package jpabook.jpashop.api;

import jpabook.jpashop.domain.Address;
import jpabook.jpashop.domain.Order;
import jpabook.jpashop.domain.OrderStatus;
import jpabook.jpashop.repository.OrderSearch;
import jpabook.jpashop.service.MemberService;
import jpabook.jpashop.service.OrderService;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import java.time.LocalDateTime;
import java.util.*;
import java.util.stream.Collectors;

/**
 * XToOne (ManyToOne, OneToOne) 에서의 성능 최적화
 * Order
 * Order -> Member
 * Order -> Delivery
 */
@RestController
@RequiredArgsConstructor
public class OrderSimpleApiController {

    ...

    @GetMapping("/api/v3/simple-orders")
    public List<SimpleOrderDTO> ordersV3() {
        List<Order> orders = orderService.findAllWithMemberDelivery();

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

테스트

fetch join을 사용하여 단 한번의 쿼리로 원하는 모든 데이터를 가져올 수 있습니다...?!! 좋네요..

fetch join으로 order->member, order->delivery를 모두 조회한 상태이기 때문에 지연로딩X

 

 

 

 

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


OrderSimpleQueryDto 생성

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

OrderSimpleApiController 코드 추가

package jpabook.jpashop.api;

import jpabook.jpashop.domain.Address;
import jpabook.jpashop.domain.Order;
import jpabook.jpashop.domain.OrderStatus;
import jpabook.jpashop.repository.OrderSearch;
import jpabook.jpashop.repository.OrderSimpleQueryDto;
import jpabook.jpashop.service.MemberService;
import jpabook.jpashop.service.OrderService;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import java.time.LocalDateTime;
import java.util.*;
import java.util.stream.Collectors;

/**
 * XToOne (ManyToOne, OneToOne) 에서의 성능 최적화
 * Order
 * Order -> Member
 * Order -> Delivery
 */
@RestController
@RequiredArgsConstructor
public class OrderSimpleApiController {

    ...

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

}

OrderService 코드 추가

package jpabook.jpashop.service;

import jpabook.jpashop.domain.Delivery;
import jpabook.jpashop.domain.Item.Item;
import jpabook.jpashop.domain.Member;
import jpabook.jpashop.domain.Order;
import jpabook.jpashop.domain.OrderItem;
import jpabook.jpashop.repository.*;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class OrderService {

    ...

    public List<OrderSimpleQueryDto> findOrderDtos() {
        return orderRepository.findOrderDtos();
    }
}

 

OrderRepository 코드 추가

package jpabook.jpashop.repository;

import jpabook.jpashop.api.OrderSimpleApiController;
import jpabook.jpashop.domain.Member;
import jpabook.jpashop.domain.Order;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;
import org.springframework.util.StringUtils;

import java.util.*;

import javax.persistence.EntityManager;
import javax.persistence.TypedQuery;
import javax.persistence.criteria.*;

@Repository
@RequiredArgsConstructor
public class OrderRepository {

   	...

    public List<OrderSimpleQueryDto> findOrderDtos() {
        return em.createQuery("select new jpabook.jpashop.repository.OrderSimpleQueryDto(o.id, o.member.name, o.orderDate, o.status, o.delivery.address) " +
                        " from Order o" +
                " join o.member m" +
                " join o.delivery d", OrderSimpleQueryDto.class)
                .getResultList();

    }
}

쿼리 문자열을 보면 new jpabook.....하며 객체 루트를 다 적어주었습니다. 이유는 엔티티, 임베디드가 아니면 어떤 객체인지 spring에서 알지 못하기 때문입니다. OrderSimpleQueryDto의 경우 @Data로 만들었기 때문에 루트를 다 적어야합니다.

이렇게 DTO로 조회하면 아래 테스트와 같이 컬럼명이 딱 필요한 만큼 가져올 수 있습니다. 하지만, 재사용성이 zero에 가깝기때문에 상황에 맞게 V3와 V4를 잘 조율해가며 사용해야 합니다.

 

테스트

이전 V3에서 생성된 쿼리문보다 훨씬 줄어든 컬럼양을 확인할 수 있습니다!

 

그렇다면 V3와 V4의 성능 차이가 많이 날까요??

그렇지 않다고 합니다. 성능을 좌우하는 것은 from절 다음부터입니다. 즉, select 절에 컬럼이 몇개 추가된다고 성능에는 영향이 없다고 봐도 좋습니다. 물론, 데이터 사이즈가 정말 큰 경우에는 문제가 되지만 그렇지 않은경우 V3를 사용하는 것도 좋다고 합니다. 

 

또한, 현재 OrderRepository에는 V4를 제외하면 Order 엔티티와 관련된 DB 조회만 있습니다. (엔티티명)+repository의 경우 해당 엔티티와 관련된 조회만 있어야합니다. 즉, 현재 V4를 실행하기 위해 OrderSimpleQueryDto를 조회하며 계층이 무너졌습니다. 리팩토링해줍시다.

repository 팩토리 안에 order.simplequery 파일을 만들어 쿼리 최적화를 위한 코드들은 해당 팩토리안에서 관리해줍시다! 이를통해 얻을 수 있는 장점은 유지보수가 편해집니다. 또한, 용도를 보다 명확하게 할 수 있습니다!

 

 

 

 

 

 

출처

 

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

스프링 부트와 JPA를 활용해서 API를 개발합니다. 그리고 JPA 극한의 성능 최적화 방법을 학습할 수 있습니다., - 강의 소개 | 인프런...

www.inflearn.com

 

+ Recent posts