Errors

QueryDSL does not support using limit() inside subqueries when used with JPA.

neal89 2025. 6. 9. 13:29

상품 목록에서 대표이미지를 보여주기로 했다. 여기서부터 문제가 생겼는데..

1. 스칼라 서브쿼리에서는 limit 옵션을 사용할 수 없다.

limit(1) 옵션을 넣어도, 실제 쿼리에는 limit 옵션이 빠진 query가 나가는걸 확인했다.
이유는.. 그냥 JPQL을 사용할 때의 제약조건이다.

여러 해결책이 있을 수 있지만, 쿼리를 분리하기로 했다.

먼저 제품 목록을 가지고 오고, 그 목록을 기준으로 join 하면서 해당하는 썸네일을 넣어주자.

 

limit을 사용했지만, 실제 쿼리에서는 나가지 않는다..

@Override
public Page<ResponseProductEntity> findAllProductsWithMinPriceAndMaxPrice(String name, String skuCode,
    Pageable pageable) {
  // 메인 쿼리: ResponseProductEntity로 프로젝션
  JPAQuery<ResponseProductEntity> query = queryFactory
      .select(new QResponseProductEntity(
          productEntity.id,
          productEntity.name,
          productEntity.description,
          categoryEntity.id,
          categoryEntity.name,
          // SKU의 이미지에서 썸네일 이미지 서브쿼리로 변경
          JPAExpressions.select(imageUrlEntity.imageUrl)
                        .from(imageUrlEntity)
                        .where(imageUrlEntity.sku.product.eq(productEntity)
                                                         .and(imageUrlEntity.isThumbnail.eq(true)))
                        .limit(1),
          skuEntity.price.min(), // SKU 최소 가격
          skuEntity.price.max(), // SKU 최대 가격
          productEntity.createdDate,
          productEntity.lastModifiedDate
      ))
      .from(productEntity)
      .leftJoin(productEntity.category, categoryEntity)
      .leftJoin(productEntity.skus, skuEntity)
      .groupBy(
          productEntity.id,
          productEntity.name,
          productEntity.description,
          categoryEntity.id,
          categoryEntity.name,
          productEntity.createdDate,
          productEntity.lastModifiedDate
      );

  // 검색 조건 동적으로 추가
  BooleanBuilder whereCondition = new BooleanBuilder();

  if (StringUtils.hasText(name)) {
    whereCondition.and(productEntity.name.lower().like("%" + name.toLowerCase() + "%"));
  }

  if (StringUtils.hasText(skuCode)) {
    whereCondition.and(
        JPAExpressions.selectOne()
                      .from(skuEntity)
                      .where(skuEntity.product.eq(productEntity)
                                              .and(skuEntity.name.lower().like("%" + skuCode.toLowerCase() + "%")))
                      .exists()
    );
  }

  // 검색 조건 적용
  query.where(whereCondition);

  // 페이징 적용
  query.offset(pageable.getOffset());
  query.limit(pageable.getPageSize());

  // 정렬 적용
  pageable.getSort().forEach(order -> {
    com.querydsl.core.types.Order direction = order.isAscending() ?
                                              com.querydsl.core.types.Order.ASC : com.querydsl.core.types.Order.DESC;
    if ("createdDate".equals(order.getProperty())) {
      query.orderBy(new com.querydsl.core.types.OrderSpecifier<>(direction, productEntity.createdDate));
    } else if ("name".equals(order.getProperty())) {
      query.orderBy(new com.querydsl.core.types.OrderSpecifier<>(direction, productEntity.name));
    }
  });

  // 쿼리 실행
  List<ResponseProductEntity> content = query.fetch();

  // 총 카운트 쿼리
  JPAQuery<Long> countQuery = queryFactory
      .select(productEntity.countDistinct())
      .from(productEntity)
      .leftJoin(productEntity.category, categoryEntity)
      .leftJoin(productEntity.skus, skuEntity)
      .where(whereCondition);

  return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne);
}

 

2. 1:N:M 문제

product엔티티가 sku를 리스트로 가지고 있고, sku가 image를 리스트로 가지고 있다.

각 엔티티의 관계에서 기본 로딩 옵션은 lazy로 줬지만, 문제가 터졌다.

해결방안은..

첫번째, 엔티티 구조를 바꿔볼수 있었다. 현재 product 엔티티에는 대표 썸네일이 지정되어 있지 않고, sku 엔티티를 통해서 지정된 썸네일을 가져와야 하는 구조였다.
두번째, 쿼리를 또 분리하자. 썸네일을 아예 다른 쿼리로 받아와서, responseproduct 엔티티에 합칠 수 있었다.

