RE-Heat 개발자 일지

[JPA 활용2] [4] API 개발 고급 - 컬렉션 조회 최적화(상편) 본문

백엔드/JPA

[JPA 활용2] [4] API 개발 고급 - 컬렉션 조회 최적화(상편)

RE-Heat 2023. 9. 1. 23:44

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

인프런 김영한 님의 강의를 듣고 작성한 글입니다.

 

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

이전 챕터에선 xToOne 관계만 있었다. 이번엔 컬렉션인 일대다 관계(OneToMany)를 조회하고 최적화하는 방법을 알아보자

 

OrderApiController

@RestController
@RequiredArgsConstructor
public class OrderApiController {

    private final OrderRepository orderRepository;

    @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;
    }
}
  • orderItem, item관계를 직접 초기화하면 Hibernate5Module 설정에 의해 엔티티를 JSON으로 생성한다.
  • 양방향 연관관계면 무한루프에 걸리지 않게 한 곳에 @JsonIgnore를 추가해야 한다. 
  • 엔티티를 직접 노출하므로 좋은 방법은 아니다

 

[2] 주문 조회 V2 : 엔티티를 DTO로 변환

OrderApiController - V2

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

    @Data
    static class OrderItemDto {
        private String itemName; //상품명
        private int orderPrice; //주문가격
        private int count; //주문 수량

        public OrderItemDto(OrderItem orderItem) {
            itemName = orderItem.getItem().getName();
            orderPrice = orderItem.getItem().getPrice();
            count = orderItem.getCount();
        }
    }
  • OrderItem은 엔티티이기 때문에 OrderDto에서 강제초기화를 해주지 않으면 null값으로 날아온다. 그래서 stream()으로 프록시를 강제 초기화 해야 데이터를 조회할 수 있다.
  • 하지만 DTO로 반환해도 DTO 안에 엔티티가 존재하면 엔티티 정보가 모두 노출되는 문제가 발생한다. 따라서 엔티티 의존을 완전히 끊으려면 OrderItemDTO도 따로 작성해야 한다.
  • Address 같은 값 타입은 노출돼도 별 문제가 없다.

실행결과

 

■ V2 버전 단점

  • N + 1 문제가 발생한다.
    • 주문 조회 1번 => 회원 2명, 배송정보 2건 => 회원 1명당 주문 상품 2개 
    • 1회 + 2회 + 2회 + (2*2) 4회 = 9번이나 쿼리가 날아간다.

 

 

[3] 주문 조회 V3 : 엔티티를 DTO로 변환 - 페치 조인 최적화

  • N + 1 문제를 해결하기 위해 페치 조인을 활용한다.

OrederApiController - V3

    @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)
                .setFirstResult(1)
                .setMaxResults(100)
                .getResultList();
    }
  • 그런데, 이렇게 하면 일대다 관계이기 때문에 데이터가 뻥튀기된다. 예를 들어 주문이 2건이지만, 상품이 4개이기 때문에 상품의 개수만큼 row를 출력하게 된다. 

일대다 관계 join 예시

  • where 조건으로 orderId가 4인 값만 나오게 했음에도 같은 row가 2개 출력된다.

 

실행결과

  • 포스트맨으로 불러봐도 같은 게 두 번 출력
  • sout으로 찍어도 같은 아이디를 가진 값이 두 번 출력된다.

 

■ 해결책 - distinct 활용

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

 

distinct 2가지 기능 제공

1. SQL에 DISTINCT 추가

2. 애플리케이션에서 엔티티 중복 제거

 

실행결과

원하는 대로 중복이 제거됐다.

 

■ 컬렉션 페치 조인의 단점

① 페이징이 불가능하다. 구체적으로 페이징 API(setFirstResult, setMaxResults)를 쓸 수 없다.

     => 하이버네이트가 경고 로그를 남기고 메모리를 불러온 다음 페이징 처리한다.

 

