2-3. 컬렉션 조회 최적화

지니🧸·2023년 3월 1일
0

Spring Boot & JPA

목록 보기
21/35

본 문서는 인프런의 실전! 스프링 부트와 JPA 활용2 - API 개발과 성능 최적화 (김영한) 강의를 공부하며 작성한 개인 노트입니다.

V1) 🛼 엔티티 직접 노출

@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;
}
  • Lazy 로딩 설정에서 프록시는 데이터를 넘겨받지 않음
  • 프록시를 강제 초기화해서 (orderItems ~ orderItems.stream()) 데이터를 받아서 뿌림
  • 엔티티 직접 노출 > 가급적 하지 말자

V2) 🛹 엔티티를 DTO로 변환

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

OrderDto

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

}

OrderItemDto

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

결론

  • DTO에 엔티티(맵핑 포함)이 있으면 안된다. > 엔티티에 대한 의존을 완전히 끊자
    • (예) OrderItem도 DTO로 바꿔야 한다
  • 지연 로딩 때문에 SQL 양이 많다

V3) ⛷️ 엔티티를 DTO로 변환 - 페치 조인 최적화

OrderRepository.java에 메서드 추가

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 없이는)fetch join은 데이터베이스 입장에서는 일대다 조인이 되기 때문에 데이터 row 수 증가
    • (예) 같은 orderId를 가진 레코드가 여러개 생김
  • distinct
    • 데이터베이스의 distinct는 레코드가 완전 똑같아야지만 제거
    • JPA의 distinct는 같은 id값을 가지면 제거
    • SQL 쿼리도 확연히 짧아짐 > SQL이 1번만 실행됨
  • 단점: 페이징 불가 (.setFirstResult(#), .setMaxResult(#))
    • collection fetch와 페이징을 함께 시도하면 하이버네이트가 메모리에서 페이징 처리함 > 매우 위험
    • 일대다 fetch join하는 순간 레코드의 순서가 섞임

컬렉션 둘 이상에 fetch join을 사용하면 안된다

데이터가 부정합하게 조회될 수 있음

문제점

전부 fetch join하기 때문에 중복데이터가 상당히 많음

V3.1) ⛸️ 엔티티를 DTO로 변환 - 페이징과 한계 돌파

컬렉션 페치 조인(fetch join)의 한계

  • 페이징 불가
    • 컬렉션 페치 조인 > 일대다 조인 > 데이터 뻥튀기
    • 일대다에서 일을 기준으로 페이징해야 하는데, 데이터는 다를 기준으로 row가 생성
      • Order를 기준으로 페이징해야 하는데, OrderItem(다)를 조인하면 OrderItem이 기준이 됨
    • 하이버네이트는 경고 로그 + 모든 디비 데이터 조회 + 메모리에서 페이징 시도

한계 돌파

페이징 + 컬렉션 엔티티 조회 방법
1. ToOne(OneToOne, ManyToOne) 관계를 모두 페치조인 (ToOne 관계는 row수를 증가시키지 않음 > 페이징 쿼리에 영향 X)
2. 컬렉션은 지연로딩으로 조회
3. 지연 로딩 성능 최적화를 위해 hibernate.default_batch_fetch_size, @BatchSize 적용

  • hibernate.default_batch_fetch_size: 글로벌 설정
  • @BatchSize: 개별 최적화
  • 효과 - 컬렉션이나 프록시 객체를 한꺼번에 설정한 size만큼 IN 쿼리로 조회

구현

컨트롤러

@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.findAllWithMembersDelivery(offset, limit);

        List<OrderDto> result = orders.stream()
                .map(o -> new OrderDto(o))
                .collect(Collectors.toList());

        return result;
    }
  • 한번에 in 쿼리로 데이터베이스에 있는 OrderAOrderBOrderItems을 모두 가져온 것

리포지토리 메서드

public List<Order> findAllWithMembersDelivery(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();
}

application.yml

