API 개발 고급 - 컬렉션 조회 최적화

유동우·2023년 8월 17일
0
post-thumbnail

주문 조회 V1 : 엔티티 직접 노출

@GetMapping("/api/v1/orders")
    public List<Order> ordersV1() {
    	List<Order> all = orderRepository.findAllByCriteria(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;
    }
   

마지막 줄의 코드는 다음과 같이 풀어쓸 수 있다

//프록시 강제 초기화 코드
for (OrderItem orderItem : orderItems) {
	orderItem.getItem().getName();
}

getItem.().getName()을 하는 이유
Name 필드가 필요한 것이 아니라, getXXX()를 함으로써 지연로딩된 엔티티 Item을 조회하여 Item 객체를 가져와 필드의 값을 모두 알 수 있다

주문 조회 V2 : 엔티티 -> DTO

DTO를 사용하는 이유에 대해서는 아래 링크를 참고하자
DTO를 사용하는 이유

@Data
static class OrderDto {

        private Long orderId;
        private String name;
        private LocalDateTime localDateTime;
        private OrderStatus orderStatus;
        private Address address;
        private List<OrderItem> orderItems;

        public OrderDto(Order order) {
            orderId = order.getId();
            name = order.getMember().getName();
            localDateTime = order.getOrderDate();
            orderStatus = order.getStatus();
            address = order.getDelivery().getAddress();
            orderItems = order.getOrderItems();
        }
}

이렇게 DTO를 생성해도 결국

엔티티가 모두 노출되기 때문에, API 스펙의 변동에 있어 자유롭지 못하다 (흔한 실수)
따라서 각 엔티티(이 코드에서는 orderItems) 모두 DTO로 만들어줘야 한다.

@Getter
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와 유사하게 OrderItemDto를 생성해줬다.

그렇다면 이제 내가 원하는 값만 뽑아낼 수 있다

마지막으로 select 쿼리가 몇 번 나갈지 생각해보자.

select 쿼리는 영속성 컨텍스트에 조회가 되어 있는 엔티티에 대해서는 발생하지 않는다.

처음에 Orders 에서 주문 2개를 확인. (select 쿼리 한 번) // 1

Member1 한 명에 대해 발생 (select 쿼리 한 번) // 1-1
이 멤버의 Delivery에 대해 쿼리 발생 (select 쿼리 한 번)
OrderItems에 대해 쿼리 발생 (select 쿼리 한 번) // 2
OrderItems의 두 개의 목록에 대해 각각의 쿼리 발생(select 쿼리 두 번)// 2-1, 2-2

Member2 한 명에 대해 발생 (select 쿼리 한 번) // 1-2
이 멤버의 Delivery에 대해 쿼리 발생 (select 쿼리 한 번)
OrderItems에 대해 쿼리 발생 (select 쿼리 한 번) // 2
OrderItems의 두 개의 목록에 대해 각각의 쿼리 발생(select 쿼리 두 번) //2-1, 2-2

orders와 orderItems 모두 컬렉션(List)이기 때문에 각각의 세부 목록까지 쿼리가 발생한다.

총 11번의 select 쿼리 발생

주문 조회 V3 : 엔티티 -> DTO : 페치조인 최적화

컬렉션이 아닐경우 페치 조인보다 컬렉션일 때 주의해야 할 부분이 존재한다

fetch join을 통해 쿼리가 1번만 실행된다

//OrderApiController
@GetMapping("/api/v3/orders")
public List<OrderDto> ordersV3(){
        List<Order> orders = orderRepository.findAllWithItem();
        List<OrderDto> result = orders.stream()
                .map(o -> new OrderDto(o))
                .collect(Collectors.toList());

        return result;
}

//OrderRepository
public List<Order> findAllWithItem() {
        return 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();
}

JPA의 distinct를 사용하여 SQL에 distinct를 추가하고, 조인해서 같은 엔티티가 조회된다면 애플리케이션에서 중복을 걸러준다.

DB에서는 1:N 관계에서 N 기준으로 데이터가 뻥튀기가 되어버려서 페이징 사용시 hibernate는 WARN을 발생시키고, 메모리에서 작업을 수행한다.
(ex) order와 orderItems에서 후자를 기준으로 데이터가 맞춰짐

1:N 을 fetch join 하는 순간 페이징 쿼리가 아예 나가지 않는다

컬렉션 페치조인을 사용하면 페이징이 불가능하고, 컬렉션 페치조인은 1개만 사용해야 한다.

주문 조회 V3.1 : 엔티티 -> DTO : 페이징과 한계 돌파

컬렉션은 페치조인을 사용하지 않고, toOne 관계인 엔티티만 페치조인으로 묶어서 코드를 작성하자

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

Order 기준에서 toOne 관계인 member, delivery를 페치조인으로 묶었다.

총 쿼리 수는,
Order 1번 (두 개의 주문 조회)

member 1번 (두 명의 멤버 조회: 멤버1, 멤버2)
delivery 1번 (멤버1에 대해 조회)
orderItems 1번 (멤버1의 두 개의 아이템 조회: JPA1, JPA2)

member 1번 (두 명의 멤버 조회: 멤버1, 멤버2)
delivery 1번 (멤버2에 대해 조회)
orderItems 1번 (멤버2의 두 개의 아이템 조회: SRPING1, SPRING2)

중복되는 쿼리들을 압축시키고,

 from
        item item0_ 
    where
        item0_.item_id in (
            ?, ?, ?, ?
        )

위와 같이 item을 한 번에 IN 쿼리로 표기하기 위해 applicaiton.yml을 다음과 같이 수정한다

//application.yml

//필요한 데이터를 100개씩 받아옴
//N+1 문제에서 어느정도 해방이 가능하다
default_batch_fetch_size: 100

OR

//Order
//위처럼 글로벌하게 말고 각각의 필드에서도 적용 가능
@BatchSize(size=1000)
@OneToMany(mappedBy = "order",cascade = CascadeType.ALL)
private List<OrderItem> orderItems = new ArrayList<>();

정리

  • ToOne 관계는 row수를 증가시키지 않기 때문에 페이징 쿼리에 영향 X
  • 컬렉션은 지연로딩으로 조회
  • 지연 로딩 성능 최적화를 위해 hibernate.default_batch_fetch_size, @BatchSize 를 적용한다
    • hibernate.default_batch_fetch_size : 글로벌 설정
    • @BatchSize: 개별 최적화
    • 컬렉션이나, 프록시 객체를 한꺼번에 설정한 size 만큼 IN 쿼리로 조회

주문 조회 V4 : JPA에서 DTO 직접 조회

public List<OrderQueryDto> findOrdersQueryDtos() {
		//findOrders(): 1:N 관계(컬렉션)를 제외한 나머지를 한번에 조회
        List<OrderQueryDto> result = findOrders(); 
		
        //findOrderItems(): 1:N 관계인 orderItems 조회
        result.forEach(o -> {
            List<OrderItemQueryDto> orderItems = findOrderItems(o.getOrderId());
            o.setOrderItems(orderItems);
        });
        return result;
}

ToOne(N:1, 1:1) 관계들을 먼저 조회하고, ToMany(1:N) 관계는 각각 별도로 처리한다.

1:N 관계인 order<->orderItems,
orderId 필드는 @JsonIgnore 처리 해줘도 된다.

주문 조회 V5 : JPA에서 DTO 직접 조회 : 컬렉션 조회 최적화

Query: 루트 1번, 컬렉션 1번
ToOne 관계들을 먼저 조회하고, 여기서 얻은 식별자 orderId로 ToMany 관계인 OrderItem을 한꺼번
에 조회
MAP을 사용해서 매칭 성능 향상(O(1))

주문 조회 V6 : JPA에서 DTO 직접 조회, 플랫 데이터 최적화

어려우므로 스킵

API 개발 고급 정리

  • 엔티티 조회
    • 엔티티를 조회해서 그대로 반환: V1
    • 엔티티 조회 후 DTO로 변환: V2
    • 페치 조인으로 쿼리 수 최적화: V3
    • 컬렉션 페이징과 한계 돌파: V3.1
      컬렉션은 페치 조인시 페이징이 불가능
       ToOne 관계는 페치 조인으로 쿼리 수 최적화
       컬렉션은 페치 조인 대신에 지연 로딩을 유지하고, 
       hibernate.default_batch_fetch_size , @BatchSize 로 최적화
  • DTO 직접 조회
    • JPA에서 DTO를 직접 조회: V4
    • 컬렉션 조회 최적화 - 일대다 관계인 컬렉션은 IN 절을 활용해서 메모리에 미리 조회해서 최적화: V5
    • 플랫 데이터 최적화 - JOIN 결과를 그대로 조회 후 애플리케이션에서 원하는 모양으로 직접 변환: V6

DTO 직접 조회보다 엔티티 조회방식을 권장

그냥 이 2개가 고트

hibernate.default_batch_fetch_size: //전역적
OR 
@BatchSize  //지역적

Reference
김영한 님 - 실전! 스프링 부트와 JPA 활용2 - API 개발과 성능 최적화

profile
효율적이고 꾸준하게

2개의 댓글

comment-user-thumbnail
2023년 8월 17일

많은 도움이 되었습니다, 감사합니다.

답글 달기
comment-user-thumbnail
2023년 9월 1일

정말 유익한 글이에요 ㅎㅎ^^ 감사합니다 ㅎㅎ

답글 달기