본문 바로가기

JPA

[JPA] 컬렉션 조회 최적화

이번 포스팅에서는 인프런에 있는 JPA활용2편을 공부하면서 Many TO One, One To One관계가 아닌 One To Many와 같은 컬렉션을 조회할때 최적화 하는 방법에 대해서 포스팅 해보려고합니다.

 

V1 - 엔티티 직접 노출

말 그대로 Entity를 리턴하는 방법입니다.

@GetMapping("/api/v1/orders")
public List<Order> orderV1() {
    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;
}

public List<Order> findAllByString(OrderSearch orderSearch) {
    String jpql = "select o From Order o join o.member m"
}

모든 관계는 지연로딩으로 맞춘상태이고, 쿼리를 조회하게 되면 Order객체의 정보들만 가져오게 됩니다.

연관관계가 맺어진 클래스는 프록시클래스로 만들어져서 해당 데이터가 사용될때 초기화가 됩니다.

그래서 orderV1()에서 쿼리 결과를 for문을 돌리면서 초기화시켜주는 모습입니다.

 

Entity를 반환하는것은 API스펙에 영향을 줄 수 있으므로 Entity를 반환하면 안된다.

V2 - 엔티티를 DTO로 변환

Entity가 선언되어 있는 부분을 모두 DTO클래스로 변경하는 방법입니다.

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

    return result;
}

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

@Getter
static class OrderItemDto {
    private  String itemName;
    private int orderPrice;
    private int count;

    public OrderItemDto(OrderItem orderItem) {
        itemName = orderItem.getItem().getName();
        orderPrice = orderItem.getOrderPrice();
        count = orderItem.getCount();
    }
}

Order로 되어있던 부분을 OrderDto클래스로 변환하면서 Entity의 모든 정보를 넘기지 않고, Dto에 필요한 데이터만을 선언하여 API를 만들었습니다. 만들고보니 OrderDto에 OrderItem엔티티가 존재하였는데, 이러한 부분도 빠짐없이 Dto로 변경해야합니다. 위 코드에서는 OrderItemDto클래스를 만들어 변환시켰습니다.

 

하지만, 이렇게 조회를 하게 되면, N+1문제가 발생하여 심각한 성능문제를 맛보게 될 수 있습니다.

OrderDto생성자를 초기화하는 과정에서 문제가 발생하게 되는데 자세하게 살펴보도록 하겠습니다.

  • Order를 조회하는 쿼리 발생
  • Member를 조회하는 쿼리 발생
  • Delivery를 조회하는 쿼리 발생
  • OrderItem을 조회하는 쿼리 발생
  • Item을 조회하는 쿼리발생(2번)

지연로딩은 영속성 컨텍스트에서 해당 데이터가 있는지 확인하고 없으면 DB를 조회하여 값을 채워두기 때문에 이러한 N+1문제로인해 심각한 성능문제를 발생시킬 수 있습니다.

 

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

쿼리가 여러번 발생하는 것을 방지하기 위해 페치조인을 사용하여 조회를 해보도록 하겠습니다.

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

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

이렇게 페치조인을 사용하게 되면 쿼리1방으로 필요한 데이터를 모두 가져올 수 있지만, 3가지 문제점이 발생합니다.

 

1. 중복되는 데이터가 발생하게 됩니다.

DB관점에서 쿼리를보면 Order테이블에 2건, OrderItem테이블에 4건이 있으면, 4줄로 조회되는게 당연합니다.

Order를 조회하여 2건이 조회되는 것을 원한다면, jpql에 distinct를 추가하면 JPA가 중복된 id값을 걸러내서 2건만 조회하는 결과를 얻어낼 수 있습니다. 또한 실제 쿼리에도 distinct가 붙어서 나가게 됩니다.(달라지는 건 없음)

2. 페이징 처리가 불가능 합니다.
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)
            .setFirstResult(1)
            .setMaxResults(10)
            .getResultList();
}

페치조인시 distinct를 사용하여 중복된 데이터를 걸러내더라도 실제 DB에서는 모든 값이 일치하지 않는이상 데이터의 중복이 제거되지 않습니다. 그래서 JPA는 어디서부터 조회를 해야되는지 판단이 안서게 되고, 이러한 문제를 해결하기 위해 모든 데이터를 Memory에 가져와서 페이징하게 됩니다.

데이터가 많을 경우, OutOfMemory에러가 발생할 수 있습니다.

3. 여러개의 컬렉션일경우 페치조인을 사용할 수 없습니다.

컬렉션이 둘 이상일때 페치조인을 사용하게되면, 데이터의 중복제거가 애매해지고, 부정합한 데이터가 발생할 수 있기때문에 이런경우는 다른방법으로 해결해야 합니다.(뒤에 설명..)

 

V3.1 - 엔티티를 DTO로 변환 - 페이징과 한계 돌파

페치조인을 사용하게되면 쿼리가 1번만 실행되는 장점이 있지만, 컬렉션이 포함된 엔티티를 조회하는 경우 1:N관계로 인해 데이터의 row가 N배로 증가하는 단점페이징이 안된다는 단점이 있었습니다.

@GetMapping("/api/v3.1/orders")
public List<OrderDto> orderV3_page(
        @RequestParam(value = "offset", defaultValue = "0") int offset,
        @RequestParam(value = "limit", defaultValue = "100") int limit) {
    List<Order> orders = orderRepository.findAllWithMemberDelivery(offset, limit);
    List<OrderDto> result = orders.stream()
            .map(o -> new OrderDto(o))
            .collect(Collectors.toList());
    return result;
}

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

[application.yml]
jpa:
    hibernate:
      ddl-auto: create
    properties:
      hibernate:
        default_batch_fetch_size: 100

위의 단점들을 해결하기위해 2가지의 변화를 주었습니다.

  1. 컬렉션 엔티티를 페치조인에서 제외시킨다.
  2. 글로벌 전략인 default_batch_fetch_size를 설정한다.(기본적으로 켜두는게 좋음)

먼저, 컬렉션을 페치조인에서 제외시킴으로써 데이터가 N배가 되는 현상을 방지하고, 페이징에 영향을 주지 않았습니다.

그로인해, 페치조인을 사용하지않은 컬렉션들은 자동으로 지연로딩으로 조회가 되어 N+1문제가 발생할 수 있는데, 그 문제점은 default_batch_fetch_size를 설정해 두었기 때문에 최적화가 가능하게 됩니다.

 

default_batch_fetch_size

- 프록시 객체를 설정한 size만큼 IN절을 사용하여 데이터를 미리 가져오는 방식입니다.

- size의 크기는 DB마다 IN절에서 사용되는 갯수에 제한이 있을 수 있으므로 1000을 넘게 사용하지 않는게 좋습니다.

 

'JPA' 카테고리의 다른 글

[JPA] 페치조인(fetch join)  (0) 2022.01.08