문제 상황
우리 프로젝트는 Spring Boot + Kotlin + JPA 환경에서 Kotlin JDSL을 사용해 타입 세이프한 쿼리를 작성하고 있었다. 새로운 요구사항으로 회원 조회 시 createdAt 필드를 추가해야 했고, 단순히 DTO에 필드를 추가했다.
data class MemberQueryDto(
val id: String,
val name: String,
val email: String,
val isActive: Boolean,
val isEmailVerified: Boolean,
val createdAt: Long, // 새로 추가한 필드
)
Entity에도 필드가 있고, JDSL 쿼리에서도 select 절에 추가했다. 완벽해 보였다. 그런데 테스트를 돌리자마자 이런 에러가 나를 반겼다.
org.hibernate.InstantiationException: Cannot instantiate query result type
'com.example.persistence.MemberQueryDto' due to:
com.example.persistence.MemberQueryDto.<init>(
java.lang.String,
java.lang.String,
java.lang.String,
java.lang.Boolean,
java.lang.Boolean,
java.lang.Long
)
생성자를 찾을 수 없다고? 분명히 저 시그니처대로 생성자가 있는데?
삽질의 시작
처음에는 필드 순서가 잘못됐나 싶었다. JDSL select 절의 필드 순서와 DTO 생성자의 파라미터 순서를 맞춰봤다. 여전히 실패.
혹시 import가 잘못됐나? 패키지 경로를 확인했다. 문제없다.
캐시 문제인가 싶어서 ./gradlew clean build도 해봤다. 여전히 실패.
에러 메시지를 다시 자세히 읽어봤다.
java.lang.NoSuchMethodException:
com.example.persistence.MemberQueryDto.<init>(
java.lang.String,
java.lang.String,
java.lang.String,
java.lang.Boolean, // 👀
java.lang.Boolean, // 👀
java.lang.Long // 👀
)
Hibernate가 찾는 생성자의 파라미터 타입이 java.lang.Boolean, java.lang.Long이다. 즉, boxed 타입을 찾고 있다.
그런데 내 DTO는?
data class MemberQueryDto(
// ...
val isActive: Boolean, // Kotlin Boolean
val isEmailVerified: Boolean, // Kotlin Boolean
val createdAt: Long, // Kotlin Long
)
여기서 뭔가 이상함을 감지했다.
원인 발견: Kotlin과 Java의 타입 시스템 차이
Kotlin 코드가 Java 바이트코드로 컴파일될 때, 타입이 어떻게 변환되는지 확인해봤다.
Kotlin에서 Boolean과 Boolean?은 다른 타입이다. 그리고 이 둘은 Java로 컴파일될 때 완전히 다른 타입이 된다.
| Kotlin | Java |
| Boolean (non-null) | boolean (primitive) |
| Boolean? (nullable) | java.lang.Boolean (boxed) |
| Long (non-null) | long (primitive) |
| Long? (nullable) | java.lang.Long (boxed) |
| Int (non-null) | int (primitive) |
| Int? (nullable) | java.lang.Integer (boxed) |
반면 String은 어떨까?
| Kotlin | Java |
| String | java.lang.String |
| String? | java.lang.String |
String은 Java에서 원래 참조 타입이므로, nullable 여부와 관계없이 동일한 java.lang.String으로 컴파일된다.
non-null이어도 boxed가 되는 예외 상황도 있다.
1. 제네릭 타입 파라미터로 사용될 때
val list: List<Int> = listOf(1, 2, 3) // 내부적으로 Integer
Any 타입으로 받을 때
val x: Any = 10 // Integer로 boxing
인터페이스/추상 클래스의 타입 파라미터
fun <T> process(value: T) { }
process(10) // Integer로 boxing
문제의 핵심
Hibernate가 DTO Projection을 할 때는 리플렉션을 통해 생성자를 찾아서 호출한다. 이때 Hibernate가 전달하려는 값들은 JDBC에서 가져온 값들이고, 이 값들은 boxed 타입(java.lang.Boolean, java.lang.Long 등)이다.
그런데 내 DTO 생성자는 Kotlin에서 non-nullable로 선언되어 primitive 타입(boolean, long)을 기대한다.
// Hibernate가 찾는 생성자
MemberQueryDto(String, String, String, Boolean, Boolean, Long)
// 실제 컴파일된 생성자
MemberQueryDto(String, String, String, boolean, boolean, long)
시그니처가 다르니 NoSuchMethodException이 발생한 것이다
해결 방법
해결은 간단했다. DTO의 primitive 타입 필드들을 nullable로 선언하면 된다.
// ❌ Before: 에러 발생
data class MemberQueryDto(
val id: String,
val name: String,
val email: String,
val isActive: Boolean,
val isEmailVerified: Boolean,
val createdAt: Long,
)
// ✅ After: 정상 동작
data class MemberQueryDto(
val id: String,
val name: String,
val email: String,
val isActive: Boolean? = null,
val isEmailVerified: Boolean? = null,
val createdAt: Long? = null,
)
nullable로 선언하면 Java에서 boxed 타입으로 컴파일되어, Hibernate가 기대하는 생성자 시그니처와 일치하게 된다.
테스트 코드 실행에 성공했다.
잠깐, Entity는 왜 괜찮을까?
여기서 한 가지 의문이 들었다. JPA Entity에서는 non-nullable primitive 타입을 잘 쓰고 있는데, 왜 Entity 는 문제가 없을까?
@Entity
@Table(name = "member")
class MemberJpaEntity(
@Id
val id: String,
val name: String,
@Column(columnDefinition = "boolean default false")
val isActive: Boolean = false, // non-nullable인데 잘 동작함
val createdAt: Long, // 이것도 non-nullable인데 문제없음
)
이유는 Hibernate가 Entity와 DTO를 인스턴스화하는 방식이 다르기 때문이다.
Entity 인스턴스화
1. 기본 생성자(또는 no-arg constructor)로 빈 객체 생성
2. 리플렉션으로 각 필드에 값을 직접 설정
DTO Projection 인스턴스화
1. select 절의 컬럼들을 파라미터로 받는 생성자를 찾음
2. 해당 생성자를 호출하여 객체 생성
Entity는 생성자 시그니처 매칭이 필요 없고, DTO Projection은 정확한 생성자 시그니처 매칭이 필수인 것이다.
전체 코드 예시
최종적으로 정리된 코드 구조는 다음과 같다.
Entity (non-nullable 가능)
@Entity
@Table(name = "member")
class MemberJpaEntity(
@Id
@Column(length = 40)
val id: String,
@Column(nullable = false)
val name: String,
@Column(nullable = false, unique = true)
val email: String,
@Column(columnDefinition = "boolean default false")
val isActive: Boolean = false,
@Column(columnDefinition = "boolean default false")
val isEmailVerified: Boolean = false,
@Column(nullable = false)
val point: Long = 0L,
@Column(nullable = false)
val createdAt: Long,
)
DTO (primitive 타입은 nullable로)
data class MemberQueryDto(
val id: String,
val name: String,
val email: String,
val isActive: Boolean? = null,
val isEmailVerified: Boolean? = null,
val point: Long? = null,
val createdAt: Long? = null,
)
JDSL 쿼리
object MemberKotlinJDSL {
fun findActiveMembers(cursor: MemberCursor?, limit: Int): SelectQuery<MemberQueryDto> {
return jpql {
selectNew<MemberQueryDto>(
path(MemberJpaEntity::id),
path(MemberJpaEntity::name),
path(MemberJpaEntity::email),
path(MemberJpaEntity::isActive),
path(MemberJpaEntity::isEmailVerified),
path(MemberJpaEntity::point),
path(MemberJpaEntity::createdAt),
).from(
entity(MemberJpaEntity::class)
).where(
path(MemberJpaEntity::isActive).eq(true)
).orderBy(
path(MemberJpaEntity::createdAt).desc()
)
}
}
}
도메인 모델로 변환 (Mapper)
object MemberMapper {
fun toDomain(dto: MemberQueryDto): Member {
return Member(
id = dto.id,
name = dto.name,
email = dto.email,
isActive = dto.isActive ?: false,
isEmailVerified = dto.isEmailVerified ?: false,
point = dto.point ?: 0L,
createdAt = dto.createdAt ?: 0L,
)
}
}
도메인 레이어에서 non-null이 필요하다면 Mapper에서 변환한다.
정리
| 타입 | Kotlin non-null | Kotlin nullable | Hibernate 기대 |
| Boolean | boolean (primitive) | java.lang.Boolean | java.lang.Boolean |
| Int | int (primitive) | java.lang.Integer | java.lang.Integer |
| Long | long (primitive) | java.lang.Long | java.lang.Long |
| Double | double (primitive) | java.lang.Double | java.lang.Double |
| String | java.lang.String | java.lang.String | java.lang.String |
| Custom Class | 참조 타입 | 참조 타입 | 참조 타입 |
핵심 규칙:
1. DTO Projection에서 primitive 타입(Boolean, Int, Long, Double 등)은 nullable로 선언
2. Entity는 Hibernate가 다른 방식으로 인스턴스화하므로 non-nullable 가능
3. String과 커스텀 객체는 nullable 여부와 관계없이 참조 타입이므로 상관없음
마치며
단순히 필드 하나 추가했을 뿐인데 테스트가 깨져서 당황했지만, 덕분에 Kotlin과 Java의 타입 시스템 차이, 그리고 Hibernate의 동작 방식에 대해 더 깊이 이해하게 되었다.
비슷한 문제로 고생하는 분들에게 이 글이 도움이 되길 바란다.