공부/Spring Boot

API 개발 고급 - 컬렉션 조회 최적화

Stair 2025. 2. 19. 19:08
반응형

https://surrealcode.tistory.com/121

 

API 개발 고급 - 지연 로딩과 조회 성능 최적화

https://surrealcode.tistory.com/120 API 개발 고급 - 준비현업에서는 API 개발을 한 이후 성능이 나오지 않아 튜닝을 하기도 하는데 보통은 아래와 같은 문제를 지키지 않아 발생한다.- 지연로딩, 조회 성

surrealcode.tistory.com

앞서 본 API 개발은 xToOne 관계만 있는 개발이었다. 이번에는 컬렉션인 OneToMany 를 조회하고, 최적화를 진행해 볼 예정이다.

 

주문내역에서 추가로 주문한 상품 정보를 추가로 조회하자.

Order 기준으로 컬렉션인 'OrderItem'과 'Item'이 필요하다.

 

컬렉션 조회 같은 경우에는 DB 입장에서 뻥튀기가 되기 때문에 성능 최적화할때 고민을 해봐야한다.

ex) 하나의 주문에 주문내역이 3개가 달려있으면 로우가 3개로 뻥튀기가 되는 문제

 

Order엔티티를 확인해보면 OrderItem이 List로 되어있다.

@OneToMany(mappedBy = "order", fetch = LAZY, cascade = CascadeType.ALL)
private List<OrderItem> orderItems = new ArrayList<>();

이 OrderItem을 따라가보면 Item을 또 찾아들어가는 구조이다.

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "item_id")
private Item item; //아이템 종류를 고를 수 있고,

 

 

엔티티를 직접 노출하며 주문을 조회하는 Controller를 만들어보았다.

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

 

POSTMAN으로 찔러보면 다음과 같은 결과를 확인할 수 있는데,

[
    {
        "id": 1,
        "member": {
            "id": 1,
            "name": "userA",
            "address": {
                "city": "서울",
                "street": "1111",
                "zipcode": "11111"
            }
        },
        "orderItems": [
            {
                "id": 1,
                "item": {
                    "id": 1,
                    "name": "JPA1 BOOK",
                    "price": 10000,
                    "stockQuantity": 99,
                    "categories": null,
                    "author": null,
                    "isbn": null
                },
                "orderPrice": 10000,
                "count": 1,
                "totalPrice": 10000
            },
            {
                "id": 2,
                "item": {
                    "id": 2,
                    "name": "JPA2 BOOK",
                    "price": 20000,
                    "stockQuantity": 98,
                    "categories": null,
                    "author": null,
                    "isbn": null
                },
                "orderPrice": 20000,
                "count": 2,
                "totalPrice": 40000
            }
        ],
        "delivery": {
            "id": 1,
            "address": {
                "city": "서울",
                "street": "1111",
                "zipcode": "11111"
            },
            "status": null
        },
        "orderDate": "2025-02-05T09:37:01.027545",
        "status": "ORDER",
        "totalPrice": 50000
    },
    {
        "id": 2,
        "member": {
            "id": 2,
            "name": "userB",
            "address": {
                "city": "진주",
                "street": "2",
                "zipcode": "22222"
            }
        },
        "orderItems": [
            {
                "id": 3,
                "item": {
                    "id": 3,
                    "name": "SPRING1 BOOK",
                    "price": 20000,
                    "stockQuantity": 197,
                    "categories": null,
                    "author": null,
                    "isbn": null
                },
                "orderPrice": 20000,
                "count": 3,
                "totalPrice": 60000
            },
            {
                "id": 4,
                "item": {
                    "id": 4,
                    "name": "SPRING2 BOOK",
                    "price": 40000,
                    "stockQuantity": 296,
                    "categories": null,
                    "author": null,
                    "isbn": null
                },
                "orderPrice": 40000,
                "count": 4,
                "totalPrice": 160000
            }
        ],
        "delivery": {
            "id": 2,
            "address": {
                "city": "진주",
                "street": "2",
                "zipcode": "22222"
            },
            "status": null
        },
        "orderDate": "2025-02-05T09:37:01.115605",
        "status": "ORDER",
        "totalPrice": 220000
    }
]

 