jpa.properties.hibernate.default_batch_fetch_size: 100
  • default_batch_fetch_sizein 쿼리의 개수를 결정
    • 한 번에 데이터를 몇 레코드씩 가져올 것인지
      • (예) 레코드가 10일 때 100으로 설정하면 in 쿼리는 한번만 실행
    • 주로 1000으로 제한 (100~1000 권장)

장점

중복없는 데이터 전송.
최적화된 쿼리

@Batch

글로벌하게 (application.yml에 정의) 정의하기보다는
디테일하게 정의하고 싶을 때

컬렉션일 때

...
public class Order {
	...
    @BatchSize(size = 1000)
    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
    private List<OrderItem> orderItems = new ArrayList<>();
}

ToOne 관계일 때 (컬렉션 X)

@BatchSize(size = 1000)
public class Item {
}

장점

  • 쿼리 호출수가 (1+N)에서 (1+1)로 최적화
  • 조인보다 디비 데이터 전송량이 최적화됨
    • OrderOrderItem을 조인하면 Order가 OrderItem만큼 중복해서 조회됨 > 중복조회
  • 페치 조인 방식과 비교해서 쿼리 호출 수가 약간 증가하지만 DB 데이터 전송량은 감소
  • 페이징이 가능 (컬렉션 페치 조인은 페이징 불가능)

결론

  • ToOne 관계는 페치조인
  • 나머지는 hibernate.default_batch_fetch_size로 최적화

V4) 🛷 JPA에서 DTO 직접 조회

엔티티를 조회하는 리포지토리(OrderRepository)와 api/화면 의존관계가 있는 리포지토리(OrderQueryRepository)의 분리

OrderQueryRepository

repository/order/query/OrderQueryRepository.class

findOrders

private List<OrderQueryDto> findOrders() {
    return em.createQuery(
                    "select new jpabook.jpashop.repository.order.query.OrderQueryDto(o.id, m.name, o.orderDate, o.status, o.address)" +
                            " from Order o" +
                            " join o.member m" +
                            " join o.delivery d", OrderQueryDto.class)
            .getResultList();
}
  • orderItems는 일대다기 때문에 여기서 처리불가

findOrderItems

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();
}
  • 위 메서드에서 처리하지 못한 orderItems 처리

findOrderQueryDtos

public List<OrderQueryDto> findOrderQueryDtos() {
    List<OrderQueryDto> result = findOrders();

    result.forEach(o -> {
        List<OrderItemQueryDto> orderItems = findOrderItems(o.getOrderId());
        o.setOrderItems(orderItems);
    });
    return result;
}
  • 위 두 메서드를 합쳐서 조회DTO리포지토리 완성

QueryDto's

repository/order/query/OrderQueryDto.class

@Data
public class OrderQueryDto {

	private Long orderId;
	private String name;
	private LocalDateTime orderDate;
	private OrderStatus orderStatus;
	private Address address;
	private List<OrderItemQueryDto> orderItems;

	public OrderQueryDto(Long orderId, String name, LocalDateTime orderDate, OrderStatus orderStatus, Address address, List<OrderItemQueryDto> orderItems) {
		this.orderId = orderId;
		this.name = name;
   	 	this.orderDate = orderDate;
    	this.orderStatus = orderStatus;
    	this.address = address;
    	this.orderItems = orderItems;
	}
    
}

repository/order/query/OrderItemQueryDto.class

@Data
public class OrderItemQueryDto {
    private Long orderId;
    private String itemName;
    private int orderPrice;
    private int count;

    public OrderItemQueryDto(Long orderId, String itemName, int orderPrice, int count) {
        this.orderId = orderId;
        this.itemName = itemName;
        this.orderPrice = orderPrice;
        this.count = count;
    }
}