엔티티를 고치는것보다 쿼리를 분리하는게 더 쉽고, 굳이 product 엔티티가 썸네일을 가질 필요가 있나... 싶어서 쿼리를 분리하는 쪽으로 마음을 먹었다.

@Override
public Page<ResponseProductEntity> findAllProductsWithMinPriceAndMaxPrice(String name, String skuCode,
    Pageable pageable) {

  // 1. 먼저 제품 목록을 가져옴
  JPAQuery<Long> productIdsQuery = queryFactory
      .select(productEntity.id)
      .from(productEntity)
      .leftJoin(productEntity.skus, skuEntity);

  // 검색 조건 적용
  BooleanBuilder whereCondition = new BooleanBuilder();
  if (StringUtils.hasText(name)) {
    whereCondition.and(productEntity.name.lower().like("%" + name.toLowerCase() + "%"));
  }
  if (StringUtils.hasText(skuCode)) {
    whereCondition.and(
        JPAExpressions.selectOne()
                      .from(skuEntity)
                      .where(skuEntity.product.eq(productEntity)
                                              .and(skuEntity.name.lower().like("%" + skuCode.toLowerCase() + "%")))
                      .exists()
    );
  }

  productIdsQuery.where(whereCondition)
                 .groupBy(productEntity.id)
                 .offset(pageable.getOffset())
                 .limit(pageable.getPageSize());

  // 정렬 적용
  pageable.getSort().forEach(order -> {
    com.querydsl.core.types.Order direction = order.isAscending() ?
                                              com.querydsl.core.types.Order.ASC : com.querydsl.core.types.Order.DESC;
    if ("createdDate".equals(order.getProperty())) {
      productIdsQuery.orderBy(new com.querydsl.core.types.OrderSpecifier<>(direction, productEntity.createdDate));
    } else if ("name".equals(order.getProperty())) {
      productIdsQuery.orderBy(new com.querydsl.core.types.OrderSpecifier<>(direction, productEntity.name));
    }
  });

  List<Long> productIds = productIdsQuery.fetch();

  if (productIds.isEmpty()) {
    return PageableExecutionUtils.getPage(List.of(), pageable, () -> 0L);
  }

  // 2. 해당 제품들의 상세 정보를 가져옴 (썸네일 이미지 포함)
  JPAQuery<ResponseProductEntity> query = queryFactory
      .select(new QResponseProductEntity(
          productEntity.id,
          productEntity.name,
          productEntity.description,
          categoryEntity.id,
          categoryEntity.name,
          // 별도 쿼리로 썸네일 이미지를 가져오는 대신 LEFT JOIN 사용
          imageUrlEntity.imageUrl,
          skuEntity.price.min(),
          skuEntity.price.max(),
          productEntity.createdDate,
          productEntity.lastModifiedDate
      ))
      .from(productEntity)
      .leftJoin(productEntity.category, categoryEntity)
      .leftJoin(productEntity.skus, skuEntity)
      .leftJoin(skuEntity.images, imageUrlEntity)
      .on(imageUrlEntity.isThumbnail.eq(true))
      .where(productEntity.id.in(productIds))
      .groupBy(
          productEntity.id,
          productEntity.name,
          productEntity.description,
          categoryEntity.id,
          categoryEntity.name,
          imageUrlEntity.imageUrl,
          productEntity.createdDate,
          productEntity.lastModifiedDate
      );

  List<ResponseProductEntity> content = query.fetch();

  // 총 카운트 쿼리
  JPAQuery<Long> countQuery = queryFactory
      .select(productEntity.countDistinct())
      .from(productEntity)
      .leftJoin(productEntity.category, categoryEntity)
      .leftJoin(productEntity.skus, skuEntity)
      .where(whereCondition);

  return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne);
}

같은 상품명이 중복되서 출력됨

 


    최종 상품목록 쿼리

썸네일이 들어가야 할 곳에 nullExpression을 먼저 넣어준다.
그 뒤에 썸네일 이미지를 얻어와서 content에 조립해주면 끝!

package teo.springjwt.product.repository.product;

import static teo.springjwt.category.QCategoryEntity.categoryEntity;
import static teo.springjwt.product.entity.QImageUrlEntity.imageUrlEntity;
import static teo.springjwt.product.entity.QProductEntity.productEntity;
import static teo.springjwt.product.entity.QSkuEntity.skuEntity;