UserA가 주문한 Order는 JPA1 BOOK, JPA2 BOOK 총 OrderItem이 두개이다.

마찬가지로 UserB가 주문한 Order는 SPRING1 BOOK, SPRING2 BOOK으로 두개인것을 확인할 수 있다.

 

엔티티를 직접 노출하며 컬렉션을 노출하는 것을 확인해보았다.

OrderItems를 람다로 돌리면서 LAZY로딩이였던 부분을 강제 초기화를 하며 데이터를 뿌려지게 한 것이다.

 

-> 내가 필요로 하지 않는 엔티티의 부분들까지 전부 노출이 되는 문제가 발생하였다.

--> 엔티티를 직접 노출하는 것은 권장하지 않는다 따라서 DTO로 바꾸어 주어야 하는데, OrderItem도 DTO로 바꿔야 하고, OrderItem안에 있는 Item까지 DTO로 바꾸어 주어야 한다.

 

 

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

API를 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;
}

@Data
static class OrderDto {
    private Long orderId;
    private String name;
    private LocalDateTime orderDate;
    private OrderStatus orderStatus;
    private Address address;
    private List<OrderItem> orderItems;

    public OrderDto(Order order) {
        orderId = order.getId();
        name = order.getMember().getName();
        orderDate = order.getOrderDate();
        orderStatus = order.getStatus();
        address = order.getDelivery().getAddress();
        order.getOrderItems().stream().forEach(o-> o.getItem().getName());
        orderItems = order.getOrderItems();
    }

}

 

order의 orderItems를 포함하는 DTO클래스를 만들어주었다. 기존과 다른 점은 orderItems 속에 있는 Item또한 LAZY로 발려있기 때문에 Item을 초기화 시켜주기 위해 DTO의 생성자 속에 람다형식의 LAZY 초기화 구문이 들어간 것이 차이점이다.

Postman으로 찔러본 결과를 확인해보자.

[
    {
        "orderId": 1,
        "name": "userA",
        "orderDate": "2025-02-05T10:08:14.484523",
        "orderStatus": "ORDER",
        "address": {
            "city": "서울",
            "street": "1111",
            "zipcode": "11111"
        },
        "orderItems": [
            {
                "id": 1,
                "item": {
                    "id": 1,
                    "name": "JPA1 BOOK",
                    "price": 10000,
                    "stockQuantity": 99,
                    "categories": null,
                    "author": null,
                    "isbn": null
                },
                "orderPrice": 10000,
                "count": 1,
                "totalPrice": 10000
            },
            {
                "id": 2,
                "item": {
                    "id": 2,
                    "name": "JPA2 BOOK",
                    "price": 20000,
                    "stockQuantity": 98,
                    "categories": null,
                    "author": null,
                    "isbn": null
                },
                "orderPrice": 20000,
                "count": 2,
                "totalPrice": 40000
            }
        ]
    },
    {
        "orderId": 2,
        "name": "userB",
        "orderDate": "2025-02-05T10:08:14.508455",
        "orderStatus": "ORDER",
        "address": {
            "city": "진주",
            "street": "2",
            "zipcode": "22222"
        },
        "orderItems": [
            {
                "id": 3,
                "item": {
                    "id": 3,
                    "name": "SPRING1 BOOK",
                    "price": 20000,
                    "stockQuantity": 197,
                    "categories": null,
                    "author": null,
                    "isbn": null
                },
                "orderPrice": 20000,
                "count": 3,
                "totalPrice": 60000
            },
            {
                "id": 4,
                "item": {
                    "id": 4,
                    "name": "SPRING2 BOOK",
                    "price": 40000,
                    "stockQuantity": 296,
                    "categories": null,
                    "author": null,
                    "isbn": null
                },
                "orderPrice": 40000,
                "count": 4,
                "totalPrice": 160000
            }
        ]
    }
]

 

 

오더의 Item까지  잘 나오긴 하지만, 문제가 있다.

Item 엔티티의 스펙이 전부 외부에 노출이 되어버리는 것이 문제이다.

엔티티가 외부에 노출되면 안된다는 뜻은 단순하게 DTO 하나 감싸서 보내라는 것이 아니라, 엔티티에 대한 의존을 완전히 끊어야 하는 것이다.

