Spring + JPA + QueryDSL 조합으로 작업 중, 서브쿼리에 .limit(1)을 분명히 썼는데도 아래와 같은 에러가 발생했다.
Subquery returns more than 1 row
"어? limit 걸었는데 왜 여러 row가 나와?"
처음엔 단순한 실수인 줄 알았지만, 파고들어 보니 구조적인 이슈였다.
1. 원인
QueryDSL은 내부적으로 다음과 같은 과정을 거쳐 쿼리를 실행한다.
QueryDSL → JPQL → SQL (DB 방언 적용) → 실행
이때 .limit() 같은 조건은 DB 방언(Dialect) 에 의해서 SQL에 적용되는데, 문제는 서브쿼리, 특히 아래 두 곳에서는 limit이 무시될 수 있다.
- 스칼라 서브쿼리:
select절 안에 들어간 서브쿼리 - where 서브쿼리:
where절 안의 서브쿼리
즉, 내가 .limit(1)을 사용했어도 JPQL에서는 인식되지 않고, SQL 최종 변환 시점에 빠지게 되는 것이다.
2. 문제
리뷰 작성자 정보를 조회하는 API를 만들던 중이었다.
작성자의 기본 정보 외에도, 다음 두 가지를 추가로 내려줘야 했다.
- 작성자가 작성한 리뷰 수
- 최근 받은 수상 이력 1건
그래서 QueryDSL로 다음과 같은 코드를 작성했다.
.select(
Projections.constructor(
ReviewAuthorProfileDto.class,
user.id,
user.name,
user.description,
user.profile,
(select count(*) from Review where user.id = :userId),
(select award_name from UserAward
where user_id = :userId
order by id desc limit 1)
)
)
코드상으론 .limit(1)도 잘 들어갔고, 눈으로 보기엔 문제가 없어 보였다.
하지만 실행하자마자 아래 에러가 떴다.
Subquery returns more than 1 row
3. 해결
3-1. 서브쿼리를 미리 실행해서 상수로 넘기기
가장 일반적이고 안정적인 방법은, 서브쿼리를 먼저 별도로 fetchFirst()로 실행하고,
그 결과를 Expressions.constant(...)로 메인 쿼리에 넣는 방식이다.
String awardName = queryFactory
.select(userAward.awardName)
.from(userAward)
.join(userAward.userRanking, userRanking)
.where(userRanking.user.id.eq(userId))
.orderBy(userAward.id.desc())
.limit(1)
.fetchFirst();
Long reviewCount = queryFactory
.select(review.count())
.from(review)
.where(review.user.id.eq(userId))
.fetchOne();
return queryFactory
.select(Projections.constructor(
ReviewAuthorProfileDto.class,
user.id,
user.userNicename,
user.userDescription,
user.userProfile,
Expressions.constant(reviewCount != null ? reviewCount : 0L),
Expressions.constant("리뷰 수상내역"),
Expressions.constant(awardName != null ? awardName : "")
))
.from(user)
.where(user.id.eq(userId))
.fetchOne();
- 안정적으로 동작
- QueryDSL 스타일 유지 가능
- 쿼리가 2~3번 나가므로 트래픽이 많다면 성능 이슈 고려 필요
3-2. 한방 쿼리가 필요한 경우 → Native Query 사용
성능이 중요하거나 반드시 한 쿼리로 끝내야 한다면, Native SQL로 처리하는 게 현실적인 선택이다.
@Query(value = """
SELECT
u.USER_ID,
u.USER_NICENAME,
u.USER_DESCRIPTION,
u.USER_PROFILE,
(SELECT COUNT(*) FROM REVIEW r WHERE r.USER_ID = u.USER_ID) AS REVIEW_COUNT,
(SELECT ua.AWARD_NAME
FROM USER_AWARD ua
JOIN USER_RANKING ur ON ur.USER_RANKING_ID = ua.USER_RANKING_ID
WHERE ur.USER_ID = u.USER_ID
ORDER BY ua.USER_AWARD_ID DESC
LIMIT 1
) AS AWARD_NAME
FROM USERS u
WHERE u.USER_ID = :userId
""", nativeQuery = true)
Object getReviewAuthorProfileNative(@Param("userId") Long userId);
- 진짜 "한방 쿼리" 가능
- DTO 매핑 수작업 필요
- 쿼리 수정/유지보수는 상대적으로 번거롭다
결론
- QueryDSL에서는 서브쿼리에
.limit(n)을 써도 실제 SQL에서는 적용되지 않을 수 있다. - 특히
select나where절 안의 서브쿼리는limit이 사라질 수 있으므로 신뢰하지 말자. - 한 방에 처리하려면 native query를 쓰고, 코드 일관성과 유지보수가 더 중요하다면 서브쿼리를 분리 fetch하는 게 안전하다.
'Java > JPA' 카테고리의 다른 글
| 실전! 스프링 부트와 JPA 활용 (김영한) - 요구사항 분석 (0) | 2022.10.13 |
|---|