사전 준비 - 조회용 샘플 데이터 입력
jpabook::jpashop::InitDb
/**
* 총 주문 2개
* userA
* JPA1 BOOK
* JPA2 BOOK
* userB
* SPRING1 BOOK
* SPRING2 BOOK
*/
@Component
@RequiredArgsConstructor
public class InitDb {
private final InitService initService;
@PostConstruct
public void init() {
initService.dbInit1();
initService.dbInit2();
}
@Component
@Transactional
@RequiredArgsConstructor
static class InitService {
private final EntityManager em;
public void dbInit1() {
Member member = createMember("userA","서울", "1", "1111");
em.persist(member);
Book book1 = createBook("JPA1 BOOK", 10000, 100);
em.persist(book1);
Book book2 = createBook("JPA2 BOOK", 20000, 100);
em.persist(book2);
OrderItem orderItem1 = OrderItem.createOrderItem(book1, 10000, 1);
OrderItem orderItem2 = OrderItem.createOrderItem(book2, 20000, 2);
Delivery delivery = createDelivery(member);
Order order = Order.createOrder(member, delivery, orderItem1, orderItem2);
em.persist(order);
}
public void dbInit2() {
Member member = createMember("userB","진주", "2", "2222");
em.persist(member);
Book book1 = createBook("SPRING1 BOOK", 20000, 200);
em.persist(book1);
Book book2 = createBook("SPRING2 BOOK", 40000, 300);
em.persist(book2);
OrderItem orderItem1 = OrderItem.createOrderItem(book1, 20000, 3);
OrderItem orderItem2 = OrderItem.createOrderItem(book2, 40000, 4);
Delivery delivery = createDelivery(member);
Order order = Order.createOrder(member, delivery, orderItem1, orderItem2);
em.persist(order);
}
private static Member createMember(String name, String city, String street, String zipcode) {
Member member = new Member();
member.setName(name);
member.setAddress(new Address(city, street, zipcode));
return member;
}
private static Delivery createDelivery(Member member) {
Delivery delivery = new Delivery();
delivery.setAddress(member.getAddress());
return delivery;
}
private static Book createBook(String name, int price, int stockQuantity) {
Book book1 = new Book();
book1.setName(name);
book1.setPrice(price);
book1.setStockQuantity(stockQuantity);
return book1;
}
}
}
※ 참고
IntelliJ shortcut
ctrl+ alt + m : duplicate code refactor(중복된 코드들을 하나의 메서드로 추출해준다.)
ctrl+ alt + v : method 반환 값에 따라 변수 선언을 해준다.
- 주문 + 배송정보 + 회원을 조회하는 API를 여러 방법으로 만들어 볼 것이다.
- 그러면서 지연로딩 때문에 발생하는 성능 문제를 단계적으로 해결할 것이다.
(Impotant) 실무에서 중요한 내용들을 다루기에 이 챕터는 모두 복습 철저히 하도록 하자.
이번 장에서는 xxToOne(ManyToOne, OneToOne) 관계에 있는 엔티티와의 성능 최적화에 집중하여 해결한다.
간단한 주문 조회 V1: 엔티티 직접 노출
V1. 엔티티 직접 노출
- Hibernate5Module 모듈 등록, LAZY=null 처리
- 양방향 관계 문제 발생 -> @JsonIgnore
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
*/
@RequiredArgsConstructor
@RestController
public class OrderSimpleApiController {
private final OrderRepository orderRepository;
@GetMapping("/api/v1/simple-orders")
public List<Order> ordersV1(){
List<Order> all = orderRepository.findAllByString(new OrderSearch());
return all;
}
}
- 우선 엔티티를 직접 노출(반환) 하는것은 좋지 않다.(저번 챕터에서도 언급)
- 해당 API를 호출하면 Order ▶ Member와 Order ▶ Delivery간에 서로의 엔티티를 조회하는 것이 무한순회를 하게되어 에러가 발생하고 제대로된 Response가 오지 못한다.
@JsonIgnore
양방향 연관관계 부분에 @JsonIgnore 을 추가해주면 Json Response를 생성할 때 연관관계 반대 쪽으로는 Ignore 하므로 무한 루프 에러는 해결된다.
하지만, 다음 에러가 발생한다.
에러 메세지를 보면 bytebuddy.ByteBuddyInterceptor에서 Type defnition error가 발생한것을 볼 수 있다. 어째서 해당 에러가 났을까?
현재 코드의 로딩 전략은 지연로딩(LAZY)전략을 사용한다. 그 말은 Member, Delivery등의 엔티티들을 조회하는 시점에서는 실제 객체가 아닌 프록시 객체를 가지고 있다.
※ 참고
요즘에는 프록시 기술을 쓸 때 bytebuddy라이브러리를 많이 사용한다. 그래서 ByteBuddyInterceptor클래스가 사실 Member member = new ByteBuddyInterceptor(); 처럼 프록시 객체가 대신 들어가 있는 것이다.
여기서 문제점은 jackson 라이브러리는 기본적으로 이 프록시 객체를 json으로 어떻게 생성해야 하는지 모르기 때문에 예외가 발생하는 것이다.
해결책
스프링 부트를 사용 중이라면 Hibernate5Module 을 스프링 Bean으로 등록하면 해결된다.
/*build.gradle*/
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-hibernate5'
/*JpashopApplication.java*/
@Bean
Hibernate5Module hibernate5Module(){
return new Hibernate5Module();
}
- gradle에 implentation 할 때 version을 따로 입력해주지 않으면 Gradle에서 자동으로 현재 버전과 잘 맞는 버전을 import해서 관리해준다.
아래와 같이 hibernate5Module설정을 통해 강제로 지연 로딩도 사용 가능하다.
@Bean
Hibernate5Module hibernate5Module(){
Hibernate5Module hibernate5Module = new Hibernate5Module();
//강제 지연 로딩 설정
hibernate5Module.configure(Hibernate5Module.Feature.FORCE_LAZY_LOADING, true);
return hibernate5Module;
}
- 이 옵션을 키면 order ▶ member, member ▶orders 양방향 연관관계 무한루프가 돌기 때문에 @JsonIgnore를 설정해줘야 한다.
※ 주의
스프링 부트 3.0 이상이면 Hibernate5Module 대신에 Hibernate5JakartaModule 을 사용해야한다
※ 주의
엔티티를 직접 노출할 때는 양방향 연관관계가 걸린 곳은 꼭! 한곳을 @JsonIgnore 처리 해야 한다.
안그러면 양쪽을 서로 호출하면서 무한 루프가 걸린다.
하지만 사실 Lazy Loading 때문에 Hibernate 모듈을 직접 등록해서 사용하는 것은 애매한 부분이 있다.
그래서 만약 위 설정 없이 원하는 결과를 반환하고 싶다면 다음과 같이 할 수도 있다.
@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;
}
하지만 이렇게 만든 엔티티를 직접 노출한 API는 너무 정보가 많고 고정되어 있다. 만약 Client 해당 API를 사용하여 이미 구현을 해뒀으면 나중에 API 스팩에 따른 변경이 너~무 힘들다.
(중요!!) 앞에서 계속 강조했듯이 정말 간단한 애플리케이션이 아니면 엔티티를 API 응답으로 외부로 노출하는 것은 좋지 않다.
따라서 Hibernate5Module을 이용하는 방법은 알아만 두고 실무에서는 DTO로 변환해서 반환하는것이 더 좋은 방법이다.
※ 주의
지연 로딩(LAZY)을 피하기 위해 즉시 로딩(EARGR)으로 설정하면 안된다! 즉시 로딩 때문에 연관관계가 필요 없는 경우에도 데이터를 항상 조회해서 성능 문제가 발생할 수 있다. 즉시 로딩으로 설정하면 성능 튜닝이 매우 어려워 진다.
항상 지연 로딩을 기본으로 하고, 성능 최적화가 필요한 경우에는 페치 조인(fetch join)을 사용해자!!
(V3에서 설명)
간단한 주문 조회 V2: 엔티티를 DTO로 변환
package jpabook.jpashop.api;
import jpabook.jpashop.domain.Address;
import jpabook.jpashop.domain.Order;
import jpabook.jpashop.domain.OrderStatus;
import jpabook.jpashop.repository.OrderRepository;
import jpabook.jpashop.repository.OrderSearch;
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.List;
import java.util.stream.Collectors;
/**
* xToOne(ManyToOne, OneToOne
* Order
* Order -> Member
* Order -> Delivery
*/
@RequiredArgsConstructor
@RestController
public class OrderSimpleApiController {
private final OrderRepository orderRepository;
@GetMapping("/api/v2/simple-orders")
public List<SimpleOrderDto> ordersV2(){
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(); //LAZY 초기화
orderDate = order.getOrderDate();
orderStatus = order.getStatus();
address = order.getDelivery().getAddress(); //LAZY 초기화
}
}
}
- 엔티티를 DTO로 변환하는 일반적인 방법이다.
- DTO가 엔티티를 파라미터로 직접 받는 것은 큰 문제가 되지 않는다. 왜냐하면 별로 중요하지 않은 곳에서 엔티티를 의존하는 것뿐이기 때문에 문제가 되지 않는다.
V1, V2의 공통된 문제점 ( N + 1 문제 )
- 쿼리가 총 1 + N + N 번 실행된다.(v1과 쿼리수 결과는 같다.)
- order 조회 1번(order 조회 결과 수가 N이 된다.)
- order → member 지연 로딩 조회 N번
- order → delivery 지연 로딩 조회 N번
- Ex: order의 결과가 2개면 최악의 경우 1 + 2 + 2번 실행된다.(최악의 경우)
- 왜 최악의 경우라도 따로 언급을 했냐면...
→ 지연로딩은 영속성 컨텍스트에서 조회하므로, 이미 조회된 경우 쿼리를 생략한다.
→ 즉, 만약 영속성 컨텍스트에 같은 @Id를 갖는 엔티티가 있으면 쿼리가 생략된다는 것이다.
- 왜 최악의 경우라도 따로 언급을 했냐면...
V2버전으로 API호출했을 때 실행되는 쿼리
- 쿼리가 1 + N + N 번 실행된 것을 볼 수 있다.
- 물론, 여기서 member가 동일할 경우 처음 조회 이후 다시 조회 하지 않기 때문에 1번의 쿼리수행이 더 줄어들 수 있지만, 흔한 케이스가 아니기에 고려하지 않는다.
- Order 조회가 많아질수록 기하급수적으로 쿼리수행이 많아지고 성능저하가 일어날 수 있다
.
만약 로딩 전략은 EAGER로 한다면 해결될까? 쿼리는 어떻게 실행될까?? 쿼리가 최적화 될까?
- Order와 Delivery 쿼리 외에도 아래와 같은 전혀 예상하지 못한 쿼리가 실행된다.
간단한 주문 조회 V3: 엔티티 DTO로 변환 → 페치 조인 최적화
V3. 엔티티를 조회해서 DTO로 변환(fetch join 사용O)
- fetch join으로 쿼리 1번 호출
참고: fetch join에 대한 자세한 내용은 JPA 기본편 참고(정말 중요함)
package jpabook.jpashop.api;
import jpabook.jpashop.domain.Address;
import jpabook.jpashop.domain.Order;
import jpabook.jpashop.domain.OrderStatus;
import jpabook.jpashop.repository.OrderRepository;
import jpabook.jpashop.repository.OrderSearch;
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.List;
import java.util.stream.Collectors;
/**
* xToOne(ManyToOne, OneToOne
* Order
* Order -> Member
* Order -> Delivery
*/
@RequiredArgsConstructor
@RestController
public class OrderSimpleApiController {
private final OrderRepository orderRepository;
@GetMapping("/api/v3/simple-orders")
public List<SimpleOrderDto> ordersV3(){
List<Order> orders = orderRepository.findAllWithMemberDelivery();
List<SimpleOrderDto> collect = orders.stream()
.map(o -> new SimpleOrderDto(o))
.collect(Collectors.toList());
return collect;
}
@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 초기화
}
}
}
...
/*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();
}
- 엔티티를 페치 조인(fetch join)을 사용해서 쿼리 1번에 조회
- 페치 조인으로 order → member, order→ delivery 는 이미 조회 된 상태 이므로 지연로딩 X
V3버전으로 API호출했을 때 실행되는 쿼리
V3까지 최적화를 통해 N+1 쿼리를 1건 쿼리로 최적화를 진행해봤다. 하지만 여기서도 쿼리를 보시다시피 엔티티를 이용하여 쿼리가 실행되고 있다.
V4에서는 이를 최적화 하는 방법을 학습할 것이다.
간단한 주문 조회 V4: JPA에서 DTO로 바로 조회
Controller
/**
* V4. JPA에서 DTO로 바로 조회
* - 쿼리1번 호출
* - select 절에서 원하는 데이터만 선택해서 조회
*/
@GetMapping("/api/v4/simple-orders")
public List<OrderSimpleQueryDto> ordersV4() {
return orderSimpleQueryRepository.findOrderDtos();
}
- 방법에 따라서 원하는 값을 선택해서 조회하는 경우 엔티티를 조회하는 리포지토리(OrderRepository)가 아니라 따로 쿼리용 리포지토리를 만들면 구분이 쉽다.
- 또한 실무에서는 통계용 쿼리, 통계용 API 등 복잡한 join 쿼리를 갖고 DTO를 바로 뽑는 경우도 많기 때문에 패키지를 따로 만들어 가는게 유지보수에도 좋다.
OrderSimpleQueryRepository 조회 전용 리포지토리
package jpabook.jpashop.repository.order.simplequery;
import jpabook.jpashop.repository.OrderSimpleQueryDto;
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.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();
}
}
- jpa는 기본적으로 엔티티나 value Object만 반환할 수 있다.
- 또한 new 연산자 방법은 엔티티를 바로 넘기는 것은 안된다. 엔티티를 바로 넘겨버리면 식별자만 넘어간다.
- new 를 통해 객체를 생성해줄 때 full package path를 입력해줘야 한다.
OrderSimpleQueryDto
package jpabook.jpashop.repository.order.simplequery;
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(Order order) {
orderId = order.getId();
name = order.getMember().getName(); //LAZY 초기화
orderDate = order.getOrderDate();
orderStatus = order.getStatus();
address = order.getDelivery().getAddress(); //LAZY 초기화
}
/* OrderSimpleQueryRepository::findOrderDtos메서드에서 사용할 생성자(Constructor)*/
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 → application 네트워크 용량 최적화(생각보단 미비하다)
- 리포지토리 재사용성이 떨어짐, API 스펙에 맞춘 코드가 리포지토리에 들어가는 단점
v3, v4 쿼리 비교
각각의 장단점이 있지만 우선 쿼리만 보면 확실히 원하는 것만 가져오는 DTO 방식이 더 짧은 것을 확인할 수 있다.
그러나 과연 성능 차이가 얼마나 날까??
- 정확히는 성능 테스트를 해봐야 알겠지만 대부분의 경우는 두 방식의 성능차이는 거의 나지 않는다.
- 우선 네트워크가 많이 발전했다.
- 그리고 대부분의 성능은 join 문과 연관이 있다. 즉, select 절에서 필드가 몇개 추가된다고 해서 성능 차이는 그렇게 없다.
- 또한 대부분 쿼리 성능은 인덱스를 잘못 걸었다든지 그런 부분이다.
- 물론 데이터 사이즈가 너무 클 때는 얘기가 다르다. select 필드가 20~30개가 되면 api 호출 횟수에 따라 select 문 최적화도 고민은 해볼 수 있다.
정리
- 엔티티를 DTO로 변환하거나, DTO로 바로 조회하는 두가지 방법은 각각 장단점이 있다. 둘 중 상황에 따라서 더 나은 방법을 선택하면 된다.
- 엔티티로 조회하면 리포지토리 재사용성도 좋고, 개발도 단순해진다. 따라서 권장하는 방법은 다음과 같다.
쿼리 방식 선택 권장 순서
- 우선 엔티티를 DTO로 변환하는 방법을 선택한다.
- 필요하면 페치 조인으로 성능을 최적화 한다. => 대부분의 성능 이슈가 해결된다(95%이상 해결 가능)
- 그래도 안되면 DTO로 직접 조회하는 방법을 사용한다. => V4 방법
- 최후의 방법은 JPA가 제공하는 네이티브 SQL이나 스프링 JDBC Template을 사용해서 SQL을 직접 사용한다