import com.querydsl.core.BooleanBuilder;
import com.querydsl.core.Tuple;
import com.querydsl.core.types.dsl.Expressions;
import com.querydsl.jpa.JPAExpressions;
import com.querydsl.jpa.impl.JPAQuery;
import com.querydsl.jpa.impl.JPAQueryFactory;
import jakarta.persistence.EntityManager;
import java.util.List;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.support.PageableExecutionUtils;
import org.springframework.util.StringUtils;
import teo.springjwt.product.dto.QResponseProductEntity;
import teo.springjwt.product.dto.ResponseProductEntity;

public class ProductEntityRepositoryCustomImpl implements ProductEntityRepositoryCustom {
  private final JPAQueryFactory queryFactory;

  public ProductEntityRepositoryCustomImpl(EntityManager entityManager) {
    this.queryFactory = new JPAQueryFactory(entityManager);
  }

  @Override
  public Page<ResponseProductEntity> findAllProductsWithMinPriceAndMaxPrice(String name, String skuCode,
      Pageable pageable) {
    // 메인 쿼리: ResponseProductEntity로 프로젝션
    JPAQuery<ResponseProductEntity> query = queryFactory
        .select(new QResponseProductEntity(
            productEntity.id,
            productEntity.name,
            productEntity.description,
            categoryEntity.id,
            categoryEntity.name,
            // 썸네일 제외, 나중에 채울거다.
            Expressions.nullExpression(),
            skuEntity.price.min(), // SKU 최소 가격
            skuEntity.price.max(), // SKU 최대 가격
            productEntity.createdDate,
            productEntity.lastModifiedDate
        ))
        .from(productEntity)
        .leftJoin(productEntity.category, categoryEntity)
        .leftJoin(productEntity.skus, skuEntity)
        .groupBy(
            productEntity.id,
            productEntity.name,
            productEntity.description,
            categoryEntity.id,
            categoryEntity.name,
            productEntity.createdDate,
            productEntity.lastModifiedDate
        );

    // 검색 조건 동적으로 추가
    BooleanBuilder whereCondition = new BooleanBuilder();

    if (StringUtils.hasText(name)) {
      whereCondition.and(productEntity.name.lower().like("%" + name.toLowerCase() + "%"));
    }

    if (StringUtils.hasText(skuCode)) {
      whereCondition.and(
          JPAExpressions.selectOne()
                        .from(skuEntity)
                        .where(skuEntity.product.eq(productEntity)
                                                .and(skuEntity.name.lower().like("%" + skuCode.toLowerCase() + "%")))
                        .exists()
      );
    }

    // 검색 조건 적용
    query.where(whereCondition);

    // 페이징 적용
    query.offset(pageable.getOffset());
    query.limit(pageable.getPageSize());

    // 정렬 적용
    pageable.getSort().forEach(order -> {
      com.querydsl.core.types.Order direction = order.isAscending() ?
                                                com.querydsl.core.types.Order.ASC : com.querydsl.core.types.Order.DESC;
      if ("createdDate".equals(order.getProperty())) {
        query.orderBy(new com.querydsl.core.types.OrderSpecifier<>(direction, productEntity.createdDate));
      } else if ("name".equals(order.getProperty())) {
        query.orderBy(new com.querydsl.core.types.OrderSpecifier<>(direction, productEntity.name));
      }
    });

    // 쿼리 실행
    List<ResponseProductEntity> content = query.fetch();


    // productId 리스트로 얻어오기
    List<Long> ids = content.stream().map(ResponseProductEntity::getId).toList();

    //sku 상품 아아디, 이미지 url 리스트로 얻어오기
    List<Tuple> productIdToThumbnail = queryFactory
        .select(imageUrlEntity.sku.product.id, imageUrlEntity.imageUrl)
        .from(imageUrlEntity)
        .where(imageUrlEntity.isThumbnail.eq(true).and(imageUrlEntity.sku.product.id.in(ids)))
        .fetch()
        .stream()
        .toList();


    content.forEach(productEntity -> {
      String imageUrl = String.valueOf(productIdToThumbnail.get(Math.toIntExact(productEntity.getId())));
      productEntity.setThumbnailUrl(imageUrl);  // setter 필요
    });


    // 총 카운트 쿼리
    JPAQuery<Long> countQuery = queryFactory
        .select(productEntity.countDistinct())
        .from(productEntity)
        .leftJoin(productEntity.category, categoryEntity)
        .leftJoin(productEntity.skus, skuEntity)
        .where(whereCondition);

    return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne);
  }
}