Spring/[인프런 김영한 실전 스프링 부트와 JPA 활용 2

[인프런 김영한 실전 스프링 부트와 JPA 활용 2 - API 개발과 성능 최적화] API 개발 고급 - 컬렉션 최적화

h2boom 2024. 9. 7. 21:19

컬렉션 조회 최적화

@Data
private class OrderDto { //주문 DTO
	private Long orderId;	
	private String name;	
	private LocalDateTime orderDate;
	private OrderStatus orderStatus;
	private Address address;
	private List<OrderItem> orderItems;
}
  • DTO 설계 시 DTO 내부에도 엔티티가 존재하면 안된다.
    • DTO 내부에 존재하는 엔티티 조차도 모두 필요한 데이터만을 가지고 DTO 형태로 만들어야 한다.

 

  • 일대다 관계에서 페치 조인해서 조회하는 경우 
    • ex) Member(N) - Team(1) 연관관계, Team1(Member1, Member2), Team2(Member3, Member4)가 있을 때
      Team과 Member를 조인해서 Team을 조회하는 경우에 원래대로라면 Team1, Team2가 조회되는 것이 정상
      하지만 조인을 통해서 1:N관계에서 N기준에 맞춰서 Member1에 대한 Team1, Member2에 대한 Team1로 Team1이 2개로 데이터가 늘어나고 마찬가지로 Team2도 2개로 늘어나게되며 DB에서 데이터 뻥튀기 현상이 발생한다.
    • JPA에서는 DB에서의 데이터 뻥튀기를 임의로 해결할 수 없기 때문에 JPA에서 distinct 옵션을 줘야 한다.
      • DB의 모든 데이터를 가지고 애플리케이션에서 한번 더 필터링하는 것으로 DB의 모든 데이터를 애플리케이션으로 가져와야 한다는 단점이 있다.
    • 1:N관계에서는 N:1과 달리 페치 조인을 하는 경우 페이징이 불가능하다.
      • DB 입장에서는 데이터가 뻥튀기 된 상태에서 페이징을 할 수가 없기 때문에
      • 이 경우 모든 데이터를 DB에서 읽어오고 메모리에서 페이징을 한다 (매우 위험하므로 사용하지 말 것)

 

  • DB distinct vs JPA distinct
    • DB의 distinct - 모든 데이터가 다 같아야 중복으로 인식하고 제거한다.
    • JPA의 distinct - DB 쿼리에 distinct 추가 + 애플리케이션에서 엔티티의 식별자 값이 같으면 중복으로 인식하고 제거한다.

 

  • 컬렉션(1:N) 페치 조인은 1개만 사용할 수 있다.
    • 컬렉션(1:N) 둘 이상에 페치 조인을 사용하면 데이터 뻥튀기가 엄청 심해지며 데이터가 부정합하게 조회될 수 있다.

 

  • 1:N 관계에서 페이징과 컬렉션 엔티티를 함께 조회하는 방법은?
    1. 먼저 XToOne 관계 (OneToOne, ManyToOne)는 모두 페치 조인한다.
      • XToOne 관계는 데이터 뻥튀기가 일어나지 않기에 페이징 쿼리에 영향을 주지 않는다.
      • XToMany 관계가 데이터 뻥튀기가 일어나는 관계
    2. 컬렉션은 지연 로딩으로 조회한다.
      = 컬렉션은 페치 조인을 사용하지 않는다.
    3. 지연 로딩 성능 최적화를 위해 hibernate.default_batch_fetch_size, @BatchSize를 적용한다.
      • hibernate.default_batch_fetch_size : 글로벌 설정
        • application.yml 파일에 설정
      • @BatchSize : 개별 최적화
        • 컬렉션인 경우 컬렉션 필드에 사용 / 엔티티의 경우 클래스에 사용
      • 이 옵션을 사용하면 컬렉션이나 프록시 객체를 한꺼번에 설정한 size만큼 IN 쿼리로 조회한다.

 

  • 일반적인 지연 로딩과 BatchSize 옵션 부여 후 조회 쿼리 차이 
    • ex) Order - OrderItem - Item 연관관계(1:N:1)에
      Order 1 - OrderItem 1 - Item 1,2
      Order 2 - OrderItem 2 - Item 3,4 일 때
    • 일반적인 지연 로딩을 사용했을 때 조회 쿼리
      • Order 조회하는 쿼리 1개 + OrderItem 1,2번을 각각 조회하는 쿼리 2개 (N+1문제) + Item 1,2,3,4번을 각각 조회하는 쿼리 4개 (N+1문제)로 총 7번의 쿼리가 수행된다.
    • BatchSize 옵션을 사용했을 때 조회 쿼리 (BatchSize를 충분히 크게 지정했을 때)
      • Order 조회하는 쿼리 1개 + OrderItem 1,2번을 한번에 IN 쿼리로 조회하는 쿼리 1개 + Item 1,2,3,4번을 IN 쿼리로 한번에 조회하는 쿼리 1개로 총 3번의 쿼리가 수행된다.
  • BatchSize 옵션을 사용하면 1+M+N...의 문제를 1+1+1...로 엄청난 성능 최적화를 한다.
  • 컬렉션에서 BatchSize 옵션을 사용하는 방식은 fetch join을 사용하는 방식보다 조회 쿼리 수는 약간 많을 수 있지만 각각을 조회하므로 데이터 중복이 없는 더 정규화된 데이터를 조회할 수 있다.
    • BatchSize 옵션 사용 시 조회 쿼리 수가 약간 증가하지만 DB 데이터 전송량이 감소하며 페이징이 가능하다.

 

  • SQL IN 연산자 : 여러 데이터 값을 조회할 때 사용한다.
    • = 연산자는 조건으로 한 가지만 지정할 수 있지만 IN 연산자는 여러 조건 (목록)으로 지정할 수 있다.

 

  • BatchSize의 크기는 100 ~ 1000 사이를 선택하는 것을 권장한다.
    • 100이나 1000이나 전체 데이터를 로딩해야하므로 메모리 사용량은 같지만 순간 부하를 어디까지 견딜 수 있는지 에따라서 결정하는 것이 좋다.

 

  • 페치 조인 결론 정리
    • XToOne 관계는 페치 조인을 해도 페이징에 영향을 주지 않기에 페치 조인으로 쿼리 수를 줄이자
    • XToMany 관계는 BatchSize로 최적화 하자

 

  • 의존 관계는 한 방향으로만 의존관계가 형성되어야 한다.
    • ex) Controller -> Repository 의존관계가 형성되어야 하는데 Repository도 Controller를 의존하면 안된다.

 

