본문 바로가기

Spring/SpringBoot

SpringBoot- 패치 조인 최적화

연관된 엔티티를 가져오는 경우

  Order에는 Member와 Delivery 엔티티가 연관되어있다고 할 때, 현재 저장 되어있는 Order 엔티티와 Member, Delivery의 정보를 함께 가져와보자.

 

설명을 위해서 기본적으로 저장되어 있는 데이터. 두개의 order 칼럼에 각 1개씩의 member, delivery 칼럼이 join 된다.

 

 1) 최적화가 되어있지 않는 경우 (1+N 문제 발생)

 Member와 Delivery 엔티티는 Order와 양방향 매핑 되어있다. 내가 만일 Order 엔티티를 단순 join문 만을 이용한 쿼리문으로 조회를 한다고 하면 다음과 같이 쿼리가 발생할 것이다.

public List<Order> findAllByString(OrderSearch orderSearch) {
    //language=JPQL
    String jpql = "select o From Order o join o.member m";
    boolean isFirstCondition = true;
    //주문 상태 검색
    if (orderSearch.getOrderStatus() != null) {
        if (isFirstCondition) {
            jpql += " where";
            isFirstCondition = false;
        } else {
            jpql += " and";
        }
        jpql += " o.status = :status";
    }
    //회원 이름 검색
    if (StringUtils.hasText(orderSearch.getMemberName())) {
        if (isFirstCondition) {
            jpql += " where";
            isFirstCondition = false;
        } else {
            jpql += " and";
        }
        jpql += " m.name like :name";
    }
    TypedQuery<Order> query = em.createQuery(jpql, Order.class)
            .setMaxResults(1000); //최대 1000건
    if (orderSearch.getOrderStatus() != null) {
        query = query.setParameter("status", orderSearch.getOrderStatus());
    }
    if (StringUtils.hasText(orderSearch.getMemberName())) {
        query = query.setParameter("name", orderSearch.getMemberName());
    }
    return query.getResultList();
}

위의 함수는 지나치게 비효율적인 코드이긴 하다. 상황에 따라서 jpql문을 추가하는 식으로 짜진 코드이다. 대충 그 정도로 넘어가자. 중요한 것은 쿼리문이 어떻게 나가느냐이다.

 

쿼리문

select  # ...(1) ORDER 테이블 조회
    order0_.order_id as order_id1_6_,
    order0_.delivery_id as delivery4_6_,
    order0_.member_id as member_i5_6_,
    order0_.order_date as order_da2_6_,
    order0_.status as status3_6_ 
from
    orders order0_ 
inner join
    member member1_ 
        on order0_.member_id=member1_.member_id limit ?

select  # ...(2) order_id = 1인 칼럼과 조인된 member
    member0_.member_id as member_i1_4_0_,
    member0_.city as city2_4_0_,
    member0_.street as street3_4_0_,
    member0_.zipcode as zipcode4_4_0_,
    member0_.name as name5_4_0_ 
from
    member member0_ 
where
    member0_.member_id=?
    
select  #...(3) order_id = 1인 칼럼과 조인된 delivery
    delivery0_.delivery_id as delivery1_2_0_,
    delivery0_.city as city2_2_0_,
    delivery0_.street as street3_2_0_,
    delivery0_.zipcode as zipcode4_2_0_,
    delivery0_.status as status5_2_0_ 
from
    delivery delivery0_ 
where
    delivery0_.delivery_id=?
    
select  #...(4) order_id = 8인 칼럼과 조인된 member
    member0_.member_id as member_i1_4_0_,
    member0_.city as city2_4_0_,
    member0_.street as street3_4_0_,
    member0_.zipcode as zipcode4_4_0_,
    member0_.name as name5_4_0_ 
from
    member member0_ 
where
    member0_.member_id=?
    
select  #...(5) order_id = 8인 칼럼과 조인된 delivery
    delivery0_.delivery_id as delivery1_2_0_,
    delivery0_.city as city2_2_0_,
    delivery0_.street as street3_2_0_,
    delivery0_.zipcode as zipcode4_2_0_,
    delivery0_.status as status5_2_0_ 