--> OrderItem 조차도 전부 DTO로 바꾸어주어야 하는 것이다.

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

 

Postman으로 찔러보도록 하자

[
    {
        "orderId": 1,
        "name": "userA",
        "orderDate": "2025-02-06T09:32:36.052829",
        "orderStatus": "ORDER",
        "address": {
            "city": "서울",
            "street": "1111",
            "zipcode": "11111"
        },
        "orderItems": [
            {
                "itemName": "JPA1 BOOK",
                "orderPrice": 10000,
                "count": 1
            },
            {
                "itemName": "JPA2 BOOK",
                "orderPrice": 20000,
                "count": 2
            }
        ]
    },
    {
        "orderId": 2,
        "name": "userB",
        "orderDate": "2025-02-06T09:32:36.185474",
        "orderStatus": "ORDER",
        "address": {
            "city": "진주",
            "street": "2",
            "zipcode": "22222"
        },
        "orderItems": [
            {
                "itemName": "SPRING1 BOOK",
                "orderPrice": 20000,
                "count": 3
            },
            {
                "itemName": "SPRING2 BOOK",
                "orderPrice": 40000,
                "count": 4
            }
        ]
    }
]

 

 

의존을 완전히 끊었기에 기존v2의 스펙은 전혀 변경되지 않으면서, 아이템의 필요한 부분만 노출시켜주고, 데이터를 바꿔줄 수 있다.

 

위 코드는 잘 생각해보면 N+1의 문제가 발생한다.

주문에 주문 내역이 두개 끼어있기 때문이다.

**컬렉션을 사용하면 최적화 문제도 같이 고려해야 하는 이유가 이것이다.**

 

 

 

이제 fetch join을 사용하여 성능을 최적화해보자.

+ 컬렉션일때의 fetch join은 참고해야할 사항이 몇가지 있다.

 

 

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

아까 v2는 LAZY인 부분을 select로 하나하나 직접 찌르며 초기화해주기 때문에 N+1 문제가 발생하였다.

이걸 유념하며 JPQL 쿼리를 짜봤다.

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

 

지금 보면 order가 orderItems를 조인하게 되었다. order는 두개이고 orderItem는 총 4개여서 join을 하게 되면 order로우가 총 4개가 되어버린다.

 

select * from orders o join order_item oi on o.order_id=oi.order_id;

 

DELIVERY_ID  MEMBER_ID  ORDER_DATE  ORDER_ID  STATUS  COUNT  ORDER_PRICE  ITEM_ID  ORDER_ID  ORDER_ITEM_ID  

1 1 2025-02-14 13:31:48.473501 1 ORDER 1 10000 1 1 1
1 1 2025-02-14 13:31:48.473501 1 ORDER 2 20000 2 1 2
2 2 2025-02-14 13:31:48.564259 2 ORDER 3 20000 3 2 3
2 2 2025-02-14 13:31:48.564259 2 ORDER 4 40000 4 2 4

 

order_id는 두개이지만 order_id한개당 order_items가 두개이기 때문에 오더가 order_items 갯수만큼 맞추기때문에 위 쿼리처럼 4개가 되는 것이다.

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

 

DB의 Distinct는 디비의 컬럼이 정확하게 일치해야 distinct가 된다. 하지만 JPA에서는 자체적으로  Order가 같은 값이면 distinct를 적용시켜준다.

--> V3는 직접 JPQL을 활용하여 페치조인을 하였으므로 쿼리가 단 한방 나갔다. N+1문제를 해결한 것이다. 튜닝의 성능을 볼 수 있다.

** 참고 : 스프링 부트 3버전대, 정확히는 하이버네이트 6버전을 사용하시면서 자동으로 distinct가 적용되면서 postman으로 찔러볼 떄는 결과가 두개만 나온다.

 

장점

1. 페치 조인으로 SQL이 한번만 실행 됨

2. distinct를 사용하여 같은 order엔티티의 중복을 제거함

단점

1. 페이징이 불가능하다.(페이징 : 내가 원하는 갯수만큼 가져오는 등의 행위)

 

