본문 바로가기

Spring/API 성능 최적화

컬렉션 조회 API 성능 최적화 (V1 ~ V3. 컬렉션 페치 조인과 데이터 뻥튀기)

https://praaay.tistory.com/6

 

API 조회 성능 최적화

추가, 수정 API 보다도 조회 API가 성능에 가장 민감한 만큼 JPA를 사용할 때 조회 API의 성능을 최적화하는 방법을 단계별로 정리하려 합니다. 주문과 배송정보 그리고 회원을 조회하는 API를 만들

praaay.tistory.com

API 조회 성능 최적화 글에서는 xToOne(OneToOne, ManyToOne) 관계로 물린 엔티티를 조회할 때 어떤 방식으로 성능을 최적화할지 살펴보았습니다. 사실 xToOne 관계라면 fetch join을 사용하면 웬만해서는 성능에 문제가 없었습니다. 하지만 이번에는 xToMany(OneToMany) 관계로 물린 엔티티를 조회하는 API의 성능을 개선해보려 합니다.
컬렉션 조회의 경우 xToOne 관계와 같이 단순히 fetch join을 사용하기에는 몇 가지 어려움이 존재합니다. 

 
위와 같은 엔티티 그래프에서 주문을 조회하고 주문과 일대다 관계를 가진 주문상품 컬렉션을 조회하여 주문이 가진 주문상품들의 name 필드를 조회하는 과정을 예로 들어 조회 API의 성능을 개선하겠습니다. 개선 과정을 V1 ~ V6로 나눠 설명하겠습니다. 'API 조회 성능 최적화' 글의 연장선으로 작성하는 글이기 때문에 겹치는 코드가 있을 수 있습니다.
 

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

@RestController
@RequiredArgsController 
public class OrderApiController {

    private finalOrderRespoitory orderRepository;
    
    /**
     * V1. 엔티티 직접 노출
     * - Hibernate5Module 모듈 등록, LAZY = null 처리
     * - 양방향 관계 문제 발생 -> @JsonIgnore
     **/
    @GetMapping("/api/v1/orders")
    public List<Order> ordersV1() {
        List<Order> all = orderRepository.findAllByString(new OrderSearch());
        for (Order order: all) {
            order.getMember().getName();
            order.getDelievery().getAddress();
            
            // 주문 엔티티의 인스턴스인 주문 아이템들을 조회
            List<OrderItem> orderItems = order.getOrderItems();
            orderItems.stream().forEach(o -> o.getItem().getName()); // Lazy 강제 초기화
        }
        return all;
    }
}

 
'API 조회 성능 최적화' 글에서도 이야기했듯이 엔티티를 API 스펙에 직접 노출하는 방법은 지양해야 하는 방법입니다. 
그렇더라도 API 스펙에 엔티티를 노출하게 되면 오류를 피하기 위해서는 양방향 연관관계면 무한 루프에 걸리지 않게 한 엔티티에 @JsonIgnore를 추가해야 하고 jackson 라이브러리가 프록시 객체를 json 객체로 생성하지 않도록 만들기 위해 Hibernate5Module 설정을 해야 합니다. 해당 내용에 대해서는 'API 조회 성능 최적화' 글에 자세히 설명해 두었습니다.
 

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

@GetMapping("/api/v2/orders")
public List<OrderDto> ordersV2() {
    List<Order> orders = orderRespository.findAllByString(new OrderSearch());
    List<OrderDto> result = orders.stream()
            .map(o -> new OrderDto(o))
            .collect(toList());
    return result;
}

@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.getAddress();
        orderItems = order.getOrderItems();
    }
}

 
V2 방식은 OrderDto를 만들고 엔티티를 DTO로 변환하여 API 스펙으로 노출하는 방식입니다.
하지만 위 코드는 여전히 API 스펙에 일부 엔티티가 그대로 노출되는 문제가 있습니다. 오? OrderDto로 엔티티를 변환하고 OrderDto를 API 스펙에 노출했는데 어떻게 엔티티를 노출하게 된 걸까요?
 
그런 바로 OrderDto 클래스의 List<OrderItem> 타입 인스턴스가 있기 때문입니다. DTO 안에 엔티티가 있기 때문에 엔티티가 API 스펙에 그대로 노출되게 됩니다. 결국 DTO로 엔티티를 한 번 래핑 한 것일 뿐 엔티티가 API에 노출되는 문제는 여전히 존재합니다. 따라서 DTO의 인스턴스를  구성할 때는 엔티티에 대한 의존을 완전히 끊어야 합니다. 그래야 엔티티가 아예 API 스펙에 노출되지 않습니다.
 
