실전! 스프링 부트와 JPA 활용2 - API 개발과 성능 최적화 : 주문 API 조회 최적화

jkky98·2024년 10월 15일
0

Spring

목록 보기
57/77

V1 엔티티 직접 노출

가장 초보적인 방식이다.

	@GetMapping("/api/v1/orders")
    public List<Order> ordersV1() {
        List<Order> all = orderRepository.findAllByCriteria(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;
    }

이 경우 orderItems와의 일대다 다대일 양방향 연관관계에 의해 무한 루프에 걸리게 되는데 이를 해결하기 위해 한쪽의 엔티티 연관관계 필드에 @JsonIgnore를 걸어야 한다. 엔티티에 수정이 들어가게 되어 이 방식은 절대 사용하지 않도록 하자.

V2 DTO 활용

위의 방식에서 DTO(데이터 전송 객체)를 따로 만들어 엔티티 대신 사용한다. 모범적인 접근이다. json 스펙을 위해 Result 객체까지 만들어 DTO를 감싸 반환하도록 한다.

@GetMapping("/api/v2/orders")
public Result ordersV2() {
    List<Order> orders = orderRepository.findAllByCriteria(new OrderSearch());
    List<OrderDto> collect = orders.stream()
            .map(o -> new OrderDto(o))
            .collect(Collectors.toList());

    return new Result(collect);
}

이 경우 앞전의 포스팅에서 단순히 DTO만 만들었던 때와 달리 OrderDto에 연관관계에 해당하는 연관관계 엔티티 리스트가 들어와야 한다.(Order에 대한 OrderItem들이 들어와야 한다.) OrderItem 또한 Dto로 만들어서 Order에 담아야 하므로 다음과 같이 코딩할 수 있다.

	@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 o) {
            orderId = o.getId();
            name = o.getMember().getName();
            orderDate = o.getOrderDate();
            orderStatus = o.getStatus();
            address = o.getDelivery().getAddress();
            orderItems = o.getOrderItems().stream()
                    .map(orderItem -> new OrderItemDto(orderItem))
                    .collect(Collectors.toList());
        }
    }

단순히 o.getOrderItems()를 사용하게 된다면 List에 담기는 것은 엔티티인 OrderItem들이 되기 때문에 이렇게 엔티티에 의존하지 않고 OrderItemDto를 구성하도록 한다.

V2에서는 쿼리 수 측면에서 굉장한 비효율을 가지고 있다. Order는 많은 연관관계가 걸려있는데 Order 하나를 가져올 때 마다 member, address가 N번, orderItem N번, item N번 이렇게 하나의 요청에 대해 대략 3N 이상의 추가 쿼리가 발생한다.

엔티티의 OneToX 필드의 기본 fetch 설정이 LAZY이기 때문이다.

V3 패치 조인

검색 쿼리 자체를 패치 조인을 활용한 jpql로 바꾸어 내어 여러 쿼리 없이 한번에 조회하도록 할 수 있다.

public List<Order> findAllWithItem() {
            return em.createQuery(
                    "select distinct o from Order o" +
                            " join fetch o.member m" + // ToX fetch join
                            " join fetch o.delivery d" + // ToX fetch join
                            " join fetch o.orderItems oi" + 
                            " join fetch oi.item i", Order.class
            ).getResultList();
    }

패치 조인으로 하여금 1+3N만큼의 쿼리 횟수가 1번으로 줄어들었다. distinct를 넣어(Hibernate6에선 안넣어도 된다.) 컬렉션 패치 조인에 해당하는 orderItems에 대해 발생하는 1:N 데이터 뻥튀기 문제를 해결한다.

1:N에서의 1에 맞춰지는 현상(데이터 뻥튀기)

1:N 관계에서 두 테이블을 조인할 경우 1에 맞추어 데이터가 늘어난다. 1을 기준으로 본다면 N에서 동일한 1을 가지는 경우가 생길 것인데 이는 1의 key를 기준으로 영속성 컨텍스트의 입장에서 모두 중복에 해당한다. 만약 N:1에서 N에 맞추어 1을 join하는 방식이라면 여러 N이 동일한 1의 정보를 가질 수는 있지만 N에서 각각은 어쨌든 다르기 때문에 영속성 컨텍스트가 이를 다루는데 문제가 없다.

하지만 데이터 뻥튀기 문제를 해결하는 중복 제거 기능은 DB가 아닌 어플리케이션 메모리에서 이루어지기 때문에 DB에서 행해지는 페이징이 불가능하다. 현재는 1개여서 괜찮지만, 컬렉션 패치 조인을 여러 개 사용할 수도 없는 노릇이다.(1:N:N 문제)

★V3.1 페이징 + 컬렉션 엔티티(1:N - 데이터 뻥튀기)

보통 이 방법이 JPA 조회의 주요 최적화이다. 대부분의 페이징 + 컬렉션 엔티티 조회 문제를 이 방법으로 해결할 수 있다.

현재 Order는 연관관계로 member(N:1), delivery(1:1), orderItems(1:N)가 존재한다. 이때 데이터 뻥튀기 문제는 1:N 존재 때문에 발생한다.

패치 조인을 이용하지 않을 경우에는 글로벌 지연로딩 설정에 의해 너무 많은 쿼리가 발생한다. 이를 극복하기 위해 모든 연관관계에 패치조인을 걸어버리면 컬렉션 엔티티에 대한 중복 문제때문에 페이징이 불가능해진다. 그래서 XtoOne에 대해서는 모두 패치조인을 걸고 OneToMany에 대해서는 지연로딩 그대로 사용하는 것이다.

"엥 그러면 결국 1+3N은 아니더라도 1+N 비효율 문제가 다시 발생하는데?"