참고 : 컬렉션 페치 조인을 사용하면 페이징이 불가능하다. 하이버네이트는 경고 로그를 남기면서 모든 데이터를 DB에서 읽어오고 경고 처리를 해준다. (DB에서는 Limit가 들어가지 않기 때문에 쿼리가 4개이다. 이 상태에서 페이징 처리가 들어가게 되면, 내가 원하지 않은 결과가 나타날 가능성이 매우 높다.)

 

 

 

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

페이징과 한계 돌파

- 컬렉션을 페치 조인하면 페이징이 불가능하다.

  - 컬렉션을 페치 조인하면 일대다 조인이 발생하므로 데이터가 예측할 수 없이 증가한다.

  - 일다대에서 일(1)을 기준으로 페이징을 하는 것이 목적이다. 그런데 데이터는 다(N)를 기준으로 row가 생성된다.

  - Order를 기준으로 페이징하고 싶은데, 다(N)인 OrderItem을 조인하면 OrderItem이 기준이 되어버린다.

- 이 경우 하이버네이트는 경고 로그를 남기고 모든 DB 데이터를 읽어서 메모리에서 페이징을 시도한다. 최악의 경우 장애로 이어질 수 있다.

 

한계 돌파

그러면 페이징 + 컬렉션 엔티티를 함께 조회하려면 어떻게 해야할까?

지금부터 코드도 단순하고, 성능 최적화도 보장하는 매우 강력한 방법을 소개하겠다. 대부분의 페이징 + 컬렉션 엔티티 조회 문제는 이 방법으로 해결할 수 있다.

 

1. 먼저 ToOne(OneToOne, ManyToOne)관계를 모두 페치 조인한다. ToOne관계는 row 수를 증가시키지 않으므로 페이징 쿼리에 영향을 주지 않는다.

2. 컬렉션은 지연로딩으로 조회한다.

3. 지연 로딩 성능 최적화를 위해 'hibernate.default_batch_fetch_size', '@BatchSize'를 적용한다.

  - hibernate.default_batch_fetch_size : 글로벌 설정

  - @BatchSize : 개별 최적화

  - 이 옵션을 사용하면 컬렉션인, 프록시 객체를 한꺼번에 설정한 size만큼 IN쿼리로 조회한다.

  

batch를 적용시키지 않은 채 코드를 돌려보자.

@GetMapping("/api/v3/orders")
public List<OrderDto> ordersV3() {
    List<Order> orders = orderRepository.findAllWithItem();
    List<OrderDto> collect = orders.stream()
            .map(o -> new OrderDto(o))
            .collect(Collectors.toList());

    return collect;

}

 

order, member, delivery는 ToOne관계이기 때문에 한번에 땡겼다. 하지만 orderItems는 컬렉션이다. orderItems를 가지고 오면, order_id에 맞는 아이템이 2개가 들어있다. 거기에 맞는 레이지로딩이 2개가 일어나게 된다. N+1문제가 발생하며,

총 7방의 쿼리가 날아간다.

 

 

이걸 batchSize를 통해 해결할 수 있다.

우선 파라미터 바인딩을 통해 페이징되는 쿼리를 짜보도록 하자.

@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> collect = orders.stream()
            .map(o -> new OrderDto(o))
            .collect(Collectors.toList());

    return collect;

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

 

Order, Member, Delivery는 ToOne관계이기 때문에 페치 조인을 하고 페이징을 하여도, 전혀 문제가 발생하지 않는다.

이후

application.yml에 배치사이즈를 지정해준다.

default_batch_fetch_size: 10

이후 postman으로 찔러보면 다음과 같은 결과가 나오게 된다.

 

첫번째 쿼리

    select
        o1_0.order_id,
        d1_0.delivery_id,
        d1_0.city,
        d1_0.street,
        d1_0.zipcode,
        d1_0.status,
        m1_0.member_id,
        m1_0.city,
        m1_0.street,
        m1_0.zipcode,
        m1_0.name,
        o1_0.order_date,
        o1_0.status 
    from
        orders o1_0 
    join
        member m1_0 
            on m1_0.member_id=o1_0.member_id 
    join
        delivery d1_0 
            on d1_0.delivery_id=o1_0.delivery_id 
    offset
        ? rows 
    fetch
        first ? rows only

 

 

 

