[JPA] firstResult/maxResults specified with collection fetch... 문제를 어떻게 해결했는가?

Dev. 로티·2022년 2월 12일
1

JPA

목록 보기
1/2
post-thumbnail

회사에서 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();
    }
}  

0개의 댓글