본문 바로가기

Spring

[Spring] 컬렉션 fetch join(페치 조인) 을 이용한 성능 최적화 및 페이징

 

 

 

이전에 엔티티에서 fetch join이 무엇인지, 그리고 이를 이용한 성능 최적화에 대해 알아 보았다.

이번에는 컬렉션  페치 조인을 알아보자.

 

 

 

 

 


 

예제 관계도

 

 

예제 소개

 

먼저, 예제에서 쓰일 엔티티들에 대해서 가볍게 설명하고 넘어가겠다.

 

 

public class Order {

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

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

    @JsonIgnore
    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
    private List<OrderItem> orderItems = new ArrayList<>();

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

    private LocalDateTime orderDate; //주문시간

    @Enumerated(EnumType.STRING)
    private OrderStatus status; //주문상태 [ORDER, CANCEL]
}

 

 

@Entity
@Getter @Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class OrderItem {

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

    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "item_id")
    private Item item;

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

    private int orderPrice; //주문 가격
    private int count; //주문 수량
}

 

 

@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "dtype")
@Getter @Setter
public abstract class Item {

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

    private String name;
    private int price;
    private int stockQuantity;

    @ManyToMany(mappedBy = "items")
    private List<Category> categories = new ArrayList<>();
}

 

 

 

order를 기준으로 orderItem과 Item은 컬렉션이다.

앞선 포스팅에서는 OneToOne 과, ManyToOne 관계에서 성능 최적화를 하는 방법을 보여줬지만, 이번에는 OneToMany 관계인 컬렉션에서 성능 최적화 하는 것을 써보려 한다.

 

 

 


 

 

 

Version 1) 엔티티 직접 노출

 

 

@GetMapping("/api/v1/orders")
    public List<Order> ordersV1() {
        List<Order> all = orderRepository.findAllByString(new OrderSearch());
        for (Order order : all) {
			order.getMember().getName(); //Lazy 강제 초기화 
        	order.getDelivery().getAddress(); //Lazy 강제 초기환
			List<OrderItem> orderItems = order.getOrderItems(); 
            orderItems.stream().forEach(o -> o.getItem().getName()); //Lazy 강제초기화
        }
        return all;
    }

 

 

엔티티를 직접 노출하고 있는 version 1 컨트롤러 코드이다.

강제로 지연로딩을 초기화 해주며, 만일 양방향 연관관계로 사용중이라면, 한쪽엔 @JsonIgnore를 달아줘야 무한루프에 빠지지 않는다.

 

엔티티를 직접 노출하고 있으므로 딱히 좋아보이진 않는다.

 

 

 

 

 

 

 

version 2) DTO 사용

 

 @Data
    static class OrderDto {

        private Long orderId;
        private String name;
        private LocalDateTime orderDate;
        private OrderStatus orderStatus;
        private Address address;
        private List<OrderItemDto> orderItems;
        public OrderDto(Order order) {
            orderId = order.getId();
            name = order.getMember().getName();
            orderDate = order.getOrderDate();
            orderStatus = order.getStatus();
            address = order.getMember().getAddress();
            orderItems = order.getOrderItems().stream()
                    .map(orderItem -> new OrderItemDto(orderItem))
                    .collect(Collectors.toList());
        }
    }

 

 

 @Data
static class OrderItemDto {

    private String itemName;
    private int orderPrice;
    private int count;

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

 

 

OrderDto를 생성해주었다.

그리고 안에서 OrderItem과 1대다 연관관계를 맺고있으므로 이에 대한 OrderItemDto도 같이 생성해주었다.

OrderItem 생성자에서 OrderItemDto를 호출하여 사용해준다.

 

@GetMapping("/api/v2/orders")
    public List<OrderDto> ordersV2(){
        List<Order> orders = orderRepository.findAllByString(new OrderSearch());
        List<OrderDto> result = orders.stream()
                .map(o -> new OrderDto(o))
                .collect(Collectors.toList());

        return result;
    }

 

 

해당 컨트롤러 코드에서는 Dto로 잘 변환해서 반환해주고 있다.

허나, 앞선 포스팅에서 이렇게 사용했을 때처럼, 너무 많은 지연로딩이 걸린다.

 

order 가 1번 호출되고, 그에 따른 member,address orderItem이 여러번 호출되게 된다. 이를 N+1 문제라고 하였다.  이러한 쿼리문의 남용은 성능 문제로 이어지고, 이제부터 페치 조인으로 풀어보도록 하겠다.

 

 

 

 

 

 

 

 

version 3) 페치 조인 사용

 

 

@GetMapping
public List<OrderDto> ordersV3() {
     List<Order> orders = orderRepository.findAllWithItem();
     List<OrderDto> result = orders.stream()
             .map(o -> new OrderDto(o))
             .collect(toList());
     return result;
 }

 

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

 

 

위처럼 컬렉션에서도 페치 조인을 적용해주면 된다. 허나 달라진 점은 distinct라는 구문이 들어간 것이다.

 

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

 

