Server/Spring

[Spring] JPA 성능 최적화

Junhong Kim 2023. 5. 6. 23:39
728x90
반응형

들어가기 전에

API 요청과 응답에는 entity를 사용하지 않고, API 스펙에 따라 별도의 DTO를 만드는 것을 권장합니다.

🙅‍♀️ 요청/응답에 entity를 사용하면? 

  • 특정 API 전용의 DTO가 아니기 때문에 어떤 요청이 넘어올지 알기 어렵습니다.
  • entity 정의가 바뀌면 API 스펙이 변경되어 API가 깨집니다.

🙆‍♂️ 요청/응답에 DTO를 사용하면? 

  • entity와 presentation layer를 분리할 수 있습니다.
    • entity는 DB에서 사용되는 데이터 구조를 정의하고 DB와 상호작용하는 클래스입니다.
    • persentation layer는 클라이언트가 요청한 데이터를 받아 그 데이터를 가공하여 응답하는 역할을 합니다.

예제 entity 정의

@Entity
@Table(name = "orders")
@Getter @Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Order {

    @Id
    @GeneratedValue
    @Column(name = "order_id")
    private Long id;

    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "member_id")
    private Member member;

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

    @OneToOne(fetch = LAZY, cascade = CascadeType.ALL)
    @JoinColumn(name = "delivery_id")
    private Delivery delivery;

    private LocalDateTime orderDate; //주문시간

    @Enumerated(EnumType.STRING)
    private OrderStatus status; //주문상태 [ORDER, CANCEL]
}
@Entity
@Getter @Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class OrderItem {

    @Id @GeneratedValue
    @Column(name = "order_item_id")
    private Long id;

    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "item_id")
    private Item item;

    @JsonIgnore
    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "order_id")
    private Order order;

    private int orderPrice; //주문 가격
    private int count; //주문 수량
}
@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "dtype")
@Getter @Setter
public abstract class Item {

    @Id
    @GeneratedValue
    @Column(name = "item_id")
    private Long id;

    private String name;
    private int price;
    private int stockQuantity;

    @ManyToMany(mappedBy = "items")
    private List<Category> categories = new ArrayList<>();
}
@Entity
@Getter @Setter
public class Member {

    @Id @GeneratedValue
    @Column(name = "member_id")
    private Long id;

    private String name;

    @Embedded
    private Address address;

    @JsonIgnore // 양방향 관계 설정시 무한 루프 방지를 위함
    @OneToMany(mappedBy = "member")
    private List<Order> orders = new ArrayList<>();

}
@Entity
@Getter @Setter
public class Delivery {

    @Id @GeneratedValue
    @Column(name = "delivery_id")
    private Long id;

    @JsonIgnore
    @OneToOne(mappedBy = "delivery", fetch = LAZY)
    private Order order;

    @Embedded
    private Address address;

    @Enumerated(EnumType.STRING)
    private DeliveryStatus status; //READY, COMP
}

예제 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(toList());
    }
}
@Data
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();
    }
}

 

OneToOne, ManyToOne 최적화

entity 조회 후 DTO 변환 (페치 조인 최적화)

@GetMapping("/api/v3/simple-orders")
public List<SimpleOrderDto> ordersV3() {
    // entity로 데이터 조회
    List<Order> orders = orderRepository.findAllWithMemberDelivery();
    // entity를 DTO로 변환
    List<SimpleOrderDto> result = orders.stream()
            .map(o -> new SimpleOrderDto(o))
            .collect(toList());
    return result;
}
public List<Order> findAllWithMemberDelivery() {
    // join fetch를 사용해서 연관된 엔티티 한번에 조회한다.
    return em.createQuery(
            "select o from Order o" +
                    " join fetch o.member m" +
                    " join fetch o.delivery d", Order.class)
            .getResultList();
}

