[JPA] xToOne 관계의 엔티티 조회 최적화

olsohee·2023년 8월 11일
0

JPA

목록 보기
12/21
post-thumbnail

주문 정보(Order)를 조회할 때 연관된 회원 정보(Member)와 배송 정보(Delivery)를 같이 조회하는 예제를 통해 조회 성능을 최적화해보자. 이때 Order와 Member는 다대일 관계이고, Order와 Delivery는 일대일 관계이다.

1. N+1 문제

1.1. 지연로딩에서의 N+1 문제 발생

OrderSimpleApiController
@RestController
@RequiredArgsConstructor
public class OrderSimpleApiController {

    private final OrderRepository orderRepository;
    private final OrderSimpleQueryRepository orderSimpleQueryRepository;

    @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;
    }
    
    @Data
    static class SimpleOrderDto {
        private Long orderId;
        private String name;
        private LocalDateTime orderDate;
        private OrderStatus orderStatus;
        private Address address;

        public SimpleOrderDto(Order order) {
            this.orderId = order.getId();
            this.name = order.getMember().getName();
            this.orderDate = order.getOrderDate();
            this.orderStatus = order.getStatus();
            this.address = order.getDelivery().getAddress();
        }
    }
}

order -> member와 order -> delivery는 지연 로딩으로 설정되어 있다. 따라서 orderRepository.findAllByString(new OrderSearch())에서 실행되는 쿼리는 order 테이블에만 sql이 실행된다. 그리고 이때 조회된 order 엔티티의 member와 address는 실제 엔티티가 아닌 프록시이다.

그리고 new SimpleOrderDto(o)를 통해 order.getMember().getName(), order.getDelivery().getAddress()가 실행되는데 회원의 이름과 배송 주소를 조회할 때 member 테이블과 delivery 테이블에 sql이 실행된다.

따라서 만약 order 테이블 조회 결과 order 엔티티가 2개 조회되었다면(sql 1번),
order1의 member 테이블과 delivery 테이블에 sql이 각각 실행되고(sql 2번),
마찬가지로 order2의 member 테이블과 delivery 테이블에 sql이 실행된다.(sql 2번)
즉 주문 정보 조회 sql 하나로 인해 4개의 sql이 추가로 실행되어 총 5개의 sql이 실행되는 N+1 문제가 발생한다.

이러한 N+1 문제를 해결하려면 페치 조인을 사용하면 된다.

참고로 지연로딩은 영속성 컨텍스트를 우선 조회하므로, 이미 영속성 컨텍스트에 있는 경우 DB 조회 쿼리를 생략한다. (ex, 두개의 order가 조회되었고, 이 두 order의 memberId가 같은 경우, 첫번째 order에서 member 테이블에 조회 쿼리를 날리고, 두번째 order에서 member를 조회할 때는 영속성 컨텍스트에 해당 member가 있으므로, DB를 조회하지 않는다.)

1.2. 페치 조인으로 문제 해결

OrderSimpleApiController
@RestController
@RequiredArgsConstructor
public class OrderSimpleApiController {

    private final OrderRepository orderRepository;
    private final OrderSimpleQueryRepository orderSimpleQueryRepository;

    @GetMapping("/api/v3/simple-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;
    }
    
    @Data
    static class SimpleOrderDto {
        private Long orderId;
        private String name;
        private LocalDateTime orderDate;
        private OrderStatus orderStatus;
        private Address address;

        public SimpleOrderDto(Order order) {
            this.orderId = order.getId();
            this.name = order.getMember().getName();
            this.orderDate = order.getOrderDate();
            this.orderStatus = order.getStatus();
            this.address = order.getDelivery().getAddress();
        }
    }
}
OrderRepository
@Repository
@RequiredArgsConstructor
public class OrderRepository {