// JPQL로 DTO를 직접 조회하는 Repository V4 예제
public List<OrderQueryDto> findOrderQueryDtos() {
        List<OrderQueryDto> result = findOrders();

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

        return result;
    }

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

    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();
    }
  • JPQL을 작성해서 DTO를 직접 조회할 때  new 연산자 안에는 컬렉션이 들어갈 수 없기에 컬렉션을 조회하기 위해 따로 JPQL을 작성해서 컬렉션을 반환해야 한다.
    • XToOne(N:1, 1:1) 관계를 먼저 조회하고 XToMany(1:N, M:N) 관계는 각각 별도로 처리한다.
    • XToOne 관계는 조인해도 데이터 수가 증가하지 않기에 최적화하기 쉽기에 한번에 조회 (쿼리 1번 => N개)
    • XToMany 관계는 조인하면 데이터 수가 증가하기에 최적화하기 어렵기에 findOrderItems() 같은 별도의 메소드로 조회한다. (쿼리 N번으로 N+1 문제 발생)

 

//DTO 직접 조회 - 컬렉션 조회 최적화 V5 예제
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(o -> o.getOrderId()));

    result.forEach(o -> o.setOrderItems(orderItemMap.get(o.getOrderId())));

    return result;
}
  • JPQL을 사용해서 IN 쿼리를 통해 한번에 조건에 맞는 모든 데이터를 조회를 해서 가져오는 방식이다. 
    • Collectors.groupingBy(매개변수) : 매개변수를 Key 값으로 갖는 Map으로 만든다.
  • 이전 예제인 JPQL을 사용해서 DTO를 직접 조회하는 예제(V4)와 V5의 차이점
    • 이전 예제(V4)는 반복문을 통해 OrderItem을 조회할 때마다 쿼리를 수행해서 조회 쿼리를 여러 번 수행
    • 현 예제(V5)는 OrderItem을 IN 쿼리 한 번에 모두 조회한다. (쿼리 총 2번)
      • Map으로 만들지 않고 반복문을 통해 수행해도 된다.

 

