스칼라 서브쿼리(Scalar Subquery)에서 Limit절 오류 해결
문제 상황
레시피의 썸네일 이미지를 가져오기 위한 쿼리 작성 시 문제가 발생했다. 목표는 각 레시피별로 fileOrder가 가장 낮은, 즉 가장 먼저 업로드한 썸네일 하나만을 추출하는 것이었다. 이를 위해 JPQL을 사용하여 서브쿼리를 구성하고, orderBy와 limit(1)을 통해 단일 결과를 얻으려 했다. 하지만 예상과 달리 문제가 발생했다.
// 레시피 썸네일 조회 서브쿼리
JPQLQuery<String> recipeThumbnailSubQuery = JPAExpressions
.select(recipeFileEntity.storedFilePath)
.from(recipeFileEntity)
.where(recipeFileEntity.recipeEntity.id.eq(recipeEntity.id), recipeFileEntity.delYn.eq("N"))
.orderBy(recipeFileEntity.fileOrder.asc())
.limit(1);
// 메인 쿼리 추출 (레시피 기본 정보 및 북마크 여부 조회)
List<RecipeMainListResponseDto> resultList = queryFactory
.select(Projections.constructor(RecipeMainListResponseDto.class,
recipeEntity.id,
recipeEntity.recipeName,
ExpressionUtils.as(nicknameSubQuery, "nickname"),
ExpressionUtils.as(bookmarkSubQuery, "bookmarkId"),
ExpressionUtils.as(recipeThumbnailSubQuery, "thumbnailFullPath")
))
.from(recipeEntity)
.where(whereCondition)
.fetch();
발생한 문제: "Scalar subquery contains more than one row" 에러
서브쿼리에서 limit(1)을 사용했음에도 불구하고, 실행된 SQL 쿼리에서 limit 절이 누락되는 현상이 발견되었다.
이로 인해 서브쿼리가 단일 행이 아닌 여러 행을 반환하게 되었고, 이는 "Scalar subquery contains more than one row"라는 에러를 발생시켰다.
💡 스칼라 서브쿼리는 단일 행과 단일 열(즉, 단일 값을) 반환하는 서브쿼리다. 이러한 특성 때문에, 스칼라 서브쿼리의 결과는 메인 쿼리에서 단일 값으로 취급된다.
예를 들어, SELECT절이나 WHERE절에서 특정 열의 값을 결정하기 위해 사용될 수 있다.
에러 발생 원인
문제의 근본적인 원인은 SQL 표준에서 서브쿼리의 사용 방식에 대한 제한에 있다. 이해를 돕기 위해, 먼저 SQL 표준과 서브쿼리에 대한 개념을 간단히 설명하겠다.
SQL 표준과 서브쿼리의 제한
SQL 표준에 따르면, SELECT 절 내에서 사용되는 서브쿼리는 결과 행의 수를 제한하는 LIMIT 절을 지원하지 않는다. 이는 대부분의 SQL 데이터베이스에서 공통적으로 나타나는 제약 사항이다.
서브쿼리에서의 LIMIT 사용의 문제
이 경우, LIMIT을 사용하여 서브쿼리 내에서 단일 행만 반환되기를 기대할 수 있지만, SQL 표준은 이러한 사용법을 보장하지 않는다. 결과적으로 데이터베이스는 서브쿼리에서 여러 행을 반환할 수 있고, 이는 상위 쿼리에서 단일 값이나 단일 행을 기대하는 경우 오류를 발생시킬 수 있다.
JPQL과 QueryDSL의 한계
JPQL과 QueryDSL에서도 이 SQL 표준의 제약을 받는다. 따라서, SELECT 절 내에서 LIMIT을 사용하는 서브쿼리를 작성할 때, 실제 실행되는 SQL 쿼리에서 LIMIT 절이 누락되는 경우가 발생한다. 이는 JPQL과 QueryDSL이 내부적으로 SQL 쿼리로 변환될 때 발생하는 한계다.
해결방법
이 문제를 해결하기 위해, 서브쿼리 내에서 추가적인 서브쿼리를 사용하는 방법을 도입했다. 이 접근법은 먼저 가장 낮은 id 값을 찾는 내부 서브쿼리를 사용하고, 이 값을 조건으로 하여 해당하는 썸네일 데이터를 추출하는 외부 서브쿼리를 구성한다. 이 방식은 SQL의 제약을 우회하며 원하는 단일 행 결과를 얻을 수 있게 한다.
// 메인 쿼리 추출
List<RecipeMainListResponseDto> resultList = queryFactory
.select(Projections.fields(RecipeMainListResponseDto.class,
recipeEntity.id,
recipeEntity.recipeName,
ExpressionUtils.as(nicknameSubQuery, "nickname"),
ExpressionUtils.as(bookmarkSubQuery, "bookmarkId"),
ExpressionUtils.as(JPAExpressions
.select(recipeFileEntity.storedFilePath)
.from(recipeFileEntity)
.where(recipeFileEntity.id.eq(
JPAExpressions
.select(recipeFileEntity.id.min())
.from(recipeFileEntity)
.where(recipeFileEntity.recipeEntity.id.eq(recipeEntity.id), recipeFileEntity.delYn.eq("N"))
)), "thumbnailFullPath")))
.from(recipeEntity)
.where(whereCondition)
.fetch();
이런 식으로 하니까 잘 적용된 모습을 확인할 수 있었다.
📣 이 글은 내가 소속된 Team Chillwave에서 진행한 사이드 프로젝트에 적용한 내용을 다시 공부하고 정리한 것이다.
다른 팀원인 "개발자의 서랍" 님의 블로그도 방문하면 도움이 될 것 같다 :)
'Spring > Spring 트러블 슈팅' 카테고리의 다른 글
MSSQL에서 offset 관련 UnsupportedOperationException: query result offset is not supported 에러 발생 (0) | 2024.04.01 |
---|---|
QueryDSL에서 Projections.constructor 사용해서 SQL 함수 사용하기 (0) | 2024.02.01 |
QueryDSL에서 NPE 해결하기 - 서브쿼리와 외부 조인의 활용 (2) | 2024.01.16 |
단위테스트에서 Static 메소드 Mock 주입 문제 해결 방법 (5) | 2023.12.25 |
스프링 시큐리티: 커스텀 에러 처리 실패 원인 분석 및 해결 방법 (0) | 2023.12.23 |