from
    delivery delivery0_ 
where
    delivery0_.delivery_id=?

해당 메서드로 저장되어 있는 엔티티를 조회하는 경우 총 5번의 쿼리문이 발생한다. 단지 order 하나만 조회하였는데 이와 연관된  member 2개, delivery 2개씩 해서 총 5개의 쿼리가 발생한다. 이를 1+N 문제라고 한다.

 

 2) 최적화가 되어있는 경우 (join fetch문 사용)

public List<Order> findAllWithMemberDelivery() {
    // join fetch: member, delivery가 Lazy로 fetchType이 설정되어 있는데,
    // join fetch 하면 프록시 무시하고 실제 엔티티를 다 넣어서 가져온다.
    // 즉, 지연로딩도 즉시로딩으로 가져온다.

    // 여기서 주의해야 할 점은 fetch join으로 가져오는 경우 Order와 연관된 Member, Delivery가 모두
    // join된 테이블을 가져오게 되는 것이다. 따라서 동일한 엔티티가 여러번 조회되는 중복 조회도 가능하다.
    List<Order> result = em.createQuery("select o from Order o " +
            "join fetch o.member m " +
            "join fetch o.delivery d", Order.class)
            .getResultList();

    return result;
}

 일단 한눈에 보기에도 코드가 훨씬 간결하다. join fetch를 사용하면, 연관된 엔티티가 Lazy로 설정되어 있더라도 바로 즉시로딩으로 가져온다. 즉시 로딩이기 때문에 모든 엔티티가 한번의 쿼리에 모두 가져와지므로 최적화가 되어있지 않은 경우에 5번의 쿼리가 나갔던 것과는 대조적으로 1번의 쿼리만으로 연관된 모든 엔티티를 가져올 수 있다. 

쿼리문

select  # 즉시로딩이므로 한번에 연관된 모든 엔티티를 다 가져옴
    order0_.order_id as order_id1_6_0_,
    member1_.member_id as member_i1_4_1_,
    delivery2_.delivery_id as delivery1_2_2_,
    order0_.delivery_id as delivery4_6_0_,
    order0_.member_id as member_i5_6_0_,
    order0_.order_date as order_da2_6_0_,
    order0_.status as status3_6_0_,
    member1_.city as city2_4_1_,
    member1_.street as street3_4_1_,
    member1_.zipcode as zipcode4_4_1_,
    member1_.name as name5_4_1_,
    delivery2_.city as city2_2_2_,
    delivery2_.street as street3_2_2_,
    delivery2_.zipcode as zipcode4_2_2_,
    delivery2_.status as status5_2_2_ 
from
    orders order0_ 
inner join
    member member1_ 
        on order0_.member_id=member1_.member_id 
inner join
    delivery delivery2_ 
        on order0_.delivery_id=delivery2_.delivery_id

컬렉션 조회 최적화

 위의 경우에는 ManyToOne, OneToOne 관계라서 Order 내부에 단일 엔티티만 조회하는 경우였다.

이번에는 OneToMany 관계로 Order의 컬렉션 엔티티인 orderItems도 조회해 보겠다.

 

Order.class

package jpabook.jpashop.domain;

@Entity
@Table(name = "ORDERS")
@Getter @Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED) //기본 생성자를 protected로 생성해 new 생성 방지
public class Order {
    @Id
    @GeneratedValue
    @Column(name = "order_id")
    private Long id;

    // 주인: Order.member
    @ManyToOne(fetch = LAZY, cascade = CascadeType.ALL)
    @JoinColumn(name = "member_id")
    private Member member;

    // 종속: Order.orderItems
    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
    private List<OrderItem> orderItems = new ArrayList<>();

