본문 바로가기

Java/JPA

QueryDSL에서 서브쿼리에 limit이 안 먹는 이유

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에서는 적용되지 않을 수 있다.
  • 특히 selectwhere 절 안의 서브쿼리는 limit이 사라질 수 있으므로 신뢰하지 말자.
  • 한 방에 처리하려면 native query를 쓰고, 코드 일관성과 유지보수가 더 중요하다면 서브쿼리를 분리 fetch하는 게 안전하다.

 

'Java > JPA' 카테고리의 다른 글

실전! 스프링 부트와 JPA 활용 (김영한) - 요구사항 분석  (0) 2022.10.13