본문 바로가기

Develop/SPRING FRAMEWORK

API 개발 - 컬렉션 조회 성능 최적화

이번 포스팅에서는 @OneToMany 관계가 설정된 Entity를 조회할 때 발생하는 이슈를 해결해보겠다.

이는 @OneToOne, @ManyToOne 관계와 달리 컬렉션을 포함하고 있기 때문에 성능 최적화를 위해 고민해야 할 요소가 더 많다.

예를 들어 1개의 Order 엔티티를 조회할 때 3개의 OrderItem을 가지고 있다면 DB 조회 시 Row가 뻥튀기(3개)될 수 있다.

1. 엔티티 직접 노출

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

orderRepository.findAllByString 결과로 가져온 Order 엔티티 내 OrderItem 엔티티를 반복문을 돌며 프록시 객체 초기화를 해주었다. 또한 OrderItem 엔티티 내 Item 엔티티를 돌며 프록시 객체 초기화를 해주었다. 지연 로딩을 해결하기 위함이다.

 

하지만 역시나 엔티티를 직접 노출하는 건 피해야 하기 때문에 좋은 방법이 아니다!

2. 엔티티를 DTO로 변환

@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(toList());
  return result;
}

@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.getDelivery().getAddress();
    orderItems = order.getOrderItems().stream()
                    .map(orderItem -> new OrderItemDto(orderItem))
                    .collect(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();
  }
}

여기서 중요한 부분은 OrderItemDto 이다. 앞서 엔티티를 직접 반환하면 안된다고 강조했다. 따라서 OrderDto을 생성하여 Order 엔티티를 한번 감싸줬다. 하지만 Order 엔티티 안에는 OrderItem 엔티티가 또 존재한다!! 이거 또한 Dto로 감싸줘야 한다. 즉, 엔티티에 대한 의존성을 완전히 끊어주는 게 좋다. 따라서 OrderItemDto을 사용한다.

 

하지만 이러한 경우 지연 로딩이 계속 걸려 있기 때문에 너무 많은 SQL이 실행된다. 조회하고자 하는 Order 엔티티의 개수가 2개이고, 각 Order는 2개의 Item 엔티티를 가지는 경우에 1번(Order 조회) + 2번(각 Order의 Member 조회) + 2번(각 Order의 Delivery 조회) + 2번(각 Order의 OrderItem 조회) + 4번(각 OrderItem의 Item 조회) = 11번의 SQL 쿼리가 나간다.

 

3. 엔티티를 DTO로 변환 - 페치 조인 최적화

@GetMapping("/api/v3/orders")
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();
}

페치 조인을 통해 Order 엔티티, OrderItem 엔티티와 연관된 엔티티를 한꺼번에 가져올 수 있다.

하지만 실제 DB에서 데이터를 가져올 때 @OneToMany 관계 때문에 아래처럼 데이터가 뻥튀기 된다.

따라서 distinct 옵션을 적용한다. 원래는 distinct 옵션을 적용하더라도 모든 Column이 동일해야만 중복을 제거해준다. 하지만 가져오는 엔티티가 중복이라면 JPA가 중복을 제거하여 처리한다. (PK 값을 비교한다)

distinct 옵션을 통해 JPA가 직접 중복을 걸러준다.

하지만 컬렉션 페치 조인을 사용하면 페이징이 불가능하다는 단점이 있다.

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)
          .setFirstResult(1)
          .setMaxResults(100)
          .getResultList();
}

컬렉션 페치 조인을 할 때 페이징 처리를 하려고 하면 하이버네이트는 위와 같은 경고 로그를 남긴다.

 

모든 데이터를 DB에서 가져와서 어플리케이션에 올린 뒤 메모리에서 페이징 해버린다. 만약 데이터가 수천 개가 넘는다면 OutOfMemoryError가 발생할 수 있어서 굉장히 위험하다.

또한 DB에서 가져오는 데이터 자체는 뻥튀기 되어있기 때문에 페이징 기준이 달라진다. 예를 들어 1번에서 3번 데이터를 가져오려고 할 때 뻥튀기된 데이터는 원하는 데이터를 정확히 가져올 수 없다.

 

마지막으로 컬렉션 페치 조인은 1개만 사용할 수 있다. 컬렉션 둘 이상에 페치 조인을 사용하게 되면 1*N*N*··*N만큼 데이터가 뻥튀기되기 때문에 정확한 결과를 가져올 수 없다.

3-1. 엔티티를 DTO로 변환 - 페이징과 한계 돌파

