[JPA] API 개발하기 - 2

yrok·2023년 10월 11일

이전 글 1 편에서는 x to One (ManyToOne, OneToOne) 관계에서 API를 개발하는 방법을 알아봤다. 이번에는 x to Many (ManyToMany, OneToMany) 관계에서 API를 어떻게 개발하는지 알아보자.

기본적으로 엔티티를 DTO로 변환해서 사용한다.

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

@Data
@AllArgsConstructor
static class OrderDto {
	
    private Long orderId;
    private String name;
    private LocalDate 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());
    }
}

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

OrderDto 생성자 내부에서 orderItems를 선언할 때도 OrderItemDto를 사용한다. 단순히 덮어씌우는 것이 아니라 엔티티는 모두 DTO로 변환해서 사용해야 엔티티에 의존하지 않고 사용할 수 있다.

잘못된 방법

@Data
@AllArgsConstructor
static class OrderDto {
	
    private List<OrderItem> orderItems;
    
    public OrderDto(Order order) {
        orderItems = order.getOrderItems()
    }
}

OrderItem 엔티티에 의존하기 때문에 잘못된 방법이다. 엔티티를 DTO로 변환해서 사용하는 것이 올바른 방법이다.

단순히 DTO로 변환해서 사용하면 N+1 문제가 발생한다.

📌 fetch join으로 N+1 문제 해결

@GetMapping("api/v3/orders")
public List<OrderDto> ordersV3() {
	List<Order> orders = orderRepository.findAllWithItem(new OrderSearch());
    List<OrderDto> result = orders.stream()
    		.map(o -> new OrderDto(o))
            .collect(Collectors.toList());
    
    return result;
}
// OrderRepository
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();
}

fetch join을 사용해 쿼리 1번으로 데이터를 조회했다. 하지만, 실제로 데이터를 조회해보면 원하던 결과가 아니다. order가 2개라서 2개의 데이터가 조회될 줄 알았는데 DB에서는 4개의 데이터가 조회됐다.

  • orderItems에 있는 4개의 orderItem 데이터를 조회한다. 이 때, order_id가 중복된 데이터가 있다. jpql에 distinct를 사용하면 sql에 distinct를 사용한 효과와 애플리케이션에서 중복을 걸러준다.
  • Postman을 사용해서 API를 호출하면 2개의 데이터가 중복 없이 출력된다.

페이징 불가능

컬렉션 fetch join을 사용하면 order 기준이 아닌 orderItems 기준으로 row가 변경되기 때문에 조회하는 데이터 수가 변경된다. ( DB에서 조회한 데이터랑 Postman으로 조회한 데이터가 수가 다른것처럼 )
따라서, 컬렉션 fetch join을 사용해 페이징 한다면 Hibernate는 경고 로그를 남기면서 모든 데이터를 DB에서 읽어오고, 메모리에서 페이징한다. -> 사용하면 안된다.

❗ 컬렉션 fetch join은 1개만 사용할 수 있다.
2개 이상 사용한다면 (1:N):N 관계로 데이터가 부정합하게 조회될 수 있다.

페이징 + 컬렉션 엔티티를 함께 조회하려면 어떻게 해야할까 ?

  • ToOne (OneToOne, ManyToOne) 관계를 모두 fetch join 한다. ToOne 관계는 row를 증가시키지 않으므로 페이징 쿼리에 영향을 주지 않는다.
  • 컬렉션은 지연 로딩으로 조회한다.
  • 지연 로딩 성능 최적화를 위해 application.yml에 설정 추가 또는 @BatchSize 적용
    • yml 설정 추가 -> 글로벌 설정
    • @BatchSize -> 개별 설정
spring:
 jpa:
 properties:
 hibernate:
 default_batch_fetch_size: 1000

BatchSize 설정은 JPA 성능 개선을 위한 옵션 중 하나이다.
여러 개의 프록시 객체를 조회할 때 WHERE 절이 같은 여러 개의 SELECT 쿼리들을 하나의 IN 쿼리로 만들어준다.

  • 장점
    • 쿼리 호출 수가 1+N -> 1+1로 최적화 된다.
    • 조인보다 DB 데이터 전송량이 최적화 된다. (Order, OrderItem을 조인하면 OrderItem 만큼 중복해서 조회된다.)
    • 모두 fetch join을 적용한 방식보다 쿼리 호출 수가 약간 증가하지만 DB 데이터 전송량이 감소한다.
    • 컬렉션 fetch join은 페이징이 불가능하지만 이 방법은 중복 호출이 없기 때문에 페이징이 가능하다.
  • 결론
    • ToOne 관계는 fetch join 적용시 row수에 영향을 주지 않는다. 따라서, ToOne 관계는 fetch join으로 쿼리 수를 줄이고, 나머지는 BatchSize 설정을 통해 해결한다.

📌 참고
default_batch_fetch_size의 크기는 적당한 사이즈를 골라야한다. 보통 100 ~ 1000 사이를 선택하는 것을 권장한다. DB에 따라 IN 절 파라미터를 1000으로 제한하기도 하기때문이다. 또한, 1000개의 데이터를 한번에 가져온다면 순간 부하가 증가할 수 있기에 순간 부하와 성능을 고려해서 설정해야한다.

profile
공부 일기장

0개의 댓글