[JPA] API 개발 고급 - 지연로딩(LAZY)과 조회 성능 최적화

이준영·2022년 11월 3일
0

주문 조회 API

최적화 파트는 매우매우매우매우 중요하니 100% 이해하고 넘어가자

내용이 많아서 길어질텐데 꼭 다 읽고 이해하기!!

시작!

🧙 :
주문 조회 API를 만들건데 이제 배송정보, 회원 정보까지 들어있는..
API를 만들면서 지연 로딩(LAZY) 때문에 발생하는 성능 문제를 해결해나갈거야

API Controller의 이름은 OrderSimpleApiController.
여기서는 xToOne의 관계(OneToOne, ManyToOne)만 다룰거야.

  • OrderSimpleApiController
package jpabook.jpashop.api;

@RestController
@RequiredArgsConstructor
public class OrderSimpleApiController {

    private final OrderRepository orderRepository;
    private final OrderSimpleQueryRepository orderSimpleQueryRepository;
    
}


version_1 : 엔티티를 직접 조회

🧙 :
결론부터 말하자면 version_1의 방법(엔티티를 직접 노출하는 방법)은 쓰면 안돼.

왜 안되는지는 저번 포스팅에도 있고 지금 더 자세히 알아보자!!

엔티티를 직접 조회

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

🤠 : orderRepository에서 findAllByString() 메서드를 사용하여 객체를 파라미터로 받아요

🧙 :
지금 우리가 예제로 가지고 있는 Order, Member, Delivery를 보면
Order —> Member 와 Order —> Delivery의 관계를 가지고 있고 둘 다 지연로딩(LAZY)이야.

  • Order —> Member / Order —> Delivery : 이건 도메인 분석 설계에서 공부한건데 Order 개체에 속성으로 Member와 Delivery가 있어
    Order : Member = 1 : N ➡️ ManyToOne
    Order : Delivery = 1 : 1 ➡️ OneToOne

  • 지연 로딩 : 지연로딩은 엔티티 클래스 개발엔티티 설계시 주의할 점에서 공부한 내용인데 간단하게 복습하자면
    xToOne인 애들은 기본 fetchEAGER 야.
    EAGER에는 문제가 있는데
    하나의 개체(Order)만 조회하고 싶은데 하나의 개체를 조회하면 그 개체(Order)를 물고있는 애들(Member, Delivery)이 줄줄이 딸려와.
    Order만 조회하고 싶어도 쓸데없이 Member와 Delivery를 조회하는 쿼리문이 작성돼서 성능이 안좋아지는거야.
    이렇게 하나의 개체를 호출했는데 애를 물고있는 애들이 줄줄이 따라오는걸 막기위한 전략이 지연로딩(LAZY) 이라는 거지!


🧐 : 지연로딩(LAZY)인게 뭐 어떻다고?

🧙 :
지연로딩인 상황에서 엔티티를 직접 노출해서 사용하는 경우 생기는 문제가 있는데

  • 양방향 관계 문제 발생 :
    Order - Member , Order - Delivery는 양방향 관계라 서로를 물고있어
    ( 양방향 관계 - 도메인 분석 설계 참고 )
    이 상황에서 쿼리문으로 개체를 호출하면??
    서로가 서로를 계속 타고 넘어가면서 무한루프가 발생!!

🧐 : 그럼 어떡해??

🧙
한쪽에 @JsonIgnore을 줘서 이 상황을 막아줘야돼.
이번 예제에선 Member, Delivery, OrderItem 에 @JsonIgnore를 붙여줘야돼

엔티티를 직접 노출할 때 양방향 관계가 걸린 곳은 한쪽에 @JsonIgnore 해줘야한다!!

무한루프를 막았다고 끝난게 아니야!!

Order 개체를 조회한다고 생각해보자.

근데 Order에는 Member 속성이 있고 얘의 fetch 타입은 LAZY야.
LAZY는 진짜 Member개체를 가져오는게 아니야!

