Programming/JPA(KYH)

JPA - API (지연 로딩과 조회 성능 최적화) 컬렉션 조회 최적화

잇(IT) 2023. 7. 22. 21:14

- 컬렉션 조회는 OneToMany의 경우에 발생한다.

 

 * V1. 엔티티 직접 노출
 * - 엔티티가 변하면 API 스펙이 변한다.
 * - 트랜잭션 안에서 지연 로딩 필요
 * - 양방향 연관관계 문제
 *
 * V2. 엔티티를 조회해서 DTO로 변환(fetch join 사용X)
 * - 트랜잭션 안에서 지연 로딩 필요
 * V3. 엔티티를 조회해서 DTO로 변환(fetch join 사용O)
 * - 페이징 시에는 N 부분을 포기해야함(대신에 batch fetch size? 옵션 주면 N -> 1 쿼리로 변경
가능)
 *
 * V4. JPA에서 DTO로 바로 조회, 컬렉션 N 조회 (1 + N Query)
 * - 페이징 가능
 * V5. JPA에서 DTO로 바로 조회, 컬렉션 1 조회 최적화 버전 (1 + 1 Query)
 * - 페이징 가능
 * V6. JPA에서 DTO로 바로 조회, 플랫 데이터(1Query) (1 Query)
 * - 페이징 불가능...

- V1 :엔티티 직접 노출
@RestController
@RequiredArgsConstructor
public class OrderApiController {

	private final OrderRepository orderRepository;
    
    @GetMapping("/api/v1/orders")
    public List<Order> orderV1() {
        List<Order> all = orderRepository.findAllByString(new OrderSearch());
        for (Order order : all) {
            order.getMember().getName();
            //LAZY 강제 초기화
            order.getDelivery().getAddress();
            //LAZY 강제 초기화

            List<OrderItem> orderItems = order.getOrderItems();
            orderItems.stream().forEach(o -> o.getItem().getName()); //LAZY 강제 초기화
            // 
//            for (OrderItem orderItem : orderItems) {
//                orderItem.getItem().getName();
//            }
        }

- Hibernate5Module 모듈 등록, LAZY=null 처리

- 양방향 관계 문제 발생 -> @JsonIgnore

 

- Order엔티티를 먼저 조회한다. 그 다음 One(Order) To Many(OrderItem) 연관 관계를 통해 Order에 포함된 OrderItem들을 찾는다.

- 연관된 엔티티도 조회하기 위해서 LAZY로 설정되어 있다면 강제 초기화 작업을 진행시킨다.

 

* 위 방법은 엔티티를 직접 노출하므로 권장하는 방법은 아니다.


- 엔티티를 DTO로 변환

- Controller

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

1. 주문을 조회한다. (현재 총 2건의 주문이 저장되어 있기 때문에 List에 2건의 주문이 들어가 있을 것이다.)

2. stream을 이용하여 OrderDto 생성자를 통해 원하는 데이터만 json으로 전달한다.

3. SQL의 경우 [Order] 엔티티 조회 쿼리 1번, [Member, Address] 엔티티 Order의 수만큼 쿼리 N번, [OrderItem] 엔티티 Order의 수만큼 쿼리 N번, [Item] 엔티티 OrderItem에 포함된 Item의 수만큼 쿼리 N번을 호출하게 된다.

 

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

            //OrderItemDto를 생성하기 전
//            order.getOrderItems().stream().forEach(o -> o.getItem().getName());
//            orderItems = order.getOrderItems();
        }
    }

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

- DTO를 사용할 때 DTO 안에도 직접적으로 엔티티를 사용하는 것은 결국 엔티티를 외부에 직접적으로 노출하는 것이기 때문에 OrderDto에 속한 OrderItem 또한 DTO로 변환해서 데이터를 넘기는 것이 좋다.


- 엔티티를 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(toList());
        return result;
    }

 

- OrderRepository에 findAllWithItem()을 추가해준다.

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

- JPQL을 이용하여 엔티티를 fetch 조인을 통해 쿼리 한번에 Order 엔티티와 OneToMany 연관관계를 가지고 있는 OrderItem과 OrderItem의 Item 엔티티까지 전부 한 번에 조회할 수 있다.

- fetch join을 이용하면 쿼리 1번에 모든 엔티티의 조회가 가능하다.

- distinct를 사용하게 되면 (DB에서는 행의 모든 데이터가 일치해야 중복이 제거되지만 JPA에서는 같은 엔티티가 조회되면 애플리케이션에서 중복을 걸러준다.)

- distinct를 통해 join에 의해 데이터가 뻥튀기 되고 중복되는 것을 막아준다.

 

* 단, fetch join을 사용할 경우 페이징이 불가능하다.


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