컬렉션 엔티티를 지연로딩으로 가져오는 대신 이를 hibernate.default_batch_fetch_size로 하여금 뭉텅이로 가져올 수 있다.

즉 쿼리 횟수 1+N을 1+1로 최적화할 수 있다는 것이다. (O(N) -> O(1))

hibernate.default_batch_fetch_size는 yaml 설정에 주어야 한다. 이는 글로벌 설정이고 보통은 이것을 100~1000으로 주어 사용한다. 하지만 개별적으로 사용하고 싶다면 클래스에 @BatchSize를 주어 독립적으로 적용도 가능하다.

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

    }

orderItems를 패치조인 하지 않고, 페이징 기능을 사용이 가능하다. 그리고 지연로딩에 의해 orderItems가 조회될 때 메모리에 hibernate.default_batch_fetch_size 설정 수만큼 먼저 가져와놓고 사용하는 것이다.

array_contains(where in)

Hibernate 6.2 이전에는 where in을 사용해서, 이후 버전에서는 array_contains를 사용해서 배치기능을 구현한다.

where in의 경우 batch size에 따라 캐시에 저장된 sql 구문이 달라진다.

where item.item_id in(?)
where item.item_id in(?,?)
where item.item_id in(?,?,?,?)

위는 각각 설정 배치 사이즈가 1,2,4일때를 나타낸다. 위 구문들을 모두 보관하고있다가 개발자의 설정에 따라 가져오는 쿼리문이 달라지는 것이다. 어쩌면 무식해보인다. 그래서 hibernate는 이 구현 방식을 array_contains로 바꾸었다.

select ... where array_contains(?,item.item_id)

바인딩 되는 부분의 ?에는 배열 1개만이 들어간다. 그렇기에 동적으로 늘어나는 sql구문에 대한 문제가 사라진 것이다.

V4 JPA에서 DTO 직접 조회

결론적으로 설명하자면 V3 방식에 비해 쿼리 성능적인 면에서 더 가벼워지는 것은 사실이나 이전 포스팅에서 배웠던 것 처럼 trade-off가 존재한다.

public List<OrderQueryDto> findOrderQueryDtos() {
        List<OrderQueryDto> result = findOrders(); // Dto 바로 가져오기

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

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

가져올 스펙에 대한 Dto를 정의하고 이를 jpql에 직접 집어넣어 필요한 필드들만 추려 가져오는 것이다. 하지만 Dto는 엔티티가 아니니 변경감지등 다양한 엔티티 기능을 사용하는 것에는 제약이 있다. 그렇기에 곧바로 화면단에 쓰이는 등의 기능에서만 사용해야 한다.

ToOne 관계들을 우선 조회한다. findOrders를 통해 우선 구성하는 모습을 볼 수 있고 그 후 findOrderItems()를 통해 1:N관계를 조회한다. 조회된 Order가 10개(1번의 쿼리)라면 10번의 쿼리가 더 나가게 된다.

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

sql in 절을 활용하여 컬렉션 조회시 발생하는 1:N 문제에 있어 최적화를 시도할 수 있다. DTO JPA 직접 조회를 선택한다면 보통 V5 방식을 채택하도록 하자.

Order를 전체 조회했더니 100건이 조회되었다. 이에 따라 연관관계인 OrderItem를 조회하기 위해서는 V4에서는 100번의 조회가 더 필요했으나 이를 1번으로 줄여주기 위해 jpql에서 where + =이 아닌 where in을 사용할 수 있다.

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

        List<Long> orderIds = toOrderIds(result);

        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;

Map 컬렉션으로 하여금 OrderId가 key가 되고 List<OrderItemQueryDto>가 value되도록 하여 setOrderItems에 value를 전달하여 OrderItems를 셋팅할 수 있다. 내부의 로직이 좀 더 복잡해지지만 확실한 최적화가 가능하다.

OSIV(Open Session In View)

OSIV는 영속성 컨텍스트 생존 범위에 대한 yaml 설정이다. 기본은 true인데 true일 경우는 다음과 같은 영속성 컨텍스트 라이프 사이클이 형성된다.

트랜잭션 범위 이상으로 우리는 변경감지등의 기능을 사용할 수 있는데, 이러한 이유는 OSIV가 true이기 때문이다. 이 설정으로 하여금 엔티티를 더 자유롭게 활용할 수 있게 된다. 실제로 위의 실습에서 service계층은 사용하지 않았고 그럼에도 엔티티 활용 기능들은 여전히 사용가능했다.

영속성 컨텍스트가 트랜잭션 범위를 넘어서도 살아있다는 것은 JPA가 계속 데이터 커넥션을 물고있다는 뜻이다. 즉 트랜잭션 범위에서만 커넥션이 사용되는 것이 아니라 요청부터 응답의 한 싸이클 동안 계속해서 커넥션을 물고있다는 것이다.

이러한 특성은 커넥션 낭비로 이어질 수 있는데, 필요 이상으로 오래 쓰레드가 커넥션을 계속 들고있다면 어플리케이션의 커넥션이 빠듯하게 유지되어 이용효율적으로 좋지 못할 경우가 존재한다.(트래픽이 클 경우)

그렇기에 보통의 API에서는 이를 끄고 Service계층에서 엔티티 사용을 명확히 끝내도록 서로 약속한다. 그렇게 커넥션을 필요 이상으로 들고있지 않도록 최적화할 수 있다. 하지만 Admin기능같이 내부자만 사용하는 작은 트래픽의 모듈의 경우 굳이 이를 꺼서 성능을 확보하는등의 최적화 과정은 불필요하므로 true로 사용해도 무방하다.

profile
자바집사의 거북이 수련법

0개의 댓글