/*
    select
        order0_.order_id as order_id1_6_0_,
        member1_.member_id as member_i1_4_1_,
        delivery2_.delivery_id as delivery1_2_2_,
        order0_.delivery_id as delivery4_6_0_,
        order0_.member_id as member_i5_6_0_,
        order0_.order_date as order_da2_6_0_,
        order0_.status as status3_6_0_,
        member1_.city as city2_4_1_,
        member1_.street as street3_4_1_,
        member1_.zipcode as zipcode4_4_1_,
        member1_.name as name5_4_1_,
        delivery2_.city as city2_2_2_,
        delivery2_.street as street3_2_2_,
        delivery2_.zipcode as zipcode4_2_2_,
        delivery2_.status as status5_2_2_ 
    from
        orders order0_ 
    inner join
        member member1_ 
            on order0_.member_id=member1_.member_id 
    inner join
        delivery delivery2_ 
            on order0_.delivery_id=delivery2_.delivery_id
*/

`order`를 조회할 때 OneToOne 관계인 `member``delivery`를 페치 조인으로 한 번에 가져옵니다.

그 결과 연관 엔티티인 `member``delivery`는 프록시 객체가 아닌 엔티티 객체가 채워져서 반환됩니다.

🚨 프로젝션 대상이 entity 일 때만 페치 조인이 가능 합니다. DTO 조회시에는 불가능합니다!
왜? 페치 조인은 엔티티 그래프에서 연관 엔티티를 한 번에 가져오기 위한 키워드이기 때문입니다.

DTO 조회

@GetMapping("/api/v4/simple-orders")
public List<OrderSimpleQueryDto> ordersV4() {
    // DTO로 조회 후 바로 반환
    return orderSimpleQueryRepository.findOrderDtos();
}
@Repository
@RequiredArgsConstructor
public class OrderSimpleQueryRepository {

    private final EntityManager em;

    public List<OrderSimpleQueryDto> findOrderDtos() {
        return em.createQuery(
                "select new jpabook.jpashop.repository.order.simplequery.OrderSimpleQueryDto(o.id, m.name, o.orderDate, o.status, d.address)" +
                        " from Order o" +
                        " join o.member m" +
                        " join o.delivery d", OrderSimpleQueryDto.class)
                .getResultList();
    }
}

/*
    select
        order0_.order_id as col_0_0_,
        member1_.name as col_1_0_,
        order0_.order_date as col_2_0_,
        order0_.status as col_3_0_,
        delivery2_.city as col_4_0_,
        delivery2_.street as col_4_1_,
        delivery2_.zipcode as col_4_2_ 
    from
        orders order0_ 
    inner join
        member member1_ 
            on order0_.member_id=member1_.member_id 
    inner join
        delivery delivery2_ 
            on order0_.delivery_id=delivery2_.delivery_id
*/

장점

  • 일반적인 SQL을 사용할 때 처럼 원하는 select 절에 원하는 데이터를 선택해서 조회할 수 있습니다.
    • JPQL에서 new 명령어를 사용해서 프로젝션 결과로 DTO를 반환합니다.
  • select 절에서 원하는 데이터를 직접 선택하므로 DB에서 애플리케이션의 네트워크 용량이 최적화 됩니다.
    • 최적화 성능이 생각보다 미비합니다. 😅

단점

  • DTO가 API 스펙에 맞춰진 코드가 Repository에 들어가며 Repository 재사용성이 떨어집니다. 
    • entity 반환 레포지토리(OrderRepository)와 DTO 반환 레포지토리(OrderQueryRepository)로 분류하는 것을 권장!

OneToMany 최적화

entity 조회 후 DTO 변환 (페치 조인 최적화 + distinct)

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

    return result;
}
public List<Order> findAllWithItem() {
    return em.createQuery(
            "select distinct o from Order o" + // distinct를 사용한 것에 유의!
                    " join fetch o.member m" + // 1:1
                    " join fetch o.delivery d" + // 1:1
                    " join fetch o.orderItems oi" + // 1:N
                    " join fetch oi.item i", Order.class) // N:1
            .getResultList();
}

