이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다.
컬렉션인 일대다 관계(OneToMany) 를 조회하고, 최적화하는 방법
아래의 순서대로 최적화를 진행하는 것이 좋다.
1. DTO를 반환
2. 컬렉션 최적화
- 페이징 필요시 : hibernate.default_batch_fetch_size , @BatchSize 로 최적화
- 페이징 필요 없을시 : FETCH 조인으로 최적화
3. DTO를 직접 조회(이 부분은 이해가 잘 되지 않아서 글에 적지 않음)
DTO를 반환
@RestController
@RequiredArgsConstructor
public class OrderApiController {
private final OrderRepository orderRepository;
// @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;
// }
@GetMapping("/api/v2/orders")
public Result ordersV2() {
List<Order> orders = orderRepository.findAllByString(new OrderSearch());
List<OrderDto> result = orders.stream()
.map(o -> new OrderDto(o))
.collect(Collectors.toList());
return new Result(result.size(), result);
}
@Data
@AllArgsConstructor
static class Result<T> {
//이 클래스로 한번 감싸서 리턴하면 추가적인 정보도 담을 수 있다.
private int size; //추가 적인 정보
private T data;
}
//Order 엔티티를 직접 반환하지 말고 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(Collectors.toList());
}
}
//Order DTO안에 또 엔티티가 있다면 그 엔티티 마저 DTO로 바꿔야 한다.
@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로 변환해야 한다.
그래야 엔티티를 외부에 노출하지 않을 수 있다.
이 방법은 N+1 문제가 발생한다.
지연 로딩으로 너무 많은 SQL 실행 수
- order 1번
- member, address -> N번(order 조회 수 만큼)
- orderItem -> N번(order 조회 수 만큼)
- item N번 -> (orderItem 조회 수 만큼)
지연 로딩은 영속성 컨텍스트에 있으면 영속성 컨텍스트에 있는 엔티티를 사용하고 없으면 SQL을 실행한다.
따라서 같은 영속성 컨텍스트에서 이미 로딩한 회원 엔티티를 추가로 조회하면 SQL을 실행하지 않는다.
FETCH JOIN 최적화
@RestController
@RequiredArgsConstructor
public class OrderApiController {
private final OrderRepository orderRepository;
/...
@GetMapping("/api/v3/orders")
public List<OrderDto> ordersV3() {
List<Order> orders = orderRepository.findAllWithItem();
List<OrderDto> result = orders.stream()
.map(o -> new OrderDto(o))
.collect(Collectors.toList());
return result;
}
}
@Repository
@RequiredArgsConstructor
public class OrderRepository {
private final EntityManager em;
//...
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 조인으로 SQL이 1번만 실행됨
distinct 를 사용한 이유는 1대다 조인이 있으므로 데이터베이스 row가 증가한다.
그 결과 같은 order 엔티티의 조회 수도 증가하게 된다.
JPA의 distinct는 SQL에 distinct를 추가하고, 더해서 같은 엔티티가 조회되면, 애플리케이션에서 중복을 걸러준다.
이 예에서 order가 컬렉션 페치 조인 때문에 중복 조회 되는 것을 막아준다.
하이버네이트6 부터는 DISTINCT 를 사용하지 않아도 애플리케이션에서 중복 제거가 자동으로 적용
그러나, 모든 경우에 distinct가 필요 없다는 것은 아니다.
특히, 쿼리의 복잡성이나 특정 엔티티 그래프 구조에 따라 여전히 distinct가 필요한 경우가 있을 수 있다.
또한, JPQL에서 distinct 키워드는 SQL의 DISTINCT와는 약간 다른 의미로, JPA 레벨에서 객체 중복을 제거하기 위해 여전히 유용할 수 있다.
하지만 페이징이 불가능하다
가능하긴 하지만 경고 로그를 남기면서 모든 데이터를 DB에서 읽어오고, 메모리에서 페이징 해버리기 때문에 매우 위험하다.
또한 컬렉션 FETCH 조인은 1개만 사용할 수 있다. 컬렉션 둘 이상에 페치 조인을 사용하면 안된다.
→ 엔티티에 컬렉션이 여러개라면 FETCH 조인을 사용하면 안된다는 뜻
Batch 사이즈 사용(페이징 가능)
페이징 + 컬렉션 엔티티를 함께 조회 방법
- ToOne(OneToOne, ManyToOne) 관계를 모두 FETCH 조인한다.
ToOne 관계는 row수를 증가시키지 않으므로 페이징 쿼리에 영향을 주지 않는다. - 컬렉션은 지연 로딩으로 조회한다.
- 지연 로딩 성능 최적화를 위해 hibernate.default_batch_fetch_size , @BatchSize 를 적용한다.
- hibernate.default_batch_fetch_size: 글로벌 설정
- @BatchSize: 개별 최적화
- 이 옵션을 사용하면 컬렉션이나, 프록시 객체를 한꺼번에 설정한 size 만큼 IN 쿼리로 조회한다.
- 하이버네이트 6.2 부터는 where in 대신에 `array_contains 를 사용
@Repository
@RequiredArgsConstructor
public class OrderRepository {
private final EntityManager em;
//...
public List<Order> findAllWithMemberDelivery(int offset, int limit) {
//ToOne 관계만 FETCH 조인
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();
}
}
@RestController
@RequiredArgsConstructor
public class OrderApiController {
private final OrderRepository orderRepository;
//...
/**
* V3.1 엔티티를 조회해서 DTO로 변환 페이징 고려
* - ToOne 관계만 우선 모두 페치 조인으로 최적화
* - 컬렉션 관계는 hibernate.default_batch_fetch_size, @BatchSize로 최적화
*/
@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);
List<OrderDto> result = orders.stream()
.map(o -> new OrderDto(o))
.collect(Collectors.toList());
return result;
}
}
application.properites
spring.jpa.properties.hibernate.default_batch_fetch_size=1000
- 쿼리 호출 수가 1 + N → 1 + 1 로 최적화 된다.
조인보다 DB 데이터 전송량이 최적화 된다.
(Order와 OrderItem을 조인하면 Order가 OrderItem 만큼 중복해서 조회된다. 이 방법은 각각 조회하므로 전송해야할 중복 데이터가 없다.) - FETCH 조인 방식과 비교해서 쿼리 호출 수가 약간 증가하지만, DB 데이터 전송량이 감소한다.
- 이 방법은 페이징이 가능하다.
default_batch_fetch_size 의 크기는 적당한 사이즈를 골라야 하는데, 100~1000 사이를 선택하는 것을 권장한다.
이 전략을 SQL IN 절을 사용하는데, 데이터베이스에 따라 IN 절 파라미터를 1000으로 제한하기도 한다. 1000으로 잡으면 한번에 1000개를 DB에서 애플리케이션에 불러오므로 DB에 순간 부하가 증가할 수 있다.
하지만 애플리케이션은 100이든 1000이든 결국 전체 데이터를 로딩해야 하므로 메모리 사용량이 같다.
1000으로 설정하는 것이 성능상 가장 좋지만, 결국 DB든 애플리케이션이든 순간 부하를 어디까지 견딜 수 있는 지로 결정하면 된다.
'Java Category > JPA' 카테고리의 다른 글
[Spring Data JPA] 쿼리 메서드 기능 (0) | 2024.08.13 |
---|---|
[Spring Data JPA] Spring Data JPA (0) | 2024.08.12 |
[JPA] 지연 로딩과 조회 성능 최적화(ManyToOne, OneToOne) (0) | 2024.08.10 |
[JPA] 병합(Merge)과 변경 감지(Dirty Checking) (0) | 2024.08.05 |
[JPA] JPQL 고급 (0) | 2024.08.03 |