// DTO 직접 조회, 플랫 데이터 최적화 V6 예제
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();
    }
    
@Data
public class OrderFlatDto {
    //OrderQueryDto
    private Long orderId;
    private String name;
    private LocalDateTime orderDate;
    private OrderStatus orderStatus;
    private Address address;

    //OrderItemQueryDto
    private String itemName;
    private int orderPrice;
    private int count;

    public OrderFlatDto(Long orderId, String name, LocalDateTime orderDate, OrderStatus orderStatus, Address address, String itemName, int orderPrice, int count) {
        this.orderId = orderId;
        this.name = name;
        this.orderDate = orderDate;
        this.orderStatus = orderStatus;
        this.address = address;
        this.itemName = itemName;
        this.orderPrice = orderPrice;
        this.count = count;
    }
}
  • JPQL 작성 시 일반적인 SQL과 유사하게 동작하기에 컬렉션이 포함될 수 없다.
    이 문제를 보완하기 위해서 조인할 데이터들을 모두 한 DTO 안에 포함시켜서 조회하는 방식(V6)
    • 총 1번의 조회 쿼리를 수행함으로 조회할 수 있지만 중복 데이터가 추가되고 애플리케이션에서 추가 작업이 크며 페이징이 불가능하다.
      => Order(1)를 기준으로 조회했지만 OrderItem(N)을 기준으로 데이터가 뻥튀기 되었기에 중복 데이터가 추가되었고 페이징이 불가하다.

 

  • 권장 순서
    1. 엔티티 조회 방식으로 우선 접근
      1. 페치 조인을 통해 쿼리 수를 최적화
      2. 컬렉션 최적화
        1. 페이징 필요 => BatchSize 옵션 사용
        2. 페이징 필요 x => 페치 조인 사용
    2. 엔티티 조회 방식으로 해결이 되지 않으면 DTO 직접 조회 방식 사용
    3. DTO 직접 조회 방식으로 해결이 안되면 네이티브 SQL, 스프링 JdbcTemplate 사용

 

  • 엔티티 조회 방식은 코드를 거의 수정하지 않고 페치 조인이나 BatchSize 옵션을 사용함으로 성능 최적화를 할 수 있다.
    • 캐시하는 경우에는 엔티티말고 DTO로 해야하는 경우를 제외하고는 주로 엔티티 조회 방식이 좋다.
  • DTO 직접 조회의 경우 성능 최적화를 하려면 많은 코드 수정이 필요하다.

 

  • DTO 조회 방식을 선택하는 경우
    • V4 예제는 코드가 단순하고 유지보수가 쉽다. 특정 데이터 한건만 조회하는 경우 사용해도 성능이 잘 나온다.
      • 1+N 문제가 발생하기에 데이터가 많아질수록 성능에 좋지 않다.
    • V5 예제는 코드가 복잡하고 여러 데이터를 한 번에 조회하는 경우 사용하기에 적합한 방식이다.
      • 데이터 개수와 상관없이 조회 쿼리는 총 1+1번 실행된다.
      • V5 예제는 DTO 조회 방식을 선택하는 경우 주로 사용하는 방법
    • V6 예제는 쿼리는 총 한 번으로 최적화 됐지만 페이징이 불가하고 V5에 비해서 성능 차이가 미비하다.
      • 페이징 사용이 많은 실무에서 사용하기엔 어려운 방법이다.

출처: [인프런 김영한 실전 스프링 부트와 JPA 활용 2 - API 개발과 성능 최적화] 

https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81%EB%B6%80%ED%8A%B8-JPA-API%EA%B0%9C%EB%B0%9C-%EC%84%B1%EB%8A%A5%EC%B5%9C%EC%A0%81%ED%99%94/dashboard

 

실전! 스프링 부트와 JPA 활용2 - API 개발과 성능 최적화 강의 | 김영한 - 인프런

김영한 | 스프링 부트와 JPA를 활용해서 API를 개발합니다. 그리고 JPA 극한의 성능 최적화 방법을 학습할 수 있습니다., 스프링 부트, 실무에서 잘 쓰고 싶다면? 복잡한 문제까지 해결하는 힘을 길

www.inflearn.com