EntityGraph를 사용하여 fetch join을 할 경우, 쿼리에서 직접적으로 페이징 처리가 어려워집니다.
이유는 다음과 같습니다:
1. 복잡한 조인 구조
예를 들어, A, B, C 세 테이블을 조인하는 경우를 생각해 봅시다.
A와 B는 1:N 관계이고, B와 C도 1:N 관계라면, 예를 들어 N이 3이라 가정할 때
페이지 크기가 10이면, 실제로 10개의 A 엔티티를 가져오더라도 조인 결과로는 최대 10 × 3 × 3 = 90개의 행(row)이 반환될 수 있습니다.
2. RDBMS와 객체지향의 차이
관계형 데이터베이스에서는 위와 같이 다중 조인된 결과에 대해 LIMIT을 적용할 때, 원래의 A 엔티티 기준으로 제한하지 않고 전체 행에 대해 제한을 걸게 됩니다.
그래서 결과적으로 full scan을 하고, 반환된 결과들을 객체로 매핑한 후 Java 코드 내에서 subList() 같은 메서드로 페이징 처리를 하게 됩니다.
해결 방법은 두 가지로 나뉩니다:
1. paging이 필요한 하나의 테이블만 조회 후 나머지 테이블 fetchJoin 또는 entityGraph 조회
A 테이블만 조회하면 SQL 단계에서 LIMIT을 적용해 원하는 페이징 처리가 가능하며, 나머지 B, C 데이터는 별도의 fetch join이나 EntityGraph를 사용하여 조회할 수 있습니다.
2. paging이 필요한 하나의 테이블만 조회 후 Batch Size 옵션을 사용한 LazyLoading
성능상 fetch join이나 EntityGraph를 사용해야 하는 경우가 아니라면, @BatchSize 애너테이션이나 spring.jpa.properties.hibernate.default_batch_fetch_size 설정을 통해 연관 엔티티들을 한 번에 조회하도록 할 수 있습니다.
예시 코드
아래 코드는 2번째 방법인 Order 테이블에서 페이징 처리를 먼저 완료하고, 이후에 OrderItem과 그에 연관된 Product를 Lazy Loading으로 초기화하는 방법입니다:
@Transactional(readOnly = true)
public Page<Order> findOrdersWithOrdersItemsAndProductsByUserId(long userId, Pageable pageable) {
// 1. 페이징된 Order 엔티티들을 조회
Page<Order> findPagedOrders = orderRepository.findAllByBuyerId(userId, pageable);
// 2. 각 Order에 포함된 OrderItem과 Product를 강제로 초기화 (Lazy Loading)
List<Long> orderIds = findPagedOrders.map(Order::getId).toList();
if (!orderIds.isEmpty()) {
// 각 Order의 OrderItems 컬렉션을 초기화
findPagedOrders.getContent().forEach(order ->
order.getOrderItems().forEach(orderItem ->
orderItem.getProduct().getId() // Product의 식별자 접근으로 초기화
)
);
}
return findPagedOrders;
}
이 방식은 Order 엔티티들을 먼저 페이징 처리한 후, 각 Order의 Lazy Loaded 컬렉션에 접근하여 배치로 초기화합니다.
만약 배치 사이즈 설정이 없다면 N+1 문제가 발생할 수 있으므로, 위에서 언급한 @BatchSize나 hibernate.default_batch_fetch_size 옵션을 통해 쿼리 수를 줄일 수 있습니다.
또한 메인 로직이 아님에도 .forEach 같은 코드들이 있으면 가독성이 떨어지는 문제도 있으므로 아래와 같이 forceLoad를 사용하여
가독성을 개선할 수 있습니다.
public static <T> void forceLoad(Collection<T> list, Function<T, ?> loader) {
list.forEach(item -> {
Object lazyProperty = loader.apply(item);
if (lazyProperty instanceof Collection) {
int noUseValue = ((Collection<?>) lazyProperty).size();
} else if (lazyProperty != null) {
String noUseValue = lazyProperty.toString();
}
});
}
// 사용법 LazyLoadingUtils.forceLoad(orders, Order::getOrderItems);
public static <T> void forceLoad(T item, Function<T, ?> loader) {
Object lazyProperty = loader.apply(item);
if (lazyProperty instanceof Collection) {
int noUseValue = ((Collection<?>) lazyProperty).size();
} else if (lazyProperty != null) {
String noUseValue = lazyProperty.toString();
}
}
// 사용법 LazyLoadingUtils.forceLoad(orderItems, OrderItem::getProduct);
'Spring Data' 카테고리의 다른 글
예외 발생 시 롤백 방지하기: Spring에서 독립적인 트랜잭션 설정 방법 (3) | 2024.09.07 |
---|---|
JPA와 Hibernate의 차이점은 무엇인가요? (0) | 2023.09.02 |
ORM이란 무엇인가요? (0) | 2023.09.02 |
JPA가 무엇인가요? (1) | 2023.09.02 |
Kafka Streams로 실시간 데이터 분석, Kafka Connect로 데이터 통합 (1) | 2023.08.08 |