위에서 지연로딩(LAZY)은 하나의 개체를 조회 할 때 얘를 물고있는 애들이 줄줄이 안딸려오게 하기 위해서 쓰는 거라고 했지??

어떻게 이게 가능하냐면 애초에 디비에서 안가져와.
LAZY. 게으른. 즉, 귀찮아서 안가져오는거야.

DB에서는 내가 조회한(주최가되는) 개체인 Order만 가져오는거지.


🧐 : 그럼 그 안가져온 애들(Member, Delivery)은 어떻게 처리하는데?

🧙 :
이때 나오는게 Proxy라는 개념인데 fetch=LAZY인 애들한테 임시 값인 ByteBuddyIntercept을 넣어놓는 거지.
이걸 프록시 객체 라고 하나봐..

그리고 마지막에 이 프록시 객체(ByteBuddyIntercept)를 어찌저찌해서 가져오나 본데
아무튼 jackson 라이브러리는 이 프록시 객체를 json으로 어떻게 생성해야되는지 몰라.

이렇게 처리되기 때문에
Member나 Delivery같이 fetch 타입이 LAZY인 객체들의 값도 필요한 상황에서는 Order를 조회함과 동시에 Member 객체를 읽으려고하면 객체가 아니라 무슨 이상한 byteBuddy 뭐시기가 들어있으니까 오류가 발생해


🧐 : 그럼 LAZY를 다시 EAGER로 바꾸면 되는거 아냐?

🧙 :
❌ 이건 절대 안돼 ❌
필요없는 데이터 싹다 끌고오고, 다른 API에서도 문제생겨. 그냥 하지마.

❗️ 무조건 지연로딩(LAZY) 가 기본❗️

  • Lazy를 디폴트로 하고 필요한것만 fetch join 이용하자

fetch join은 V3에서 배울거야


🧐 : 그럼 뭐 어떡하라고..

🧙
이렇게 지연 로딩인 경우에는

  • Hibernate5Module을 이용 :
  1. builde.gradle에서 라이브러리 추가해주고
    implementation 'com.fasterxml.jackson.datatype:jackson-datatype-hibernate5'
  2. @Bean 이용해서 hibernate5Module을 스프링 빈에 등록하면
package jpabook.jpashop;

@SpringBootApplication
public class JpashopApplication {

	...
    
	// OrderSimpleApiController v1을 위한 hibernate5Module 사용
	@Bean
	Hibernate5Module hibernate5Module(){
		Hibernate5Module hibernate5Module = new Hibernate5Module();
        // FORCE_LAZY_LOADING 옵션
        hibernate5Module.configure(Hibernate5Module.Feature.FORCE_LAZY_LOADING, true);
		return hibernate5Module();
	}

}
  1. hibernate에 옵션(Force Lazy Loading)을 추가해서 원하는 데이터를 뽑아올 수 있어.

또다른 방법으로는
.get을 이용해서 원하는 데이터 조회하는 방법이 있는데

	@GetMapping("/api/v1/simple-orders")
      public List<Order> ordersV1() {
          List<Order> all = orderRepository.findAllByString(new OrderSearch());
         
          **************************************************
          ****** 이 부분이 get 이용하여 원하는 데이터 조회하기! ******

          for (Order order : all) {
			order.getMember().getName(); //Lazy 강제 초기화
			order.getDelivery().getAddress(); //Lazy 강제 초기화
          }
          
          **************************************************
		  return all; 
            
    }

이렇게 하면
fetch가 LAZY여서 아직 해당 객체의 데이터 안가져왔는데 .get으로 데이터를 가져오라는 명령어를 받으면 LAZY가 강제 초기화돼!

BUT, 다시 한번 말하지만 엔티티를 외부로 노출하는 방법은 쓰지마!

Hibernate5Module, .get 을 쓰기보다는 엔티티를 DTO 변환해서 반환하는게 더 좋은 방법!!


version_2 : 엔티티를 DTO로 변환

