본문 바로가기
Spring/Spring Boot + JPA

[Spring Boot + JPA] API 개발과 성능 최적화 - 2. 지연 로딩과 조회 성능 최적화

by yoon_seon 2023. 5. 1.

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

먼저 엔티티를 직접 반환하는 것은 좋지 않다. 여기서는 예제만을 위해 엔티티를 직접 노출하는 소스를 작성했다.

 

OrderSimpleApiController 생성

@RestController
@RequiredArgsConstructor
public class OrderSimpleApiController {

    private final OrderRepository orderRepository;

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

해당 API를 호출하면 양방향 매핑이 설정되어있는 Order ↔ Member와 Order ↔ Delivery 간 서로를 참조해 무한루프로 StackOverflowError가 발생한다.

 

@JsonIgnore 

직렬화 시 해당 필드를 포함시키고 싶지 않을 때 선언하는 어노테이션이다. 해당 어노테이션을 사용하면 Response 데이터에서 해당 필드가 제외된다.

양방향 관계의 문제를 해결하기 위해 @JsonIgnore 어노테이션을 양방향 관계에 있는 모든 엔티티(Order ↔ Member와 Order ↔ Delivery) 한쪽에 추가해 주면 무한 루프 에러는 해결된다.

하지만, 다음 에러가 발생한다.

현재 코드의 로딩 전략은 지연로딩(LAZY) 전략을 사용한다. 그 말은 Member, Delivery 등의 엔티티들을 조회하는 시점에서는 실제 객체가 아닌 프록시 객체를 가지고 있다. 그렇기 때문에 jackson 라이브러리는 기본적으로 이 프록시 객체를 json으로 어떻게 생성해야 하는지 모르기 때문에 예외가 발생하는 것이다.

 

Hibernate5Module을 스프링 Bean으로 등록하면 해결할 수 있다.

  • 스프링부트 3.0 미만 : Hibernate5JakartaModule 등록
  • 스프링부트 3.0 이상 : Hibernate5Module 등록
@SpringBootApplication
public class JpaShopApplication {

   public static void main(String[] args) {
      SpringApplication.run(JpaShopApplication.class, args);
   }

   @Bean
   Hibernate5JakartaModule hibernate5JakartaModule() {
      Hibernate5JakartaModule hibernate5JakartaModule = new Hibernate5JakartaModule();
      // 강제 지연 로딩 설정
      hibernate5JakartaModule.configure(Hibernate5JakartaModule.Feature.FORCE_LAZY_LOADING, true);
      return hibernate5JakartaModule;
   }
}

이 프로젝트는 스프링 부트 3.0 이상 버전을 사용하고 있다.

Application 클래스에 Hibernate5JakartaModule을 스프링 Bean으로 등록하고 강제 지연 로딩으로 설정하였다.

설정 후 API 테스트 시 에러가 해결된 것을 확인할 수 있다.

 

hibernate5JakartaModule.configure(Hibernate5JakartaModule.Feature.FORCE_LAZY_LOADING, true); 옵션을 주지 않고 response 반환 전 Lazy 강제 초기화하여 프록시 객체가 아닌 실제 객체를 반환하여 해결할 수도 있다.

 

앞에서 계속 강조했듯이 정말 간단한 애플리케이션이 아니면 엔티티를 API 응답으로 외부로 노출하는 것은 좋지 않다.

따라서 Hibernate5Module를 사용하기보다는 DTO로 변환해서 반환하는 것이 더 좋은 방법이다.

 

지연 로딩(LAZY)을 피하기 위해 즉시 로딩(EARGR)으로 설정하면 안 된다!

즉시 로딩 때문에 연관관계가 필요 없는 경우에도 데이터를 항상 조회해서 성능 문제가 발생할 수 있다. 즉시 로딩으로 설정하면 성능 튜닝이 매우 어려워진다. 

항상 지연 로딩을 기본으로 하고, 성능 최적화가 필요한 경우에는 페치 조인(fetch join)을 사용해라!(V3 에서 설명)

 

 

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

orderV2 API 및 SimpleOrderDTO 추가

@RestController
@RequiredArgsConstructor
public class OrderSimpleApiController {

    private final OrderRepository orderRepository;
    
    // ...
    
    @GetMapping("/api/v2/sample-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;
    }
    
