관리 메뉴

나구리의 개발공부기록

API 개발 고급 - 지연로딩과 조회 성능 최적화, 간단한 주문조회 V1 - 엔터티를 직접 노출, V2 - 엔터티를 DTO로반환, V3 - 페치 조인 최적화, V4 - JPA에서 DTO로 바로 조회 본문

인프런 - 스프링부트와 JPA실무 로드맵/실전! 스프링 부트와 JPA 활용2 - API 개발과 성능 최적화

API 개발 고급 - 지연로딩과 조회 성능 최적화, 간단한 주문조회 V1 - 엔터티를 직접 노출, V2 - 엔터티를 DTO로반환, V3 - 페치 조인 최적화, V4 - JPA에서 DTO로 바로 조회

소소한나구리 2024. 10. 23. 23:50

출처 : 인프런 - 실전! 스프링부트와 JPA활용2 - API개발과 성능 최적화 (유료) / 김영한님  
유료 강의이므로 정리에 초점을 두고 코드는 일부만 인용


** API 개발 고급 - 지연로딩과 조회 성능 최적화 목표

  • 실무에서 JPA를 사용하려면 API 고급편의 내용을 100% 이해해야함, 그만큼 매우 중요함
  • 주문 + 배송정보 + 회원을 조회하는 API를 생성하고 지연 로딩 때문에 발생하는 성능 문제를 단계적으로 해결

1. 간단한 주문 조회 V1 - 엔터티를 직접 노출(사용하면 안되는 방식)

1) 엔터티를 직접 노출하는 컨트롤러

(1) OrderSimpleApiController - ordersV1()생성 및 실행 - 첫번째 문제 발생

  • 매우 간단하게 리포지토리에서 전체 내역을 조회한 결과를 받은 List를 그대로 반환
  • Postman으로 컨트롤러에 매핑한 url로 요청을 보내면 서버는 무한루프에 빠지고 Jackson라이브러리의 안전장치가 동작하여 중첩이 1000이 되었을때 예외를 발생시켜 차단하는 동작이 실행되는 안좋은 상황이 벌어짐
package jpabook.jpashop.api;

/**
 * XToOne 관계의 성능 최적화(ManyToOne, OneToOne)
 * Order 조회
 * Order -> Member
 * Order -> Delivery
 */
@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;
    }
}

 

(2) 첫번째 문제 해결 - 두번째 문제 발생

  • Order와 양방향 연관관계가 걸려있는 Entity들의 필드들을 모두 @JsonIgnore를 입력하여 Json으로 반환하지 못하도록 설정
  • 이렇게 하면 양방향 중 하나만 처리하게 되기 때문에 무한루프가 발생하는 문제는 해결됨
  • 그러나 다시 API 요청을 보내보면 이제 500 에러가 발생하며 Type definition error .... hibernate.proxy.pojo.bytebuddy.ByteBuddyInterceptor .... 라는 메시지들을 남김
// Member
@JsonIgnore
@OneToMany(mappedBy = "member")
private List<Order> orders = new ArrayList<>();

// OrderItem
@JsonIgnore
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "order_id")
private Order order;

// Delivery
@JsonIgnore
@OneToOne(mappedBy = "delivery", fetch = FetchType.LAZY)
private Order order;

 

(3) 두번째 문제 해결

  • 문제의 원인은 연관관계 매핑시 fetch를 지연로딩으로 설정했기 때문에 DB에서 값을 가져오는 것은 Order의 정보만 가져오고 Member나 Delivery는 가져오지 않고 프록시로 동작하게 됨(프록시 동작방식은 JPA기본편 프록시 내용 참고)
  • 즉, Order에서 멤버나 딜리버리를 쭉 가져올 때 프록시로 동작하게 되면서 실제 멤버나 딜리버리가 아니라 프록시객체인 ByteBuddyInterceptor가 대신 동작하고 있어서 문제가 발생한 것이므로 이러한 동작을 하지 않도록 설정해줘야 함
  • build.gradle과 main메서드에 아래의 코드들을 입력하고 실행하면 위의 문제가 해결되고 정상적으로 API 응답 메시지가 뜸(연관관계의 필드의 값들은 null로 반환)
  • 주석처리된 지연로딩 설정을 강제로 로딩하는 코드를 주석을 풀고 실행하게 되면 null로 되어있던 Member와 Delivery값도 모두 가져오게 됨
  • 그러나 LAZY로 되어있던 모든 값들을 DB에서 조회하게 되므로 성능낭비가 매우 심하게 발생함
