RE-Heat 개발자 일지

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

백엔드/JPA

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

RE-Heat 2023. 9. 3. 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

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

 

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

  • 화면이나 API 용도에 한정된 쿼리는 패키지를 따로 파서 구현한다.
  • 이렇게 하면 화면과 관련된 리포지토리와 범용 리포지토리를 분리할 수 있다.

 

OrderApiController

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

 

OrderQueryRepository

@Repository
@RequiredArgsConstructor
public class OrderQueryRepository {
    private final EntityManager em;

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

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

    }
}
  • findOrders()로 DB로부터 직접 OrderQueryDto에 데이터를 받아온다. (아직 컬렉션 필드인 OrderItems 값이 빈 상태)
  • 반복문으로 OrderQueryDto 객체를 탐색한 뒤 findOrderItems() 메서드를 호출한다. 이때 OrderQueryDto 객체 id값을 넘겨준다.
  • findOrderItems()는 OrderQueryDto 객체에 담긴 id값을 이용해 DB에서 orderItem 값을 조회한다.
  • 조회된 OrderItem값을 o.setOrderItems()를 이용해 OrderQueryDto에 담고 반복문이 끝나면 컬렉션 필드가 할당된 OrderQueryDto를 반환한다.

 

OrderQueryDto

@Data
public class OrderQueryDto {
    @JsonIgnore
    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) {
        this.orderId = orderId;
        this.name = name;
        this.orderDate = orderDate;
        this.orderStatus = orderStatus;
        this.address = address;
    }
}
  • 생성자에서 컬렉션 필드인 orderItems를 생략했다.
    • 데이터 뻥튀기 문제를 해결하기 위해 직접 주입하는 방식을 택했기 때문이다.
  • 만일 클라이언트가 요구하는 API 스펙에서 orderId가 필요 없다면 @JsonIgnore를 붙여주면 된다.

 

OrderItemQueryDto

@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;
    }
}
  • 컬렉션 필드를 위한 DTO다. 

 

■ 실행결과

 

하지만 이렇게 하면 결과적으로 N + 1 문제가 발생한다.

 where절의 비교 대상이 하나 이므로 다수를 조회하면 쿼리를 반복실행할 수밖에 없기 때문이다.

 

 

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

[5] 챕터에서 발생한 N + 1 문제를 해결하려면 IN 키워드를 사용하면 된다.

 

OrderApiController

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

 

 

OrderQueryRepository - findAllByDto_optimization()

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

        //주문 데이터만큼 Map에 올림
        Map<Long, List<OrderItemQueryDto>> orderItemMap = findOrderItemMap(toOrderIds(result));

        //모자랐던 orderItem 데이터 채워준다.
        result.forEach(o -> o.setOrderItems(orderItemMap.get(o.getOrderId())));

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

        Map<Long, List<OrderItemQueryDto>> orderItemMap = orderItems.stream()
                .collect(Collectors.groupingBy(orderItemQueryDto -> orderItemQueryDto.getOrderId()));
        return orderItemMap;
    }

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

① toOrderIds()

findOrders()로 불러온 OrderQueryDto 리스트의 OrderId를 List<Long>에 담고 리턴. 이 값을 인자로 findOrderItemMap()을 호출

 

② findOrderItemMap()

1. LIst<OrderItemQueryDto> orderItems

  • where 조건에 in :orderIds 추가 - in으로 파라미터를 모아 한 번에 보내는 게 키워드
  • toOrderIds()로 받아온 orderId들을 파라미터로 담아 보냄.

2. Map<Long, ...> orderItemMap : Long 부분에 orderItemQueryDto.getOrderId() 키 값으로 넣어 줌.

3. orderId를 키값으로 하고 List<OrderItemQueryDto>를 value값으로 하는 OrderItemMap을 반환

 

③ result.forEach()

OrderItemMap에 orderId값을 넣어서 일치하는 값을 가져온 후 setOrderItems로 비었던 orderItem 데이터를 채워준다.

 

 

■ 실행결과

Query 2번 : 루트 1번, 컬렉션 1번

우측 쿼리를 보면 기존엔 루프를 돌았는데, 여기선 in 절로 한꺼번에 조회하는 것을 확인할 수 있다.

 