🧙 :
이제 위에서 노래를 부르던 엔티티를 DTO로 변환하는 방법을 알아보자!

엔티티를 DTO로 변환하는 코드

	@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;
    }
    
    // DTO 생성
    @Data
    static class SimpleOrderDto {

        private Long orderId;
        private String name;
        private LocalDateTime orderDate; // 주문시간
        private OrderStatus orderStatus;
        private Address address;

        // DTO가 엔티티를 파라미터로 받는건 괜찮다
        public SimpleOrderDto(Order order) {
            orderId = order.getId();
            name = order.getMember().getName();         // 여기서 LAZY 초기화
            orderDate = order.getOrderDate();
            orderStatus = order.getStatus();
            address = order.getDelivery().getAddress(); // 여기서 LAZY 초기화
        }
    }

🤠 : 간단한 코드 설명

* API 스펙에 맞는 DTO(OrderSimpleDto)를 만들고(with. @Data)
* orderRepository에서 정의된 findAll( )사용해서 모든 주문 객체를 조회해서 List<Order>에 넣고
* 그 List<Order>에 있는 애들을 List<OrderSimpleDto>에 옮겨주면 
* map을 사용해서 List<OrderSimpleDto> 안에 있는 애들을 하나씩 불러내면서 출력해주는 로직
* OrderSampleDto에서는 변수 선언하고 생성자에서 변수 초기화 해주면 끝!
🧐 : 엔티티 외부로 노출하지 (엔티티를 직접적으로 파라미터로 받지) 말라며?!
* DTO에선 엔티티 외부로 노출돼도 괜찮아~~!!

🧙 :
이 방법을 쓰면 엔티티를 외부로 노출 하지 않을 수 있지만 N + 1 문제라는 성능적 측면에서의 문제가 생겨. (쿼리 수가 v1이랑 똑같다)


N + 1 문제란?

userA는 [Book_A & Book_B]를 주문했고, userB는 [Book_C & Book_D]를 주문한 상황
주문 조회(Orders)에는 Member, Delivery가 엮여있다. 이 때 주문 조회를 하면??
Orders 를 호출하는 쿼리(조회의 주최가되는 객체),
userA를 확인하기 위해서 Member 호출하는 쿼리, & userA의 주소를 확인하기 위해서 Delivery를 호출하는 쿼리
userB를 확인하기 위해서 Member 호출하는 쿼리, & userA의 주소를 확인하기 위해서 Delivery를 호출하는 쿼리

위에서 쿼리라는 말이 몇번나왔지?? —> Member 2개, Delivery 2개, Order 1개. 총 다섯번
이게 바로 N + 1 문제라는 건데 물고있는게 많고, 주문수가 많을 수록 점점 더 커지겠지??

N + 1 문제 :
하나의 쿼리를 날리는데 조회되는 결과의 개수만큼 쿼리가 추가적으로 나오는 것

위의 상황에서 최악의 경우 N(2) + N(2) + 1 번 호출된다!

🧐 : 최악이 아닌 경우는 뭐야?

🧙 :
최악이 아닌경우는 userA가 [Book_A &BookB] 와 [Book_C, Book_D] 를 주문한 경우인데
이런 경우는 Member 조회를 한번만 해도 되기 때문에(주문한 사람이 한명이니까) 최악이 아니야.
이게 가능한 이유는 지연로딩은 영속성 컨텍스트에서 조회 하므로 이미 조회된 경우 쿼리를 생략한다. 라고하네.


version_3 : 엔티티를 DTO로 변환 & Fetch Join

🧐 : 뭐야 그럼 DTO로 변환해도 엔티티를 외부로 노출시키지만 않았지 성능은 완전 꽝이잖아??

🧙 :
DTO로 변환하면 맞아. 하지만 Fetch Join이란걸 사용하면 N + 1 문제 해결할 수 있어!!


Fetch Join이란?

