회사에서 JPA를 사용하다 fetch join을 사용하는데에 있어 페이징을 해야하는 상황이 발생했었는데요.
예시코드로 먼저 보여드리도록 하겠습니다!
(예시 코드는 이해를 돕기 위해 구성한 코드로 회사 코드와 전혀 관련이 없는 점 참고 부탁드립니다.
@Entity
@Getter
@Table(name = "products")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(nullable = false)
private long price;
@Getter(AccessLevel.NONE)
@Embedded
private ProductImages productImages;
private Product(String name, long price, List<ProductImage> productImages) {
this.name = name;
this.price = price;
setProductImages(productImages);
}
public static Product of(String name, long price, List<ProductImage> productImages){
return new Product(name,price, productImages);
}
private void setProductImages(List<ProductImage> productImages){
productImages.forEach(productImage -> productImage.setProduct(this));
this.productImages = ProductImages.listOf(productImages);
}
public List<ProductImage> getProductImages(){
return productImages.getProductImages();
}
}
@Getter
@Embeddable
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ProductImages {
@OneToMany(mappedBy = "product", cascade = CascadeType.ALL)
private List<ProductImage> productImages;
private ProductImages(List<ProductImage> productImages){
verifyNotEmpty(productImages);
verifyValidOrdering(productImages);
this.productImages = productImages;
}
private void verifyNotEmpty(List<ProductImage> productImages){
if(productImages.isEmpty()){
throw new IllegalArgumentException();
}
}
private void verifyValidOrdering(List<ProductImage> productImages) {
boolean[] orderingCheckArr = new boolean[productImages.size()];
int totalCount = productImages.size();
for (ProductImage productImage : productImages) {
int ordering = productImage.getOrdering();
verifyValidOrdering(ordering, totalCount);
orderingCheckArr[ordering] = true;
}
verifyValidOrderingCheckArr(orderingCheckArr);
}
private void verifyValidOrdering(int ordering, int productImagesCount){
if (isInValidOrdering(ordering, productImagesCount)) {
throw new IllegalArgumentException();
}
}
private boolean isInValidOrdering(int ordering, int productImagesCount){
return ordering < 0 || ordering >= productImagesCount;
}
private void verifyValidOrderingCheckArr(boolean[] orderingCheckArr){
for (boolean validOrdering : orderingCheckArr) {
if(!validOrdering){
throw new IllegalArgumentException();
}
}
}
public static ProductImages listOf(List<ProductImage> productImages){
return new ProductImages(productImages);
}
public int totalCount() {
return productImages.size();
}
}
@Entity
@Table(name = "product_images")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ProductImage {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private int ordering;
@Column(nullable = false)
private String imagePath;
@ManyToOne
@Getter(AccessLevel.NONE)
private Product product;
void setProduct(Product product){
this.product = product;
}
private ProductImage(String imagePath, int ordering){
this.imagePath = imagePath;
this.ordering = ordering;
}
public static ProductImage of(String imagePath, int ordering){
return new ProductImage(imagePath, ordering);
}
}
@Service
@RequiredArgsConstructor
public class ProductService {
private final ProductRepository productRepository;
@Transactional
public ProductResource create(ProductDto productDto) {
List<ProductImage> productImages = productDto.getProductImages().stream()
.map(productImage -> ProductImage.of(productImage.getImagePath(), productImage.getOrdering()))
.collect(toList());
Product product = Product.of(productDto.getName(), productDto.getPrice(), productImages);
productRepository.save(product);
return ProductResource.from(product);
}
@Transactional(readOnly = true)
public List<ProductResource> getProducts(ProductSearchDto productSearchDto) {
List<Product> products = productRepository.findAll(productSearchDto);
return products.stream().map(ProductResource::from).collect(toList());
}
}
@Repository
@Transactional
@RequiredArgsConstructor
public class ProductRepositoryImpl implements ProductRepository {
private final JPAQueryFactory jpaQueryFactory;
@PersistenceContext private EntityManager entityManager;
@Override
public List<Product> findAll(ProductSearchDto productSearchDto) {
return jpaQueryFactory.select(product)
.from(product)
.innerJoin(product.productImages().productImages, productImage)
.fetchJoin()
.limit(productSearchDto.getSize())
.offset(productSearchDto.getSize() * productSearchDto.getPage())
.fetch();
}
@Override
public void save(Product product) {
entityManager.persist(product);
}
}
사용자에게 상품 리스트를 제공하는데에 있어 해당 상품의 모든 이미지를 함께 제공해야한다.
위 요구사항을 충족시키고 정상적인 쿼리가 수행되는지를 확인하기 위해 간단한 테스트 케이스를 작성 후 수행되는 쿼리를 확인해보았습니다.
@Test
void 상품_리스트_조회(){
// given
ProductSearchDto productSearchDto = ProductSearchDto.of(0, 10);
// when
List<ProductResource> products = productService.getProducts(productSearchDto);
// then
assertNotNull(products);
}
[결과]
2022-02-12 17:00:25.453 WARN 10189 --- [ main] o.h.h.internal.ast.QueryTranslatorImpl : HHH000104: firstResult/maxResults specified with collection fetch; applying in memory!
Hibernate:
select
product0_.id as id1_1_0_,
productima1_.id as id1_0_1_,
product0_.name as name2_1_0_,
product0_.price as price3_1_0_,
productima1_.image_path as image_pa2_0_1_,
productima1_.ordering as ordering3_0_1_,
productima1_.product_id as product_4_0_1_,
productima1_.product_id as product_4_0_0__,
productima1_.id as id1_0_0__
from
products product0_
inner join
product_images productima1_
on product0_.id=productima1_.product_id
보기에는 기능이 정상적으로 수행되는 모습을 볼 수 있었으나…
쿼리를 확인해보니 limit을 사용하지 않고 있었고, 로그에 이러한 문구가 지속적으로 출력되는 모습을 볼 수 있었습니다.
HHH000104: firstResult/maxResults specified with collection fetch; applying in memory!
우선 왜 페이징 쿼리가 수행되지 않은지에 대해 찾아보니 아래와 같은 정보를 얻을 수 있었습니다.
패치 조인 페이징을 사용해 해당 엔티티와 그에 연관된 데이터를 가져올 경우 JPA 입장에서 정확히 몇개의 데이터를 가져와야하는지 예측할 수 없다. 그렇기에 firstResult, maxResults 값이 무시되기 때문에, 조회하는 결과값을 모두 메모리에 적재 후 적재한 데이터를 가지고 페이징을 처리한다.
결론적으로 이 문제는 서비스를 운영하는데에 있어 만약 데이터가 많으면 많을수록 성능적으로 큰 치명타를 가져올 수 있다는 판단을 하였습니다.
다양한 정보 중 batchSize를 지정하여 해결할 수 있다는 해결책을 얻을 수 있었지만 모든 이미지를 사용자에게 보여줘야하는 문제가 걸려있기 때문에 해당 방법으로는 이 문제를 해결할 수 없을 것이라 판단하였습니다.
많은 고민 끝에 저는 상품 정보와 해당 상품에 대한 이미지를 따로 조회한 후 stream으로 묶자는 결정했습니다.
제가 이러한 결정을 내린 이유는 다음과 같습니다.
쿼리 수는 증가하지만, 상품 조회시 커버링 인덱스를 통해 페이지 성능을 개선할 수 있음
상품 이미지 조회 시 in 조건을 통해 상품키에 대한 인덱스를 활용할 수 있음
수정한 코드는 다음과 같습니다.
(모든 코드는 첨부하지 않은 점 참고 부탁드립니다)
모든 코드를 보시려면 여기를 눌러주세요^^
@Getter
public class ProductRecord {
private long id;
private String name;
private long price;
public ProductRecord(long id, String name, long price) {
this.id = id;
this.name = name;
this.price = price;
}
}
@Getter
public class ProductImageRecord {
private long id;
private String imagePath;
private int ordering;
private long productId;
public ProductImageRecord(long id, String imagePath, int ordering, long productId) {
this.id = id;
this.imagePath = imagePath;
this.ordering = ordering;
this.productId = productId;
}
}
@Getter
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class ProductImageResource {
private long id;
private String imagePath;
private int ordering;
private long productId;
public static ProductImageResource from(ProductImageRecord productImage){
return new ProductImageResource(productImage.getId(),
productImage.getImagePath(),
productImage.getOrdering(),
productImage.getProductId());
}
public static ProductImageResource of(ProductImage productImage, Long productId) {
return new ProductImageResource(productImage.getId(),
productImage.getImagePath(),
productImage.getOrdering(),
productId);
}
}
@Getter
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class ProductResource {
private long id;
private String name;
private long price;
private List<ProductImageResource> productImages;
public static ProductResource from(Product product){
List<ProductImageResource> productImages = product.getProductImages().stream().map(productImage -> ProductImageResource.of(productImage, product.getId())).collect(toList());
return new ProductResource(product.getId(),
product.getName(),
product.getPrice(),
productImages);
}
public static ProductResource of(ProductRecord product, List<ProductImageResource> productImages) {
return new ProductResource(
product.getId(),
product.getName(),
product.getPrice(),
productImages
);
}
}
@Service
@RequiredArgsConstructor
public class ProductService {
private final ProductRepository productRepository;
private final ProductImageRepository productImageRepository;
@Transactional
public ProductResource create(ProductDto productDto) {
List<ProductImage> productImages = productDto.getProductImages().stream()
.map(productImage -> ProductImage.of(productImage.getImagePath(), productImage.getOrdering()))
.collect(toList());
Product product = Product.of(productDto.getName(), productDto.getPrice(), productImages);
productRepository.save(product);
return ProductResource.from(product);
}
private final static List<ProductResource> EMPTY_PRODUCT_RESOURCE_LIST = Collections.EMPTY_LIST;
@Transactional(readOnly = true)
public List<ProductResource> getProducts(ProductSearchDto productSearchDto) {
List<ProductRecord> products = productRepository.findAll(productSearchDto);
Set<Long> productIds = products.stream()
.map(ProductRecord::getId)
.collect(toSet());
if(productIds.isEmpty()){
return EMPTY_PRODUCT_RESOURCE_LIST;
}
Map<Long, List<ProductImageResource>> productImageMap = loadProductImageRecordMapByProductIds(productIds);
return products.stream()
.map(product -> ProductResource.of(product, productImageMap.get(product.getId())))
.collect(toList());
}
private Map<Long, List<ProductImageResource>> loadProductImageRecordMapByProductIds(Set<Long> productIds) {
List<ProductImageRecord> productImages = productImageRepository.findAllByProductIds(productIds);
return productImages.stream()
.map(ProductImageResource::from)
.collect(groupingBy(ProductImageResource::getProductId));
}
}
@Repository
@Transactional
@RequiredArgsConstructor
public class QuerydslProductRepository implements ProductRepository {
private final JPAQueryFactory jpaQueryFactory;
@PersistenceContext private EntityManager entityManager;
private final static List<ProductRecord> EMPTY_PRODUCT_RECORD_LIST = Collections.EMPTY_LIST;
@Override
public List<ProductRecord> findAll(ProductSearchDto productSearchDto) {
List<Long> productIds = jpaQueryFactory.select(product.id)
.from(product)
.limit(productSearchDto.getSize())
.offset(productSearchDto.getSize() * productSearchDto.getPage())
.fetch();
if(productIds.isEmpty()){
return EMPTY_PRODUCT_RECORD_LIST;
}
return jpaQueryFactory.select(constructor(ProductRecord.class,
product.id,
product.name,
product.price
))
.from(product)
.where(product.id.in(productIds))
.fetch();
}
@Override
public void save(Product product) {
entityManager.persist(product);
}
}
@Repository
@RequiredArgsConstructor
public class QuerydslProductImageRepository implements ProductImageRepository {
private final JPAQueryFactory jpaQueryFactory;
@Override
public List<ProductImageRecord> findAllByProductIds(Set<Long> productIds) {
return jpaQueryFactory.select(constructor(ProductImageRecord.class,
productImage.id,
productImage.imagePath,
productImage.ordering,
productImage.product().id
))
.from(productImage)
.where(productImage.product().id.in(productIds))
.fetch();
}
}