문제 상황
문제 목록을 조회할 때 각 문제의 Top 유저를 함께 가져와야 했다. QueryDSL로 구현하려면, 기본적으로 문제를 조회하는 메인 쿼리와, 각 문제에 대한 Top 유저를 구하는 서브쿼리가 필요했다.
Top 유저를 조회하는 기준 컬럼을 내림차순 정렬한 뒤 상위 1명만 선택한다는 아이디어로 설계했다. 즉, 정렬 후 limit 1을 적용해 한 행만 가져오는 방식을 떠올렸다.
실행을 돌려보니, Scalar subquery contains more than one row; SQL statement: 에러가 떴다.
문제 코드
public List<HomeProblemQuery> findPopularProblem(int limit) {
QProblem problem = QProblem.problem;
QProblemImage problemImage = QProblemImage.problemImage;
QUserRating topRating = new QUserRating("topRating");
QUser topUser = new QUser("topUser");
return queryFactory
.select(Projections.constructor(HomeProblemQuery.class,
problem.id,
problem.title,
problem.submissionCount,
problem.submitterCount,
// ✅SubQuery
JPAExpressions.select(topUser.identifier)
.from(topRating)
.join(topRating.user, topUser)
.where(topRating.problem.eq(problem))
.orderBy(topRating.rating.desc())
.limit(1), // ✅상위 1명
problem.description,
problemImage.imageKey
))
.from(problem)
.leftJoin(problem.iconKey, problemImage)
.orderBy(problem.submissionCount.desc())
.limit(limit)
.fetch();
}
실제 쿼리를 살펴보면 아래와 같다.
select
p1_0.problem_id,
p1_0.title,
p1_0.submission_count,
p1_0.submitter_count,
(select // ✅SubQuery 시작
u1_0.identifier
from
user_rating ur1_0
join
users u1_0
on u1_0.user_id=ur1_0.user_id
where
ur1_0.problem_id=p1_0.problem_id
order by
ur1_0.rating desc), // ❓limit 1 누락
p1_0.description,
ik1_0.image_key
from
problem p1_0
left join
problem_image ik1_0
on ik1_0.problem_image_id=p1_0.icon_image_id
order by
p1_0.submission_count desc
fetch
first ? rows only
2025-09-28T12:19:59.765+09:00 WARN 22604 --- [nio-8080-exec-6] o.h.engine.jdbc.spi.SqlExceptionHelper : SQL Error: 90053, SQLState: 90053
2025-09-28T12:19:59.765+09:00 ERROR 22604 --- [nio-8080-exec-6] o.h.engine.jdbc.spi.SqlExceptionHelper : Scalar subquery contains more than one row; SQL statement:
서브쿼리에서 limit 절이 누락된 것을 확인할 수 있다.
원인 분석
JPQL 자체가 서브쿼리에서 LIMIT/OFFSET을 지원하지 않는다. QueryDSL JPA는 내부적으로 JPQL을 생성하기 때문에, 서브쿼리의 `.limit()`/`.offset()`이 무시된다.
해결 방법
서브쿼리에 min()을 적용해 우회적으로 해결했다. min은 대상 집합에서 최소값 하나로 축약하므로, 동률이 있어도 결과가 자연스럽게 단일 값이 된다. 정렬 후 limit 1을 쓰는 것과 같은 효과를 볼 수 있다.
최종 코드
public List<HomeProblemQuery> findPopularProblems(int limit) {
QProblem problem = QProblem.problem;
QProblemImage problemImage = QProblemImage.problemImage;
JPQLQuery<String> topIdentifier = getTopIdentifier(problem); // ✅TopUser 가져오기
return queryFactory
.select(Projections.constructor(HomeProblemQuery.class,
problem.id,
problem.title,
problem.submissionCount,
problem.submitterCount,
topIdentifier, // ✅활용
problem.description,
problemImage.imageKey
))
.from(problem)
.leftJoin(problem.iconKey, problemImage)
.orderBy(problem.submissionCount.desc())
.limit(limit)
.fetch();
}
private JPQLQuery<String> getTopIdentifier(QProblem problem) {
QUserRating ur = QUserRating.userRating;
QUserRating urForMaxRating = new QUserRating("urForMaxRating");
QUserRating urForMaxWin = new QUserRating("urForMaxWin");
QUserRating urForEarliest = new QUserRating("urForEarliest");
// 최고 레이팅
JPQLQuery<Integer> maxRating =
JPAExpressions.select(urForMaxRating.rating.max())
.from(urForMaxRating)
.where(urForMaxRating.problem.eq(problem));
// 최고 승수
JPQLQuery<Integer> maxWinAtMaxRating =
JPAExpressions.select(urForMaxWin.win.max())
.from(urForMaxWin)
.where(
urForMaxWin.problem.eq(problem),
urForMaxWin.rating.eq(maxRating)
);
// 가장 이른 시각
JPQLQuery<LocalDateTime> earliestAtTie =
JPAExpressions.select(urForEarliest.updatedAt.min())
.from(urForEarliest)
.where(
urForEarliest.problem.eq(problem),
urForEarliest.rating.eq(maxRating),
urForEarliest.win.eq(maxWinAtMaxRating)
);
return JPAExpressions.select(ur.user.identifier.min()) // ✅limit 1과 같은 효과
.from(ur)
.where(
ur.problem.eq(problem),
ur.rating.eq(maxRating),
ur.win.eq(maxWinAtMaxRating),
ur.updatedAt.eq(earliestAtTie)
);
}
Top User의 기준 우선순위는
- rating이 가장 높을 것
- rating이 같을 경우, win이 가장 높을 것
- rating과 win이 같을 경우, updatedAt이 가장 오래되었을 것
이렇게 하면 1명으로 좁혀진다! 그래야만 한다
그리고 마지막으로 min()을 통해서 1row를 명시적으로 반환한다.
Top user를 찾는 로직은 여러 번 재사용될 예정이라 메서드로 분리했다.
결과 확인
select
p1_0.problem_id,
p1_0.title,
p1_0.submission_count,
p1_0.submitter_count,
(select
min(u1_0.identifier) // ✅min 적용 확인
from
user_rating ur1_0
join
users u1_0
on u1_0.user_id=ur1_0.user_id
where
ur1_0.problem_id=p1_0.problem_id
and ur1_0.rating=(
select
max(ur2_0.rating)
from
user_rating ur2_0
where
ur2_0.problem_id=p1_0.problem_id
)
and ur1_0.win=(
select
max(ur3_0.win)
from
user_rating ur3_0
where
ur3_0.problem_id=p1_0.problem_id
and ur3_0.rating=(
select
max(ur4_0.rating)
from
user_rating ur4_0
where
ur4_0.problem_id=p1_0.problem_id
)
)
and ur1_0.updated_at=(
select
min(ur5_0.updated_at)
from
user_rating ur5_0
where
ur5_0.problem_id=p1_0.problem_id
and ur5_0.rating=(
select
max(ur6_0.rating)
from
user_rating ur6_0
where
ur6_0.problem_id=p1_0.problem_id
)
and ur5_0.win=(
select
max(ur7_0.win)
from
user_rating ur7_0
where
ur7_0.problem_id=p1_0.problem_id
and ur7_0.rating=(
select
max(ur8_0.rating)
from
user_rating ur8_0
where
ur8_0.problem_id=p1_0.problem_id
)
)
)),
p1_0.description,
ik1_0.image_key
from
problem p1_0
left join
problem_image ik1_0
on ik1_0.problem_image_id=p1_0.icon_image_id
order by
p1_0.submission_count desc
fetch
first ? rows only
회고
이 에러를 해결하기 위해 여러 방법을 많이 시도해봤는데 이렇게 자체 오류는 근본적으로 해결하기 어려운 것 같다. 그래도 우회적으로 잘 해결한 것 같아서 만족스럽다. 그리고 메서드를 분리해 코드 가독성도 더 좋아졌다.
'SPRING > TIL' 카테고리의 다른 글
| [Study] SNS FIFO : MessageGroupId 지정 (0) | 2025.10.09 |
|---|---|
| [Study] @Modifying: 벌크 연산과 영속성 컨텍스트 관리 (5) | 2025.09.29 |