    // 주인: Order.delivery
    @OneToOne(fetch = LAZY, cascade = CascadeType.ALL)
    @JoinColumn(name = "delivery_id")
    // 꿀팁! OneToOne일때 주인 지정: 많이 사용하는 놈한테 주인설정을 준다.
    private Delivery delivery;

    @Enumerated(EnumType.STRING)
    private OrderStatus status;

    private LocalDateTime orderDate;

    // 연관관계 메소드 (연관관계 편의 메소드의 위치는 핵심적으로 컨트롤하는 쪽)
    public void setMember(Member member) {
        this.member = member;
        member.getOrders().add(this);
    }

    public void addOrderItem(OrderItem orderItem) {
        this.orderItems.add(orderItem);
        orderItem.setOrder(this);
    }

    public void setDelivery(Delivery delivery) {
        this.delivery = delivery;
        delivery.setOrder(this);
    }

    //==생성 메서드==//
    public static Order createOrder(Member member, Delivery delivery, OrderItem... orderItems) {
        Order order = new Order();
        order.setMember(member);
        order.setDelivery(delivery);
        for (OrderItem orderItem : orderItems) {
            order.addOrderItem(orderItem);
        }
        order.setStatus(OrderStatus.ORDER); // 주문 상태: ORDER로 초기화
        order.setOrderDate(LocalDateTime.now());  // 주문 시간 현재
        return order;
    }

    //==비즈니스 로직==//

    /**
     * 주문 취소
     */
    public void cancel() {
        // 이미 배송 완료된 경우
        if (delivery.getStatus() == DeliveryStatus.COMP) {
            // 배송 취소 불가
            throw new IllegalStateException("이미 배송완료된 상품은 취소가 불가능합니다.");
        }

        // 주문 취소
        setStatus(OrderStatus.CANCEL);
        for (OrderItem orderItem : orderItems) {
            // 주문 수량 복구
            orderItem.cancel();
        }
    }
    //==조회 로직==//

    /**
     * 전체 주문 가격 조회
     */
    public int getTotalPrice() {
        int totalPrice = 0;
        for (OrderItem orderItem : orderItems) {
            totalPrice += orderItem.getTotalPrice();
        }
        return totalPrice;
    }
}

 


주의! 잘못된 내용일 수 있음.  쿼리문에 distinct가 없으면 order가 컬렉션인 OrderItems에 의해 데이터 뻥튀기 됨. 

 1) Dto를 이용하여 조회하기

OrderItem.class

package jpabook.jpashop.domain;

@Entity
@Table(name = "order_item")
@Getter @Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED) //기본 생성자를 protected로 선언해 생성
public class OrderItem {
    @Id
    @GeneratedValue
    @Column(name = "order_item_id")
    private Long id;

    @ManyToOne(fetch = LAZY, cascade = CascadeType.ALL)
    @JoinColumn(name = "item_id")
    private Item item;

    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "order_id")
    private Order order;

    private int orderPrice;
    private int count;

    /*// protected로 선언해서 생성 메서드 외에 생성하는 경우를 차단함.
    protected OrderItem() {
    }*/

    //==생성 메서드==//
    public static OrderItem createOrderItem(Item item, int orderPrice, int count) {
        OrderItem orderItem = new OrderItem();
        orderItem.setItem(item);
        orderItem.setOrderPrice(orderPrice);
        orderItem.setCount(count);

        // 주문한 양만큼 item의 재고를 줄임
        item.removeStock(count);

        return orderItem;
    }

    //==비즈니스 로직==//
    public void cancel() {
        getItem().addStock(count);
    }

    //==조회 로직==//

    /**
     * 주문 상품 전체 가격 조회
     */
    public int getTotalPrice() {
        return getOrderPrice() * getCount();
    }
}

 order와 orderItem은 일대다 관계이고, orderItem과 item은 다대일 관계로 매핑되어 있다. 만일 order를 조회시에 orderItem을 가져오기 위해서 다음과 같은 방식으로 조회할 수 있을 것이다.

package jpabook.jpashop.api;

@RestController
@RequiredArgsConstructor
public class OrderApiController {