// build.gradle에 hibernate6 추가
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-hibernate6'

// 애플리케이션 main메서드에 아래의 설정을 @Bean으로 등록
@Bean
public Hibernate6Module hibernate6Module() {
    Hibernate6Module hibernate6Module = new Hibernate6Module();
//  지연로딩으로 설정한 연관관계들을 강제로 로딩하는 설정
//  hibernate6Module.configure(Hibernate6Module.Feature.FORCE_LAZY_LOADING, true);
    return hibernate6Module;
}

 

(4) ordersV1() 수정

  • main 메서드에서 지연로딩을 강제로 호출하는 것이아니라 원하는 것만 선택해서 지연로딩 설정된 연관관계 필드들을 로드할 수 있음
  • orderRepository에서 조회된 주문들을 반복문으로 꺼내서 원하는 필드만 선택한 뒤에 아무 값을 꺼내면 해당 필드의 값들이 강제로 초기화 되면서 특정 필드의 값만 가져오도록 설정할 수 있음
  • API 요청을 보내보면 정상적으로 응답이 반환됨
@GetMapping("/api/v1/simple-orders")
public List<Order> ordersV1() {
    List<Order> all = orderRepository.findAllByString(new OrderSearch());
    for (Order order : all) {
        order.getMember().getName();      // getName()이 실행될 때 해당 필드가 강제로 초기화 됨
        order.getDelivery().getAddress(); // getAddress()가 실행될 때 해당 필드가 강제로 초기화 됨
    }
    return all;
}

2) 정리

  • 그러나 API개발은 이렇게 복잡한 스펙으로 반환되도록 개발하지 않고 필요한 정보만 딱 정하여 API 스펙을 반환하도록 개발해야함
  • 그렇기 때문에 계속 강조하듯이 엔터티를 API응답을 외부로 노출하지 말고 DTO로 원하는 스펙만 정의해서 반환해야함
  • 지연 로딩을 피하기위해 즉시 로딩으로 연관관계 설정하여 개발하는 경우가 있는데 JPA강의에서 배웠듯이 데이터를 항상 조회하기 때문에 성능 튜닝하는 것이 매우 어려워지고 성능 문제(N+1)가 발생함
  • 항상 무조건 DTO를 사용해서 API를 반환하고, 지연 로딩을 기본으로 한 뒤에 성능 최적화가 필요한 경우는 페치 조인(fetch join, V3에서 설명)을 사용하도록 해야함

2. 간단한 주문 조회 V2 - 엔터티를 DTO로 변환

1) DTO를 사용하는 컨트롤러

(1) ordersV2() 추가 및 실행

  • 엔터티를 직접 반환하지 않고 API스펙을 정의한 DTO클래스를 생성하여 조회된 값을 DTO로 변환하여 반환
  • 매핑된 url로 API 요청을 하면 정의된 스펙대로 응답이오며 Entity를 직접 반환했을때의 문제를 대부분 해결함
  • stream을 이용해서 map()메서드를 호출하여 DTO로 변환하는 코드는 인자에 들어갈 람다식을 메서드참조로 변환하여 중간연산을 수행
  • collect()로 스트림의 최종연산을 진행하고 인자의 값으로 Collectors클래스를 static import하여 toList()를 호출할 수 있도록하여 최대로 코드를 간략화 시켰음
public class OrderSimpleApiController {
    // ... 기존 코드 생략

    @GetMapping("/api/v2/simple-orders")
    public List<SimpleOrderDto> ordersV2() {
        return orderRepository.findAllByString(new OrderSearch()) // 조회 후 stream으로 연산된 결과를 바로 반환
                .stream()                   // 조회된 값을 스트림으로 변환하여 작업
                .map(SimpleOrderDto::new)   // lamda를 메서드 참조로
                .collect(toList());         // Collectors static import
    }

    @Data
    static class SimpleOrderDto {
        private Long orderId;
        private String name;
        private LocalDateTime orderDate;
        private OrderStatus orderStatus;
        private Address address;

        // 생성자로 Dto의 데이터를 바로 입력할 수 있도록 order를 파라미터로 받는 생성자 추가
        public SimpleOrderDto(Order order) {
            orderId = order.getId();
            name = order.getMember().getName();
            orderDate = order.getOrderDate();
            orderStatus = order.getStatus();
            address = order.getDelivery().getAddress();
        }
    }
}

 

