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

윤경·2021년 10월 28일
0

Spring Boot

목록 보기
51/79
post-thumbnail

⭐️ 실무에서 JPA를 사용하기 위해서는 정!!!!말 중요한 내용 ⭐️

어차피 나중에 직접 겪게 될 오류 혼자 처리하고 혼자 헤매고 싶으면 대충 넘기던가!!

그리고 참고로 포스트에 기재된 코드만으로 이루어 진 것이 아님. Repository에 추가적인 코드가 필요하고, 따로 클래스가 필요하기도 함.(Github참고)


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

목표: 주문 + 배송정보 + 회원을 조회하는 API 만들기
지연 로딩 때문에 발생하는 성능 문제를 단계적으로 해결하기

참고로 V1은 대충 들어도 됨. 어차피 엔티티를 직접 노출하는건,,👎

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

지연 로딩이기 때문에 null

이렇게 결과가 다 나오지 않고 null로 나오는 이유는 지연로딩
order → member, order → address는 지연 로딩으로 설정되어있다. 따라서 실제 엔티티 대신 프록시로 존재한다.

jackson 라이브러리는 기본적으로 이 프록시 객체를 json으로 어떻게 생성해야 하는지 모른다. ➡️ 예외 발생

Hibernate5Module을 스프링 빈으로 등록하면 해결할 수 있다. (좋은 방법은 아님)
기본적으로 초기화 된 프록시 객체만 노출시키고 초기화 되지 않은 프록시 객체는 노출되지 않는다.

hibernate5Module.configure(Hibernate5Module.Feature.FORCE_LAZY_LOADING, true);
이 옵션을 사용하면 order → member, order → address 양방향 연관관계를 계속 로딩하게 된다. 따라서 @JsonIgnore 옵션을 한 곳에 주어야 한다.

lazy 강제 초기화

정리

엔티티를 직접 노출할 때는 양방향 연관관계가 걸리 곳은 꼭 한 쪽은 @JsonIgnore 처리하기
그렇지 않으면 양쪽을 서로 호출하며 무한 루프에 빠지게 된다.

앞 포스트에서 강조했듯 정말 간단한 애플리케이션이 아니라면 엔티티를 API 응답으로 외부에 노출시키는 것은 좋지 않은 방법이다.
따라서 Hibernate5Module을 사용하기 보다는 DTO로 변환해 반환하는 방법을 사용하자.

지연로딩(LAZY)를 피하기 위해 즉시로딩(EAGER)으로 설정하지 말자.
즉시 로딩 때문에 연관관계가 필요 없는 경우에도 항상 데이터를 조회해 성능에 문제가 발생할 수 있다. 즉시로딩으로 설정하면 성능 튜닝이 매우 어려워진다.
항상 기본으로 지연 로딩을 사용하며, 성능 최적화가 필요한 경우에는 fetch join을 사용하도록 하자.


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

: 엔티티를 DTO로 변환하는 일반적인 방법
V1, 2 둘 다 쿼리가 너무 많이 호출된다는 문제점이 있다.

쿼리가 총 1 + N + N번 실행된다.
(예제에서는 주문이 2개이기 때문에 1+2+2 = 5)

order 조회 1번(order 조회 결과 수가 N이 된다.)
order → member 지연 로딩 조회 N번
order → delivery 지연 로딩 조회 N번

최악의 경우 주문이 4개면 1+4+4번이 실행된다는 것이다.
(지연 로딩은 영속성 컨텍스트에서 조회하므로, 이미 조회된 경우 쿼리를 생략한다.)

    @GetMapping("/api/v2/simple-orders")
    public List<SimpleOrderDto> orderV2() {
        List<Order> orders = orderRepository.findAllByString(new OrderSearch());
        List<SimpleOrderDto> result = orders.stream()
                .map(o -> new SimpleOrderDto(o))
                .collect(Collectors.toList());

        return result;
    }

포스트맨


[3] 간단한 주문 조회 V3: 엔티티를 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;
    }

쿼리 딱 한 번

엔티티를 JPA fetch join 기능으로 쿼리가 너무 많이 발생하던 문제를 쿼리 한 번으로 조회할 수 있게 함
fetch join으로 order → member, order → delivery는 이미 조회된 상태이므로 지연로딩 X

즉, LAZY(지연로딩)으로 설정해놓고 필요할 때만 fetch join을 활용하면 웬만한 성능 문제를 해결할 수 있다.


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

  • 일반적인 SQL을 사용할 때처럼 원하는 값을 선택해 조회
  • new 명령어를 사용해 JPQL의 결과를 DTO로 즉시 변환
  • SELECT 절에서 원하는 데이터를 직접 선택하므로 DB → 애플리케이션 네트워크 용량 최적화(사실 생각보다 미비)
  • 리포지토리 재사용성이 떨어진다. API 스펙에 맞춘 코드가 리포지토리에 들어간다는 단점이 있다.
    (V3는 재사용 가능)

단축키

Command + option + L: 줄맞춤

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

포스트맨포스트맨 결과는 V3와 동일하지만 쿼리를 살펴보면

select차이V3와는 달리 모든 것을 select하지 않고 원하는 것만 select 한 것을 확인할 수 있다.

정리

엔티티를 DTO로 변환하거나, DTO로 바로 조회하는 두 가지 방법은 각각의 장단점이 존재한다. 둘 중 상황에 맞는 방법을 선택하자.

엔티티로 조회하면 리포지토리 재사용성도 좋고 개발도 단순해진다.

쿼리 방식 선택 권장 순서

  1. (V2) 우선 엔티티를 DTO로 변환하는 방법을 항상 먼저 선택
  2. (V3) 필요하면 fetch Join으로 성능을 최적화 → 여기서 대부분의 성능 이슈가 해결됨
  3. (V4) 그래도 안된다면 DTO로 직접 조회하는 방법
  4. 최후의 방법은 JPA가 제공하는 네이티브 SQL이나 스프링 JDBC Template를 사용해 SQL을 직접 사용

profile
개발 바보 이사 중

0개의 댓글