- 컬렉션을 fetch join하면 페이징이 불가능하다.

   - 컬렉션을 페치 조인하면 OneToMany join이 발생하므로 데이터가 예측할 수 없이 증가한다.

   - 페이징은 OneToMany에서 One을 기준으로 하는 것이 목적이다. 하지만 join을 하게되면 Many를 기준으로 row가 생성된다.

   - OneToMany join을 하게되면 기본적으로 Many가 기준이 되어버린다.


- OneToMany, 컬렉션 fetch join 페이징 해결 방법

1. xToOne 관계를 모두 fetch join한다. (xToOne 관계는 row수를 증가시키지 않으므로 페이징 쿼리에 영향을 주지 않는다.)

2. 컬렉션은 지연 로딩 LAZY로 조회한다.

3. 지연 로딩 성능 최적화를 위해

hibernate.default_batch_fetch_size
@BatchSize

위의 2가지를 적용한다.

1. hibernate.default_batch_fetch_size: 글로벌 설정

2. @BatchSize: 개별 최적화

- 이 옵션을 사용하면 컬렉션이나, 프록시 객체를 한꺼번에 설정한 size 만큼 IN 쿼리로 조회한다.

 

- OrderRepository에 다음 코드를 추가해준다.

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

 

- Controller

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

- V3.1 엔티티를 조회해서 DTO로 변환 페이징 고려

- ToOne 관계만 우선 모두 페치 조인으로 최적화

- 컬렉션 관계는 hibernate.default_batch_fetch_size, @BatchSize로 최적화

   - 위의 옵션을 사용하면 컬렉션이나, 프록시 객체를 한꺼번에 설정한 size만큼 IN 쿼리로 조회한다.

 

- application.yml 파일에 다음을 추가해준다.

spring:
...
 	jpa:
 	 properties:
 		hibernate:
		   default_batch_fetch_size: 1000

- 페이징이 정상적으로 적용되었다.

 

- in 쿼리를 통해 한번에 DB에 있는 튜플들을 전부 조회한다. (batch size만큼 미리 가져온다.)

 

- default_batch_fetch_size : 글로벌 배치 사이즈로 한 번 설정해놓으면 모든 곳에 적용되며, 1000을 권장으로 한다.

- 개별로 설정하려면 각 컬렉션 필드나, 엔티티 클래스에 @BatchSize를 작성하여 사용하면 된다.

 

- 장점

1. 쿼리 호출 수가 1 + 1로 최적화 된다.

2. join보다 DB 데이터 전송량이 최적화된다.


- JPA에서 DTO 직접 조회

 

- Controller

@GetMapping("/api/v4/orders")
    public List<OrderQueryDto> ordersV4() {
        return orderQueryRepository.findOrderQueryDtos();
    }

 

- DTO 조회용 OrderQueryRepository 클래스 생성

@Repository
@RequiredArgsConstructor
public class OrderQueryRepository {

 	private final EntityManager em;
    
 /**
 * 컬렉션은 별도로 조회
 * Query: 루트 1번, 컬렉션 N 번
 * 단건 조회에서 많이 사용하는 방식
 */
 
 	public List<OrderQueryDto> findOrderQueryDtos() {
    
 //루트 조회(toOne 코드를 모두 한번에 조회)
 	List<OrderQueryDto> result = findOrders();
 
 //루프를 돌면서 컬렉션 추가(추가 쿼리 실행)
 	result.forEach(o -> {
 		List<OrderItemQueryDto> orderItems =
		findOrderItems(o.getOrderId());
        
 		o.setOrderItems(orderItems);
 });
 return result;
 }
 
 /**
 * 1:N 관계(컬렉션)를 제외한 나머지를 한번에 조회
 */
 	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:N 관계인 orderItems 조회
 */
 	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();
 			}
}

 

- OrderQueryDto

@Data
@EqualsAndHashCode(of = "orderId")
public class OrderQueryDto {

    private Long orderId;
    private String name;
    private LocalDateTime orderDate;
    private OrderStatus orderStatus;
    private Address address;
    private List<OrderItemQueryDto> orderItems;

    public OrderQueryDto(Long orderId, String name, LocalDateTime orderDate, OrderStatus orderStatus, Address address) {
        this.orderId = orderId;
        this.name = name;
        this.orderDate = orderDate;
        this.orderStatus = orderStatus;
        this.address = address;
    }

 

- OrderItemQueryDto

@Data
public class OrderItemQueryDto {

    private Long orderId;
    private String itemName;
    private int orderPrice;
    private int count;