정리
① ToOne관계들을 먼저 조회한다.(findOrders)

② findOrders()로 얻은 식별자 OrderId로 ToMany 관계인 orderItem을 한꺼번에 조회 (JPQL에 in 넣어 줌)

③ MAP을 사용해 매칭 성능 향상 O(1)

 

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

쿼리를 한 번만 호출해서 데이터를 가져오자!

 

OrderFlatDto

@Data
public class OrderFlatDto {

    private Long orderId;
    private String name;
    private LocalDateTime orderDate; //주문시간
    private Address address;
    private OrderStatus orderStatus;

    private String itemName;//상품 명
    private int orderPrice; //주문 가격
    private int count;      //주문 수량

	//생성자 생략

}

 

OrderQueryRepository - findAllByDto_flat()

    public List<OrderFlatDto> findAllByDto_flat() {
        List<OrderFlatDto> resultList = 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();
        return resultList;
    }
  • 일대다 조인이므로 데이터가 중복되어서 조회된다.
  • 따라서 컨트롤러에서 중복을 걸러내 OrderQueryDto에 알맞게 매칭하는 작업이 필요하다.

 

OrderApiController

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

stream()으로 OrderFlatDto를 OrderQueryDto 형태로 변환하는 작업을 진행. (여기서 중복이 걸러진다)

 

추가]

  • OrderQueryDto에 컬렉션까지 포함한 dto 생성자를 추가해야 한다.
  • 중복을 구분하기 위해선 OrderQueryDto에 @EqualsAndHashCode(of="orderId) 애노테이션을 추가해야 한다.

 

■ 정리

장점

① Query 1번으로 모든 데이터를 정리할 수 있다.

단점

① 쿼리는 한 번이만, 일대다 조인으로 인해 DB => 애플리케이션으로 전달하는 데이터에 중복데이터가 추가돼 상황에 따라 V5보다 더 느릴 수도 있다.

② 애플리케이션에서 분리, 중복제거하는 작업이 복잡한 편

③ 페이징이 불가능하다. (DB에서 중복된 데이터가 날아 옴 )

 

[8] API 개발 고급 정리

■ 정리

① 엔티티 조회 방식

  • 엔티티를 그대로 반환하는 것은 사용하면 안 된다.
  • 엔티티를 조회한 후 DTO로 변환해서 반환한다.
  • 지연로딩을 통해 엔티티를 조회하면 많은 쿼리가 날아가므로 페치 조인을 사용한다.
  • 컬렉션 타입은 페치 조인을 쓰면 페이징이 불가능하다.
    • 해결책 : ToOne관계는 페치 조인으로 쿼리 수를 최적화하고 컬렉션은 지연 로딩을 유지한다. 대신hibernate.dfault_batch_fetch_size 혹은 @BatchSize로 페이징 처리를 한다.

② JPA에서 DTO로 직접 조회

  • JPA에서 DTO로 직접 조회할 때 컬렉션(일대다)은 IN절을 이용해 최적화한다. 이때 메모리를 활용
  • 플랫 데이터를 최적화하려면 JOIN 결과 그대로 조회 후 애플리케이션에서 원하는 모양으로 다듬어야 한다.

 

■ 권장 개발 순서

  1. 엔티티 조회 방식으로 우선 접근
    • 페치 조인으로 쿼리 수 최적화
    • 컬렉션 최적화
      • 페이징 필요하면 hibernate.dfault_batch_fetch_size 혹은 @BatchSize로 최적화
      • 페이징 필요 X → 페치 조인 사용
  2. 엔티티 조회 방식으로 해결이 안되면 DTO 조회 방식 사용
    • V4는 코드가 단순하고 유지보수가 쉬운 게 장점. 특정 주문 한 건만 조회할 땐 이 방법이 제일 낫다.
    • V5는 V4에서 발생하는 N+1문제 해결 가능
    • V6는 쿼리조차 한 번에 해결. but 페이징이 불가능하고 데이터가 많으면 중복 전송이 증가해 V5와 비교 시 성능 차이도 미비하다.
    • 실무에선 DTO 조회방식을 쓸 땐 paging이 사실상 필수여서 V5를 많이 쓴다.
  3. DTO 조회 방식으로 해결이 안되면 NativeSQL or 스프링 jdbcTemplate 사용