    private final OrderRepository orderRepository;

	/**
 	* V2. 엔티티를 조회해서 DTO로 변환(fetch join 사용X)
 	* - 트랜잭션 안에서 지연 로딩 필요
 	*/
    @GetMapping("/api/v2/orders")
    public List<OrderDto> ordersV2(){
        List<Order> all =
                orderRepository.findAllByString(new OrderSearch());
        List<OrderDto> collect =
                all.stream().map(o -> new OrderDto(o)).collect(Collectors.toList());
        return collect;
    }

    @Getter
    static class OrderDto {
        private Long orderId;
        private String name;
        private LocalDateTime orderDate;
        private OrderStatus orderStatus;
        private Address address;
        private List<OrderItemDto> orderItems; // 엔티티를 Dto로 감쌈

        /*
        private List<OrderItem> orderItems; : Dto 내부에 엔티티가 존재한다.
        => 엔티티에 의존하므로 orderItems도 별도의 Dto를 만들어서 반환해야 한다.
        */

        OrderDto(Order order) {  //생성자
            orderId = order.getId();
            name = order.getMember().getName();
            orderDate = order.getOrderDate();
            orderStatus = order.getStatus();
            address = order.getDelivery().getAddress();

            // OrderItem 엔티티를 그대로 반환하는 것이 아닌 별도의 Dto에 담아서 반환함으로써
            // Dto가 내부적으로 엔티티에 의존하는 것을 방지할 수 있다.
            orderItems = order.getOrderItems().stream()
                    .map(o -> new OrderItemDto(o))
                    .collect(Collectors.toList());
        }
    }
    
    @Getter
    static class OrderItemDto {
        private String itemName;
        private int orderPrice;
        private int count;

        OrderItemDto(OrderItem orderItem){
            this.itemName = orderItem.getItem().getName();
            this.orderPrice = orderItem.getOrderPrice();
            this.count = orderItem.getCount();
        }
    }
}

 여기서 눈여겨 봐야할 것은 Order 엔티티를 조회해서 OrderDto에다 담아서 반환을 할 때, Order의 컬렉션 필드인 orderItems도 OrderItemDto라는 별도의 Dto에 담아서 반환하는 것이다. API를 설계하는데 있어서 엔티티를 노출시키면 안된다는 것을 배웠다. 이 또한 이를 준수하기 위해서 컬렉션 엔티티인 orderItems를 별도의 엔티티에 담아서 리스트의 형태로 반환하고 있다. 이것은 지금까지 배운바를 잘 준수한 훌륭한 설계이다.

 그런데 문제가 하나있다. OrderItem과 Item 엔티티가 서로 FetchType이 LAZY로 연관되어 있어서 OrderItem을 조회하면 그와 연관된 Item이 지연로딩으로 조회된다는 것이다. 이 말은 Order 엔티티의 orderItems 리스트의 크기가 100이라면 Item 엔티티 100개가 지연로딩으로 조회된다는 것이다. 따라서 Item의 select 쿼리가 100번 나간다는 것이다. 이는 성능에 악영향을 미칠 수 있다. 

 이를 해결하기 위해 join fetch를 사용하자.

 

 2) join fetch 조회하기

package jpabook.jpashop.api;

@RestController
@RequiredArgsConstructor
public class OrderApiController {

    private final OrderRepository orderRepository;

    /**
     * V3. 엔티티를 조회해서 DTO로 변환(fetch join 사용O)
     * - 페이징 시에는 N 부분을 포기해야함(대신에 batch fetch size? 옵션 주면 N -> 1 쿼리로 변경
     가능)
     */
    @GetMapping("/api/v3/orders")
    public List<OrderDto> ordersV3(){
        List<Order> all = orderRepository.findAllWithItem();
        List<OrderDto> collect =
                all.stream().map(o -> new OrderDto(o)).collect(Collectors.toList());

        return collect;
    }
    @Getter
    static class OrderDto {
        private Long orderId;
        private String name;
        private LocalDateTime orderDate;
        private OrderStatus orderStatus;
        private Address address;
        private List<OrderItemDto> orderItems; // 엔티티를 Dto로 감쌈

