Spring/자바 ORM 표준 JPA 프로그래밍

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

kyung.Kh 2024. 8. 15. 01:35

주문 + 배송 정보 + 회원을 조회하는 API를 만드는 부분으로 지연 로딩 때문에 발생하는 성능 문제를 해결해가며 진행한다.

참고로 이번 부분은 정말 중요하며 실무에서 JPA를 사용하려면 100% 이해해야 한다!!!

간단한 주문 조회

V1 : 엔티티를 직접 노출

이전 글에서 작성했던 것처럼 엔티티를 직접 노출하는 것은 좋지 않다.

  • order -> member와 order -> delivery는 지연 로딩이다. 따라서 실제 엔티티 대신에 프록시가 존재한다.
  • jackson 라이브러리는 기본적으로 이 프록시 객체를 json으로 어떻게 생성해야 하는지 모르기 때문에 예외가 발생한다.

이는 Hibernate5JakartaModule을 스프링 빈으로 등록하여 해결할 수 있다.

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

Hibernate5JakartaModule를 사용하여 해결하기 위해서는 엔티티를 직접 노출할 때는 양방향 연관관계가 걸린 곳은 무조건 한 곳에 @JsonIgnore을 추가해야 한다. 안그러면 양쪽을 서로 호출하면서 무한 루프가 걸린다. (사용 x)

정말 간단한 애플리케이션이 아니면 엔티티를 노출하는 것은 정말 좋지 않기 때문에 Hibernate5JakartaModule을 사용하기 보다는 DTO로 변환해서 반환하는 것이 더 좋은 방법이다.

 

지연 로딩(LAZY)을 피하기 위해 즉시 로딩(EAGER)으로 설정하면 절대 안된다. 즉시 로딩 때문에 연관관계가 필요 없는 경우에도 데이터를 항상 조회해서 성능 문제가 생길 수 있고, 성능 튜닝도 매우 어려워 진다. 따라서, 항상 지연 로딩(LAZY)을 기본으로 하고, 성능 최적화가 필요한 경우에는 페치 조인(fetch join)을 사용해야 한다.(V3에서 설명)

V2 : 엔티티를 DTO로 변환

@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 초기화
        }
    }

V1의 문제를 해결한 방법으로 엔티티를 dto로 변환하는 방법이다. 이는 일반적인 방법이지만 쿼리가 1+N+N번 실행된다.

  1. Order 조회 1번(order 조회 결과 수가 N이다)
  2. order -> member 지연 로딩 조회 N번
  3. order -> delivery 지연 로딩 조회 N번

위에서는 order의 결과가 2개이므로 1+2+2 번 실행된다. 지연 로딩은 영속성 컨텍스트에서 조회하므로, 이미 조회된  경우 쿼리를 생략한다. 즉, 쿼리가 너무 많이 나가기 때문에 성능이 안 나온다.

V3 : 엔티티를 DTO로 변환 - 페치 조인 최적화

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

// OrderRepository
public List<Order> findAllWithMemberDelivery() {  // member와 delivery의 LAZY를 무시하고 다 가져옴
    return em.createQuery(
                    "select o from Order o " +
                    "join fetch o.member m " +
                    "join fetch o.delivery d", Order.class
    ).getResultList();
}

V2에서는 쿼리가 1+N+N 번 나가서 성능이 좋지 않다는 이슈가 있었다. 이를 해결하기 위해 엔티티를 페치 조인(fetch join)을 적용한다.페치 조인을 적용하면 order -> member, order -> delivery 는 이미 조회된 상태이므로 지연 로딩이 발생하지 않기 때문에 쿼리가 1번만 실행된다.

V4 : JPA에서 DTO로 바로 조회

// OrderSimpleAPIController
@GetMapping("/api/v4/simple-orders")
public List<OrderSimpleQueryDto> ordersV4() {
    // repository는 순수한 엔티티를 조회하는데 사용 
    // orderSimpleQueryRepository.findOrderDtos()는 특별한 경우이므로 따로 분리(repository에 있으면 용도가 애매해짐)
    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();
    }
}
/** OrderSimpleQueryDto 리포지토리에서 DTO 직접 조회 */
@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;
        }
}

V3와 V4를 실행했을 때, 모두 쿼리가 한 번 나가지만 SELECT 부분을 보면 V3은 Order의 모든 값을 SELECT 하지만, V4의 경우에는 SELECT 부분에서 DTO에 필요한 부분만 SELECT 하였다.

  • V3 : Order 전체를 SELECT 하여 join fetch로 필요한 것을 고름 -> 재사용성이 뛰어남, 코드 가독성 좋음
  • V4 : DTO에 해당되는 값만 SELECT 함 -> DTO를 사용할 때만 사용할 수 있고 JPQL을 직접 작성해야 하지만 성능은 조금 더 좋음, 재사용성이 없고, 코드가 지저분해보임

그럼 V3과 V4의 성능 차이가 많이 나는가? 

대부분의 성능은 JOIN에서 발생하기 때문에 SELECT 절에서 필드 몇 개 더 넣는다고 성능을 많이 잡아먹지 않는다.

V4의 단점 상쇄 방법

Repository는 순수한 엔티티를 조회/검색/성능 최적화를 위한 페치 조인하는 데 사용해야 한다.

findOrderDtos()는 DTO를 조회하는 것으로 이러한 코드는 Repository가 아닌 Repository 하위에 성능 최적화 쿼리용 패키지를 새로 생성해주 넣는 방법을 사용하는 것이 좋다.

정리

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

엔티티로 조회하면 Repository 재사용성도 좋고, 개발도 단순해진다.


쿼리 방식 선택 권장 순서

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

[출처]

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 → 이 글은 김영한님의 JPA 활용 2편 강의 중 3장을 듣고 정리한 내용입니다.

728x90