    private final EntityManager em;

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

orderRepository.findAllWithMemberDelivery()를 통해 실행되는 쿼리는 페치 조인을 적용한 쿼리이다. select o from Order o join fetch o.member m join fetch o.delivery d와 같이 페치 조인을 적용했기 때문에 DB에서 order를 조회할 때 select o.*. m.*, d.*처럼 order와 member, delivery를 함께 조회한다. 즉 하나의 sql로 order, member, delivery를 한 번에 조회한다. 따라서 N+1 문제가 발생하지 않는다.

참고로 지연 로딩으로 설정하더라도 페치 조인이 적용되어 있으면 지연 로딩이 무시되고 페치 조인이 우선한다.

조회 성능 최적화 Tip

즉시 로딩으로 설정하면, 연관관계가 필요없는 경우일지라도 항상 연관된 테이블에도 추가적인 sql이 실행되는 문제가 발생한다. 따라서 항상 지연 로딩을 기본으로 하자.

만약 연관된 엔티티를 자주 함께 조회하는 경우라면, 이는 지연 로딩으로 설정되어 있기 때문에 자주 N+1 문제가 발생하게 되고, 성능 저하의 원인이 된다. 따라서 이런 경우에만 특정 엔티티 조회시 연관된 엔티티도 sql 한 번에 함께 조회하도록 페치 조인을 사용하자.


2. JPA에서 DTO로 바로 조회

대부분 DB에서 엔티티를 조회하면, 엔티티를 그대로 사용하지 않고 DTO로 변환하는 과정이 발생한다. 그런데 위 1, 2번과 같은 경우, DB에서 엔티티를 조회할 때 DTO로 변환할 때 필요 없는 필드까지 모두 조회하게 된다. 예를 들어 DTO로 order의 id, orderDate만 필요한데 order의 모든 칼럼을 조회하는 것이다.

따라서 이 부분에서 성능 최적화를 위해 DTO로 바로 조회하는 법을 알아보자.

OrderSimpleApiController
@RestController
@RequiredArgsConstructor
public class OrderSimpleApiController {

    private final OrderRepository orderRepository;
    private final OrderSimpleQueryRepository orderSimpleQueryRepository;

    @GetMapping("/api/v4/simple-orders")
    public List<OrderSimpleQueryDto> orderV4() {
        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();
    }
}
@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;
    }
}

select new jpabook.jpashop.repository.order.simplequery.OrderSimpleQueryDto(o.id, m.name, o.orderDate, o.status, d.address)와 같이 new 명령어를 통해 DTO에 맞춰서 DB에서 데이터를 조회할 수 있다. 즉 select 절에서 필요한 데이터만 조회하기 때문에 네트워크 용량이 최적화된다(그러나 미미한 수준).

이때 주의할 점은 new 명령어를 사용할 때 DTO의 전체 패키지 명을 적어주어야 한다. 그리고 해당 DTO 클래스에는 new 명령어를 통해 활용될 생성자가 정의되어 있어야 한다.

DTO로 바로 조회할 경우, 리포지토리가 api에 의존히는 문제 발생

DTO로 바로 조회하는 경우, 리포지토리의 조회 메서드가 특정 api 스펙에 의존한다는 문제가 있다. 이는 재사용성이 거의 없고 해당 api에서만 사용 가능하다. 그런데 리포지토리는 가급적 엔티티만을 조회하고 재사용성이 뛰어나야 한다. 따라서 DTO로 바로 조회하는 경우, 해당 코드를 별개의 리포지토리로 분리하여, 엔티티만 조회하는 순수하고 재사용성이 높은 리포지토리(ex, OrderRepository)와 특정 api에 의존하는 리포지토리(OrderSimpleQueryRepository)를 분리하는 것이 좋다.


3. 정리

엔티티를 조회해서 DTO로 변환하거나, DTO로 바로 조회하는 방법은 각각의 장단점이 있다.

  • 엔티티를 조회하는 경우

    • 장점: 조회 코드의 재사용성이 높다.

    • 단점: DTO 변환시 사용되지 않는 데이터까지 select 절을 통해 조회한다.

  • DTO로 바로 조회하는 경우

    • 장점: select 절에서 DTO 변환에 필요한 데이터만 조회하기 때문에 네트워크 용량이 최적화된다. (단 이는 생각보다 미비하다.)

    • 단점: 조회 코드가 특정 DTO에 의존한다. 즉 리포지토리가 api에 의존하게 되어 리포지토리 재사용성이 떨어진다.

따라서 조회 성능 최적화 권장 순서는 다음과 같다.

  1. DB에서 엔티티로 조회한 후, DTO로 변환하는 방법을 사용한다.

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

  3. 그래도 추가적인 최적화가 필요하면, 엔티티가 아닌 DTO로 조회하는 방법을 사용한다.

  4. 최후의 방법으로는 네이티브 SQL이나 스프링 JDBC Template을 사용해서 SQL을 직접 작성한다.

profile
공부한 것들을 기록합니다.

0개의 댓글