/*

select
        distinct order0_.order_id as order_id1_6_0_,
        member1_.member_id as member_i1_4_1_,
        delivery2_.delivery_id as delivery1_2_2_,
        orderitems3_.order_item_id as order_it1_5_3_,
        item4_.item_id as item_id2_3_4_,
        order0_.delivery_id as delivery4_6_0_,
        order0_.member_id as member_i5_6_0_,
        order0_.order_date as order_da2_6_0_,
        order0_.status as status3_6_0_,
        member1_.city as city2_4_1_,
        member1_.street as street3_4_1_,
        member1_.zipcode as zipcode4_4_1_,
        member1_.name as name5_4_1_,
        delivery2_.city as city2_2_2_,
        delivery2_.street as street3_2_2_,
        delivery2_.zipcode as zipcode4_2_2_,
        delivery2_.status as status5_2_2_,
        orderitems3_.count as count2_5_3_,
        orderitems3_.item_id as item_id4_5_3_,
        orderitems3_.order_id as order_id5_5_3_,
        orderitems3_.order_price as order_pr3_5_3_,
        orderitems3_.order_id as order_id5_5_0__,
        orderitems3_.order_item_id as order_it1_5_0__,
        item4_.name as name3_3_4_,
        item4_.price as price4_3_4_,
        item4_.stock_quantity as stock_qu5_3_4_,
        item4_.artist as artist6_3_4_,
        item4_.etc as etc7_3_4_,
        item4_.author as author8_3_4_,
        item4_.isbn as isbn9_3_4_,
        item4_.actor as actor10_3_4_,
        item4_.director as directo11_3_4_,
        item4_.dtype as dtype1_3_4_ 
    from
        orders order0_ 
    inner join
        member member1_ 
            on order0_.member_id=member1_.member_id 
    inner join
        delivery delivery2_ 
            on order0_.delivery_id=delivery2_.delivery_id 
    inner join
        order_item orderitems3_ 
            on order0_.order_id=orderitems3_.order_id 
    inner join
        item item4_ 
            on orderitems3_.item_id=item4_.item_id
*/

장점

  • 페치 조인으로 SQL이 1번만 실행됩니다.
    • 1:N 조인이 있으면 DB row가 N에 맞춰서 데이터가 뻥튀기 돼며 그 결과 중복 조회되는 `order` 엔티티 수가 증가합니다.
    • 중복 조회되는 `order` 엔티티에 1:N 관계에 대한 엔티티 데이터를 모두 채워서 반환됩니다. 그러나 중복 조회된 결과가 원하는 값이 아니기 때문에 `distinct` 키워드를 사용해서 `order`가 컬렉션 페치 조인으로 인해 중복 조회되는 것을 막아야 합니다. 따라서, 중복된 데이터가 너무 많이 조회되므로 한 방쿼리도 조회했더라도 오히려 성능이 안좋을 수 있습니다. 🥲
[JPA dstinct의 두 가지 기능]
1. SQL에 distinct 키워드를 추가한다.
2. JPA에서는 같은 엔티티가 조회되면 애플리케이션에서 중복을 걸러준다. (🚨)
-> DB에서는 row가 모두 같아야 중복이 제거되므로, 실제 날라가는 쿼리를 실행해보면 중복 제거 되지 않는다.

distinct 쿼리를 직접 실행했을 때 결과

단점

  • 1:N(컬렉션)을 페치 조인하면 페이징이 불가능 합니다.
    • 왜? distinct로 중복이 제거되어도 최초 쿼리에서 중복된 결과를 가져왔기 때문에 페이징이 불가능합니다.
      (하이버네이트는 경고 로그를 남기면서 모든 데이터를 DB에서 읽어오고 메모리에서 페이징합니다. 👻)

firstResult/maxResults specified with collection fetch; applying in memory

entity 조회 후 DTO 변환 (페치 조인 최적화 + paging)

@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);
    // entity를 순회하면서 DTO로 변환하면서 쿼리가 실행된다. in 절이 수행되는 것을 주의깊게 보자!
    List<OrderDto> result = orders.stream()
            .map(o -> new OrderDto(o))
            .collect(toList());

    return result;
}

/*
    select
        orderitems0_.order_id as order_id5_5_1_,
        orderitems0_.order_item_id as order_it1_5_1_,
        orderitems0_.order_item_id as order_it1_5_0_,
        orderitems0_.count as count2_5_0_,
        orderitems0_.item_id as item_id4_5_0_,
        orderitems0_.order_id as order_id5_5_0_,
        orderitems0_.order_price as order_pr3_5_0_ 
    from
        order_item orderitems0_ 
    where
        orderitems0_.order_id in (
            ?, ?
        )
*/