간단히 말해서 조회의 주체가 되는 객체 이외에 Fetch Join이 걸린 연관 엔티티도 함께 SELECT 하여 모두 영속화하는것이 Fetch Join이다.

자세한 설명은 일반 Join과 Fetch Join의 차이, Fetch Join vs 일반 Join(feat.DTO)를 참고하자

🧙 :
이렇게 Fetch Join을 사용하면 쿼리 1번에 조회가 가능해져!!

Fetch Join 사용하여 엔티티를 DTO로 변환하는 코드

	@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(Collectors.toList());

        return result;
    }

🤠 : 간단한 코드 설명

* version_2에서 만든  OrderSimpleDto 객체를 이용
* orderRepository에서 모든 주문 객체를 조회해서 List에 넣을건데
V2에서는 findAll()을 사용해서 객체를 조회한 뒤 List에 넣었잖아?
* V3에서는 fetch join을 적용한 findAllWithMemberDelivery( ) 를 사용해서 객체를 조회한뒤 Lsit에 넣을거야.
* 그러기 위해서는 findAllWithMemberDelivery( ) 를 만들어주고
* orderRepository에 있는 findAllWithMemberDelivery()사용해서 모든 주문 객체를 조회해서 List<Order>에 넣고
* List<Order>에 있는 객체들을 List<OrderSimpleDto>에 옮겨주면
* map으로 List<OrderSimpleDto> 안에 있는 애들을 하나씩 조회!!

findAllWithMemberDelivery() 메서드 정의 (in. 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();
    }

🤠 : 간단한 코드 설명.. 할거는 딱히 없고 이거는 그냥 sql 문이니까.. 공부하세용




version_4 : JPA에서 DTO로 바로 조회

🧐 : 근데 v2도 그렇고 v3도 그렇고 뭔가 일을 두번 하고 있는 것 같은데??

🧙 :
v2,v3 모두 엔티티를 DTO로 바꿔주고 있어.
OrderRepository에서 엔티티로 조회한걸 다시 DTO에 넣어서 조회하고 있잖아??

엔티티 ➡️ DTO 과정을 없애고 바로 JPA에서 DTO로 바로 조회 할 수 있는 방법이 있는데 그게 바로 지금부터 배울 내용이야

v2,3에서는 (entity)Repository에서 findAllWithMemberDelivery()를 통해 엔티티를 조회 했는데
v4에서는 DtoRepository에서 findDtos( )를 통해 DTO를 조회하려고해

  • DtoRepository : OrderSimpleQueryRepository
  • findDtos() : findOrderDtos( )

를 만들자!!

OrderSimpleQueryRepository &findOrderDtos( )는 오로지 API에서 조회를 위해서만 사용하는 리포지토리와 메서드이기 때문에 엔티티를 조회할 수 있는 리포지토리와 메서드랑은 성격이 달라서 따로 빼주는게 좋아

repository 패키지에서 orderSimpleQuery 패키지를 만들고 여기에
OrderSimpleQueryRepository 리포지토리와
DTO인 OrderSimpleQueryDto를 만들자

결론 : OrderSimpleQueryRepository & findOrderDtos( )OrderSimpleQueryDto 추가


JPA가 직접 조회할 DTO 생성 : OrderSimpleQueryDto

package jpabook.jpashop.repository.order.simplequery;

@Data
public class OrderSimpleQueryDto {

    private Long orderId;
    private String name;
    private LocalDateTime orderDate; //주문시간 private OrderStatus orderStatus;
    private OrderStatus orderStatus;
    private Address address;

    // DTO가 엔티티를 파라미터로 받는게 아니라 받아올 파라미터를 일일히 열거해주어야 한다
    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;
    }
}

🤠 : 간단한 코드 설명

* 기본적인 틀은 v2,v3에서 사용한 DTO와 똑같아요
* 차이점은 생성자에 파라미터를 엔티티로 받는것이 아니라 받아올 데이터 하나하나 가져와야 해요

DtoRepository인 'OrderSimpleQueryRepository'

package jpabook.jpashop.repository.order.simplequery;