(2) 지연로딩설정으로 인한 N+1 문제 발생

  • 그러나 v1에서도 발생한 N+1 쿼리 문제가 동일하게 발생되며 응답내용을 정상적이지만 쿼리 로그를 보면 조회되는 데이터갯수만큼 select 쿼리가 발생된 것을 확인할 수 있음
  • order 조회 1번, order에서 member 지연로딩 조회 N번, order에서 delivery 지연로딩 조회 N번이 발생하여 order의 결과가 4개일 때 최악의 경우 총 9번(1 + 4 + 4) 실행되어버림
  • 물론 지연로딩은 영속성 컨텍스트에서 조회되기 때문에 이미 조회된 경우 쿼리를 생략한다고 해도 성능에 문제가 있는것은 맞기에 최적화를 해주어야 함

3. 간단한 주문 조회 V3 - V2를 페치 조인 최적화

1) JPQL의 페치조인을 사용하는 컨트롤러

(1) orderV3() 추가

  • orderV3메서드의 구현은 기존의 orderV2와 동일하나 OrderRepository에서 최적화조회를 위한 메서드를 만들어서 주문리스트를 조회하도록 변경됨
public class OrderSimpleApiController {
    // ... 기존 코드 생략
    @GetMapping("/api/v3/simple-orders")
    public List<SimpleOrderDto> ordersV3() {
        List<Order> orders = orderRepository.findAllWithMemberDelivery();
        return orders.stream().map(SimpleOrderDto::new).collect(toList());
    }
}

 

(2) OrderRepository에 페치조인 사용하여 조회하는 메서드를 추가

  • JPQL을 활용하여 Order와 Order의 member와 delivery를 페치 조인(join fetch)를 활용하여 쿼리를 한방에 가져오도록 변경
  • 기본값이 fetch join 적용 시 left join으로도 할 수 있으며 페치 조인의 자세한 내용은 JPA강의의 JPQL 문법강의를 참고
  • 페치 조인으로 쿼리를 최적화하는 방식은 실무에서 매우 많이 사용하기 때문에 꼭 알고 있어야 함
public class 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();
    }
}

 

(3) API 응답시 발생한 SQL 쿼리 비교

  • v2와 v3의 응답 결과는 동일하지만 발생된 쿼리는 7개와 1개로 어마어마한 성능 최적화가 이루어졌음

 

** 참고

  • 하이버네이트 5에서는 @OneToOne 관계일 때 mappedBy쪽의 프록시 객체가 지연로딩이 제대로 동작하지 않아서 추가 쿼리가 발생되지 않아 5개만 출력되었으나 하이버네이트6에서 이런 부분이 개선되어 오히려 쿼리가 7개로 늘어나게 됨

4. 간단한 주문 조회 V4 - JPA에서 DTO로 바로 조회

1) Repository에서 DTO를 바로 조회하는 컨트롤러

(1) orderV4() 추가

  • 컨트롤러에서 DTO로 변환하는 코드가 없이 Repository에서 DTO로 바로 조회하는 메서드를 호출하여 결과를 반환하는 컨트롤러
public class OrderSimpleApiController {
    // ... 기존 코드 생략
    @GetMapping("/api/v4/simple-orders")
    public List<OrderSimpleQueryDto> ordersV4() {
        return orderRepository.findOrderDtos();
    }
}

 

(2) OrderRepository 메서드 추가

  • 페치 조인을 사용하지 않고 일반 join을 사용
  • new 연산자를 사용하여 쿼리의 결과를 Entity가 아닌 별도로 정의한 DTO타입으로 반환하도록 JPQL 쿼리를 작성하여 Order Entity를 조회하는 것이아니라 DTO를 조회함
  • new 연산자를 이용하여 select 절에 직접 조회한 대상을 지정할 수 있지만, 타입으로 지정된 DTO클래스의 경로를 패키지까지 모두 작성해줘야하는 번거로움이 있음
  • 그러나 생각보다 성능최적화의 성능차이가 미비하며, 리포지토리 재사용성이 떨어지며 API 스펙에 맞춤 코드가 리포지토리에 들어가는 단점이 있음
public class OrderRepository {
    // ... 기존 코드 생략
    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();
    }
}

 

(3) OrderSimpleQueryDto생성

  • 해당 DTO클래스를 사용할 repository 패키지에 DTO클래스를 정의
  • 기존에 API Controller의 inner class로 정의한 SimpleOrderDto와 동일한 구조이지만 Controller의 DTO를 Repository의 반환값으로 사용하게 되면 Controller에서 Repository로 의존관계가 거꾸로 흐르게되어 아키텍처 구조가 깨지기 때문에 Repository패키지에 별도의 DTO를 생성해서 사용함
  • JPQL의 new 문법에서 해당 DTO를 사용할 때 생성자의 값으로 Order를 입력하면 Entity가 입력되어 OrderSimpleQueryDto의 필드를 초기화하는 생성자는 Order가 아닌 각각의 필드를 파라미터로 주입받도록 설정
