RE-Heat 개발자 일지

[JPA 활용2] [3] API 개발 고급 - 지연 로딩과 조회 성능 최적화 본문

백엔드/JPA

[JPA 활용2] [3] API 개발 고급 - 지연 로딩과 조회 성능 최적화

RE-Heat 2023. 8. 31. 22:20

https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81%EB%B6%80%ED%8A%B8-JPA-API%EA%B0%9C%EB%B0%9C-%EC%84%B1%EB%8A%A5%EC%B5%9C%EC%A0%81%ED%99%94/dashboard

 

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

스프링 부트와 JPA를 활용해서 API를 개발합니다. 그리고 JPA 극한의 성능 최적화 방법을 학습할 수 있습니다., 스프링 부트, 실무에서 잘 쓰고 싶다면? 복잡한 문제까지 해결하는 힘을 길러보세요

www.inflearn.com

인프런 김영한 님의 강의를 듣고 작성한 글입니다.

 

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

주문 + 배송정보 + 회원을 조회하는 API를 만들자

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

단, 여기선 xToOne만 다룰 계획이다.(Many To One, One To One)

 

OrderSimpleApiController

/**
 * xToOne
 * Order
 * Order -> Member [Many to One]
 * Order -> Delivery [One to One]
 */

@RestController
@RequiredArgsConstructor
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;
    }
}

① 엔티티를 직접 노출하는 건 좋지 않다 => V2에서 DTO로 변환할 계획

② 그냥 실행하면 양방향 관계여서 무한 루프를 돈다.

③ 무한 루프를 해결하려면 양방향 중 한쪽에 @JsonIgnore를 붙여줘야 한다.

④ @JsonIgnore를 붙여도 typedefinition 오류가 발생한다.

이유 :

  • 지연 로딩은 초기화 전엔 임시로 Proxy객체를 넣어둔다.
    • 코드로 표현 private Member member = new ByteBuddyInterceptor()
    • 요즘에 많이 쓰이는 프록시 객체가 bytebuddy
  • 그런데 jackson 라이브러리는 프록시 객체를 json으로 변환하는 방법을 알지 못한다.

⑤ 해결방법은 Hibernate4Module을 등록하면 된다.

1. build.gradle에 implementation 'com.fasterxml.jackson.datatype:jackson-datatype-hibernate5' 추가

2. JpashopApplication.java에 다음 코드 추가

@Bean
Hibernate5Module hibernate5Module() {
    Hibernate5Module hibernate5Module = new Hibernate5Module();
    //강제 지연 로딩 설정
    //hibernate5Module.configure(Hibernate5Module.Feature.FORCE_LAZY_LOADING, true);
    return hibernate5Module;
}
  • 강제 지연 로딩 설정을 하면 양방향 연관관계를 계속 로딩하게 되므로 @JsonIgnore 옵션을 한 곳에 꼭 달아야 한다.

■ 실행화면

  • 왼쪽은 강제 지연 로딩 옵션 적용 이전 버전 : order만 있고 member, orderItem, delivery 등은 null 값이다.
  • 우측은 강제지연로딩 옵션 적용 버전 : orderItem, delivery 등도 db에서 가져온 것을 확인할 수 있다.

 

강제 지연 로딩 옵션을 끄고 원하는 값만 얻는 방법

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

get으로 데이터를 가져오라는 명령을 하면 LAZY가 강제 초기화된다. 여기선 Member와 Delivery만 가져오게 세팅

 

■ 실행화면

 

문제점

1. API 스펙이 복잡하다.

2. 쓸데없는 정보까지 노출한다.

 

참고]

LAZY가 아닌 EAGER로 바꾸면 n+1 조회한다. 또 다른 API 쪽도 문제가 발생한다.

 

결론 : 엔티티를 노출하지 말고 DTO를 만들어라!!!

 

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

OrderSimpleApiController

@RestController
@RequiredArgsConstructor
public class OrderSimpleApiController {

    private final OrderRepository orderRepository;

    @GetMapping("/api/v2/simple-orders")
    public List<SimpleOrderDto> ordersV2() {
        //ORDER 2개 조회됨.
        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 초기화
        }
    }
}
  • API 스펙에 맞는 DTO 작성. 생성자로 order를 받아와 각 필드에 넣어주는 방식
  • orderRepository에 정의된 findAllByString()으로 주문 객체를 조회해 List<Order>에 옮김
  • Order객체를 stream()을 이용해 Dto에 넣어 줌.

 

문제점