두번째 쿼리

    select
        oi1_0.order_id,
        oi1_0.order_item_id,
        oi1_0.count,
        oi1_0.item_id,
        oi1_0.order_price 
    from
        order_item oi1_0 
    where
        oi1_0.order_id in (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)

 

 

세번째 쿼리

    select
        i1_0.item_id,
        i1_0.dtype,
        i1_0.name,
        i1_0.price,
        i1_0.stock_quantity,
        i1_0.artist,
        i1_0.etc,
        i1_0.author,
        i1_0.isbn,
        i1_0.actor,
        i1_0.director 
    from
        item i1_0 
    where
        i1_0.item_id in (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)

 

두번째 쿼리를 보면 뭔가 이상하다.

IN(?,?,?...?) 이 포함되었다.

한번에 IN 쿼리를 날려 가져오게 된다.

 

세번째 쿼리도 batch 옵션을 켜고 나서 아이템 4개를 한방에 다 땡겨온다.

 

1+M+N이였던 쿼리가 1+1+1으로 획기적으로 줄어들게 되었다.

--> 이정도의 최적화면 왠만한 성능은 전부 보장된다고 볼 수 있다.

 

개별로 설정하려면 @BatchSize를 적용하면 된다.(컬렉션은 컬렉션 필드에, 엔티티는 엔티티 클래스에 적용)

@BatchSize(size = 100)
@OneToMany(mappedBy = "order", fetch = LAZY, cascade = CascadeType.ALL)
private List<OrderItem> orderItems = new ArrayList<>();

 

 

장점

  - 쿼리 호출 수가 '1 + N' -> '1 + 1'로 최적화된다.

  - 조인보다 DB 데이터 전송량이 최적화 된다.

  - 페치 조인 방식과 비교해서 쿼리 호출 수가 약간 증가하지만 DB 데이터 전송량이 감소한다.

  - 컬렉션 페치 조인은 페이징이 불가능 하지만 이 방법은 페이징이 가능하다

결론

  - ToOne 관계는 페치 조인해도 페이징에 영향을 주지 않는다. 따라서 ToOne관계는 페치 조인으로 쿼리 수를 줄이고 해결하고, 나머지는 'hibernate.default_batch_fetch_size'로 최적화 하자.

 

 

참고 : default_batch_fetch_size의 크기는 적당한 사이즈를 골라야 하는데, 100~1000 사이를 선택하는 것을 권장한다. 이 전략을 SQL IN절을 사용하는데, 데이터베이스에 따라 IN 절 파라미터를 1000으로 제한하기도 한다. 1000으로 잡으면 한번에 1000개를 DB에서 애플리케이션에 불러오므로 DB에 순간 부하가 증가할 수 있다. 하지만 애플리케이션은 100이든 1000이든 결국 전체 데이터를 로딩해야 하므로 메모리 사용량이 같다. 1000으로 설정하는 것이 성능상 가장 좋지만, 결국 순간 부하를 어디까지 견딜 수 있는지로 결정하면 된다.

 

 

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

JPA에서 DTO를 직접 조회하는 방법을 알아보자.

이전에는 컬렉션이 없었지만 지금은 컬렉션이 포함되어있다.

우선 핵심 비즈니스 로직을 분리하기 위해 패키지를나누었다.

orderRepository는 order 엔티티를 조회하는등의 용도로 쓰이는것이고

query는 화면이나 쿼리, API에 의존이 있는 관계이기 때문에 떼어내기 위해서 패키지를 분류하였다.

@Data
public class OrderItemQueryDto {

    @JsonIgnore
    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;
    }
}

 

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


}

postman을 통해 결과를 조회하면 의도대로 결과를 뱉게 된다.

