본문 바로가기
Back-end/Spring Boot + REST API

스프링부트 API JPA 최적화 (N+1) 컬렉션 조회 최적화, DTO 조회 성능 향상

by javapp 자바앱 2023. 12. 21.
728x90

컬렉션 조회 최적화

 

ERD

 

 

1. 엔티티 조회 - 페치 조인으로 쿼리 수 최적화

OrderApiController

/**
 * 페치 조인
 */
@GetMapping("/api/v3/orders")
public ResponseEntity<Result<List<OrderDto>>> orderV3() {
    // N 만큼 데이터 나옴
    List<Order> orders = repository.findAllWithItem();

    List<OrderDto> result = orders.stream()
            .map(o -> new OrderDto(o))
            .toList();
    return ResponseEntity.ok(new Result<>(result));
}

Order 엔티티 객체로 쿼리 결과를 받아서

Dto 로 변환작업을 한다음 반환

 

 

OrderRepository

findAllWithItem()

public List<Order> findAllWithItem() {
   return em.createQuery(
           "SELECT distinct o from Order o " + //  distinct : 엔티티 중복시 엔티티를 걸러서 컬렉션에 담아준다.
                   " JOIN FETCH o.member m" +          // ToOne (페치조인)
                   " JOIN FETCH o.delivery d" +        // ToOne (페치조인)
                   " JOIN FETCH o.orderItems oi" +     // ToMany
                   " JOIN FETCH oi.item i", Order.class)
           .getResultList();
}
더보기
" JOIN FETCH o.orderItems oi" +     // ToMany

이런식의 쿼리는 컬렉션 페치조인 이기 때문에 페이징 불가능

해당 컬렉션조회(ToMany) 는 List와 같은 컬렉션이기 때문에 row 수를 증가시켜서 페이징 쿼리에 영향을 줌.

그래서 페이징을 사용할 수 없다.

 

 

 

OrderDto

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

order.getOrderItems() 에서 지연로딩 발생

 

 

Result

@Data
@AllArgsConstructor
public class Result<T> {
    int count;
    private T data;

    public Result(T data) {
        this.data = data;
    }
}

 

 

 

API 요청 결과

{
    "count": 0,
    "data": [
        {
            "orderId": 1,
            "name": "userA",
            "orderDate": "2023-11-18T19:20:44.210208",
            "orderStatus": "ORDER",
            "address": {
                "city": "서울",
                "street": "1",
                "zipcode": "15352"
            },
            "orderItems": [
                {
                    "itemName": "JPA1 BOOK",
                    "orderPrice": 10000,
                    "count": 10000
                },
                {
                    "itemName": "JPA2 BOOK",
                    "orderPrice": 20000,
                    "count": 20000
                }
            ]
        },

 

 

 

 

2. Dto 쿼리 조회

컬렉션을 JPA에서 DTO 직접 조회

@GetMapping("/api/v4/orders")
public ResponseEntity<Result<List<OrderQueryDto>>> orderV4() {
    List<OrderQueryDto> orderQueryDtos = queryRepository.findOrderQueryDtos();
    return ResponseEntity.ok(new Result<>(orderQueryDtos));
}

 

 

OrderQueryDto

@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) {
        this.orderId = orderId;
        this.name = name;
        this.orderDate = orderDate;
        this.orderStatus = orderStatus;
        this.address = address;
    }
}

 

 

findOrderQueryDtos

public List<OrderQueryDto> findOrderQueryDtos() {
    List<OrderQueryDto> result = findOrders(); // query 1번 -> N개
    result.forEach(o -> {
        List<OrderItemQueryDto> orderItems = findOrderItems(o.getOrderId()); // query N번
        o.setOrderItems(orderItems);
    });
    return result;
}

단점은 N + 1 : 쿼리 1번 조회시 루프를 돌면서 N개의 쿼리가 날라감.

List<OrderItemQueryDto>

 

 

OrderItemQueryDto

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