package jpabook.jpashop.repository;

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

 

(4) 적용 후 결과와 v3, v4의 비교

  • 위 코드를 적용하고 API를 호출 해보면 생성되는 쿼리에서 select 구문에 원하는 데이터만 정확히 조회되는 것을 확인할 수 있음
  • 즉, v4는 일반적인 SQL을 사용할 때처럼 원하는 값을 직접 선택해서 가져오도록 쿼리를 작성한 것임
  • join의 쿼리는 둘의 차이가 없지만 v3는 select절에서 DB의 데이터를 더 가져오기 때문에 네트워크를 조금 더 사용하게 되어 성능이 조금 느릴 수 있음
  • v4에 비해 재사용성이 좋은 장점이 있으며, 대부분의 성능저하는 join이나 where문에서 조건이 이상해져서 인덱스를 잘못 잡거나 인덱스를 잘못 사용할 때 발생하지 select문에 몇개 더 추가된다고해서 애플리케이션 관점에서는 성능저하가 엄청 크게 난다고 보기는 어려움
  • 만일 데이터사이즈의 크기가 매우 커서 select 필드가 막 수십개가 되고 API트래픽이 어마어마하게 들어오거나 고객의 요청이 실시간으로 계속 요청되는 등의 상황에서 단 몇초라도 성능 최적화가 필요한 상황이라면 성능차이가 좀더 날수는 있음

 

2) 구조 변경

  • 지금 OrderRepository는 설계 구조상 Entity를 조회하는 곳에 사용해야 Entity의 객체 그래프를 조회하는 것을 그나마 최적화 할 수있는데, v4버전을 작성하면서 DTO가 repository패키지에 들어오고 DTO를 조회하는 코드와 Entity를 조회하는 코드가 섞여있음
  • 이런 경우에는 repository 패키지의 하위 패키지에 추가적인 패키지를 만들어서 별도의 DTO로 조회하는 쿼리나 복잡한로직의 쿼리들을 뽑아서 관리하면 유지보수성이 용이해지기 때문에 실무에서는 화면에 종속되는 DTO는 Entity와 분리해서 관리하는 것이 좋음
  • 물론 실무에서는 패키지구조를 order패키지의 하위에 Order관련된 클래스들이 들어있고 DTO로 조회하는 패키지도 해당 패키지의 하위에 생성하여 관리하도록 하겠지만, 지금은 이미 구조가 repository에 전부 모아놨기에 현재 상태에서 리펙토링을 실시

(1) OrderSimpleQueryRepository 생성

  • repository에 order.simplequery패키지들을 추가하여 OrderSimpleQueryRepository클래스를 생성하고 OrderRepository에 있던 findOrderDtos()를 옮겨와서 다시 작성
  • DTO클래스도 해당 패키지의 위치로 이동할 것이기 때문에 JPQL쿼리의 DTO클래스 경로를 수정해주어야 함
package jpabook.jpashop.repository.order.simplequery;

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

 

(2) OrderSimpleQueryDto 위치 변경

  • 새로 생성한 OrderSimpleQueryRepository가 위치한 ~.order.simplequery 패키지로 DTO클래스의 위치를 이동
package jpabook.jpashop.repository.order.simplequery;

@Data
public class OrderSimpleQueryDto {
    // ... 기존 코드 동일
}

 

(3) OrderSimpleApiController 수정

  • API 컨트롤러에서 OrderSimpleQueryRepository를 설정하고 findOrderDtos를 사용하도록 변경
package jpabook.jpashop.api;

@RequiredArgsConstructor
public class OrderSimpleApiController {

    private final OrderRepository orderRepository;
    private final OrderSimpleQueryRepository orderSimpleQueryRepository;

    // ... 기존코드 생략

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

}

2) 정리

  • v1은 절대 사용하지 말것
  • 우선 Entity를 DTO로 변환하는 방법을 선택하고 필요할 때 페치 조인으로 성능을 최적화하면 대부분의 성능 이슈가 해결됨
  • 그래도 안되는 문제는 DTO로 직접 조회하는 방법을 사용
  • 최후의 방법으로는 JPA가 제공하는 네이티브 SQL이나 스프링 JDBC Template을 사용해서 SQL을 직접 사용하면 됨