[
    {
        "orderId": 1,
        "name": "userA",
        "orderDate": "2025-02-18T14:09:10.283392",
        "orderStatus": "ORDER",
        "address": {
            "city": "서울",
            "street": "1111",
            "zipcode": "11111"
        },
        "orderItems": [
            {
                "itemName": "JPA1 BOOK",
                "orderPrice": 10000,
                "count": 1
            },
            {
                "itemName": "JPA2 BOOK",
                "orderPrice": 20000,
                "count": 2
            }
        ]
    },
    {
        "orderId": 2,
        "name": "userB",
        "orderDate": "2025-02-18T14:09:10.376144",
        "orderStatus": "ORDER",
        "address": {
            "city": "진주",
            "street": "2",
            "zipcode": "22222"
        },
        "orderItems": [
            {
                "itemName": "SPRING1 BOOK",
                "orderPrice": 20000,
                "count": 3
            },
            {
                "itemName": "SPRING2 BOOK",
                "orderPrice": 40000,
                "count": 4
            }
        ]
    }
]

 

정리

1. Query : 루트 1번, 컬렉션 N번 실행

2. ToOne(N:1, 1:1) 관계들을 먼저 조회하고, ToMany(1:N)관계는 각각 별도로 처리한다.

  - ToOne관계는 조인해도 데이터 row 수가 증가하지 않는다.

  - ToMany(1:N)관계는 조인하면 row 수가 증가한다.

3. row 수가 증가하지 않는 ToOne관계는 조인으로 최적화하기 쉬우므로 한번에 조회하고, ToMany관계는 최적화하기 어려우므로 findOrderItems()같은 별도의 메서드로 조회한다.

 

 

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

이전 V4는 직접 조회는 하지만 N+1문제가 발생하였다 쿼리를 보면 where id로 하나씩 찔러보기 때문이다.

이번 V5는 IN절을 사용하여 쿼리 한방에 조회할 수 있게 설계하여 N+1 문제를 해결해보도록 하자.

@GetMapping("/api/v5/orders")
public List<OrderQueryDto> ordersV5(){
    return orderQueryRepository.findAllByDto_optimization();
}
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::getOrderId));

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

    return result;
}

 

중간에 orderItems를 stream해주며 이케저케 하면 map으로 변환된다. 키값은 id값이다

 

1번 쿼리

    select
        o1_0.order_id,
        m1_0.name,
        o1_0.order_date,
        o1_0.status,
        d1_0.city,
        d1_0.street,
        d1_0.zipcode 
    from
        orders o1_0 
    join
        member m1_0 
            on m1_0.member_id=o1_0.member_id 
    join
        delivery d1_0 
            on d1_0.delivery_id=o1_0.delivery_id

 

 

2번 쿼리

    select
        oi1_0.order_id,
        i1_0.name,
        oi1_0.order_price,
        oi1_0.count 
    from
        order_item oi1_0 
    join
        item i1_0 
            on i1_0.item_id=oi1_0.item_id 
    where
        oi1_0.order_id in (?, ?)

 

 

쿼리 두번으로 최적화가 되는 것을 볼 수 있다.

ToOne관계들을 먼저 조회하고, 여기서 얻은 식별자 orderId로 ToMany관계인 OrderItem을 한꺼번에 조회하였다.

 

 

 

주문 조회 V6: JPA에서 DTO로 직접 조회, 플랫 데이터 최적화

V5를 최적화 하기 위한 V6를 만들자.

V6는 DB에서 한번에 가져오는 것이다. Order랑 OrderItem을 조인하고 OrderItem과 Item을 조인해서 한방쿼리로 가져오는 것이다. 한방쿼리로 플랫하게 가져온다.


@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;

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

 

모든 데이터를 한방에 긁기 때문에 필요한 데이터를 포함한 OrderFlatDto를 만들어주고,

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

}

 

필요한걸 전부 조인해서 결과값을 나타내는 쿼리를 작성한다.

 

Postman으로 결과를 찔러보면

