QueryDSL에서 SELECT 절 서브쿼리와 Enum을 함께 다룰 때 예기치 않은 문제가 발생했다.
이번 포스팅에서는 팔로워 목록을 조회하는 쿼리에서 발생한 오류와 이를 해결한 두 가지 방법을 비교 분석해 보았다.
나를 팔로우 하고 있는 회원을 조회하는 API에서 구현이 잘못된 부분이 있었다.
나의 팔로워 조회 API 여도, 그 중 status 필드는 나 기준으로 상대방에 대한 팔로우 여부를 나타내야해서 서브쿼리를 작성해야 했다.
문제 상황
다음은 팔로워 목록을 조회하며, 특정 조건에 따라 FollowStatus Enum 값을 반환하려는 QueryDSL 쿼리다.
private List<RelationUserInfo> getFollowerDetails(Long userId, Long cursor, Long pageSize) {
QFollow f2 = new QFollow("f2"); // 별도의 별칭 인스턴스 생성
return queryFactory
.select(Projections.constructor(
RelationUserInfo.class,
follow.userId.as("userId"),
follow.targetUserId.as("followUserId"),
user.nickName.as("nickName"),
user.imageUrl.as("userProfileImage"),
// 서브쿼리로 상대방을 내가 팔로우하는지 체크
new CaseBuilder()
.when(JPAExpressions
.selectOne()
.from(f2) // 별도 별칭 사용
.where(f2.userId.eq(userId)
.and(f2.targetUserId.eq(follow.userId))
.and(f2.status.eq(FollowStatus.FOLLOWING)))
.exists())
.then(FollowStatus.FOLLOWING)
.otherwise(FollowStatus.UNFOLLOW).as("status"),
supporter.followReviewCountSubQuery(follow.userId),
supporter.followRatingCountSubQuery(follow.userId)
))
.from(follow)
.leftJoin(user).on(user.id.eq(follow.userId))
.where(follow.targetUserId.eq(userId)
.and(follow.status.eq(FollowStatus.FOLLOWING)))
.orderBy(follow.lastModifyAt.desc())
.offset(cursor)
.limit(pageSize + 1)
.fetch();
RelationUserInfo는 record로 정의된 DTO이며, FollowStatus Enum을 포함한다
public record RelationUserInfo(
Long userId,
Long followUserId,
String nickName,
String userProfileImage,
FollowStatus status,
Long reviewCount,
Long ratingCount
) {
}
FollowStatus 는 Enum타입이고 다음과 같이 정의되어 있다:
public enum FollowStatus {
FOLLOWING("팔로잉"),
UNFOLLOW("언팔로우");
private final String description;
FollowStatus(String description) {
this.description = description;
}
@JsonCreator
public static FollowStatus parsing(String followStatus) {
// 문자열을 FollowStatus로 변환 로직
}
}
문제 상황 : CaseBuilder와 Enum 직접 사용
아래와 같이 CaseBuilder를 사용했다.
private List<RelationUserInfo> getFollowerDetails(Long userId, Long cursor, Long pageSize) {
QFollow f2 = new QFollow("f2"); // 별도의 별칭 인스턴스 생성
return queryFactory
.select(Projections.constructor(
RelationUserInfo.class,
follow.userId.as("userId"),
follow.targetUserId.as("followUserId"),
user.nickName.as("nickName"),
user.imageUrl.as("userProfileImage"),
// 서브쿼리로 상대방을 내가 팔로우하는지 체크
new CaseBuilder()
.when(JPAExpressions
.selectOne()
.from(f2) // 별도 별칭 사용
.where(f2.userId.eq(userId)
.and(f2.targetUserId.eq(follow.userId))
.and(f2.status.eq(FollowStatus.FOLLOWING)))
.exists())
.then(FollowStatus.FOLLOWING)
.otherwise(FollowStatus.UNFOLLOW).as("status"),
supporter.followReviewCountSubQuery(follow.userId),
supporter.followRatingCountSubQuery(follow.userId)
))
.from(follow)
.leftJoin(user).on(user.id.eq(follow.userId))
.where(follow.targetUserId.eq(userId)
.and(follow.status.eq(FollowStatus.FOLLOWING)))
.orderBy(follow.lastModifyAt.desc())
.offset(cursor)
.limit(pageSize + 1)
.fetch();
}
발생한 오류
이 코드를 실행하면 다음과 같은 예외가 발생했다:
org.springframework.orm.jpa.JpaSystemException:
Caused by: org.hibernate.query.sqm.sql.ConversionException:
Could not determine ValueMapping for SqmParameter: SqmPositionalParameter(2)
오류 원인
검색으로 알아낸 원인은 다음과 같았다. 검색 결과가 많지 않았다ㅜㅜ
- Enum 객체의 직접 사용:
- CaseBuilder에서 .then(FollowStatus.FOLLOWING)과 .otherwise(FollowStatus.UNFOLLOW)로 Enum 객체를 직접 전달했다.
- Hibernate는 JPQL에서 Enum을 처리할 때 이를 문자열(예: 'FOLLOWING')이나 ordinal 값으로 변환해야 하지만, QueryDSL의 CaseBuilder가 Enum 객체를 Expression으로 제대로 매핑하지 못했다. 이로 인해 Hibernate가 SqmParameter를 처리할 때 타입 매핑을 결정하지 못해 ConversionException이 발생했다.
- Hibernate와 QueryDSL의 호환성 문제:
- QueryDSL 5.0.0과 Hibernate 6.2.22에서 CaseBuilder가 Enum 값을 직접 처리할 때 발생하는 문제다. Hibernate는 ?2와 같은 파라미터로 Enum 객체를 바인딩하려 했지만, 이를 SQL 값으로 변환할 방법을 찾지 못했다.
Hibernate가 쿼리를 생성하는 과정에서 Enum 값을 파라미터로 매핑하지 못해 JpaSystemException이 발생했다. 이는 QueryDSL과 Hibernate 간의 Enum 처리 방식 차이에서 비롯된 문제로, 쿼리 실행이 실패했다.
해결 방법 1 : Enum 자체를 전달하지 않고, .name()으로 문자열 전달
.then(FollowStatus.FOLLOWING.name())
.otherwise(FollowStatus.UNFOLLOW.name()).as("status"),
이렇게 Enum의 내장함수인 .name()을 사용하면 문자열로 전달할 수 있다.
하지만 이 방법은 dto에 문자열을 파라미터로 받는 생성자를 추가해주어야 한다.
private List<RelationUserInfo> getFollowerDetails(Long userId, Long cursor, Long pageSize) {
QFollow f2 = new QFollow("f2"); // 별도의 별칭 인스턴스 생성
return queryFactory
.select(Projections.constructor(
RelationUserInfo.class,
follow.userId.as("userId"),
follow.targetUserId.as("followUserId"),
user.nickName.as("nickName"),
user.imageUrl.as("userProfileImage"),
// 서브쿼리로 상대방을 내가 팔로우하는지 체크
new CaseBuilder()
.when(JPAExpressions
.selectOne()
.from(f2) // 별도 별칭 사용
.where(f2.userId.eq(userId)
.and(f2.targetUserId.eq(follow.userId))
.and(f2.status.eq(FollowStatus.FOLLOWING)))
.exists())
.then(FollowStatus.FOLLOWING.name())
.otherwise(FollowStatus.UNFOLLOW.name()).as("status"),
supporter.followReviewCountSubQuery(follow.userId),
supporter.followRatingCountSubQuery(follow.userId)
))
.from(follow)
.leftJoin(user).on(user.id.eq(follow.userId))
.where(follow.targetUserId.eq(userId)
.and(follow.status.eq(FollowStatus.FOLLOWING)))
.orderBy(follow.lastModifyAt.desc())
.offset(cursor)
.limit(pageSize + 1)
.fetch();
}
해결 방법 2 : String 반환 후 DTO에서 변환
Expressions.stringTemplate 을 사용해서도 문제를 해결할 수 있었다
{0} 등을 이용해서 직접 파라미터를 전달 할 수 있다.
private List<RelationUserInfo> getFollowerDetails(Long userId, Long cursor, Long pageSize) {
QFollow f2 = new QFollow("f2");
BooleanExpression isFollowing = JPAExpressions
.selectOne()
.from(f2)
.where(f2.userId.eq(userId)
.and(f2.targetUserId.eq(follow.userId))
.and(f2.status.eq(FollowStatus.FOLLOWING)))
.exists();
return queryFactory
.select(Projections.constructor(
RelationUserInfo.class,
follow.userId.as("userId"),
follow.targetUserId.as("followUserId"),
user.nickName.as("nickName"),
user.imageUrl.as("userProfileImage"),
Expressions.stringTemplate(
"CASE WHEN {0} THEN {1} ELSE {2} END",
isFollowing,
FollowStatus.FOLLOWING.name(),
FollowStatus.UNFOLLOW.name()
).as("status"),
supporter.followReviewCountSubQuery(follow.userId),
supporter.followRatingCountSubQuery(follow.userId)
))
.from(follow)
.leftJoin(user).on(user.id.eq(follow.userId))
.where(follow.targetUserId.eq(userId)
.and(follow.status.eq(FollowStatus.FOLLOWING)))
.orderBy(follow.lastModifyAt.desc())
.offset(cursor)
.limit(pageSize + 1)
.fetch();
}
하지만 여기서도 생성자 이슈가 있어서 DTO record에 (RelationUserInfo)에 추가 생성자를 정의했다.
생성자 오류가 발생하는 이유
record에서 자동으로 생성해주는 생성자는 파라미터가 FollowStatus 타입의 status가 전달되는 생성자를 자동으로 만들어준다.
하지만 QueryDsl의 Expressions.StringTemplates(...)는 문자열의 FOLLOWING, UNFOLLOW를 전달해서 생성자 오류가 발생한다.
그래서 아래와 같이 string타입의 status를 전달받아서 FollowStatus 타입으로 바꿔주는 생성자를 추가로 정의해줘야 한다.
@Builder
public record RelationUserInfo(
Long userId,
Long followUserId,
String nickName,
String userProfileImage,
FollowStatus status,
Long reviewCount,
Long ratingCount
) {
public RelationUserInfo(Long userId, Long followUserId, String nickName, String userProfileImage,
String status, Long reviewCount, Long ratingCount) {
this(userId, followUserId, nickName, userProfileImage,
FollowStatus.parsing(status), reviewCount, ratingCount);
}
}
생성된 쿼리
SELECT
f.userId AS userId,
f.targetUserId AS followUserId,
u.nickName AS nickName,
u.imageUrl AS userProfileImage,
CASE WHEN EXISTS (
SELECT 1 FROM Follow f2
WHERE f2.userId = ?1
AND f2.targetUserId = f.userId
AND f2.status = 'FOLLOWING'
) THEN 'FOLLOWING' ELSE 'UNFOLLOW' END AS status,
...
FROM Follow f
LEFT JOIN Users u ON u.id = f.userId
WHERE f.targetUserId = ?2 AND f.status = 'FOLLOWING'
ORDER BY f.lastModifyAt DESC
해결 이유
- Enum 처리 회피: 쿼리 단계에서 FollowStatus Enum 객체를 사용하지 않고, 대신 문자열로 변환해 Hibernate가 이를 파라미터로 매핑할 필요를 없앴다.
- DTO 레벨 변환: String 값을 FollowStatus로 변환하는 로직을 쿼리 밖(DTO 생성자)으로 이동시켜 Hibernate와 QueryDSL 간의 충돌을 방지했다.
- 명확한 타입 매핑: Hibernate가 처리해야 할 값이 단순 문자열로 바뀌면서 ValueMapping 오류가 발생하지 않았다.
결론
- 문제 원인은 CaseBuilder로 Enum을 직접 처리하려다 Hibernate가 이를 파라미터로 매핑하지 못해 실패했다. ConversionException(ValueMapping 오류)은 QueryDSL과 Hibernate 간의 Enum 처리 충돌에서 비롯된 문제였다.
- 해결책은 쿼리에서 Enum 대신 문자열을 반환하고, DTO에서 변환 로직을 처리함으로써 Hibernate의 한계를 우회했다는 것.