하지만, 컬렉션 페치 조인을 사용하면 페이징이 불가능하다. 하이버네이트는 경고 로그를 남기면서 모든 데이터를 DB에서 읽어오고, 메모리에서 페이징 해버리고 이는 매우 위험한 현상이다.  또한, 컬렉션 엔티티가 중복 조회된다. 이말은 즉슨, 데이터가 중복되어 조회되며, 데이터가 뻥튀기되어 도출되어 버린다는 것이다. 이 문제점을 이제 고치고 성능 최적화도 하며 두마리 토끼를 잡아보자!

 

 

 

 

 

 

version 3.1) 문제점 해결 및 성능 최적화

 

1. XXToOne 관계는 페치조인을 해주자.

 

 ToOne 관계는 앞서 말했던 것처럼 문제 없이 페치조인을 이용해 성능 최적화가 된다.

 

 

2.컬렉션은 지연 로딩으로 조회하고, 지연 로딩 성능 최적화를 위해 `hibernate.default_batch_fetch_size` (글로벌), `@BatchSize` (세부설정)를 적용한다.

 

 

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

 

그리고 해당 코드를 레포지토리에 추가해준다.

 

아까 코드와 달라진 점은 distinct 구문을 제거해주고, offset,limit을 적용해준다는 점이다.

setFirstResult로 결과값이 도출될 첫번째 번호를, setMaxResults로 페이징 최대치를 적어준다.

 

 

@GetMapping("/api/v3.1/orders")
public List<OrderDto> ordersV3_page(
 	@RequestParam(value = "offset", defaultValue= "0") int offset,
    @RequestParam(value = "limit", defaultValue= "100") int limit) {
     List<Order> orders = orderRepository.findAllWithMemberDelivery(offset, limit);
     List<OrderDto> result = orders.stream()
             .map(o -> new OrderDto(o))
             .collect(toList());
     return result;
}

 

 

spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 1000

 

 

default_batch_fetch_size를 설정파일에 추가하여 글로벌 옵션을 적용해준다.

개별로 설정하려면 `@BatchSize` 를 적용하면 된다. -> 컬렉션은 컬렉션 필드에, 엔티티는 엔티티 클래스에 적용

 

 

이렇게 되면, N+1문제도 해결되고, 조인보다 DB 데이터 전송량이 최적화 된다. (OrderOrderItem을 조인하면 OrderOrderItem 만큼 중복해서 조회된다. 이 방법은 각각 조회하므로 전송해야할 중복 데이터가 없다.)  이렇게 하여 페이징을 가능하게 만들어 주었다.

 

 

 

위 코드를 사용시 발생하는 쿼리문

 

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 limit ?
offset ?

select orderitems0_.order_id      as order_id5_5_1_,
       orderitems0_.order_item_id as order_it1_5_1_,
       orderitems0_.order_item_id as order_it1_5_0_,
       orderitems0_.count         as count2_5_0_,
       orderitems0_.item_id       as item_id4_5_0_,
       orderitems0_.order_id      as order_id5_5_0_,
       orderitems0_.order_price   as order_pr3_5_0_
from order_item orderitems0_
-- in 절로 땡겨온다.
where orderitems0_.order_id in (
                                ?, ?
    )

select item0_.item_id        as item_id2_3_0_,
       item0_.name           as name3_3_0_,
       item0_.price          as price4_3_0_,
       item0_.stock_quantity as stock_qu5_3_0_,
       item0_.artist         as artist6_3_0_,
       item0_.etc            as etc7_3_0_,
       item0_.author         as author8_3_0_,
       item0_.isbn           as isbn9_3_0_,
       item0_.actor          as actor10_3_0_,
       item0_.director       as directo11_3_0_,
       item0_.dtype          as dtype1_3_0_
from item item0_
-- in 절로 땡겨온다.
where item0_.item_id in (
                         ?, ?
    )

 

컬렉션 관계는 Batch_size로 IN절로 SIZE만큼 한꺼번에 몰아서 가져온다

 

 

 

 

 

 

 

default_batch_fetch_size 사이즈 정하기

 

default_batch_fetch_size 의 크기는 적당한 사이즈를 골라야 하는데, 100~1000 사이를선택하는 것을 권장한다. 이 전략을 SQL IN 절을 사용하는데, 데이터베이스에 따라 IN 절 파라미터를1000으로 제한하기도 한다. 1000으로 잡으면 한번에 1000개를 DB에서 애플리케이션에 불러오므로 DB에 순간 부하가 증가할 수 있다. 하지만 애플리케이션은 100이든 1000이든 결국 전체 데이터를 로딩해야하므로 메모리 사용량이 같다. 1000으로 설정하는 것이 성능상 가장 좋지만, 결국 DB든 애플리케이션이든 순간 부하를 어디까지 견딜 수 있는지로 결정하면 된다.