① V1, V2 모두 N+1 문제가 발생한다.

  • 조건 : userA·userB가 각각 책 2개를 주문했고 Orders엔 Member, Delivery가 매핑돼 있다.
  • 이때 주문 조회를 하면
    • Order를 호출하는 쿼리 1번
    • userA를 확인하기 위해 Member를 호출하는 쿼리 1번, 주소 확인 위해 delivery 호출하는 쿼리 1번
    • userB를 확인하기 위해 Member를 호출하는 쿼리 1번, 주소 확인 위해 delivery 호출하는 쿼리 1번
    • 총 다섯 번의 쿼리가 날아간다.
  • 이를 N+1 문제라 부른다. 

 

해결책 : 이렇게 많은 쿼리가 날아가는 것을 방지하려면 fetch join을 써야 한다.

 

[3] 간단한 주문 조회 V3 : 엔티티를 DTO로 변환 - 페치 조인 최적화

 

OrderSimpleApiController - V3

    @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;
    }
  • 엔티티를 조회한 후 DTO로 변환해서 반환하는 건 동일
  •  페치 조인을 쓴 findAllWithMemberDelivery() 메소드 불러오는 게 차이점

 

 

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으로 member와 delivery를 inner join해서 불러온다.

 

SQL문

  • V2와 달리 쿼리가 한 번만 날아간다. (N + 1 문제 해결)

 

결괏값

V2와 결과값은 동일하다

 

  • 페치 조인을 사용하면 order → member, order → delivery는 이미 조회된 상태라 지연 로딩 X
  • 다시 말해 조회 당시 실제 엔티티를 가져오므로 지연 로딩 없이 바로 사용가능하다. 따라서 페치 조인이 지연 로딩보다 우선이다.

자세한 내용은 [JPA] [11] 객체지향 쿼리 언어 - 중급 문법 (상편) 참조

 

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

 

OrderSimpleApiController - V4

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

 

OrderSimpleQueryRepository

@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();
    }
}
  • new를 이용해 객체를 생성. DTO로 바로 조회를 하도록 만드는 데, 이 객체를 생성할 때 반드시 full package path를 입력해야 한다.
  • 이때 OrderSimpleQueryDto(Order o)로 받으면 엔티티의 식별자만 넘어와서 직접 값을 넣어줘야 한다.
  • 그래서 Dto를 만들 때 엔티티로 넘기지 않고 분리해줘야 한다.

 

OrderSimpleQueryDto

@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; //LAZY 초기화
    }
}
  • V3 DTO와 달리 Order o로 넘기지 않고 다 분리시킨 것을 확인할 수 있다.

 

■ V4 장점과 단점

① 장점

  • 일반적인 SQL를 사용할 때처럼 원하는 값을 선택해 조회할 수 있다.

페치 조인(좌)는 모든 값을 가져오지만, DTO로 바로 조회하는 V4(우)는 원하는 값만 가져온 것을 확인할 수 있다.

  • new 명령어를 사용해 JPQL의 결과를 DTO로 즉시 반환한다. - 따라서 V4는 V3와는 달리 stream()을 써서 DTO로 변환해 주는 과정이 없다.
  • SELECT 절에서 원하는 데이터를 직접 선택하므로 DB → 애플리케이션 네트워크 용량 최적화를 할 수 있다. 단, 효과는 미비한 편이다.

② 단점

  • 리포지토리 재사용성이 떨어진다. API 스펙에 맞춘 코드가 리포지토리에 들어가므로 딱 한 가지 케이스에만 활용할 수 있다.

V4 단점 관련 해결책 

① 리포지토리에 order.simplequery 폴더를 별도로 만든다. - 범용 리포지토리 코드와 분리한다는 의미

  • 이러면 범용 리포지토리는 순수 엔티티 조회할 때만 사용할 수 있다.(재사용성 UP)

■ V3(페치 조인) vs V4(JPA에서 DTO 바로 조회)

V3 : 재사용성이 좋음, JPQL 코드가 간략함

V4 : 성능이 V3보다 나은 편

=> 고객 트래픽이 많고 데이터 사이즈가 크면 V4도 고려해야 한다.

 

■ 쿼리 방식 선택 권장 순서

1. 우선 엔티티를 DTO로 변환하는 방법을 선택한다. V2

2. 필요하면 페치 조인으로 성능을 최적화한다. V3

3. 그래도 안되면 DTO로 직접 조회하는 방법을 사용한다. V4

4. 최후의 방법은 JPA가 제공하는 네이티브 SQL이나 스프링 JDBC Template을 사용해 SQL을 직접 사용하는 것이다.