JPA Fetch Join과 페이징을 함께 사용할 때 주의점
~ToMany 관계에서 Fetch Join과 페이징을 함께 사용하면 OutOfMemoryError가 발생할 수 있다는 점을 주의해야 한다.
예를 들어, 아래와 같이 Product(1)-ProductCategory(N) 관계가 있을 때, ProductJpaRepository의 findProductWithSlice 처럼 Fetch Join과 페이징을 함께 사용하는 경우에 OOM이 발생할 수 있다.
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@OneToMany(mappedBy = "product", cascade = CascadeType.PERSIST)
private List categories = new ArrayList();
// ... 중략
}
@Entity
@Getter
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
class ProductCategory {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
private Product product;
@ManyToOne(fetch = FetchType.LAZY)
private Category category;
}
interface ProductJpaRepository extends JpaRepository {
@Query("""
select p
from Product p
join fetch p.categories pc
join fetch pc.category c
order by p.id desc
""")
Slice findProductWithSlice(Pageable pageable);
}
왜 OOM이 발생하는가?
실제로 findProductWithSlice를 호출하면 서버에서 다음과 같은 경고 메시지를 보여준다.
firstResult/maxResults specified with collection fetch; applying in memory
실행되는 쿼리를 확인해도 페이징 쿼리가 발생하지 않는다.
select
p.id,
pc.product_id,
pc.id,
c.id,
c.name
from
product p
join
product_category pc
on p.id = pc.product_id
join
category c
on c.id = pc.category_id
order by p.id desc
ProductCategory를 조인하면 Product의 결과도 함께 증가한다. (카티션 프로덕트)
따라서, 페이징을 위해 설정한 값이 의도한 대로 동작하기 어려워 JPA는 전체 결과를 메모리에 적재한 다음에 가공하여 페이징을 수행한다.
이때, 수많은 데이터가 메모리에 적재된다면 OOM이 발생할 가능성이 있다.
해결방안
단순히 Fetch Join을 사용하지 않으면 된다.
하지만, Fetch Join을 사용하지 않으면 ProductCategory 리스트를 조회하기 위해서 N + 1 쿼리가 발생할 수 있다.
Slice result = productJpaRepository.findProductWithSlice(pageRequest);
result.forEach(product -> System.out.println(product.getCategories())); // N + 1
이를 해결하기 위해서는 @BatchSize와 default_batch_fetch_size 옵션을 사용할 수 있는데, 이 기능은 Parent 엔티티(Product)의 Child 엔티티 컬렉션(ProductCategory)을 조회할 때, 영속성 컨텍스트에서 관리하는 Parent 엔티티의 식별자를 IN 절에 추가하여 Child 엔티티를 조회하는 기능이다.
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@BatchSize(size = 10)
@OneToMany(mappedBy = "product", cascade = CascadeType.PERSIST)
private List categories = new ArrayList();
// ... 중략
}
예를 들어, 위와 같이 categories 위에 @BatchSize를 추가하면 다음과 같은 쿼리가 발생한다.
select
pc.product_id,
pc.id,
pc.category_id
from
product_category pc
where
pc.product_id in (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)