@Entity
@Table(name = "SOME_ENTITY")
public class SomeEntity {
    @Id 
    @Column(name = "id")
    private Long id;

    @Column(name = "created_at")
    private LocalDateTime createdAt;
}
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class SomeDto {
	private LocalDateTime createdAt;
}

위와 같은 Entity와 결과를 받아올 클래스가 있다고 가정하자.

여기서 Querydsl 의 Projections를 이용하여 createdAt 이라는 값을 select 한다고 해보자. 그러면 아래와 같이 작성할 수 있을 것이다.

@Override
public Optional<SomeDto> find1() {
    QSomeEntity d = QSomeEntity.someEntity;

    return Optional.ofNullable(
            queryFactory
                    .select(Projections.fields(SomeDto.class
                            , d.createdAt
                    ))
                    .from(d)
                    .fetchFirst()
    );
}
@Override
public Optional<SomeDto> find2() {
    QSomeEntity d = QSomeEntity.someEntity;

    return Optional.ofNullable(
            queryFactory
                    .select(Projections.fields(SomeDto.class
                            , Expressions.dateOperation(LocalDateTime.class, Ops.DateTimeOps.ADD_MONTHS, d.createdAt, Expressions.asNumber(12)).as("createdAt")
                    ))
                    .from(d)
                    .fetchFirst()
    );
}
@Override
public Optional<SomeDto> find3() {
    QSomeEntity d = QSomeEntity.someEntity;

    return Optional.ofNullable(
            queryFactory
                    .select(Projections.fields(SomeDto.class
                            , new CaseBuilder()
                                        .when(d.id.eq(1L)).then(Expressions.dateOperation(LocalDateTime.class, Ops.DateTimeOps.ADD_MONTHS, d.createdAt, Expressions.asNumber(12)))
                                        .when(d.id.eq(2L)).then(d.createdAt)
                                        .otherwise(Expressions.dateOperation(LocalDateTime.class, Ops.DateTimeOps.ADD_MONTHS, d.createdAt, Expressions.asNumber(12)))
                                        .as("createdAt")
                    ))
                    .from(d)
                    .fetchFirst()
    );
}

위 3개의 메서드 중에서 실패하는 메서드 하나가 있다. 세 번째 메서드 find3 의 경우는 아래와 같이 오류가 나면서 실패한다.

org.springframework.dao.InvalidDataAccessApiUsageException: Can not set java.time.LocalDateTime field ...SomeEntity.createdAt to java.sql.Date; nested exception is ...
...
Caused by: java.lang.IllegalArgumentException: Can not set java.time.LocalDateTime field ...SomeEntity.createdAt to java.sql.Date

재밌는 사실은 위의 CaseBuilder 로 작성된 첫 번째 when 을 빼버리고 실행하면 두 번째 when, 세 번째 otherwise 전부 작동이 잘 된다는 점이다.

@Override
public Optional<SomeDto> find3() {
    QSomeEntity d = QSomeEntity.someEntity;

    return Optional.ofNullable(
            queryFactory
                    .select(Projections.fields(SomeDto.class
                            , new CaseBuilder()
                                        //.when(d.id.eq(1L)).then(Expressions.dateOperation(LocalDateTime.class, Ops.DateTimeOps.ADD_MONTHS, d.createdAt, Expressions.asNumber(12)))
                                        .when(d.id.eq(2L)).then(d.createdAt)
                                        .otherwise(Expressions.dateOperation(LocalDateTime.class, Ops.DateTimeOps.ADD_MONTHS, d.createdAt, Expressions.asNumber(12)))
                                        .as("createdAt")
                    ))
                    .from(d)
                    .fetchFirst()
    );
}

이 문제는 사실 Querydsl 문제가 아니고 Hibernate 에서 발생하는 이슈이다.

 

결론은 방언으로 등록된 add_months 함수의 반환 데이터 타입이 java.sql.Date 이어서 그렇다. Oracle 의 경우 add_months 라는 함수를 통해 날짜를 더하거나 뺄 수가 있는데 Hibernate 에서 기본적으로 제공해주는 방언으로 Date 로 돌려주도록 되어 있다.

 

그렇다면 언제 어떻게 이런 타입으로 전달받게 되는 걸까?

 

Hibernate에는 내부적으로 HQL 노드를 찾으면서 어떤 데이터 타입으로 돌려주어야 하는지 정하는 클래스가 있다.

HQLQueryPlan 클래스의 생성자에서 쿼리문을 해석할 때 Case 문의 경우 SelectClause 클래스를 통해 데이터 타입을 전달받게 된다.

HQLQueryPlan ~ 112
SelectClause ~ 172
등록된 방언이 있으면 해당 방언의 데이터 타입으로 돌려주게 된다.

여기서 SqlNode 인터페이스 구현체에 따라서 돌려주는 데이터 타입이 다른데, 위에서는 MethodNode 로서 등록된 방언에 따라 DataType 객체로 돌려주게 된다.

두 번째 when 의 경우는 실제 우리가 적었던 entity 필드로 잡히게 되고, DotNode 로서 dataType 이라는 필드로 LocalDateTimeType 이라는 객체로 돌려주게 된다.

 

이렇게 만들어진 Type 객체는 Hibernate에서 Jdbc의 ResultSet을 만든 것을 가지고 Loader 클래스에서 마지막에 변환이 된다.

이때 QueryLoader 클래스에서 getResultRow 메서드를 통해 row 를 하나씩 변환하는데 BasicExtractor 라는 곳에서 맵핑된 타입을 가지고 돌려주게 된다.

 

결국, 이를 해결하려면 따로 방언으로 등록해서 사용하거나, Projections의 constructor 를 이용하여 생성자에서 Date로 받아 타입 변환으로 해결해야 한다.