앞선 3번 방법으로는 페이징 문제를 해결할 수 없었다. 따라서 이번에 소개할 방법으로 페이징 + 컬렉션 엔티티를 함께 조회함과 동시에 성능 최적화도 보장할 수 있다. 대부분의 페이징 + 컬렉션 엔티티 조회 성능 이슈는 이 방법으로 해결할 수 있다.

 

@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;
}
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();
}
spring:
	jpa:
    	properties:
        	hibernate:
            	default_batch_fetch_size: 1000

먼저 @OneToOne, @ManyToOne 관계는 데이터에 아무 영향을 주지 않기 때문에 페치 조인으로 한번에 가져온다.

그리고 default_batch_fetch_size 옵션을 적용하면 지연 로딩이 걸려있는 객체를 설정한 SIZE만큼 IN 쿼리로 한꺼번에 가져온다.

따라서 이제는 페이징이 가능해졌다! (개별적으로 설정하고 싶다면 @BatchSize를 적용하면 된다)

따라서 1번(Order 조회) + 1번(Order의 OrderItem 조회) + 1번(OrderItem의 Item 조회) = 3번의 SQL 쿼리가 나간다. (만약 가져와야할 데이터의 수가 BatchSize보다 많다면 추가로 SQL이 실행)

 

물론 앞선 3번 방법은 한번의 SQL 쿼리로 모든 데이터를 가져오긴 했다. 그럼 이 방법의 성능이 더 떨어지는 걸까? 그렇지만은 않다.

조인을 통해 한방 쿼리로 모든 데이터를 가져오게 된다면 데이터 뻥튀기로 인해 DB에서 중복된 데이터를 가져오게 된다. 따라서 데이터의 수가 많다면 DB 데이터 전송량이 증가한다. 하지만 BatchSize을 통해 쿼리를 나눠서 가져오게 된다면 필요한 데이터만 IN 쿼리로 쏙쏙 골라서 가져오기 때문에 중복 데이터가 없다. 따라서 가져와야할 데이터가 많다면 오히려 성능이 더 잘 나올 수 있다.

 

그렇다면 BatchSize을 얼마로 설정하는 게 적절한가에 대한 문제가 남아있다. 일반적으로 100~1000 사이를 선택하는 걸 권장한다. 데이터베이스에 따라 IN 쿼리의 파라미터를 1000으로 제한하기도 하기 때문에 확인이 필요하다. 어찌 됐든 전체 데이터를 DB에서 로딩해야하기 때문에 어플리케이션 메모리 사용량은 항상 동일하다. 하지만 무작정 1000으로 잡게 되면 한번에 1000개씩 DB에서 가져오기 때문에 DB에 순간 부하가 증가할 수 있다. 따라서 이러한 순간 부하를 얼마나 잘 견딜 수 있는지만 잘 고려하여 결정하면 된다.

 

4. JPA에서 DTO 직접 조회

@GetMapping("/api/v4/orders")
public List<OrderQueryDto> ordersV4() {
	return orderQueryRepository.findOrderQueryDtos();
}
public List<OrderQueryDto> findOrderQueryDtos() {

  List<OrderQueryDto> result = findOrders();

  result.forEach(o -> {
    List<OrderItemQueryDto> orderItems = findOrderItems(o.getOrderId());
    o.setOrderItems(orderItems);
  });
  return result;
}

private List<OrderQueryDto> findOrders() {
  return em.createQuery("select new jpabook.jpashop.repository.order.query.OrderQueryDto(o.id, m.name, o.orderDate, o.status, d.address)" +
                      " from Order o" +
                      " join o.member m" +
                      " join o.delivery d", OrderQueryDto.class)
                      .getResultList();
}


private List<OrderItemQueryDto> findOrderItems(Long orderId) {
  return em.createQuery("select new jpabook.jpashop.repository.order.query.OrderItemQueryDto(oi.order.id, i.name, oi.orderPrice, oi.count)" +
                      " from OrderItem oi" +
                      " join oi.item i" +
                      " where oi.order.id = : orderId", OrderItemQueryDto.class)
                      .setParameter("orderId", orderId)
                      .getResultList();
}

우선 앞선 포스팅에서 설명한 것처럼 특정 프레젠테이션 또는 API에 종속적인 쿼리들은 별도로 Repository을 생성하여 분리한다.

 

먼저 @OneToOne, @ManyToOne 관계가 설정되어 있는 Member, Delivery는 join을 통해 한꺼번에 가져온다.

처음엔 왜 여기서 페치 조인을 사용하지 않는지 궁금했다. fetch join은 select 결과에서 항상 엔티티를 조회해야 한다. 따라서 여기처럼 원하는 데이터를 하나씩 선택하여 DTO로 직접 조회하는 경우에는 fetch join을 사용할 수 없다. 따라서 일반 join을 사용한다.

 

