상세 컨텐츠

본문 제목

[SPRING] JPA 활용2 | API 개발과 성능 최적화(2)

JAVA/기본 & 강의복습

by ranlan 2021. 4. 21. 01:27

본문

728x90

V1. 엔티티 직접 노출

V2. 엔티티를 DTO로 반환

 

 

V3. 페치조인 최적화

public List<Order> findAllV3() {
        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번 실행

# 2021-04-21 02:03:14.705 DEBUG 24650 --- [nio-8080-exec-9] org.hibernate.SQL                        : 
    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

일대다 연관관계가 있어 동일한 row가 출력됨(예를 들어 order은 2건이지만 각각 주문마다 orderItem이 2개일 때 4개의 row가 출력됨) → distinct 로 해결

원래 데이터베이스에서는 row의 모든 속성이 같아야 distinct가 적용되지만 jpa가 자체적으로 아이디값을 비교하여 중복되는 값을 버림으로써 객체단에서 중복 해결

 

문제 1) 페이징 불가 → 메모리단에서 페이징이 이뤄져 경고 발생 (WARN) firstResult/maxResult specified with collection fetch, applied in memory

문제 2) 컬렉션 페치 조인은 1개만 사용 가능 - 일대다 대 다 조인으로 데이터가 부정합하게 조회될 수 있음

 

 

V3.1. 페이징과 한계 돌파

OrderRepository

public List<Order> findAllV3_1(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();
}

컬렉션 페치 조인시 페이징 처리 방법

- XToOne(OneToOne, ManyToOne) 관계를 모두 페치 조인 (XToOne관계는 row수에 영향을 주지 않음)

- 컬렉션은 지연로딩으로 초기화

- 지연로딩 성능 최적화를 위해 BatchSize 적용(*default_batch_fetch_size의 크기는 100~1000사이가 적당)

 

application.yml

jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 100

배치 사이즈 전역 설정

@BatchSize

 

배치 사이즈를 설정하면 컬렉션이나, 프록시 객체를 한꺼번에 설정한 size 만큼 IN 쿼리로 조회

# 2021-04-21 01:36:23.872 DEBUG 24650 --- [nio-8080-exec-2] org.hibernate.SQL                        : 
    select
        orderitem0_.order_id as col_0_0_,
        item1_.name as col_1_0_,
        orderitem0_.order_price as col_2_0_,
        orderitem0_.count as col_3_0_ 
    from
        order_item orderitem0_ 
    inner join
        item item1_ 
            on orderitem0_.item_id=item1_.item_id 
    where
        orderitem0_.order_id in (
            ? , ? , ? , ?
        )

쿼리 호출 수가 1 + N 에서 1 + 1로 최적화되어 조인 쿼리보다 데이터 전송량 최적화되었다. 페이징 또한 가능하다.

 

 

V4. JPA에서 DTO로 직접 조회

OrderQueryRepository

public List<OrderQueryDto> findOrderQueryDtos() {
        
    // 1
    List<OrderQueryDto> result = findOrders();

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

- XToOne 관계는한번에 조회 (row수에 영향을 주지 않음)

- XToMany 관계부터 별도로 조회 (row수에 영향을 줌)

 

 

V5. 컬렉션 조회 최적화

 v5.1. 

OrderQueryRepository

public List<OrderQueryDto> findAllByDto_optimization_1() {

    // 1
    List<OrderQueryDto> result = findOrders();

    // 2
    List<Long> orderIds = result.stream()
                .map(o -> o.getOrderId())
                .collect(Collectors.toList());

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

    // 4
    Map<Long, List<OrderItemQueryDto>> orderItemMap = orderItems.stream()
                .collect(Collectors.groupingBy(orderItemsQueryDto -> orderItemsQueryDto.getOrderId()));

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

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

1) 루트(XToOne) 조회 1번

# 2021-04-21 01:36:23.858 DEBUG 24650 --- [nio-8080-exec-2] org.hibernate.SQL                        : 
    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

2) OrderItem을 조회하기 위해 Order의 Id만 리스트로 받음

3) XToMany를 위한 별도 조회 - OrderItem 조회