[
    {
        "orderId": 1,
        "name": "userA",
        "orderDate": "2025-02-19T10:26:17.212395",
        "orderStatus": "ORDER",
        "address": {
            "city": "서울",
            "street": "1111",
            "zipcode": "11111"
        },
        "itemName": "JPA1 BOOK",
        "orderPrice": 10000,
        "count": 1
    },
    {
        "orderId": 1,
        "name": "userA",
        "orderDate": "2025-02-19T10:26:17.212395",
        "orderStatus": "ORDER",
        "address": {
            "city": "서울",
            "street": "1111",
            "zipcode": "11111"
        },
        "itemName": "JPA2 BOOK",
        "orderPrice": 20000,
        "count": 2
    },
    {
        "orderId": 2,
        "name": "userB",
        "orderDate": "2025-02-19T10:26:17.307151",
        "orderStatus": "ORDER",
        "address": {
            "city": "진주",
            "street": "2",
            "zipcode": "22222"
        },
        "itemName": "SPRING1 BOOK",
        "orderPrice": 20000,
        "count": 3
    },
    {
        "orderId": 2,
        "name": "userB",
        "orderDate": "2025-02-19T10:26:17.307151",
        "orderStatus": "ORDER",
        "address": {
            "city": "진주",
            "street": "2",
            "zipcode": "22222"
        },
        "itemName": "SPRING2 BOOK",
        "orderPrice": 40000,
        "count": 4
    }
]

 

User등 몇몇가지들이 중복을 포함해서 나가는 것을 볼 수 있다.

 

또한 쿼리를 확인해보면 

    select
        o1_0.order_id,
        m1_0.name,
        o1_0.order_date,
        o1_0.status,
        d1_0.city,
        d1_0.street,
        d1_0.zipcode,
        i1_0.name,
        oi1_0.order_price,
        oi1_0.count 
    from
        orders o1_0 
    join
        member m1_0 
            on m1_0.member_id=o1_0.member_id 
    join
        delivery d1_0 
            on d1_0.delivery_id=o1_0.delivery_id 
    join
        order_item oi1_0 
            on o1_0.order_id=oi1_0.order_id 
    join
        item i1_0 
            on i1_0.item_id=oi1_0.item_id

 

다음과 같은 쿼리 한방만 나가는 것을 볼 수 있다.

위 쿼리의 단점은 두가지가 있는데, 우선 첫번째로 결과값을 페이징할 수 없다(Order 기준으로) 또한 V6 API 스펙을 확인해보면

@GetMapping("/api/v6/orders")
public List<OrderFlatDto> ordersV6(){
    return orderQueryRepository.findAllByDto_flat();
}

 

이전에는 OrderQueryDto를 List하여 반환하였지만 v6는 OrderFlatDto를 List하여 기존 API와 스펙이 맞지 않다.

 

이 문제는 내가 직접 중복을 걸러주는 노가다성 작업을 통해 해결할 수 있다.

이 OrderFlatDto를 반환했던 API를 루프를 루프를 돌리는 등 여러가지 방법을 통해 OrderFlatDto 스펙에 맞춰주어 반환하는 것이다.

@GetMapping("/api/v6/orders")
public List<OrderQueryDto> ordersV6(){
    List<OrderFlatDto> orderFlats = orderQueryRepository.findAllByDto_flat();

    Map<Long, List<OrderItemQueryDto>> orderItemMap = new HashMap<>();
    Map<Long, OrderQueryDto> orderMap = new HashMap<>();

    orderFlats.forEach(orderFlat -> {
        Long orderId = orderFlat.getOrderId();
        if(orderMap.get(orderId) == null){
            orderMap.put(orderId,new OrderQueryDto(orderId, orderFlat.getName(), orderFlat.getOrderDate(), orderFlat.getOrderStatus(), orderFlat.getAddress()));
        }

        if(orderItemMap.get(orderId) == null){
            orderItemMap.put(orderId,new ArrayList<OrderItemQueryDto>());
        }
        orderItemMap.get(orderId).add(new OrderItemQueryDto(orderId, orderFlat.getItemName(), orderFlat.getOrderPrice(), orderFlat.getCount()));
    });

    orderItemMap.forEach((orderId, orderItem)->{
        orderMap.get(orderId).setOrderItems(orderItem);
    });

    return new ArrayList<OrderQueryDto>(orderMap.values());
}

 

다시 postman으로 찔러보면 중복이 제거된 것을 볼 수 있다.

