-
#실전2 - 6 지연 로딩과 조회 성능 최적화SPRING-BOOT 2021. 3. 8. 21:40
API 개발 고급 - 지연 로딩과 조회 성능 최적화
주문 + 배송정보 + 회원을 조회하는 API를 만들자
지연 로딩 때문에 발생하는 성능 문제를 단계적으로 해결해보자.
참고: 지금부터 설명하는 내용은 정말 중요합니다. 실무에서 JPA를 사용하려면 100% 이해해야 합니다.
안그러면 엄청난 시간을 날리고 강사를 원망하면서 인생을 허비하게 됩니다.
간단한 주문 조회 V1: 엔티티를 직접 노출
package jpabook.jpashop.api; import jpabook.jpashop.domain.Address; import jpabook.jpashop.domain.Order; import jpabook.jpashop.domain.OrderStatus; import jpabook.jpashop.repository.*; 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 static java.util.stream.Collectors.toList; /** * * xToOne(ManyToOne, OneToOne) 관계 최적화 * Order * Order -> Member * Order -> Delivery * */ @RestController @RequiredArgsConstructor public class OrderSimpleApiController { private final OrderRepository orderRepository; /** * V1. 엔티티 직접 노출 * - Hibernate5Module 모듈 등록, LAZY=null 처리 * - 양방향 관계 문제 발생 -> @JsonIgnore */ @GetMapping("/api/v1/simple-orders") public List<Order> ordersV1() { List<Order> all = orderRepository.findAll(); for (Order order : all) { order.getMember().getName(); //Lazy 강제 초기화 order.getDelivery().getAddress(); //Lazy 강제 초기환 } return all; } } 엔티티를 직접 노출하는 것은 좋지 않다. (앞장에서 이미 설명) order memb
· 엔티티를 직접 노출하는 것은 좋지 않다. (앞장에서 이미 설명)
· order →member 와 order →address 는 지연 로딩이다. 따라서 실제 엔티티 대신에 프록시 존재
· jackson 라이브러리는 기본적으로 이 프록시 객체를 json으로 어떻게 생성해야 하는지 모름 → 예외 발생 · Hibernate5Module 을 스프링 빈으로 등록하면 해결(스프링 부트 사용중)
이렇게 전체 호출하게되면 Order엔 Member Member엔 Order가 있어 Jackson이 계속 만들어내서 무한루프 발생
이 양방향 연관관계 문제를 어떻게 해결해야할까?
양방향에 걸리는 곳을 전부 다 @JsonIgnore을 걸어줘야한다.
그래야지 Json 생성할때도 얘는 ignore니깐 반대 쪽은 안해줘야지 하고 판단한다.
이렇게 설정하고도 에러발생 두번째 문제가 있다. 두번째 문제는 지연로딩이다.
지연로딩이 걸려있으면 직접 DB에서 들고오지 않고 Member객체에 실제로 손을댈때 DB에서 Member를 가져와서 Proxy 초기화를 시켜준다.
그러므로 hibernate에서 null을 넣을 수는 없으니 가짜 PROXY member객체를 생성해놓는다.
여기서는 proxy.pojo.bytebuddy 라이브러리 문제가 발생한거다.
실제로는 이런식으로 매핑되어있음 이문제는 결국 jackson 라이브러리가 루프를 돌리는데 Order를가지고 멤버를뽑는데 순수한 Member가 아니고 bytebuddy이기때문에 jackson이 여기서 손을못쓰고 에러를 발생시킨 것이다.
Hibernate5Module 등록(지연로딩일 경우는 Json을 아무것도 안뿌리게 하는 모듈)
@Bean Hibernate5Module hibernate5Module() { return new Hibernate5Module(); }
· 기본적으로 초기화 된 프록시 객체만 노출, 초기화 되지 않은 프록시 객체는 노출 안함
참고: 다음 라이브러리를 추가하자
mvnrepository.com/artifact/com.fasterxml.jackson.datatype/jackson-datatype-hibernate5
Maven Repository: com.fasterxml.jackson.datatype » jackson-datatype-hibernate5
Add-on module for Jackson (http://jackson.codehaus.org) to support Hibernate (http://hibernate.org) version 5.x data types. VersionRepositoryUsagesDate2.12.x2.12.2Central 0 Mar, 20212.12.1Central 0 Jan, 20212.12.0Central1Nov, 20202.12.0-rc2Central 0 Nov, 2
mvnrepository.com
build.gradle에 등록
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-hibernate5'
다음과 같이 설정하면 강제로 지연 로딩 가능
@Bean Hibernate5Module hibernate5Module() { Hibernate5Module hibernate5Module = new Hibernate5Module(); //모듈만 하게될경우에는 lazy로 설정된것은 다 null로 리턴 hibernate5Module.configure(Hibernate5Module.Feature.FORCE_LAZY_LOADING,true); //Json 호출하는 시점에 Lazy 로딩을 해버림 -> 데이터가 다나오게됨 return hibernate5Module; }
모듈만 하게될경우에는 lazy로 설정된것은 다 null로 리턴
hibernate5Module.configure(Hibernate5Module.Feature.FORCE_LAZY_LOADING,true); 함으로서Json 호출하는 시점에 Lazy 로딩을 해버림 -> 데이터가 다나오게됨
· 이 옵션을 키면 order -> member , member -> orders 양방향 연관관계를 계속 로딩하게 된다. 따라서 @JsonIgnore 옵션을 한곳에 주어야 한다.
강제 초기화 두번째 proxy를 초기화 시켜 lazy 강제 초기화(hibernate5Module.configure(Hibernate5Module.Feature.FORCE_LAZY_LOADING,true); 주석 후 실행)
@GetMapping("/api/v1/simple-orders") public List<Order> ordersV1(){ List<Order> all = orderRepository.findAllByCriteria(new OrderSearch()); for (Order order : all) { order.getMember() //order.getMember() 프록시 객체 .getName(); //.getName()때 실제 name을 끌고와야하기 때문에 DB에서 //MEMBER 호출 Lazy 강제 초기화 order.getDelivery().getAddress(); //위랑 같음 } return all; }
주의: 엔티티를 직접 노출할 때는 양방향 연관관계가 걸린 곳은 꼭! 한곳을 @JsonIgnore 처리 해야 한다. 안그러면 양쪽을 서로 호출하면서 무한 루프가 걸린다.
참고: 앞에서 계속 강조했듯이 정말 간단한 애플리케이션이 아니면 엔티티를 API 응답으로 외부로 노출하는 것은 좋지 않다. 따라서 Hibernate5Module 를 사용하기 보다는 DTO로 변환해서 반환하는 것이 더 좋은 방법이다.
주의: 지연 로딩(LAZY)을 피하기 위해 즉시 로딩(EARGR)으로 설정하면 안된다! 즉시 로딩 때문에 연관관계가 필요 없는 경우에도 데이터를 항상 조회해서 성능 문제가 발생할 수 있다. 즉시 로딩으로 설정하면 성능 튜닝이 매우 어려워 진다.
항상 지연 로딩을 기본으로 하고, 성능 최적화가 필요한 경우에는 페치 조인(fetch join)을 사용해라!(V3 에서 설명)
간단한 주문 조회 V2: 엔티티를 DTO로 변환
/** * V2. 엔티티를 조회해서 DTO로 변환(fetch join 사용X) * - 단점: 지연로딩으로 쿼리 N번 호출 */ @GetMapping("/api/v2/simple-orders") public List<SimpleOrderDto> ordersV2() { // List<Order> orders = orderRepository.findAllByCriteria(new OrderSearch()); // List<SimpleOrderDto> collect = orders.stream() //주문을 심플오더디티오로 변경 // .map(o -> new SimpleOrderDto(o))// .map은 a를 b로 바꾸는 것이다. // .collect(Collectors.toList()); //얘를 collect로 해서 list로 변환 // return collect; // List<Order> orders = orderRepository.findAllByCriteria(new OrderSearch()); // return orders.stream() //주문을 심플오더디티오로 변경 // .map(o -> new SimpleOrderDto(o)) // .collect(Collectors.toList()); // return orderRepository.findAllByCriteria(new OrderSearch()).stream() // .map(o -> new SimpleOrderDto(o)) // .collect(Collectors.toList()); return orderRepository.findAllByCriteria(new OrderSearch()).stream() .map(SimpleOrderDto::new) .collect(Collectors.toList()); } @Data static class SimpleOrderDto{ private Long orderId; private String name; private LocalDateTime orderDate; private OrderStatus orderStatus; private Address address; public SimpleOrderDto(Order order){ //DTO가 엔티티를 가지는것은 문제가 되지 않는다. orderId = order.getId(); name =order.getMember().getName(); orderDate =order.getOrderDate(); orderStatus =order.getStatus(); address= order.getDelivery().getAddress(); } }
· 엔티티를 DTO로 변환하는 일반적인 방법이다.
· 필드 이름도 v1에서는 엔티티가 결국 노출되는거기때문에 좋지가 않았다.
(엔티티가 아니라 절대적으로 DTO로 묶어서 보내야한다.)
하지만 v2에서는 api 스팩에 딱맞춰서 최적화해서 결과 도출
V1 V2 · 쿼리가 총 1 + N + N번 실행된다. (v1과 쿼리수 결과는 같다.)//(1+ N(2)) 첫번째 N회원 +배송 N번
· order 조회 1번(order 조회 결과 수가 N이 된다.)
· order -> member 지연 로딩 조회 N 번
· order -> delivery 지연 로딩 조회 N 번
· 예) order의 결과가 4개면 최악의 경우 1 + 4 + 4번 실행된다.(최악의 경우)
· 지연로딩은 영속성 컨텍스트에서 조회하므로, 이미 조회된 경우 쿼리를 생략한다.
(이로 인해 조금 줄어들순 있지만 꼭 그렇지는 않다.)
EX)
빨간 블럭을 호출할때 Lazy가 초기화됨. · V1,V2가 공통으로 가지고 있는 문제점 Lazy로 인한 DB Query가 너무 많이 호출된다.
· V2 - order ,member ,delivery 세개의 entity를 건든다 결과적으로 3개의 테이블을 조회함.
1번 째 - > order, member, delivery 조회
2번 쟤 - > member, delivery 조회
총 5번 조회...
간단한 주문 조회 V3: 엔티티를 DTO로 변환 - 페치 조인 최적화 (중요)
/** * 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() { //select절에서 다가져옴 한방쿼리로 order member delivery를 다들고옴. //lazy proxy다 무시하고 진짜 값 다채워서 가져온다 return em.createQuery("select o from Order o" + " join fetch o.member" + " join fetch o.delivery d" , Order.class) .getResultList(); }
select절에서 다가져옴 한방쿼리로 order member delivery를 다들고옴.
lazy proxy다 무시하고 진짜 값 다채워서 가져온다· 엔티티를 페치 조인(fetch join)을 사용해서 쿼리 1번에
· 조회 페치 조인으로 order -> member, order -> delivery는 이미 조회 된 상태이므로 지연로딩X
fetch join으로 한방에 쿼리를 들고옴. 간단한 주문 조회 V4: JPA에서 DTO로 바로 조회
/** * V4. JPA에서 DTO로 바로 조회 * - 쿼리 1번 호출 * - select 절에서 원하는 데이터만 선택해서 조회 */ @GetMapping("/api/v4/simple-orders") public List<OrderSimpleQueryDto> ordersV4() { return orderSimpleQueryRepository.findOrderDtos(); }
3번같은 경우에는 엔티티르르 조회해서 바로 DTO로 변환했다. 이번에는 JPA에서 DTO로 꺼내는것 조금더 성능이 최적화된다.
OrderSimpleQueryRepository 조회 전용 리포지토리
Repository에 findOrderDtos메서드가 있기때문에 SimpleOrderDto를 쓰게되면
의존관계가 Repository를 컨트롤러로 보는 이상한사태가 일어날 수 있어서 Dto를 새로 만든다.의존관계는 한방향으로 흘러야한다. 역방향으로 흐르면 안된다.
findOrderDtos method ( X )
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 { public List<OrderSimpleQueryDto> findOrderDtos() { return em.createQuery( "select o from Order o" + " join o.member m " + "join o.delivery d",OrderSimpleQueryDto.class) .getResultList(); //여기서 보면 o가 select되는데 OrderSimpleQueryDto에 매핑이 될수가 없다. 생성자에 들어가서 매핑되고 그러는게 아니기 때문에 //JPA는 entity나 value object(embedable)만 반환 할 수 있다. Dto같은것은 안되고 new operation을 꼭써야한다. } }
여기서 보면 o가 select되는데 OrderSimpleQueryDto에 매핑이 될수가 없다. 생성자에 들어가서 매핑되고 그러는게 아니기 때문이다.
JPA는 entity나 value object(embedable)만 반환 할 수 있고,Dto같은 것은 반환안된다. 그래서 new operation을 꼭써야한다.findOrderDtos method ( x ) Ojbect는 식별자로 써서 안됨, 파라미터로 입력해야함
return em.createQuery( "select new jpabook.jpashop.repository.OrderSimpleQueryDto(o)" + " from Order o" + " join o.member m " + "join o.delivery d",OrderSimpleQueryDto.class) .getResultList();
new jpabook.jpashop.repository.OrderSimpleQueryDto(o) 만약 이렇게 생성자를 o를 넣으면 좋겠지만
o를 넣지 못한다. Order Entity를 못받는 이유는?
JPA는 왓다갓다할때 기본적으로 식별자로 반환한다(order의 식별자) 그래서 안되고
다 파라미터로 넣어줘야한다.
findOrderDtos method ( O )
public List<OrderSimpleQueryDto> findOrderDtos() { return em.createQuery( "select new jpabook.jpashop.repository.OrderSimpleQueryDto(" + "o.id,m.name,o.orderDate,o.orderDate,d.address)" + //address는 valye 타입이라서 가능 " from Order o" + " join o.member m " + "join o.delivery d",OrderSimpleQueryDto.class) .getResultList(); }
물리적으로는 계층이 나눠져있지만 논리적으로는 계층이 다 깨져있는것이다.
api repository로 화면을 의존하고 있는 것이다. API 스팩이 바뀌면 얘를 뜯어서 고쳐야한다. 즉 Repositoy를 또 수정해야한다.
-OrderSimpleQueryDto는 쿼리를 짯다뿐이지 API 스팩이 여기에 들어와있는것 그런데repository는
Entity들의 객체 그래프를 조회할 떄 사용한다. 그래서 강사님기준 Entity를 순수하게 조회하거나 필요한것을 성능 최적화를 위해서 fetch join 하는거까진 상관없음.
OrderSimpleQueryDto 리포지토리에서 DTO 직접 조회
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; } }
아래는 똑같지만 select절은 훨신 짧아짐 V3 vs V4
V3는 join까지는 똑같지만 Select절에서 데이터 많이 들고옴. -> 네트워크를 더 많이 쓴다.
그렇다고해서 V4가 더좋다는 것도아니다. V3, V4 둘간의 우열을 가리기 어렵다.
V3
-Order를 가지고와서 fetch join으로 원하는것만 select-> 외부의 모습을 건드리지 않는 상태
내부의 원하는 것만 fetch join으로 성능 튜닝
-재사용성이 좋음. 원하는 DTO로 변경가능
-엔티티를 조회했기때문에 비지니스로직에서 써서 데이터를 변경하거나 할수 있음
-성능 최적화에서 조금떨어짐
V4
-실제 쿼리를짜듯이 JPQL로 가져와서 쓰는것 . 해당 화면에서만 해당 DTO로 쓰지 재사용성이 안좋음
-로직재활용 불가능
-코드상 지저문하다.
- DTO를 조회했기때문에 아에 변경 불가 (Entity가 아니기때문에 얘를 바꿔바야 JPA에서 할 수 있는게 없다.)
- 성능 최적화에서 조금더 나음
· 일반적인 SQL을 사용할 때 처럼 원하는 값을 선택해서 조회
· new 명령어를 사용해서 JPQL의 결과를 DTO로 즉시 변환
· SELECT 절에서 원하는 데이터를 직접 선택하므로 DB 애플리케이션 네트웍 용량 최적화(생각보다 미비)
· 리포지토리 재사용성 떨어짐, API 스펙에 맞춘 코드가 리포지토리에 들어가는 단점
4번 단점 상쇄방법 Repository는 Entity를 조회해야하는데 써야한다. 그래서 V2,V3에는 맞다
Dto를 조회한다? 그럼 API 스팩이 Repository에 들어와있는것이다.
그래서 이를 해결할려면 repository하위나 별도로 쿼리용 성능최적화를 가지는 패키지 하나를 만든다.
OrderSimpleQueryDto도 이동. 이 Repository는 화면에 그냥 박히는것이다. 화면에딱 의존적인건데 로직은 무겁고 복잡한것은 별도로 뽑아서 이렇게 만든다. 이래야 유지보수성이 좋아진다.
정리
앤티티를 성능 최적화를 하거나,엔티티를 DTO로 변환하거나, DTO로 바로 조회하는 두가지 방법은 각각 장단점이 있다. 둘중 상황에 따라서 더 나은 방법을 선택하면 된다. 엔티티로 조회하면 리포지토리 재사용성도 좋고, 개발도 단순해진다. 따라서 권장하는 방법은 다음과 같다.
쿼리 방식 선택 권장 순서
1. 우선 엔티티를 DTO로 변환하는 방법을 선택(V2)한다.
2. 필요하면 페치 조인으로 성능을 최적화(V3) 한다. 대부분의 성능 이슈가 해결된다.
3. 그래도 안되면 DTO로 직접 조회하는 방법(V4)을 사용한다.
4. 최후의 방법은 JPA가 제공하는 네이티브 SQL이나 스프링 JDBC Template을 사용해서 SQL을 직접 사용한다.
'SPRING-BOOT' 카테고리의 다른 글
#실전2 - 8 OSIV와 성능 최적화 (0) 2021.03.12 #실전2 - 7 컬렉션 조회 최적화 (0) 2021.03.09 #실전2 - 5 API 개발 고급 수강 전 조회용 샘플 데이터 입력 (0) 2021.03.08 #실전2 - 4 회원조회 API (0) 2021.03.08 #실전2 - 3 회원수정 API (0) 2021.03.08