@Repository
@RequiredArgsConstructor
public class OrderSimpleQueryRepository {

    private final EntityManager em;

    
}

🤠 : 리포지토리이기 때문에 EntityManger 하나 생성해주세용

객체 조회를 도와줄 'findOrderDtos( )' 메서드

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

🤠 : 간단한 코드 설명

* SQL문은 공부하세용
* v3에서는 엔티티를 조회한 후에 DTO에 넣는 과정이었기 때문에 SELECT 뒤에 객체(Orders)가 들어갔는데 
* 이제는 DTO를 직접 조회하는 것이기 때문에 OrderSimpleQueryDto를 넣어줄거야(경로까지 다 넣어줘야돼..? 그냥 OrderSimpleQueryDto 이렇게 넣으면 안돼..?)
* 이때 new 명령어를 사용해서 JPQL의 결과를 DTO로 즉시 변환
* v3에서와는 다르게 일반 join을 사용

🧙 :
이제 귀찮아지는게

  1. DTO에서는 생성자에 파라미터를 엔티티로 받는것이 아니라 받아올 데이터 하나하나 가져와야하고

  2. find( ) 메서드에서 v3에서는 엔티티를 가져온 뒤에 DTO로 감싸는 거였기 때문에 SELECT문에 객체(Order)가 들어가면 됐는데
    지금은 DTO를 직접 조회하는 것이기 때문에 new 명령어와 함께 OrderSimpleQueryDto를 경로까지 포함해서 넣어줘야돼

귀찮다 = 유지보수 귀찮/어려워진다

아무튼 이렇게 findOrderDtos( ) 가 만들어졌으면 컨트롤러에서
그냥 저 findOrderDtos( )를 불러오면 끝!!!

Contoller에서 findOrderDtos() 불러오기

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

🤠 : orderSimpleQueryRepository에서 작성한 findOrderDtos()메서드를 호출해요

🧙 :
v4같은 경우 SELECT절에서 원하는 데이터를 직접 선택하므로 성능 최적화가 이뤄지는데 사실 이 정도 성능은 무시해도 될 정도로 작다고해.
쿼리문의 성능을 결정 짓는건 그 밑에 있는 조인이나, 조건..?
그래도 select에서 뽑아낼 데이터가 엄청 많으면 이렇게 하는게 유의미 하겠지?!
근데 이렇게 하면 리포지토리의 재사용성이 떨어지고, API 스펙에 맞춘 코드가 리포지토리에 들어간다는 단점이 있어서 v3와 v4 중 뭘 쓸지는 너가 선택할 사항 😉



'엔티티를 DTO로 변환하기' vs 'DTO로 직접 받기'

엔티티를 DTO로 변환하거나, DTO로 바로 조회하는 두가지 방법은 각각 장단점이 있다.
둘중 상황에 따라서 더 나은 방법을 선택하면 된다.

  • 엔티티를 DTO로 변환하기 : 엔티티로 조회하면 리포지토리 재사용성도 좋고, 개발도 단순해진다
  • DTO로 직접 받기 : SELECT 절에서 원하는 데이터를 직접 선택하므로 최적화 중에 최적화

쿼리 방식 선택 권장 순서

  1. 엔티티 외부로 노출 할 생각은 하지마라
  2. 엔티티를 DTO로 변환하는 방법 선택하고 필요하면 fetch join으로 성능 최적화(여기까지하면 대부분 성능 이슈 해결된다)
  3. 그래도 안되면 DTO로 직접 조회하는 방법 사용
  4. 이래도 안돼??? 최후의 방법은 JPA가 제공하는 네이티브 SQL이나 스프링 JDBC Template을 사용해서 SQL을 직접 사용한다.



추가 공부할 것

🧐 : API Response 받을 때 배열로 받으면 안좋다고..?
🧐 : orderRepository에서 Controller쪽으로 의존관계가 생기면 큰일난다고..?

profile
화이팅!

0개의 댓글