회사에서 SQL 쿼리를 작성하다 보니 CASE WHEN을 사용해야 하는 경우가 있었는데(안티패턴으로 취급되기도 하지만) QueryDSL에서 CASE WHEN 과 함께 enum 으로 받고 싶어서 사용해보다가 뭔가 이상한 구석이 있어서 한번 기록으로 적어보았다.

 

QueryDSL에서 CASE WHEN 사용법의 경우 이곳에 잘 정리되어 있다.


사용법

우선 EnumPath 를 사용할 경우 예시로 아래와 같이 사용할 수 있다.

@RequiredArgsConstructor
public class PartnerSubAccountRepositorySupportImpl implements PartnerSubAccountRepositorySupport {
    private final JPAQueryFactory queryFactory;
    
    @Override
    public List<SubAccountInfo> test() {
        QPartnerSubAccount entity = QPartnerSubAccount.partnerSubAccount;
        return queryFactory
                .select(
                        Projections.constructor(SubAccountInfo.class
                                , entity.seqNo.as("seqNo")
                                , entity.resellerId
                                , entity.registDate
                                , new CaseBuilder()
                                        .when(subAccount.suspendDate.isNotNull())
                                        .then(Expressions.enumPath(SubAccountStatusType.class, "'SUSPEND'"))
                                        .otherwise(Expressions.enumPath(SubAccountStatusType.class, "'USE'"))
                                        .as("statusFlag")
                        ))
                .from(entity)
                .fetch();
    }
}

위의 쿼리를 실행하면 아래처럼 실행된다.

select
    * 
from
    ( select
        partnersub0_.seqno as col_0_0_,
        partnersub0_.reseller_id as col_1_0_,
        partnersub0_.registdate as col_2_0_,
        case 
            when partnersub0_.suspenddate is not null then 'SUSPEND' 
            else 'USE' 
        end as col_3_0_
    from
        TESTSCHEMA.PARTNER_SUB_ACCOUNT partnersub0_ )

Expressions.enumPath 사용법은 아래와 같다.

Expressions.enumPath(Enum 클래스, 왼쪽의 Enum 클래스를 valueOf 로 해석할 수 있는 값);

여기서 한 가지 주의해야 할 것이 있다. select 표현식에 숫자 자료형인 데이터를 함께 넣고, 거기에 Path 인터페이스로 할당되지 않도록 만들어주어야 한다.