이후 @OneToMany 관계가 설정된 컬렉션을 별도로 조회하여 채워 넣는다.

 

이때는 1번(Order+Member+Delivery 조회) + 2번(각 Order의 OrderItem+Item 조회) = 3번의 SQL 쿼리가 나간다.

5. JPA에서 DTO 직접 조회 - 컬렉션 조회 최적화

@GetMapping("/api/v5/orders")
public List<OrderQueryDto> ordersV5() {
	return orderQueryRepository.findAllByDto_optimization();
}
public List<OrderQueryDto> findAllByDto_optimization() {

  List<OrderQueryDto> result = findOrders();

  Map<Long, List<OrderItemQueryDto>> orderItemMap = findOrderItemMap(toOrderIds(result));
  result.forEach(o -> o.setOrderItems(orderItemMap.get(o.getOrderId())));
  return result;
}

private List<Long> toOrderIds(List<OrderQueryDto> result) {
  return result.stream()
              .map(o -> o.getOrderId())
              .collect(toList());
}

private Map<Long, List<OrderItemQueryDto>> findOrderItemMap(List<Long> orderIds) {

  List<OrderItemQueryDto> orderItems = em.createQuery("select new jpabook.jpashop.repository.order.query.OrderItemQueryDto(oi.order.id, i.name, oi.orderPrice, oi.count)" +
                                        " from OrderItem oi" +
                                        " join oi.item i" +
                                        " where oi.order.id in :orderIds", OrderItemQueryDto.class)
                                        .setParameter("orderIds", orderIds)
                                        .getResultList();

  return orderItems.stream()
  		.collect(Collectors.groupingBy(OrderItemQueryDto::getOrderId));
}

우선 @xToOne 관계가 설정된 엔티티들을 먼저 가져온다. 이후 해당 Order 엔티티의 ID값을 IN 쿼리의 파라미터로 넘겨서 OrderItem 엔티티를 한꺼번에 가져온다. 그 다음 OrderItem 엔티티를 Map을 사용하여 OrderId를 기준으로 채워 넣었다.

 

따라서 1번(Order+Member+Delivery 조회) + 1번(OrderItem 조회) = 2번의 SQL 쿼리가 나간다.

6. JPA에서 DTO로 직접 조회 - 플랫 데이터 최적화

@GetMapping("/api/v6/orders")
public List<OrderQueryDto> ordersV6() {
  List<OrderFlatDto> flats = orderQueryRepository.findAllByDto_flat();

  return flats.stream()
          .collect(groupingBy(o -> new OrderQueryDto(o.getOrderId(), o.getName(), o.getOrderDate(), o.getOrderStatus(), o.getAddress()),
          mapping(o -> new OrderItemQueryDto(o.getOrderId(), o.getItemName(), o.getOrderPrice(), o.getCount()), toList())))
          .entrySet().stream()
          .map(e -> new OrderQueryDto(e.getKey().getOrderId(), e.getKey().getName(), e.getKey().getOrderDate(), e.getKey().getOrderStatus(), e.getKey().getAddress(), e.getValue()))
          .collect(toList());
}
public List<OrderFlatDto> findAllByDto_flat() {

return em.createQuery(
        "select new jpabook.jpashop.repository.order.query.OrderFlatDto(o.id, m.name, o.orderDate, d.address, o.status, i.name, oi.orderPrice, oi.count)" +
        " from Order o" +
        " join o.member m" +
        " join o.delivery d" +
        " join o.orderItems oi" +
        " join oi.item i", OrderFlatDto.class)
        .getResultList();
}
@Data
@EqualsAndHashCode(of = "orderId")
public class OrderQueryDto {
  ...
}

Order 엔티티와 연관된 엔티티들을 join을 통해 한번에 가져와서 Dto을 생성한다. 이때 조인으로 인해 DB에서는 중복된 데이터가 넘어오게 된다. (@OneToMany 관계 때문) 또한 응답 타입이 생각처럼 만들어지지 않는다.

이를 해결하기 위해 어플리케이션 단에서 groupingBy을 통해 직접 Dto을 생성해줄 수 있다. 이때 orderId을 통해 동일한 OrderQueryDto 비교를 하기 위해서 @EqualsAndHashCode(of = "orderId")를 추가해주어야 한다.

하지만 이렇게 하더라도 당연히 페이징 처리는 불가능하다.

 

SQL 쿼리 1번에 데이터를 모두 가져올 수 있지만 중복 데이터가 넘어오기 때문에 상황에 따라 앞선 5번 방법보다 느릴 수 있다. 또한 어플리케이션에서 해주어야 하는 추가 작업이 크고 페이징이 불가능하다는 단점이 있다.