예시]

  • setFirstResult(1)을 쓸 때 우리가 기대하는 건 orderId = 11 값을 불러오는 것이다.(참고] 0부터 시작)
  • 그런데 컬렉션 페치 조인 시 DB에선 중복된 값이 조회된다.

  • 이를 기준으로 조회 시작위치가 1번인 값을 가져오면 orderId = 4인 값을 가져오게 된다.
  • 이렇게 되면 기준을 잡기가 어려워지므로 JPA는 필요한 모든 값을 다 가져오고 그걸 페이징 처리하는 형식을 취한다.
    • SQL문엔 변화가 없어 만일 백만 건을 다 가져와 버리면 큰 문제가 발생한다.

 

컬렉션 페치 조인 관련 자세한 내용은 [JPA] [11] 객체지향 쿼리 언어 - 중급 문법 (상편) 확인

 

[4] 주문 조회 V3.1 : 엔티티를 DTO로 변환 - 페이징과 한계 돌파

■ 페이징 한계 돌파

  • 컬렉션을 페치 조인하면 페이징이 불가능하다. 
    • 일대다 조인이 발생해 데이터가 예측할 수 없이 뻥튀기된다.
    • 1:N에서 1을 기준으로 페이징을 하는 게 목적인데, N을 기준으로 row가 생성된다.
    • Order(1)을 기준으로 하고 싶은데, OrderItem(N)이 기준이 된다는 의미다.
  • 하이버네이트가 모든 DB 데이터를 읽어온 뒤 페이징 처리를 하면 서비스 장애가 날 수 있다. 

 

■ 해결 방안

  • xToOne(One To One, Many To One) 관계는 모두 페치 조인한다.
    • ToOne관계는 row 수를 증가시키지 않아 페이징 쿼리에 영향을 주지 않는다.
  • 컬렉션은 지연로딩으로 조회한 후 최적화 위해 hibernate.default_batch_fetch_size, @BatchSize를 적용한다.
    • hibernate.default_batch_fetch_size : 글로벌 설정
    • @BatchSize : 개별 최적화
    • 이 옵션을 사용하면 컬렉션이나 프록시 객체를 한꺼번에 설정한 size만큼 IN 쿼리로 조회한다.

 

■ 해결방안 실제 적용

OrderApiController

    @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(Collectors.toList());

        return result;
    }
  • @RequestParam으로 setFirstResult에 넣을 offset, setMaxResults()에 넣을 limit값 url로 받아 옴
  • OrderDto로 변환한 후 반환

① xToMany만 페치 조인으로 가져 옴.

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

 

 

② 글로벌 batch size 적용

application.yml

spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 100
  • 미리 in절로 가져올 데이터 수 지정

 

■ SQL

페이징이 적용되는 걸 확인할 수 있다.
한 번의 in 쿼리로 orders와 관련된 oderItem을 다 가져온다.(미리 세팅한 100만큼 불러온다)
item 네 개를 한 번에 당겨온다.

 

③ @BatchSize로 개별 최적화하는 방법

1. 컬렉션일 때

컬렉션 위에 @BatchSize 애노테이션을 달고 size를 지정한다.

2. ToOne관계일 때

엔티티 클래스에 @BatchSize를 단다.

 

■ 장점

  • 쿼리 호출 수가 1 + N → 1 + 1로 최적화된다.
  • 조인보다 DB 데이터 전송량이 최적화된다.
    • Order와 OrderItem을 조인하면 Order가 OrderItem만큼 중복해서 조회된다. 반면 이 방법은 각각 조회하므로 전송해야 할 중복 데이터가 없다.  
  • 페치 조인 방식과 비교하면 쿼리 호출 수가 약간 증가하지만, DB데이터 전송량이 감소한다는 것을 알 수 있다. 
  • 컬렉션 페치 조인은 페이징이 불가능하지만, 이 방법은 페이징이 가능하다.

 

결론 : xToOne 관계는 페이징에 영향을 주지 않으므로 페치 조인으로 쿼리 수를 줄이고, 나머지는 hibernate.default_batch_fetch_size로 최적화하면 된다.

 

참고] default_batch_fetch_size의 적정 값

  • 100~1000 사이를 선택하는 것을 권장한다.(WAS와 DB가 버틸 수 있는 정도가 적정하다)
    • 데이터베이스에 따라 IN 절 파라미터를 1000으로 제한하기도 한다.
  • 1000개 이상은 DB에서 app으로 한 번에 당겨올 때 부하가 걸려 사용하지 않는다.