OrderDto의 List<OrderItem> 타입도 OrderDto와 마찬가지로 다른 DTO로 변환하여 API에 뿌려야 합니다.
아래 코드를 살펴봅시다.

@GetMapping("/api/v2/orders")
public List<OrderDto> ordersV2() {
    List<Order> orders = orderRespository.findAllByString(new OrderSearch());
    List<OrderDto> result = orders.stream()
            .map(o -> new OrderDto(o))
            .collect(toList());
    return result;
}

@Data
static class OrderDto {

    private Long orderId;
    private String name;
    private LocalDateTime orderDate;
    private OrderStatus orderStatus;
    private Address address;
    // DTO 안에도 엔티티가 없도록
    private List<OrderItemDto> orderItems;
    
    public OrderDto(Order order) {
        orderId = order.getId();
        name = order.getMember().getName();
        orderDate = order.getOrderDate();
        orderStatus = order.getStatus();
        address = order.getDelievery().getAddress();
        orderItems = order.getOrderItems().stream()
                .map(orderItem -> new OrderItemDto(orderItem))
                .collection(toList());
    }
}

@Data
static class OrderItemDto {
    
    private String itemName;
    private int orderPrice;
    private int count;
    
    public OrederItemDto(OrderItem orderItem) {
        itemName = orderItem.getItem().getName();
        orderPrice = orderItem.getOrderPrice();
        count = orderItem.getCount();
    }
}

 
OrderItemDto를 만들어서 OrderDto에서 순수한 엔티티 상태로 존재하던 OrderItem 컬렉션을 OrderItemDto 컬렉션으로 변환하여 API 스펙에 엔티티를 노출하지 않도록 만들었습니다. 
 
이제 엔티티가 API 스펙에 노출되어 생기는 문제는 해결했습니다. 하지만 지연 로딩에 의한 N + 1 문제는 여전히 남아있습니다. Order 엔티티와 Member, Address, OrderItem 엔티티가 지연 로딩으로 설정되고 OrderItem과 Item 엔티티가 지연 로딩으로 설정되어 너무 많은 SQL이 실행되는 N + 1 문제는 성능적으로 악영향을 미칩니다.
 
위에 ordersV2 메서드로 호출되는 총 SQL문 실행 수를 세보면 아래와 같습니다.

  • order 1번 호출
  • member, address N번(order 조회 수만큼)
  • orderItem N번(order 조회 수만큼)
  • item N번(orderItem 조회 수만큼)

물론 지연 로딩은 DB에 쿼리를 날리기 전에 영속성 컨텍스트에 있는 엔티티라면 별도로 SELECT SQL문을 날리지 않지만, 보통은 새로운 데이터를 조회하는 경우가 많기 때문에 영속성 컨텍스트의 캐싱은 의미가 적습니다.
 
이처럼 페치 조인을 사용하지 않고 엔티티를 DTO로 변환하는 방법은 지연 로딩 관계를 맺고 있는 엔티티가 많다면 성능 측면에서 지양해야 합니다. 아래 V3에서 엔티티를 DTO로 변환할 때 페치 조인을 활용해 성능을 최적화하는 과정을 살펴봅시다. 컬렉션을 조회할 때는 페치 조인에 추가되는 부분이 있으니 주의해야 합니다.
 

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

// OrderApiController 코드
@GetMapping("/api/v3/orders")
public List<OrderDto> ordersV3() {
    List<Order> orders = orderRepository.findAllWithTeam();
    List<OrderDto> result = orders.stream()
            .map(o -> new OrderDto(o))
            .collect(toList());
            
    return result;
}

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

 
참고로 JPA의 fetch join 문법은 결과적으로 DB 입장(SQL 입장)에서는 join과 같습니다. 대신 SELECT 절에 원하는 데이터를 더 넣느냐 마느냐의 차이입니다.
 
V3에서는 DTO 구조는 V2에서 구현했던 OrderDto를 사용합니다. V2와 달리 V3에서는 fetch join을 사용해 SQL
1번으로 원하는 데이터를 SELECT 하려 합니다. 하지만 위 코드와 같이 단순히 fetch join을 컬렉션 데이터 조회에 사용하면 안 됩니다. 우리는 Order의 Member, Delivery와 함께 OrderItems를 조회하고 있습니다. 알다시피 OrderItems는 Order 엔티티와 일대다 관계를 갖는 컬렉션입니다. 따라서 위 코드에서 join fetch o,orderItems oi 부분으로 Order와 컬렉션인 OrderItems를 조인하게 됩니다.
 

