모르지 않다는 것은 아는것과 다르다.

Spring Jpa

조회 성능 최적화

채마스 2022. 2. 27. 00:56

최적화 방법

  • 먼저 @ManyToOne, @OneToOne 의 경우
    • fetch join을 이용해서 쿼리 수를 최적화 한다.
  • @OneToMany 의 경우
    • 페이징이 필요 없는 경우 -> fetch join을 이용해서 쿼리 수를 최적화 한다.
    • 페이징이 필요한 경우 -> fetch join 을 사용하지 않는다. -> hibernate.default_batch_fetch_size 를 500 정도로 설정해서 최적화 한다.




Entity 관계도 와 성능 문제 상황

  • 위와 같이 주문, 주문상품, 고객, 배달, 상품 엔티티가 있다고 가정하자.
  • 만약 주문 목록을 조회하는데 3개의 주문이 조회됐다.
    • 여기서 주문과 고객, 배달이 toOne 으로 연결 되어있다. -> 몇번의 쿼리가 나갈까?
      • order 목록을 조회하는 쿼리 1번
      • order 와 연관된 고객 조회하는 쿼리 3번(N번)
      • order 와 연관된 배달 조회하는 쿼리 3번(N번)
      • 결국은 1 + 3 + 3 (1 + N + N) = 7번의 쿼리가 나간다.
    • 만약 연관관계가 1개 더 추가된다면?
      • 1 + 3 + 3 + 3 이 될 것이다. (1 + N + N + N + N)
    • 만약 이 상황에서 주문 갯수가 1개 더 추가된다면?
      • 1 + 4 + 4 + 4 가 될 것이다.
  • 위의 문제를 1 + N 문제라고 한다.
  • 위의 문제를 fetch join으로 해결할 수 있다.
  • 물론 최악의 경우로 생각한 것이다. -> 영속성 컨텍스트에 있는 엔티티의 경우 쿼리가 나가지 않기 때문이다.




Entity 코드

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

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

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

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

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

    private LocalDateTime orderDate;

    @Enumerated(EnumType.STRING)
    private OrderStatus status;

    public void setMember(Member member) {
        this.member = member;
        member.getOrders().add(this);
    }

    public void addOrderItem(OrderItem orderItem) {
        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.setOrderDate(LocalDateTime.now());
        return order;
    }
}
  • 고객 Entity
@Entity
@Getter @Setter
public class Member {

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

    private String name;

    @Embedded
    private Address address;

    // orders 테이블의 member 필드, read-only, 맵핑된 거울
    @JsonIgnore
    @OneToMany(mappedBy = "member")
    private List<Order> orders = new ArrayList<>();
}
  • 주문상품 Entity
@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;

    // == 생성 메서드 ==
    public static OrderItem createOrderItem(Item item, int orderPrice, int count) {
        OrderItem orderItem = new OrderItem();
        orderItem.setItem(item);
        orderItem.setOrderPrice(orderPrice);
        orderItem.setCount(count);
        item.removeStock(count);
        return orderItem;
    }
}
  • 상품 Entity
@Entity
@Getter @Setter
public 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<>();
}





컬렉션 조회 최적화

  • 만약 @ManyToOne에서 주문 1개가 orderItem 3개를 갖는다면 조인 시점에서 3개로 뻥튀기된다.
  • Order 기준으로 컬렉션인 OrderItem 와 Item 이 필요하다면?
  • Member, Delivery 와는 다르게 @ManyToOne 이다.
  • 이런경우 컬렉션을 포함하고 있다.
  • 먼저 양방향 연관관계면 무한 루프에 걸리지 않게 한곳에 @JsonIgnore 를 추가해야 한다.
  • 쿼리 수
    • order 1번
    • orderItem N번(order 조회 수 만큼)
    • item N번(orderItem 조회 수 만큼)
    • 위와 같이 N * N 이 되기 때문에 성능적인 문제가 발생할 수 있다.
  • 위의 쿼리수 문제를 해결하기 위해서 fetch join 을 하게 된다면?
    • 조인으로 인해 중복된 데이터가 뻥튀기 된다. -> 1 : N 에서 N만큼 뻥튀기 된다.
    • 위의 문제를 해결하기 위해서 distinct 를 사용할 수 있다.
      • JPA 에서의 distinct 는 ID 값이 같으면 중복을 제거해준다. -> 모든 필드에 값이 다같아야 중복을 제거해주는 디비상에서 distinct 를 붙여서 쿼리를 날리는 거랑은 다르다.




