티스토리 뷰

현재 Order 엔티티와 Member 엔티티는 ManyToOne 관계이고, Order 엔티티와 Delivery 엔티티의 관계는 OneToOne 관계입니다. Order 엔티티를 조회하는 경우에, API 스펙에서 Member의 이름과 Delivery 주소를 요구한다면 OrderDto에서 해당 정보를 함께 반환해야합니다. 

@Entity
@Table(name = "orders")
@Getter @Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Order {

    @Id @GeneratedValue
    @Column(name = "order_id")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id")
    private Member member;


    @OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
    @JoinColumn(name = "delivery_id")
    private Delivery delivery;


}

현재 @GetMapping "/api/v2/simple-orders" 은 모든 주문 정보를 반환하는 API입니다. 주문 저장소에서 모든 주문 정보를 가져와 주문 Dto로 변경하여 반환하는 구조입니다.

@GetMapping("/api/v2/simple-orders")
public List<SimpleOrderDto> ordersV2(){
    List<Order> orders = orderRepository.findAllByCriteria(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 username;
    private LocalDateTime orderDate;
    private OrderStatus orderStatus;
    private Address address;

    public SimpleOrderDto(Order order) {
        orderId = order.getId();
        username = order.getMember().getName(); // Member 객체를 호출하기 때문에 LAZY 로딩
        orderDate = order.getOrderDate();
        orderStatus = order.getStatus();
        address = order.getDelivery().getAddress(); // Delivery 객체를 호출하기 때문에 LAZY 로딩
    }
}

 

아래 사진은 주문 엔티티를 2개 생성한 후, 주문 2개를 조회했을때 발생한 쿼리입니다. Order와 Member, Delivery는 Lazy loading으로 설정 되어 있습니다. 그러므로 주문 1개를 조회할 때 주문 데이터를 가져오고, 이후에 멤버와 주소 엔티티가 사용될 때 추가적인 쿼리가 발생합니다. 즉 주문 1개를 조회하는 쿼리로 인해 2개의 추가적인 쿼리가 발생한 것입니다.

 

모든 주문 엔티티를 조회하였기 때문에, 총 주문 엔티티는 2개이고 1(주문 엔티티 조회) + 2(멤버 2명) + 2(주소 2개) 의 쿼리가 발생한 것입니다. 만약에 주문 엔티티가 2개가 아니라 엄청난 데이터를 가지고 있다면? 쿼리 양은 엄청나게 증가할 것입니다. 흔히 말하는 N+1 문제가 발생한 것입니다. N+1 문제를 해결하기 위해서는 어떻게 해야할까요? 이후에 결국 사용될 엔티티(멤버, 주소)라면 프록시로 가져오지 않고 초기 쿼리 한방에 모든 데이터를 가져오면 추가적인 쿼리가 발생하는 것을 막을 수 있을 것입니다.

전체 주문 조회 쿼리 발생
주문1 에 해당하는 멤버, 주소 데이터 조회
주문2에 해당하는 멤버, 주소 데이터 조회


첫번째 방법은 fetch join을 사용하여 N+1 문제를 풀어낼 수 있습니다.

public List<Order> findAllWithMemberDelivery() {
        return em.createQuery(
                "select o from Order o" +
                " join fetch o.member m" +
                " join fetch o.delivery", Order.class)
                .getResultList();
}

@GetMapping "api/v3/simple-orders" 는 fetch join을 사용한 findAllWithMemberDelivery 함수를 이용하여 모든 주문 정보를 가져옵니다. 이후 동일하게 주문 객체를 주문 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;
}

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

    public SimpleOrderDto(Order order) {
        orderId = order.getId();
        username = order.getMember().getName(); // Member 객체를 호출하기 때문에 LAZY 로딩
        orderDate = order.getOrderDate();
        orderStatus = order.getStatus();
        address = order.getDelivery().getAddress(); // Delivery 객체를 호출하기 때문에 LAZY 로딩
    }
}

아래 사진은 주문 데이터를 가져올 때 멤버 데이터와 주소 데이터를  fetch join을 사용하여 한번에 가져온 쿼리의 결과입니다. 모든 주문을 조회하는 쿼리를 날릴 때, Member 와 Delivery를 join하여 모든 데이터를 가져오는 것을 알 수 있습니다. 이렇게 되면 Lazy 로딩에 의한 추가 쿼리가 발생하지 않는 것을 볼 수 있습니다 !


또 하나의 방법은 JPA에서 DTO로 바로 조회하는 것입니다.

OrderSimpleQueryDto 라는 Dto를 하나 생성합니다. 해당 Dto에는 앞선 과정과 마찬가지로 Order 데이터, Member의 name, Delivery의 address를 반환하는 멤버 변수를 가지고 있습니다. 일반적인 SQL을 사용할 때 처럼 원하는 값을 선택하여 조회한다는 개념입니다.

@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; // Member 객체를 호출하기 때문에 LAZY 로딩
        this.orderDate = orderDate;
        this.orderStatus = orderStatus;
        this.address = address; // Delivery 객체를 호출하기 때문에 LAZY 로딩
    }

}

new 명령어를 사용하여 JPQL의 결과를 즉시 DTO로 변환할 수 있습니다. SELECT 절에서 원하는 데이터를 직접 선택하므로 DB에서 애플리케이션으로 가져오는 데이터의 양을 줄일 수 있습니다. fetch join은 모든 컬럼을 조인하여 데이터베이스에서 가져오기 때문에 조금 더 많은 데이터를 애플리케이션으로 가져옵니다.

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

아래 사진은 JPA에서 주문 DTO로 바로 조회하여 발생한 쿼리입니다. fetch join에 비해 훨씬 줄어든 select 절을 확인할 수 있습니다. Dto에 설정한 멤버 변수들만 select하여 가지고 온 것을 확인할 수 있습니다. 그러나 아쉬운 점은 repository 재사용성이 떨어진다는 것입니다.

즉 해당 API 스펙에만 맞춘 코드가 repository에 추가로 들어가게 된다는 단점이 존재한다는 것입니다.

 

결론적으로 fetch join을 사용해서 풀어내거나 JPA에서 DTO로 바로 조회하여 N+1 문제를 풀어낼 수 있습니다. 좀 더 재사용성이 높은 fetch join 방법과, 원하는 컬럼만 선택하여 가져올 수 있는 DTO 방법 둘 다 장단점이 존재합니다. 그러나 엔티티로 조회하면 repository 재사용성도 좋아지고 개발이 좀 더 단순해지므로 fetch join을 이용해 해결하는 방법을 권장합니다. 

댓글