    public OrderItemQueryDto(Long orderId, String itemName, int orderPrice, int count) {
        this.orderId = orderId;
        this.itemName = itemName;
        this.orderPrice = orderPrice;
        this.count = count;
    }
}

- Query : 루트 1번, 컬렉션 N번 실행

- ToOne(N:1, 1:1) 관계들을 먼저 조회하고, ToMany(1:N) 관계는 각각 별도로 처리한다.

   - ToOne 관계는 조인해도 데이터 row 수가 증가하지 않는다.

   - ToMany(1:N) 관계는 조인하면 row 수가 증가한다.

- row 수가 증가하지 않는 ToOne 관계는 조인으로 최적화 하기 쉬우므로 한번에 조회하고, ToMany 관계는 최적화 하기 어려우므로 findOrderItems() 같은 별도의 메서드로 조회한다.


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

- OrderApiController에 다음 메서드 추가

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

 

- OrderQueryRepository에 다음 코드 추가

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)
 			.setParameter("orderIds", orderIds)
 			.getResultList();
            
 	return orderItems.stream()
 		.collect(Collectors.groupingBy(OrderItemQueryDto::getOrderId));
}

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. Query : 루트 1번, 컬렉션 1번

2. 데이터를 한꺼번에 처리할 때 많이 사용하는 방식

3. ToOne 관계들을 먼저 조회하고, 여기서 얻은 식별자 orderId로 ToMany 관계인 OrderItem을 한꺼번에 조회

4. MAP을 사용해서 매칭 성능 향상(O(1))


- JPA에서 DTO로 직접 조회, 플렛 데이터 최적화

 

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

 

- OrderQueryDto에 생성자 추가

public OrderQueryDto(Long orderId, String name, LocalDateTime orderDate, 
                         OrderStatus orderStatus, Address address, 
                         List<OrderItemQueryDto> orderItems) {
        this.orderId = orderId;
        this.name = name;
        this.orderDate = orderDate;
        this.orderStatus = orderStatus;
        this.address = address;
        this.orderItems = orderItems;
    }

 

- OrderQueryRepository에 추가

public List<OrderFlatDto> findAllByDto_flat() {
        return 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();
    }

 

- OrderFlatDto

@Data
public class OrderFlatDto {

    private Long orderId;
    private String name;
    private LocalDateTime orderDate;
    private OrderStatus orderStatus;
    private Address address;

    private String itemName;
    private int orderPrice;
    private int count;

    public OrderFlatDto(Long orderId, String name, LocalDateTime orderDate, 
                        OrderStatus orderStatus, Address address, 
                        String itemName, int orderPrice, int count) {
        this.orderId = orderId;
        this.name = name;
        this.orderDate = orderDate;
        this.orderStatus = orderStatus;
        this.address = address;
        this.itemName = itemName;
        this.orderPrice = orderPrice;
        this.count = count;
    }
}

1. Query : 1번

2. 단점

2.1. 쿼리는 한번이지만 조인으로 인해 DB에서 애플리케이션에 전달하는 데이터에 중복 데이터가 추가되므로 상황에 따라 V5보다 더 느릴 수 도 있다.

2.2. 애플리케이션에서 추가 작업이 크다.

2.3. 페이징 불가능


- 엔티티 조회

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

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

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

4. 컬렉션 페이징과 한계 돌파: V3.1

   4.1. 컬렉션은 페치 조인시 페이징이 불가능

   4.2. ToOne 관계는 페치 조인으로 쿼리 수 최적화

   4.3. 컬렉션은 페치 조인 대신에 지연 로딩을 유지하고, hibernate.default_batch_fetch_size , @BatchSize 로 최적화

 

- DTO 직접 조회

1. JPA에서 DTO를 직접 조회: V4

2. 컬렉션 조회 최적화 - 일대다 관계인 컬렉션은 IN 절을 활용해서 메모리에 미리 조회해서 최적화: V5

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

 

- 권장 순서

1. 엔티티 조회 방식으로 우선 접근

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

 1.2. 컬렉션 최적화

  1.2.1. 페이징 필요 hibernate.default_batch_fetch_size , @BatchSize 로 최적화

  1.2.2. 페이징 필요X 페치 조인 사용

2. 엔티티 조회 방식으로 해결이 안되면 DTO 조회 방식 사용

3. DTO 조회 방식으로 해결이 안되면 NativeSQL or 스프링 JdbcTemplate

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

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

728x90

'Programming > JPA(KYH)' 카테고리의 다른 글

JPA - API (지연 로딩과 조회 성능 최적화) 기본  (0) 2023.07.21
JPA - API 개발 기본  (0) 2023.07.19
JPA - 벌크 연산  (0) 2023.07.19
JPA - 엔티티 직접 사용  (0) 2023.07.19
JPA - 페치 조인 (2)  (0) 2023.07.19