지난 글 끝에 엔티티를 data class로 못 쓰는 이유가 하나 더 있다고 적어 뒀습니다. 코틀린 클래스가 기본으로 final이라 프록시를 못 만든다는 거였죠. 그 얘기를 마저 하겠습니다.
새 프로젝트를 스프링 이니셜라이저에서 코틀린으로 만들면 이 문제를 겪을 일이 없습니다. 필요한 설정이 전부 들어간 채로 나오니까요. 문제는 기존 자바 프로젝트입니다. 빌드 설정은 그대로인데 파일만 코틀린이 되면 하나씩 터집니다. 제가 그랬습니다.
플러그인을 넣고 빼 가며 무엇이 어떻게 깨지는지 확인했습니다. Spring Boot 3.3.4, Kotlin 2.0.21, JDK 21 기준입니다.
애플리케이션 클래스 하나에서 막힙니다
엔티티도 서비스도 아직 건드리지 않았는데 첫 줄에서 멈췄습니다.
@SpringBootApplication
class App
Configuration problem: @Configuration class 'App' may not be final.
Remove the final modifier to continue.
메시지가 말 그대로입니다. 스프링은 @Configuration 클래스를 상속해 프록시를 만드는데, 코틀린 클래스는 기본이 final이라 상속할 수가 없습니다. 자바는 반대로 기본이 상속 가능이고 막으려면 final을 붙이죠. 그 차이가 여기서 정면으로 부딪힙니다.
모든 클래스에 open을 붙이는 게 답은 아닙니다. 컴파일러 플러그인이 필요한 애너테이션이 붙은 클래스만 열어 줍니다.
plugins {
kotlin("plugin.spring") version "2.0.21"
}
@Component, @Configuration, @Service, @Transactional, @Async, @Cacheable이 붙은 클래스가 대상입니다. 소스 코드에는 final이 그대로 있고 컴파일 결과만 열립니다. 그래서 IDE에서 보면 여전히 final이라 처음엔 좀 헷갈렸습니다.
엔티티는 두 가지가 더 필요합니다
엔티티는 조건이 하나 더 붙습니다. JPA는 기본 생성자로 객체를 만든 뒤 필드를 채우는데, 코틀린에서 생성자 인자를 선언하면 기본 생성자가 없어집니다.
plugin.jpa를 넣으면 @Entity가 붙은 클래스에 기본 생성자가 자동으로 생깁니다. 그리고 지연 로딩 프록시를 만들려면 엔티티도 열어야 하는데, 이건 목록에 직접 적어야 합니다.
allOpen {
annotation("jakarta.persistence.Entity")
annotation("jakarta.persistence.MappedSuperclass")
annotation("jakarta.persistence.Embeddable")
}
plugin.spring이 스프링 애너테이션만 챙기고 JPA 것은 안 챙기기 때문입니다. 저는 이걸 몰라서 앱은 뜨는데 지연 로딩이 안 되는 상태로 한참 있었습니다.
둘을 넣고 리플렉션으로 직접 찍어 봤습니다.
Team 클래스가 final 인가 : false
Team 에 기본 생성자가 있는가 : 예
지연 로딩 프록시 클래스 : Team$HibernateProxy$WVgNd5KL
프록시가 Team 의 하위인가 : true
네 줄이 각각 하나씩 확인해 줍니다. 클래스가 열렸고, 기본 생성자가 생겼고, 하이버네이트가 프록시를 만들었고, 그 프록시가 엔티티의 하위 타입입니다. 하나라도 어긋나면 지연 로딩이 실패합니다.
리포지터리가 안 만들어진다면
여기서 한 번 더 걸렸습니다. 설정을 다 맞췄는데 이런 오류가 났습니다.
NoClassDefFoundError: kotlin/reflect/full/KClasses
Error creating bean with name 'teamRepository'
메시지만 보면 리포지터리 설정이 잘못된 것 같은데 원인은 의존성 하나였습니다. 스프링 데이터가 코틀린 리플렉션을 씁니다.
implementation(kotlin("reflect"))
이니셜라이저로 만들면 기본으로 들어 있어서, 기존 프로젝트에 코틀린을 얹을 때만 만나는 문제입니다. 저처럼 리포지터리 코드를 한참 들여다보지 마시길.
스프링 공식 문서의 코틀린 지원 항목에도 이 의존성이 필요하다고 적혀 있습니다. 읽을 때는 그러려니 했는데 오류 메시지가 리포지터리를 가리키니 연결이 안 되더군요.
요청 본문이 안 풀린다면
이건 제가 직접 재현하지는 않았고, 널리 알려진 항목이라 함께 적습니다. 잭슨이 코틀린의 기본 인자와 널 가능 정보를 읽으려면 별도 모듈이 필요합니다.
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
없으면 요청 본문을 역직렬화할 때 기본 생성자를 찾지 못하거나, 널 불가로 선언한 필드에 널이 들어갑니다. 뒤쪽이 특히 고약합니다. 컨트롤러는 멀쩡히 통과하고 서비스 어딘가에서 NPE가 나는데, 스택 트레이스에는 요청 본문 얘기가 한 줄도 없습니다.
요청 DTO를 data class로 쓰면서 기본 인자를 준 경우가 특히 그렇습니다. 잭슨이 기본 인자를 모르면 인자 없는 생성자를 찾다가 실패하거나, 필드를 빈 채로 두고 객체를 만듭니다. DTO에 기본값을 넣어 두는 습관이 있다면 이 모듈은 선택이 아닙니다.
한 번에 맞추는 설정
파일을 옮기기 전에 이걸 먼저 맞추면 위의 문제가 대부분 발생하지 않습니다.
plugins {
kotlin("jvm")
kotlin("plugin.spring") // @Component 계열을 open 으로
kotlin("plugin.jpa") // @Entity 에 기본 생성자
kotlin("plugin.lombok") // 기존 롬복 클래스를 코틀린에서 읽기
}
allOpen { // 엔티티도 열어야 지연 로딩이 된다
annotation("jakarta.persistence.Entity")
annotation("jakarta.persistence.MappedSuperclass")
annotation("jakarta.persistence.Embeddable")
}
dependencies {
implementation(kotlin("reflect"))
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
}
롬복 플러그인을 쓴다면 설정 파일도 하나 필요합니다.
# lombok.config
lombok.addLombokGeneratedAnnotation = true
자바 프로젝트에 롬복이 있다면 이게 없으면 코틀린에서 그 클래스의 게터가 안 보입니다. 코틀린이 자바보다 먼저 컴파일되는데, 그때 읽는 게 자바 소스라 롬복이 만들 게터가 아직 없기 때문입니다. 이 얘기는 따로 한 편이 필요할 것 같습니다.
플러그인 네 개 중 셋은 버전을 코틀린 버전과 맞춰야 합니다. 코틀린을 올릴 때 플러그인 버전을 안 올리면 컴파일이 깨지니, 버전을 변수 하나로 묶어 두는 편이 나중에 덜 귀찮습니다.
오류 메시지로 원인 찾기
| 메시지 | 빠진 것 |
|---|---|
@Configuration class ... may not be final |
plugin.spring |
| 엔티티에 기본 생성자가 없다는 오류 | plugin.jpa |
| 앱은 뜨는데 지연 로딩이 안 된다 | allOpen 목록의 @Entity |
NoClassDefFoundError: kotlin/reflect/... |
kotlin-reflect 의존성 |
| 요청 본문 역직렬화 실패 | jackson-module-kotlin |
Cannot access 'field ...': it is private |
plugin.lombok |
세 번째가 가장 조용합니다. 앱이 정상으로 뜨고 예외도 안 나는데 연관을 읽는 순간 초기화가 안 됩니다. 나머지는 부팅할 때 바로 걸려서 오히려 낫습니다.
순서대로 보면 위의 셋은 빌드 설정, 아래 셋은 의존성 문제입니다. 부팅이 안 되면 위쪽을, 부팅은 되는데 동작이 이상하면 아래쪽을 먼저 봅니다. 제 경우는 위에서 아래로 정확히 하나씩 밟았고, 한 번에 다 맞췄으면 반나절을 아꼈을 겁니다.
설정이 맞았는지 확인하는 법
문서만 보고 넘어가면 세 번째 같은 건 놓칩니다. 테스트 하나로 찍어 보는 편이 확실합니다.
println(java.lang.reflect.Modifier.isFinal(Team::class.java.modifiers)) // false 여야 한다
println(runCatching { Team::class.java.getDeclaredConstructor() }.isSuccess) // true 여야 한다
지연 로딩까지 확인하려면 연관을 하나 꺼내 클래스 이름을 찍어 봅니다. Team$HibernateProxy$ 같은 이름이 나오면 프록시가 만들어진 것이고, 그냥 Team이 나오면 이미 초기화됐거나 프록시를 못 만든 겁니다.
이 세 줄을 통합 테스트에 한 번 넣어 두면 나중에 누가 플러그인을 빼도 CI에서 잡힙니다. 설정 파일은 리팩터링하다가 조용히 사라지는 일이 있어서, 저는 이걸 테스트로 박아 두는 쪽을 택했습니다.
마무리
코틀린으로 스프링을 쓰면서 겪는 초반 문제는 대부분 언어가 아니라 빌드 설정입니다. 그리고 전부 부팅 시점이나 첫 쿼리에서 드러나기 때문에, 파일을 옮기기 전에 설정부터 맞춰 두면 하루치 삽질이 사라집니다.
순서를 정리하면 이렇습니다. 플러그인 네 개와 allOpen 목록, 의존성 두 개를 먼저 넣습니다. 그다음 테스트 코드나 DTO처럼 영향이 적은 파일부터 옮깁니다. 엔티티는 가장 마지막입니다. 준비 없이 옮기면 프록시가 막히고 지연 로딩이 깨지는데, 그게 바로 위에서 본 세 번째 항목입니다.
자바 개발자를 위한 코틀린
문법이 아니라 생성되는 코드를 봅니다
이 글에서 쓴 방식 그대로, 코틀린이 자바로 무엇이 되는지 디컴파일해서 확인한 책입니다. 널 안전성부터 코루틴·스프링 설정까지 여덟 장이고, 모든 수치와 오류 메시지는 실제로 돌려 얻었습니다. 확인에 쓴 코드는 도커로 그대로 재현됩니다.
공개 준비 중입니다. 나오면 알려 드릴 수 있도록 텔레그램 채널에 소식을 올리겠습니다.
이 주제를 더 깊게 보려면
이 링크를 통해 수강 신청하면 운영자에게 소정의 수수료가 지급될 수 있습니다. 강의 가격은 달라지지 않습니다.