/*
    select
        item0_.item_id as item_id2_3_0_,
        item0_.name as name3_3_0_,
        item0_.price as price4_3_0_,
        item0_.stock_quantity as stock_qu5_3_0_,
        item0_.artist as artist6_3_0_,
        item0_.etc as etc7_3_0_,
        item0_.author as author8_3_0_,
        item0_.isbn as isbn9_3_0_,
        item0_.actor as actor10_3_0_,
        item0_.director as directo11_3_0_,
        item0_.dtype as dtype1_3_0_ 
    from
        item item0_ 
    where
        item0_.item_id in (
            ?, ?, ?, ?
        )
*/
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();
}

/*
    select
        order0_.order_id as order_id1_6_0_,
        member1_.member_id as member_i1_4_1_,
        delivery2_.delivery_id as delivery1_2_2_,
        order0_.delivery_id as delivery4_6_0_,
        order0_.member_id as member_i5_6_0_,
        order0_.order_date as order_da2_6_0_,
        order0_.status as status3_6_0_,
        member1_.city as city2_4_1_,
        member1_.street as street3_4_1_,
        member1_.zipcode as zipcode4_4_1_,
        member1_.name as name5_4_1_,
        delivery2_.city as city2_2_2_,
        delivery2_.street as street3_2_2_,
        delivery2_.zipcode as zipcode4_2_2_,
        delivery2_.status as status5_2_2_ 
    from
        orders order0_ 
    inner join
        member member1_ 
            on order0_.member_id=member1_.member_id 
    inner join
        delivery delivery2_ 
            on order0_.delivery_id=delivery2_.delivery_id limit ?
*/
  • OneToOne, ManyToOne 관계는 모두 페치 조인합니다.
    • 예제에서는 `member``delivery`를 페치 조인합니다.
  • 컬렉션은 지연 로딩으로 조회합니다.
    • 예제에서는 `orderItem`의 fetchType이 `LAZY` 입니다.
  • 지연 로딩 성능 최적화를 위해 `hibernate_default_batch_fetch_size(전역 설정)` 또는 `@BatchSize(개별 설정)`를 적용합니다.
    • 위 옵션을 적용하지 않으면, entity 리스트를 순회하며 DTO를 변환할 때 1:N 관계의 엔티티 조회시 N+1 문제가 발생합니다.
    • 위 옵션을 적용하면, 컬렉션이나 프록시 객체를 한꺼번에 설정한 size 만큼 `in` 절로 조회합니다.
      (N+1 문제를 어느정도 해결할 수 있지만 한 방 쿼리로는 만들 수는 없습니다. 🤨)

장점

  • 쿼리 호출 수가 `1 + N + N` 에서 `1 + 1 + 1`로 최적화됩니다.
    • Order 1번, OrderItem N번, Item N번 -> Order 1번, OrderItem 1번, Item 1번
  • 컬렉션 fetch join 한 것보다 DB 데이터 전송량이 최적화됩니다.
    • 이전에는 Order와 OrderItem을 1:N 조인을하면 Order가 OrderItem 만큼 중복해서 조회됐지만, 이번 방법은 각각 조회하므로 데이터가 중복해서 조회되지 않습니다.
  • 이전과 비교해서 쿼리 호출 수가 조금 증가하지만 DB 데이터 전송량이 감소합니다.
    • 왜? 중복된 데이터는 가져오지 않기 때문입니다.
  • 컬렉션 fetch join은 페이징이 불가능하지만 이번 방법은 paging이 가능합니다.
    • 쿼리 결과를 페이징해야 한다면 이번 방법을 사용해야합니다! 👊

요약

OneToOne, OneToMany 관계는 페치 조인해도 페이징에 영향을 주지 않습니다. 따라서, ToOne 관계는 먼저 fetch join으로 쿼리 수를 줄입니다. 이후 OneToMany 관계의 컬렉션 조인은 `hibernate_default_batch_fetch_size(전역 설정)` 또는 `@BatchSize(개별 설정)`로 최적화합니다.

DTO로 직접 조회