컬렉션 페치 조인에서의 데이터 뻥튀기

Order와 Member 그리고 Delivery를 조인할 때까지는 전혀 문제가 없습니다. 하지만 Order와 OrderItems를 조인하면 데이터가 뻥튀기됩니다. 이때 데이터 뻥튀기는 아래와 같은 방식입니다.
 
만약 Order의 테이블 로우가 2개이고 OrderItems가 4개라면 두 테이블을 조인했을 때 결과는 4개가 됩니다. 주문이 2개이고 각 주문이 2개의 OrderItems를 가지고 있다면, Order를 기준으로 조회했기 때문에 우리가 원하는 SELECT 데이터는 2개일 것입니다.

 

하지만 데이터베이스 입장에서는 컬렉션과 조인하면 컬렉션 로우 개수(N)로 조인의 결과가 생성됩니다. 이를 뻥튀기된다고 말하는 것입니다. 
 
데이터베이스 입장으로 Order와 OrderItems 조인을 다시 살펴봅시다. 만약 Order가 2개이고 그 Order에 해당되는 OrderItem이 두 개씩일 때 데이터베이스 테이블을 조회해 보면 아래와 같은 나올 것입니다.


이렇게 두 테이블이 있는 상황에서 Order를 조회하며 각 Order가 가진 OrderItem 컬렉션을 조회하는 것이기 때문에 Order 두 개를 얻기를 기대할 것입니다. 하지만 Order와 OrderItems를 조인하는 순간 OrderId로 둘을 조인하며 Order가 4개로 나옵니다. 심지어 아래와 같이 Order_ID가 중복된 상태로 4개의 데이터가 나옵니다. 실제 결과보다 뻥튀기되는 것입니다.

 

데이터가 뻥튀기되면 성능 문제도 있겠지만, 데이터 조회 기능 자체를 수행할 수 없게 됩니다. 위 테이블의 JSON 객체로 변환한다면 아래와 같이 나옵니다.

[
    {
        "oderId": 5,
        "name": "userA",
        "oderStatus": "ORDER",
        "address": {
            "city": "서울",
            "street": "1",
            "zipcode": "1111"
        },
        "orderItems": [
            {
                "itemName": "JPA1 Book",
                "orderPrice": 10000,
                "count": 10
            },
            {
                "itemName": "JPA2 Book",
                "orderPrice": 10000,
                "count": 12
            }
        ]
    },
    {
        "oderId": 5,
        "name": "userA",
        "oderStatus": "ORDER",
        "address": {
            "city": "서울",
            "street": "1",
            "zipcode": "1111"
        },
        "orderItems": [
            {
                "itemName": "JPA1 Book",
                "orderPrice": 10000,
                "count": 10
            },
            {
                "itemName": "JPA2 Book",
                "orderPrice": 10000,
                "count": 12
            }
        ]
    },
    {
        "oderId": 6,
        "name": "userB",
        "oderStatus": "ORDER",
        "address": {
            "city": "부산",
            "street": "2",
            "zipcode": "2222"
        },
        "orderItems": [
            {
                "itemName": "JPA1 Book",
                "orderPrice": 10000,
                "count": 9
            },
            {
                "itemName": "JPA2 Book",
                "orderPrice": 10000,
                "count": 7
            }
        ]
    },
    {
        "oderId": 6,
        "name": "userB",
        "oderStatus": "ORDER",
        "address": {
            "city": "부산",
            "street": "2",
            "zipcode": "2222"
        },
        "orderItems": [
            {
                "itemName": "JPA1 Book",
                "orderPrice": 10000,
                "count": 9
            },
            {
                "itemName": "JPA2 Book",
                "orderPrice": 10000,
                "count": 7
            }
        ]
    }
]

 
위의 JSON 객체를 보면 1, 2번째 객체가 동일하고 3, 4번째 객체가 완전히 동일합니다. 실제로 두 객체의 주소값을 확인하면 서로 같습니다. 동일한 데이터가 뻥튀기된 것입니다.
 

JPA의 Distinct