# 2021-04-21 01:36:23.872 DEBUG 24650 --- [nio-8080-exec-2] org.hibernate.SQL                        : 
    select
        orderitem0_.order_id as col_0_0_,
        item1_.name as col_1_0_,
        orderitem0_.order_price as col_2_0_,
        orderitem0_.count as col_3_0_ 
    from
        order_item orderitem0_ 
    inner join
        item item1_ 
            on orderitem0_.item_id=item1_.item_id 
    where
        orderitem0_.order_id in (
            ? , ? , ? , ?
        )

4) Order와 OrderItem 컬렉션을 Map으로 변환

5) 루프를 돌며 컬렉션 추가 (추가 쿼리 실행하지 않음)

 

 v5.2. 

OrderQueryRepository

public List<OrderQueryDto> findAllByDto_optimization() {

    // 1
    List<OrderQueryDto> result = findOrders();

    Map<Long, List<OrderItemQueryDto>> orderItemMap = findOrderItemMap(toOrderIds(result));

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

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

// 3
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)
            .setParameter("orderIds", orderIds)
            .getResultList();

    return orderItems.stream()
            .collect(Collectors.groupingBy(orderItemsQueryDto -> orderItemsQueryDto.getOrderId()));
}

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

위와 동일한 원리이나 로직 모두 메서드화

1) 루트 조회 1번 (XToOne)

2) Order의 아이디조회하여 리스트 반환

3) 아이디 리스트로 OrderItem(OneToMany) 조회하여 Map으로 반환

4) 루프를 돌며 컬렉션에 추가

 

쿼리는 총 두번 : 루트 1번, 컬렉션 1번 + Map을 사용해서 매칭 성능 향상(O(1))

 

 

V6. 플랫 데이터 최적화

OrderQueryRepository

public List<OrderFlatDto> findAllByDto_flat() {

    List<OrderFlatDto> flats =  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();

    return flats;
}

OrderApiController

@GetMapping("/api/v6/orders")
public List<OrderQueryDto> ordersV6() {
    List<OrderFlatDto> flats = orderQueryRepository.findAllByDto_flat();
    
    return flats.stream()
                .collect(groupingBy(o -> new OrderQueryDto(o.getOrderId(), o.getName(), o.getOrderDate(), o.getOrderStatus(), o.getAddress()),
                        mapping(o -> new OrderItemQueryDto(o.getOrderId(), o.getItemName(), o.getOrderPrice(), o.getCount()), toList())
                )).entrySet().stream()
               .map(e -> new OrderQueryDto(e.getKey().getOrderId(),e.getKey().getName(), e.getKey().getOrderDate(), e.getKey().getOrderStatus(), e.getKey().getAddress(), e.getValue()))
               .collect(toList());
}

컨트롤러에서 flatDTO를 다시 QueryDTO로 변환

 

쿼리 한번에 실행

# 2021-04-21 01:55:23.585 DEBUG 24650 --- [nio-8080-exec-5] org.hibernate.SQL                        : 
    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_,
        item4_.name as col_5_0_,
        orderitems3_.order_price as col_6_0_,
        orderitems3_.count as col_7_0_ 
    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

데이터 중복 제거로 인해 v5보다 속도가 느릴 수 있다. 애플리케이션에서의 추가 작업이 증가하고 페이징이 불가하다.

그 이후 성능 최적화는  캐시 사용를 사용한다.(* 엔티티에서는 절대 캐시하면 안되고 DTO에서 캐시 사용해야 함)

 

 

API 개발 고급 정리

- 엔티티 조회 -

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

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

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

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

- DTO 직접 조회 -

V4. JPA에서 DTO를 직접 조회
V5. 컬렉션 조회 최적화 - 일대다 관계인 컬렉션은 IN 절을 활용해서 메모리에 미리 조회해서 최적화

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

 

※ API 성능 최적화 권장 순서

1) 엔티티 조회 방식으로 접근

1-1) 페치 조인으로 쿼리 수 최적화

1-2) 컬렉션 최적화 (페이징 필요 / 불필요)

2) DTO 조회 방식 사용

3) NativeSQL이나 스프링 JdbcTemplate

 

 

 

728x90

관련글 더보기

댓글 영역