컬렉션 + 페이징 문제

  • 컬렉션 페치 조인을 사용하면 페이징이 불가능하다.
  • 하이버네이트는 경고 로그를 남기면서 모든 데이 터를 DB에서 읽어오고, 메모리에서 페이징 해버린다(매우 위험하다).
  • 컬렉션 페치 조인은 1개만 사용할 수 있다.
  • 컬렉션 둘 이상에 페치 조인을 사용하면 안된다. 데이터가 부정합하게 조회될 수 있다.
  • 그렇기 때문에 컬렉션 패치 조인은 사용하면 안된다.




컬렉션 + 페이징 문제 해결

  • 우선 컬렉션은 지연 로딩으로 조회한다.
  • 지연 로딩 성능 최적화를 위해 hibernate.default_batch_fetch_size , @BatchSize 를 적용한다.
    • hibernate.default_batch_fetch_size: 글로벌 설정
    • @BatchSize: 개별 최적화
    • 이 옵션을 사용하면 컬렉션이나, 프록시 객체를 한꺼번에 설정한 size 만큼 IN 쿼리로 조회한다.
    • 쿼리호출수가 1+N 에서 1+1 로 최적화된다.
    • 조인보다 DB 데이터 전송량이 최적화 된다.
    • 페치 조인 방식과 비교해서 쿼리 호출 수가 약간 증가하지만, DB 데이터 전송량이 감소한다.
    • 컬렉션 페치 조인은 페이징이 불가능 하지만 이 방법은 페이징이 가능하다.




default_batch_fetch_size 의 크기 설정

  • 100~1000 사이를 선택 하는 것을 권장한다.
  • 이 전략을 SQL IN 절을 사용하는데, 데이터베이스에 따라 IN 절 파라미터를 1000으 로 제한하기도 한다.
  • 1000으로 잡으면 한번에 1000개를 DB에서 애플리케이션에 불러오므로 DB에 순간 부하가 증가할 수 있다.
  • 하지만 애플리케이션은 100이든 1000이든 결국 전체 데이터를 로딩해야 하므로 메모리 사용량이 같다.
  • 1000으로 설정하는 것이 성능상 가장 좋지만, 결국 DB든 애플리케이션이든 순간 부 하를 어디까지 견딜 수 있는지로 결정하면 된다.
  • 결론은 맥시멈 1000을 넘기면 안되고 100~1000 을 권장하기 때문에 그냥 500으로 설정하면 마음 편하게 사용할 수 있다.




결론

@GetMapping("/api/v3/simple-orders")
    public List<SimpleOrderDto> ordersV3() {
        List<Order> orders = orderRepository.findAllWithMemberDelivery();

        List<SimpleOrderDto> result = orders.stream()
                .map(SimpleOrderDto::new)
                .collect(Collectors.toList());

        return result;
    }
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();
}
  • order 입장에서 고객과 배달은 @ManyToOne, @OneToOne 이기 때문에 fetch join 을 진행한다.
  • 페이징 처리가 필요하다면
    • 그리고 @OneToMany 인 OrderItem은 fetch join을 진행하지 않는다.
      • 당연히 OrderItem와 @ManyToOne 인 Item 도 fetch join 이 진행될 수 없다. -> OrderItem 에서 끊겼기 때문이다.
      • 대신 default_batch_fetch_size 를 설정한다.
          spring: jpa:
              properties:
              hibernate:
                  default_batch_fetch_size: 500




REFERENCES

  • 김영한님의 스프링 부트와 JPA 활용2

'Spring Jpa' 카테고리의 다른 글

JPA Id 생성전략 설정하기  (0) 2022.02.27
영속성 컨텍스트  (0) 2022.02.27
값타입  (0) 2022.02.27
OSIV  (0) 2022.02.27
Spring Data Jpa  (0) 2022.02.27