OneToMany 연관관계

 

 

findOrders

public List<OrderQueryDto> findOrders() {
    return em.createQuery(
            "select new com.inflearn.optimization.domain.order.repository.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();
}

ToOne 쿼리 조회

offset과 limit으로 페이징 가능하게 변경가능

더보기
public List<Order> findAllWithMemberDelivery(String offset, String limit) {
    return em.createQuery(
                    "SELECT o FROM Order o" +
                            " JOIN FETCH o.member m" +
                            " JOIN FETCH o.delivery d", Order.class)
            .setFirstResult(Integer.parseInt(offset))
            .setMaxResults(Integer.parseInt(limit))
            .getResultList();
}

 

 

findOrderItems

private List<OrderItemQueryDto> findOrderItems(Long orderId) {
    return em.createQuery(
            "select new com.inflearn.optimization.domain.order.repository.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();
}

쿼리 조회

 

 

API 요청 결과

{
    "count": 0,
    "data": [
        {
            "orderId": 1,
            "name": "userA",
            "orderDate": "2023-11-18T19:20:44.210208",
            "orderStatus": "ORDER",
            "address": {
                "city": "서울",
                "street": "1",
                "zipcode": "15352"
            },
            "orderItems": [
                {
                    "orderId": 1,
                    "itemName": "JPA1 BOOK",
                    "orderPrice": 10000,
                    "count": 100
                },
                {
                    "orderId": 1,
                    "itemName": "JPA2 BOOK",
                    "orderPrice": 20000,
                    "count": 2
                }
            ]
        },

 

 

 

3. Dto 쿼리 조회 - IN 쿼리로 메모리 사용으로 O(1) 최적화 조회

일대다 관계인 컬렉션은 IN 절을 활용해서 메모리에 미리 조회해서 최적화

 

findAllByDto_optimization

public List<OrderQueryDto> findAllByDto_optimization() {
    List<OrderQueryDto> orderDtos = findOrders();
    List<Long> orderIds = orderDtos.stream().map(o -> o.getOrderId()).collect(Collectors.toList());
    // ctrl alt v
    List<OrderItemQueryDto> orderItems = em.createQuery(
                    "select new com.inflearn.optimization.domain.order.repository.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();

    // 최적화 - 메모리에서 매칭해서 set
    Map<Long, List<OrderItemQueryDto>> orderItemMap = orderItems.stream()
            .collect(Collectors.groupingBy(orderItemQueryDto -> orderItemQueryDto.getOrderId()));// id를 기준으로 map 으로
    orderDtos.forEach(o -> o.setOrderItems(orderItemMap.get(o.getOrderId()))); // set OrderItem
    return orderDtos;
}

groupingBy: 키를 기준으로 값에 List 컬렉션으로 Map 생성

하나의 주문Id 에 여러 Item List 를 Map으로 감쌈

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

 

 

Order 쿼리와 , 주문에 대한 Order Item 쿼리

 

 

 

 

권장 순서

  1. 엔티티 조회 방식으로 우선 접근
    1. 페치조인으로 쿼리 수를 최적화
    2. 컬렉션 최적화
      1. 페이징 필요 : batch size 로 최적화
      2. 페이징 X : 페치 조인 사용
  2. 엔티티 조회 방식으로 해결이 안되면, DTO 조회 방식사용
  3. DTO 조회 방식으로 해결이 안되면 NativeSQL or 스프링 jdbcTemplate

* 엔티티 조회 방식JPA가 많은 부분을 최적화 해주기 때문에

단순한 코드를 유지하면서, 성능을 최적화 할 수 있는 장점이 있다.

 

* DTO를 직접 조회하는 방식은 성능을 최적화 하거나 성능 최적화 방식을 변경할 때 많은 코드를 변경해야 한다.

DTO 캐시를 하거나

트래픽이 너무 많으면 보통 캐시를 쓴다.

 

 

 

 

 

 

댓글