위의 코드를 보면 entity.seqNo 가 있는데 이는 Entity 에서 Long 으로 정의되어 있고 아래처럼 작성해야 작동하게 된다.

                        Projections.constructor(SubAccountInfo.class
                                , entity.seqNo.as("seqNo") // NumberExpression
                                //, entity.seqNo // NumberPath

enumPath 를 사용하려는데 왜 굳이 이렇게 해야 할까? 이상하다.

참고로 이렇게 하지 않으면 argument type mismatch 라는 오류가 나타난다.


이유

QueryDSL의 경우 JDBC를 통해 데이터를 가져오고 난 후 select 로 가져온 모든 컬럼 값들의 형변환 작업이 이루어지게 되는데 이때 하나의 변환 클래스로 모든 컬럼 값들의 변환이 이루어지고, 이 변환 클래스는 처음 .select() 를 사용할 때 결정된다.

// JPAQueryFactory.java
@Override
public <T> JPAQuery<T> select(Expression<T> expr) {
    return query().select(expr);
}
// JPAQuery.java
@Override
public <U> JPAQuery<U> select(Expression<U> expr) {
    queryMixin.setProjection(expr);
    @SuppressWarnings("unchecked") // This is the new type
    JPAQuery<U> newType = (JPAQuery<U>) this;
    return newType;
}
// QueryMixin.java
public <E> Expression<E> setProjection(Expression<E> e) {
    e = convert(e, Role.SELECT);
    metadata.setProjection(e);
    return e;
}
// JPAQueryMixin.java
@SuppressWarnings("unchecked")
@Override
public <RT> Expression<RT> convert(Expression<RT> expr, Role role) {
    expr = (Expression<RT>) expr.accept(mapAccessVisitor, null);
    expr = (Expression<RT>) expr.accept(listAccessVisitor, null);
    if (role == Role.ORDER_BY) {
        if (expr instanceof Path) {
            expr = convertPathForOrder((Path) expr);
        } else {
            expr = (Expression<RT>) expr.accept(replaceVisitor, null);
        }
    }
    return Conversions.convert(super.convert(expr, role));  // 이 부분에서 결정된다.
}

파고 들어가다보면 사실상 Conversions 라는 클래스에서 반환되는 것을 볼 수 있다. 더 들어가다보면 이런 부분이 있다.

위의 convert 로직을 통해 select 절에 있는 모든 표현식을 살펴보면서 변환에 이용될 클래스를 먼저 찾게 된다.

결국 NumberConversions 또는 인자로 넘어온 ConstantHidingExpression 를 반환하게 되는데, 이 두 개의 클래스는 공통적으로 FactoryExpressionBase 를 상속해서 사용하고 있고, newInstance 라는 메서드를 Override 하고 있다. 이 newInstance 라는 메서드를 기억해두자.

그러면 위의 newInstance 는 어디서 호출될까? QueryDSL에서는 쿼리를 실행하고 아래의 로직을 타게 되면서 변환 작업이 이루어지게 된다.

// Loader.java
private List listIgnoreQueryCache(SharedSessionContractImplementor session, QueryParameters queryParameters) {
	return getResultList( doList( session, queryParameters ), queryParameters.getResultTransformer() );
}
// QueryLoader.java
@SuppressWarnings("unchecked")
@Override
protected List getResultList(List results, ResultTransformer resultTransformer) throws QueryException {
	// meant to handle dynamic instantiation queries...
	HolderInstantiator holderInstantiator = buildHolderInstantiator( resultTransformer );
	if ( holderInstantiator.isRequired() ) {
		for ( int i = 0; i < results.size(); i++ ) {
			Object[] row = (Object[]) results.get( i );
			Object result = holderInstantiator.instantiate( row ); // 여기
			results.set( i, result );
		}
        
        ...
// FactoryExpressionTransformer.java
public final class FactoryExpressionTransformer implements ResultTransformer {

    private static final long serialVersionUID = -3625957233853100239L;

    private final transient FactoryExpression<?> projection;

    public FactoryExpressionTransformer(FactoryExpression<?> projection) {
        this.projection = projection; // 1
    }

    ...

    @Override
    public Object transformTuple(Object[] tuple, String[] aliases) {
        if (projection.getArgs().size() < tuple.length) {
            Object[] shortened = new Object[projection.getArgs().size()];
            System.arraycopy(tuple, 0, shortened, 0, shortened.length);
            tuple = shortened;
        }
        return projection.newInstance(tuple); // 2
    }

}

아까 전에 말했던 Conversions 에서 반환된 클래스가 위 코드의 1 에 들어가고, 나중에 transformTuple 메서드를 실행하게 되면서 위 코드의 2 를 실행하게 된다. 즉, 위에서 언급했던 newInstance 가 이곳에서 실행된다.

 

다시 newInstance 메서드를 보자.

 

NumberConversions 에서는 Enum 변환에 대한 로직이 있는데, ConstantHidingExpression 에서는 Enum 변환에 대한 로직이 없다. Enum 변환을 위해서는 NumberConversions 를 반환하도록 해야 하는데, Conversions 클래스의 needsNumberConversions를 보면 어떻게 해야 하는지 알 수 있다.

// Conversions.java
private static boolean needsNumberConversion(Expression<?> expr) {
    expr = ExpressionUtils.extract(expr);
    return Number.class.isAssignableFrom(expr.getType()) && !Path.class.isInstance(expr);
}

즉, select 절을 이용할 때 표현식이 숫자 자료형이어야 하고 Path 인터페이스를 사용하지 않아야 한다.

QueryDSL의 경우 Q클래스를 만들고 나면 기본적으로 Entity 내의 컬럼들은 공통적으로 Path 인터페이스를 구현한 클래스를 사용하게 된다.

이를 그대로 사용하면 안 된다는 소리다.

초반에 예시로 QueryDSL 코드를 작성했던 것을 보자.

Projections.constructor(SubAccountInfo.class
          , entity.seqNo.as("seqNo") // NumberExpression
          //, entity.seqNo // NumberPath

위에서 Q클래스를 통해 컬럼을 사용할 때 위에서 반환되는 것은 얼추 보면 문제 없을 것 같고 비슷한 것 같지만 실제로는 다르다는 것을 알 수 있다.

.as 를 사용하게 되면 Path 인터페이스에서 벗어나므로 NumberConversions 로 이용하게 만들 수 있다.

 

따라서 enumPath 를 사용할 때 숫자 자료형인 컬럼에 .as 를 사용하여 NumberConversions 로 사용하도록 만들어주어야 비로소 제대로 사용할 수 있다.