        /*
        private List<OrderItem> orderItems; : Dto 내부에 엔티티가 존재한다.
        => 엔티티에 의존하므로 orderItems도 별도의 Dto를 만들어서 반환해야 한다.
        */

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

            // OrderItem 엔티티를 그대로 반환하는 것이 아닌 별도의 Dto에 담아서 반환함으로써
            // Dto가 내부적으로 엔티티에 의존하는 것을 방지할 수 있다.
            orderItems = order.getOrderItems().stream()
                    .map(o -> new OrderItemDto(o))
                    .collect(Collectors.toList());
        }

        @Getter
        static class OrderItemDto {
            private String itemName;
            private int orderPrice;
            private int count;

            OrderItemDto(OrderItem orderItem){
                this.itemName = orderItem.getItem().getName();
                this.orderPrice = orderItem.getOrderPrice();
                this.count = orderItem.getCount();
            }
        }
    }
}

orderV3( ) 메서드도 orderV2( )처럼 Dto를 사용하고, 거의 비슷하다. 다른 점이 있다면 orderV3( )는 orderRepository에서 Order 엔티티를 조회할 때 findAllWithItem() 메서드를 이용해 join fetch 문으로 쿼리를 날린다는 차이가 있다.

package jpabook.jpashop.repository;

@Repository
@RequiredArgsConstructor
public class OrderRepository {

    private final EntityManager em;
		
        // 생략 ...
        
    public List<Order> findAllWithItem() {
        // orderItems의 item까지 join fetch로 한번의 쿼리만으로 다 끌고 옴.
        List<Order> result = em.createQuery("select o from Order o " +
                        "join fetch o.member m " +
                        "join fetch o.delivery d " +
                        "join fetch o.orderItems oi " +
                        "join fetch oi.item i", Order.class)
                .getResultList();

        return result;
    }
}

 join fetch는 지연로딩을 즉시로딩으로 만들어주기 때문에 연관된 것들을 모두 한꺼번에 가져온다. 따라서 select 문이 한번만 나가게 된다. 위의 경우 member, delivery, orderItems, item을 모두 한번에 가져온다.

    select
        order0_.order_id as order_id1_6_0_,
        member1_.member_id as member_i1_4_1_,
        delivery2_.delivery_id as delivery1_2_2_,
        orderitems3_.order_item_id as order_it1_5_3_,
        item4_.item_id as item_id2_3_4_,
        order0_.delivery_id as delivery4_6_0_,
        order0_.member_id as member_i5_6_0_,
        order0_.order_date as order_da2_6_0_,
        order0_.status as status3_6_0_,
        member1_.city as city2_4_1_,
        member1_.street as street3_4_1_,
        member1_.zipcode as zipcode4_4_1_,
        member1_.name as name5_4_1_,
        delivery2_.city as city2_2_2_,
        delivery2_.street as street3_2_2_,
        delivery2_.zipcode as zipcode4_2_2_,
        delivery2_.status as status5_2_2_,
        orderitems3_.count as count2_5_3_,
        orderitems3_.item_id as item_id4_5_3_,
        orderitems3_.order_id as order_id5_5_3_,
        orderitems3_.order_price as order_pr3_5_3_,
        orderitems3_.order_id as order_id5_5_0__,
        orderitems3_.order_item_id as order_it1_5_0__,
        item4_.name as name3_3_4_,
        item4_.price as price4_3_4_,
        item4_.stock_quantity as stock_qu5_3_4_,
        item4_.artist as artist6_3_4_,
        item4_.etc as etc7_3_4_,
        item4_.author as author8_3_4_,
        item4_.isbn as isbn9_3_4_,
        item4_.actor as actor10_3_4_,
        item4_.director as directo11_3_4_,
        item4_.dtype as dtype1_3_4_ 
    from
        orders order0_ 
    inner join
        member member1_ 
            on order0_.member_id=member1_.member_id 
    inner join
        delivery delivery2_ 
            on order0_.delivery_id=delivery2_.delivery_id 
    inner join
        order_item orderitems3_ 
            on order0_.order_id=orderitems3_.order_id 
    inner join
        item item4_ 
            on orderitems3_.item_id=item4_.item_id

 한번에 모든 것을 다 가져오다 보니 쿼리문 자체는 길지만 단 한번의 쿼리만 나가서 성능상 이점을 가질 수 있다.