    @Data
    static class SimpleOrderDTO {
        private Long orderId;
        private String name;
        private String 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 초기화
        };
    }
}
  • 엔티티를 DTO로 변환하는 일반적인 방법이다.
  • 쿼리가 총 1 + N + N번 실행된다. (v1과 쿼리수 결과는 같다.)
    → 물론, 여기서 member가 동일할 경우 처음 조회 이후 다시 조회하지 않기 때문에 1번의 쿼리수행이 더 줄어들 수 있지만, 흔한 케이스가 아니기에 고려하지 않는다.
  • order 조회 1번(order 조회 결과 수가 N이 된다.)
    order → member 지연 로딩 조회 N 번, order → delivery 지연 로딩 조회 N 번
    예) order의 결과가 4개면 최악의 경우 1 + 4 + 4번 실행된다.(최악의 경우)
    Order 조회가 많아질수록 쿼리수행이 많아지고 성능저하가 일어날 수 있다.

 

 

📌 간단한 주문 조회 V3: 엔티티 DTO로 변환 → 페치 조인 최적화

orderV3 API 추가

@RestController
@RequiredArgsConstructor
public class OrderSimpleApiController {

    private final OrderRepository orderRepository;
    
    // ...
    
    @GetMapping("/api/v3/sample-orders")
    public List<SimpleOrderDTO> orderV3() {
        List<Order> orders = orderRepository.findAllWithMemberDelivery();
        List<SimpleOrderDTO> result = orders.stream()
                .map(o -> new SimpleOrderDTO(o))
                .collect(Collectors.toList());
        return result;
    }
}

 

OrderRepository에 findAllWithMemberDelivery 추가

@Repository
@RequiredArgsConstructor
public class OrderRepository {

	// ...
    
    public List<Order> findAllWithMemberDelivery() {
        return em.createQuery(
                        "select o from orders o" +
                         " join fetch o.member m" +
                         " join fetch o.delivery d", Order.class)
                .getResultList();
    }
}
  • 엔티티를 페치 조인(fetch join)을 사용해서 쿼리 1번에 조회
  • 페치 조인으로 order member , order delivery는 이미 조회된 상태 이므로 지연로딩 X.
    따라서 N+1 문제 해결

 

 

📌 간단한 주문 조회 V4: JPA에서 DTO로 바로 조회

V2, V3는 OrderRepository에서 엔티티로 조회한 결과를 DTO로 변환하여 반환하고 있다.

V4는 엔티티를 DTO로 변환하는 과정을 없애고 JPA에서 바로 DTO로 결괏값을 받도록 변경한다.

 

orderV4 API 추가

@RestController
@RequiredArgsConstructor
public class OrderSimpleApiController {

    private final OrderRepository orderRepository;
    
    // ...
    
    @GetMapping("/api/v4/sample-orders")
    public List<OrderSimpleQueryDTO> orderV4() {
        return orderSimpleQueryRepository.findOrderDTOs();
    }
}

 

OrderSimpleQueryRepository DTO 생성

@Data
public class OrderSimpleQueryDTO {
    private Long orderId;
    private String name;
    private String orderDate;
    private OrderStatus orderStatus;
    private Address address;

    public OrderSimpleQueryDTO(Long orderId, String name, String orderDate, OrderStatus orderStatus, Address address) {
        this.orderId = orderId;
        this.name = name;
        this.orderDate = orderDate;
        this.orderStatus = orderStatus;
        this.address = address;
    }
}

 

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

 

 

OrderSimpleQueryRepository.findOrderDTOs()는 API에서 조회를 위해서만 사용하는 리포지토리와 메서드이기 때문에 엔티티를 조회할 수 있는 리포지토리와 메서드와는 성격이 달라 따로 분리해 주었다.

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

정리

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

엔티티로 조회하면 리포지토리 재사용성도 좋고, 개발도 단순해진다. 따라서 권장하는 방법은 다음과 같다.

 

쿼리 방식 선택 권장 순서

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

 

 

 


해당 글은 인프런의 [실전! 스프링 부트와 JPA 활용 2 - API 개발과 성능 최적화] 강의를 정리한 내용입니다.

 

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

스프링 부트와 JPA를 활용해서 API를 개발합니다. 그리고 JPA 극한의 성능 최적화 방법을 학습할 수 있습니다., - 강의 소개 | 인프런

www.inflearn.com

댓글