결론

  • 쿼리 - 루트 1번, 컬렉션 N번 실행
  • ToOne(N:1, 1:1) 관계 먼저 조회. ToMany(1:N) 관계는 각각 별도 처리
    • ToOne 관계는 조인해도 데이터 row 수 증가 X
    • ToMany(1:N) 관계는 조인하면 row 수 증가
  • row 수가 증가하지 않는 ToOne 관계는 최적화가 편리.
  • ToMany 관계는 최적화가 어려움 > 별도 메서드 (findOrderItems()) 사용
  • 단점: 루프를 돈다

V5) 🥌 JPA에서 DTO 직접 조회 - 컬렉션 조회 최적화

OrderQueryRepository에 생성

public List<OrderQueryDto> findAllByDto_optimization() {
        List<OrderQueryDto> result = findOrders();
        List<Long> orderIds = result.stream()
                        .map(o -> o.getOrderId())
                                .collect(Collectors.toList());
        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();
        Map<Long, List<OrderItemQueryDto>> orderItemMap = orderItems.stream()
                .collect(Collectors.groupingBy(orderItemQueryDto -> orderItemQueryDto.getOrderId()));
        result.forEach(o -> o.setOrderItems(orderItemMap.get(o.getOrderId())));
        return result;
    }
  • 쿼리 2회 (루트 1, 컬렉션 1)
  • ToOne 관계 먼저 조회. 조회에서 얻은 식별자 orderIdToMany 관계 조회
  • map으로 매칭성능 향상

V6) 🎿 JPA에서 DTO로 직접 조회 - 플랫 데이터 최적화

repository/order/query/OrderFlatDto.class

@Data
public class OrderFlatDto {
    private Long orderId;
    private String name;
    private LocalDateTime orderDate;
    private OrderStatus orderStatus;
    private Address address;

    private String itemName;
    private int orderPrice;
    private int count;
}
  • OrderQueryDtoOrderQueryItemDto의 필드 모두 포함

메서드 추가 - repository/order/query/OrderRepository.class

public List<OrderFlatDto> findAllByDto_flat() {
    return em.createQuery(
            "select new jpabook.jpashop.repository.order.query.OrderFlatDto(o.id, m.name, o.orderDate, o.status, d.address, 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();
}

스펙 맞추기

OrderFlatDto > OrderQueryDto

@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());
}
  • 쿼리는 한번
  • 단점
    • 디비에서 앱에 전달하는 데이터에 중복 데이터가 추가됨
    • 앱에서 추가 작업이 큼
    • 페이징 불가

⛷️ API 개발 고급 정리

엔티티 조회

  • 엔티티 조회 후 꼭 DTO로 변환하자
  • 페치 조인은 좋지만 컬렉션은 페치 조인하면 페이징이 불가능하다는 한계
  • ToOne 관계는 페치 조인으로 쿼리 수 최적화. 컬렉션은 페치 조인 대신 지연 로딩 유지하고 batch size 조정

DTO 직접 조회

  • 컬렉션 조회 최적화: IN 절로 메모리에서 미리 조회
  • 플랫 데이터 최적화: JOIN 결과를 그대로 조회 후 앱에서 원하는 모양으로 변환

권장 순서

  1. 엔티티 조회로 접근
    A. 페치 조인으로 쿼리 수 최적화
    B. 컬렉션 최적화
    i. 페이징 필요 > hibernate.default_batch_fetch_size, @BatchSize
    1. 페이징 필요 X > 페치 조인 사용
  2. 엔티티 조회 방식으로 해결이 안되면 DTO 조회 방식 사용
  3. DTO 조회 방식으로 해결이 안되면 NativeSQL/JdbcTemplate

DTO 조회 선택지

  • 쿼리 1번 실행이라고 항상 좋은 방법은 아님
  • V4 - 단순한 코드
    • 특정 주문 한건 조회시 성능 좋음
  • V5 - 코드가 복잡함
    • 여러 주문을 한꺼번에 조회할 때
    • 운영상황에 따라 100배 이상 성능 차이
  • V6 - 완전히 다른 접근방식
    • 쿼리 한번으로 최적화
    • Order 기준 페이징 불가
profile
우당탕탕

0개의 댓글