따라서 컬렉션 엔티티를 조회하는 경우에는 join fetch를 사용한다.

 

distinct

public List<Order> findAllWithItem() {
 return em.createQuery(
 "select distinct o from Order o" +
 " join fetch o.member m" +
 " join fetch o.delivery d" +
 " join fetch o.orderItems oi" +
 " join fetch oi.item i", Order.class)
 .getResultList();
}

 페치 조인으로 SQL이 1번만 실행됨

distinct 를 사용한 이유는 1대다 조인이 있으므로 데이터베이스 row가 증가한다. 그 결과 같은 order 엔티티의 조회 수도 증가하게 된다. JPA의 distinct는 SQL에 distinct를 추가하고, 더해서 같은 엔티티가 조회되면, 애플리케이션에서 중복을 걸러준다. 이 예에서 order가 컬렉션 페치 조인 때문에 중복 조회 되는 것을 막아준다.

문제점)

컬렉션 페치 조인은 distinct 사용 시 페이징이 불가능하다.

  • 페이징 : setFirstResult(), setMaxResult()의 메서드로 가져올 데이터의 범위를 지정하는 것

 컬렉션은 Distinct를 사용한 경우 페이징이 불가하다.

왜냐하면 jpql 쿼리문에 distinct를 사용하면 어플리케이션 단에서는 distinct가 적용되어 중복엔티티가 제거되는 효과를 기대할 수 있다. 하지만 sql에 붙여진 distinct 키워드는 적용이 되지 않을 수 있다.(만일 모든 column의 값이 동일한 row가 존재한다면 중복제거가 가능하다. 근데 그러는 경우는 거의 없다.)  따라서 우리가 어플리케이션 상에서는 중복이 제거된 엔티티만 받았다고 해도, DB 상에서는 중복된 데이터가 존재하고 있을 수 있다.

 

예시)

 예를 들어 기존에는 컬렉션 페치 조인으로 동일한 order1 엔티티가 100개로 뻥튀기 되었다고 가정하자. jpql에 distinct문을 집어넣어 프로그램 상으로는 단일한 order1 엔티티 하나만 받았다. 그러나 sql에 distinct는 동일한 row가 한개도 존재하지 않아서 중복이 제거되지 않았다고 하자. 그때 우리가 페이징을 통해서 1번부터 3번까지 받는다고 하자.

List<Order> orders = em.createQuery(query,Order.class)
						.setFirstResult(1).setMaxResult(3)
                        .getResultList()

이것의 본래 의미는 "나는 order1,order2,order3를 조회하겠다"이지만 방금 말했다시피 DB 상에는 order1이 100개로 뻥튀기 되어있다. 따라서 조회되는 세개의 엔티티는 모두 order1이 된다. 


   join fetch로 가져온 엔티티는 프록시가 아니다.

첨언하자면 join fetch를 통해서 가져온 엔티티들은 기존 fetchType이 LAZY라도, 프록시가 아닌 실제 엔티티이다.

'Spring > SpringBoot' 카테고리의 다른 글

SpringBoot- @DeleteMapping  (0) 2023.08.26
SpringBoot-JPA에서 DTO로 바로 조회하기  (0) 2023.07.07
SpringBoot- API  (0) 2023.07.04
SpringBoot- @Valid, @ResponseBody, @RequestBody  (0) 2023.06.28
SpringBoot- @GetMapping, @PostMapping  (0) 2023.06.28