[
    {
        "orderId": 1,
        "name": "userA",
        "orderDate": "2025-02-19T10:41:07.812602",
        "orderStatus": "ORDER",
        "address": {
            "city": "서울",
            "street": "1111",
            "zipcode": "11111"
        },
        "orderItems": [
            {
                "itemName": "JPA1 BOOK",
                "orderPrice": 10000,
                "count": 1
            },
            {
                "itemName": "JPA2 BOOK",
                "orderPrice": 20000,
                "count": 2
            }
        ]
    },
    {
        "orderId": 2,
        "name": "userB",
        "orderDate": "2025-02-19T10:41:07.85449",
        "orderStatus": "ORDER",
        "address": {
            "city": "진주",
            "street": "2",
            "zipcode": "22222"
        },
        "orderItems": [
            {
                "itemName": "SPRING1 BOOK",
                "orderPrice": 20000,
                "count": 3
            },
            {
                "itemName": "SPRING2 BOOK",
                "orderPrice": 40000,
                "count": 4
            }
        ]
    }
]

 

정리

- 쿼리를 단 한번만 날려서 조회할 수 있다.

- 쿼리는 한번이지만 조인으로 인해 DB에서 애플리케이션에 전달하는 데이터에 중복 데이터가 추가되므로 상황에따라 V5보다 더 느릴 수 있다.

- 애플리케이션에서 추가 작업이 크다.

- 페이징이 불가능하다.

 

 

 

 

총정리

엔티티 조회 방식 

V1 : 엔티티를 조회해서 그대로 반환

V2 : 엔티티 조회 후 DTO로 변환

V3 : 페치 조인으로 쿼리 수 최적화

V3.1 : 컬렉션 페이징과 한계 돌파

  - 컬렉션은 페치 조인 시 페이징이 불가능

  - ToOne 관계는 페치 조인으로 쿼리 수 최적화

  - 컬렉션은 페치 조인 대신에 지연 로딩을 유지하고, hibernate.default_batch_fetch_size, @BatchSize로 최적화

 

DTO 직접 조회 방식

V4 : JPA에서 DTO를 직접 조회

V5 : 컬렉션 조회 최적화 - 일대다 관계인 컬렉션은 IN절을 사용해서 메모리에 미리 조회하여 최적화

V6 : 플랫 데이터 최적화 - JOIN 결과를 그대로 조회 후 애플리케이션에서 원하는 모양으로 직접 변환한다.

 

 

권장 순서는 다음과 같다.

1. 엔티티 조회 방식으로 우선 접근한다.

  1.1 페치 조인으로 쿼리 수를 최적화 한다.

  1.2 컬렉션을 최적화 한다.

    1.2.1 페이징 필요시 'hibernate.default_batch_fetch_size','@BatchSize'로 최적화한다.

    1.2.2 페이징이 필요 없을 시 페치 조인을 사용한다.

2. 엔티티 조회 방식으로 해결이 안되면 DTO 조회 방식 사용

3. DTO 조회 방식으로 해결이 안되면 NativeSQL or 스프링 JdbcTemplate를 사용하여 처리한다.

 

 

참고 : 엔티티 조회 방식은 페치 조인이나,  'hibernate.default_batch_fetch_size','@BatchSize' 같이 코드를 거의 수정하지 않고, 옵션만 약간 변경해서, 다양한 성능 최적화를 시도할 수 있다. 반면에 DTO를 직접 조회하는 방식은 성능을 최적화하거나 성능 최적화 방식을 변경할 때 많은 코드를 변경해야 한다.

 

참고 : 개발자는 성능 최적화와 코드 복잡도 사이에서 줄타기를 해야한다. 항상 그런 것은 아니지만, 보통 성능 최적화는 단순한 코드를 복잡한 코드로 몰고 간다.

엔티티 조회 방식은 JPA가 많은 부분을 최적화 해주기 때문에, 단순한 코드를 유지하면서, 성능을 최적화 할 수 있다.

반면에 DTO 조회 방식은 SQL을 직접 다루는 것과 유사하기 때문에, 둘 사이에 줄타기를 해야 한다.

 

반응형

'공부 > Spring Boot' 카테고리의 다른 글

Spring JPA과 QueryDSL  (1) 2025.02.20
API 개발 고급 - 실무 필수 최적화  (0) 2025.02.19
API 개발 고급 - 지연 로딩과 조회 성능 최적화  (0) 2025.02.03
API 개발 고급 - 준비  (0) 2025.01.31
API 개발 기본  (0) 2025.01.31