이런 문제를 해결하기 위해서 JPA에서는 distinct라는 문법을 제공합니다. distinct를 사용하면 일대다 조인이 있을 때 데이터베이스가 증가하는 현상을 막아줍니다. JPA의 distinct는 SQL에 distinct를 추가하고, SQL에 의해 조회된 엔티티를 애플리케이션에서 중복된 데이터를 걸러줍니다. 기존 OrderRepository 클래스의 findAllWithItem 메서드에 distinct 문법을 추가한 코드를 살펴봅시다.

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

 
JPA의 distinct 문법을 사용하면 DB의 쿼리문에 distinct를 넣어 주지만, DB 쿼리문에 있는 distinct는 한 줄의 로우의 모든 칼럼 데이터가 같아야 중복을 제거해 줍니다. 그래서 사실 DB 쿼리로는 distinct를 날리지만 중복된 데이터를 거의 걸러주지 못합니다. 이런 이유로 JPA의 distinct 문법은 DB에서 데이터를 받은 이후 두 번째로 JPA가 자체적으로 애플리케이션에서 중복된 데이터를 걸러줍니다. 위의 예시에서는 Order로 join 했기 때문에 JPA는 DB에서 얻은 결과를 OrderId를 기준으로 중복을 제거해 줍니다. 그럼 아래와 같은 결과를 얻게 될 것입니다.

[
    {
        "oderId": 5,
        "name": "userA",
        "oderStatus": "ORDER",
        "address": {
            "city": "서울",
            "street": "1",
            "zipcode": "1111"
        },
        "orderItems": [
            {
                "itemName": "JPA1 Book",
                "orderPrice": 10000,
                "count": 10
            },
            {
                "itemName": "JPA2 Book",
                "orderPrice": 10000,
                "count": 12
            }
        ]
    },
    {
        "oderId": 6,
        "name": "userB",
        "oderStatus": "ORDER",
        "address": {
            "city": "부산",
            "street": "2",
            "zipcode": "2222"
        },
        "orderItems": [
            {
                "itemName": "JPA1 Book",
                "orderPrice": 10000,
                "count": 9
            },
            {
                "itemName": "JPA2 Book",
                "orderPrice": 10000,
                "count": 7
            }
        ]
    }
]

 
이제 우리가 데이터가 뻥튀기되지 않고 예상했던 결과가 나옵니다. 당연히 페치 조인을 사용했기 때문에 SQL도 1번만 실행됩니다. 하지만 JPA의 distinct 문법도 데이터베이스에서 뻥튀기된 데이터를 얻은 이후 중복된 데이터를 제거하여 결과로 리턴하는 방식이기 때문에 아쉬운 부분이 있습니다.
 

컬렉션 페치 조인을 사용할 때 페이징을 사용할 수 없습니다.

아쉬운 부분은 바로 아래 코드와 같은 페이징이 불가능하다는 것입니다.

em.createQuery(~)
  .setFirstResult(1) // 페이징 전략
  .setMaxResult(100) // 페이징 전략
  .getResultList();

 
JPA의 distinct 문법을 사용해 일대다 조인을 사용했다면, 페이징 기능을 사용할 수 없습니다. 이는 어마어마한 단점이 됩니다. 그렇다면 왜 페이징이 불가능할까요?
 
컬렉션 페치 조인을 사용하며 페이징 기능도 사용했다면, 하이버네이트는 경고 로그를 남기면서 모든 데이터를 DB에서 읽어오고, 메모리에서 페이징을 해버립니다. distinct 문법에 의해 JPA가 중복된 데이터를 제거하기 전에 페이징을 실행하기 때문에 우리가 예상하는 결과와 아예 다른 결과가 나오게 됩니다. 위의 예를 생각해 보면 2개의 결과에서 페이징을 하는 것이 아니라 중복된 데이터가 포함된 4개의 결과에서 페이징을 하는 것입니다. 따라서 절대로 컬렉션 페치 조인을 사용할 때 페이징 기능을 함께 사용해서는 안 됩니다.
 

컬렉션 페치 조인은 한 번만 사용할 수 있습니다.

또한 컬렉션 페치 조인은 1개만 사용할 수 있습니다. 컬렉션 둘 이상에 페치 조인을 사용하면 안 됩니다. 데이터가 부정합 하게 조회될 수 있습니다. 
 
다음 V4부터는 V3보다 더 괜찮은 방법을 찾아보겠습니다. V4부터 다음 글에서 다뤄보겠습니다.