@GetMapping("/api/v5/orders")
public List<OrderQueryDto> ordersV5() {
    return orderQueryRepository.findAllByDto_optimization();
}
public List<OrderQueryDto> findAllByDto_optimization() {

    //루트 조회(toOne 코드를 모두 한번에 조회)
    List<OrderQueryDto> result = findOrders();

    //orderItem 컬렉션을 MAP 한방에 조회
    Map<Long, List<OrderItemQueryDto>> orderItemMap = findOrderItemMap(toOrderIds(result));

    //루프를 돌면서 컬렉션을 일일히 추가(추가 쿼리 실행X)
    result.forEach(o -> o.setOrderItems(orderItemMap.get(o.getOrderId())));

    return result;
}


private List<Long> toOrderIds(List<OrderQueryDto> result) {
    return result.stream()
            .map(o -> o.getOrderId())
            .collect(Collectors.toList());
}

private Map<Long, List<OrderItemQueryDto>> findOrderItemMap(List<Long> orderIds) {
    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) // in 절 사용
            .setParameter("orderIds", orderIds)
            .getResultList();

    return orderItems.stream()
            .collect(Collectors.groupingBy(OrderItemQueryDto::getOrderId));
}
  • 이전과 동일하게 OneToOne, ManyToOne 관계들은 먼저 `Order`와 함께 join하여 조회하고, 조회된 Order의 orderId를 추출하여 OneToMany 관계인 `OrderItem`의 조회시 in 절에 사용하여 OrderItem 을 쿼리 한 번에 조회합니다. 이때, orderItem의 ToOne 관계인 `Item`을 함께 조회합니다.
  • 그리고 여기서 눈 여겨 보아야할 것 은 orderItemMap을 만들어서 애플리케이션에서 Order의 OrderItem을 셋팅해 주었다는 것 입니다. 이 방법을 사용해서 Order의 OrderItem을 매핑하는 성능을 향상 시킬 수 있습니다.

컬렉션 조회 최적화 정리

entity 조회

  • OneToOne, ManyToOne 관계는 fetch join으로 쿼리 수를 최적화합니다.
    • ToOne 관계는 fetch join 하더라도 대상이 1이기 때문에 데이터가 뻥튀기 돼지 않습니다.
  • 컬렉션 fetch join 시 페이징이 불가능하므로 다음 과정을 진행하여 최적화합니다.
    • 컬렉션은 fetch join 대신에 Lazy Loading 설정을 유지합니다.
    • `hibernate.default_batch_fetch_size` 또는 `@BatchSize` 설정으로 지연 로딩 최적화합니다.

DTO 직접 조회

  • entity 조회와 동일하게 OneToOne, ManyToOne 관계는 fetch join으로 쿼리 수를 최적화합니다.
  • OneToMany 관계인 컬렉션은 `in` 절을 사용해서 메모리에 미리 조회하고 이후 값을 셋팅 해주는 방법으로 최적화합니다.
    • 즉, 1:N 관계를 메모리에 다 로딩하고 1에 해당하는 컬렉션을 애플리케이션에서 매핑합니다.

최적화 권장 순서

  • entity 조회 방식 사용
    • 모든 연관관계는 지연 로딩으로 변경합니다.
      • 왜? 즉시 로딩은 예상치 못한 쿼리가 실행될 수 있습니다.
    • ToOne 관계
      • fetch join 으로 쿼리 수를 최적화합니다.
      • ToOne 관계는 fetch join 하더라도 대상이 1이기 때문에 데이터가 뻥튀기 돼지 않습니다.
    • ToMany 관계
      • 페이징 불필요시: `fetch join` + `distinct`
      • 페이징 필요시: `hibernate.default_batch_fetch_size` 또는 `@BatchSize` 설정으로 지연 로딩 최적화
  • entity 조회 방식으로 해결이 안되면?
    • DTO 조회 방식 사용해서 네트워크 전송량을 줄입니다.
  • DTO 조회 방식으로도 해결이 안되면 NativeSQL 또는 스프링 JdbcTemplate을 사용해서 최적화합니다.
    • 최후의 수단

참고

실전! 스프링 부트와 JPA 활용2 - API 개발과 성능 최적화 (김영한님)

728x90
반응형