팀 프로젝트에서 Java로 작성된 Spring Boot 백엔드를 Kotlin으로 점진적으로 마이그레이션하기로 했다. "한 번에 다 바꾸자"는 욕심을 버리고, entity/dto 클래스부터 시작해서 충돌을 최소화하며 전환하는 방식을 선택했다. 이 글은 그 과정에서 마주친 문제들과 선택의 이유를 기록한 글이다.
처음엔 단순하게 생각했다. Java 클래스를 Kotlin으로 변환할 때 Lombok 어노테이션(@Getter, @Builder, @AllArgsConstructor 등)을 그냥 유지하면 되지 않을까?
// 이렇게 하면 되지 않을까?
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
class Policy { ... }
결과는 빌드 실패였다.
error: cannot find symbol
Policy.builder()
kapt(Kotlin Annotation Processing Tool) 는 Kotlin 코드에서 어노테이션을 처리하는 도구인데, Kotlin primary constructor에 붙은 Lombok 어노테이션은 제대로 처리하지 못한다. Lombok은 원래 Java 컴파일러의 APT(Annotation Processing Tool)를 기반으로 동작하도록 설계되었기 때문이다.
Lombok @Builder를 대체하기 위해 Kotlin의 companion object에 Builder 클래스를 직접 구현했다.
@Entity
@Table(name = "policy")
class Policy(
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
var id: Int? = null,
var plcyNo: String? = null,
// ... 모든 필드를 primary constructor로
) {
companion object {
// Java 코드에서 Policy.builder() 그대로 사용 가능
@JvmStatic fun builder() = Builder()
@JvmStatic fun from(item: PolicyItem, rawJson: String?): Policy = Policy(
plcyNo = item.plcyNo(),
// ...
)
}
// 마이그레이션 안정성을 위해 임시로 builder 패턴 직접 구현
// 최종적으로는 삭제 필요
class Builder {
private var plcyNo: String? = null
fun plcyNo(v: String?) = apply { plcyNo = v }
fun build() = Policy(plcyNo = plcyNo, ...)
}
}
핵심은 기존 Java 코드(Policy.builder()...build())를 전혀 수정하지 않아도 된다는 점이다. @JvmStatic을 붙이면 Java에서 companion object 메서드를 정적 메서드처럼 호출할 수 있다.
빌드가 계속 실패했는데, 원인이 의외의 곳에 있었다.
> Error while evaluating property 'javacOptions' of task ':kaptKotlin'.
> 25.0.1
kapt가 JDK 25 버전 문자열을 파싱하지 못하는 버그였다. 버전 번호가 두 자리(25)가 되면서 kapt 내부 파싱 로직이 실패하는 것이다.
이 문제 때문에 kapt가 실행 자체를 못하게 되니:
@AllArgsConstructor → 생성자 생성 ❌@QueryProjection → Q클래스 생성 ❌즉, 앞서 마주쳤던 여러 오류의 근본 원인이 사실 하나였다.
JAVA_HOME=/Library/Java/JavaVirtualMachines/jdk-21.jdk/Contents/Home \
./gradlew clean build
build.gradle.kts에 이미 java { toolchain { languageVersion = JavaLanguageVersion.of(21) } } 가 설정되어 있었지만, Gradle toolchain 설정이 kapt에는 자동으로 적용되지 않는다는 함정이 있었다. kapt는 별도의 Gradle Worker로 실행되기 때문이다.
PolicySearchCondition이라는 Java 클래스를 Kotlin 컨트롤러에서 사용할 때 문제가 생겼다.
// Java
@Getter @Builder @NoArgsConstructor @AllArgsConstructor
public class PolicySearchCondition {
private String keyword;
private Integer age;
// ...
}
// Kotlin
val condition = PolicySearchCondition(
request.keyword,
request.age,
// ... 8개 인수
) // ❌ Too many arguments for 'constructor(): PolicySearchCondition'
JDK 21로 바꿔도 동일한 오류가 발생했다. 이건 JDK 버전 문제가 아니라 Kotlin + Java + Lombok 혼재의 구조적 문제였다.
Kotlin 컴파일러는 Java Lombok이 생성한 생성자를 컴파일 타임에 알 수 없다. kapt가 stub을 생성해줘야 하는데, Kotlin → Java 혼재 프로젝트에서 이 순서가 꼬이는 것이다.
data class PolicySearchCondition(
val keyword: String? = null,
val age: Int? = null,
val earn: Int? = null,
val regionCode: String? = null,
val jobCode: String? = null,
val schoolCode: String? = null,
val marriageStatus: String? = null,
val keywords: List<String>? = null,
)
Lombok 의존을 완전히 제거하고 Kotlin data class로 전환하면 kapt 없이도 생성자가 자동 생성된다.
ServiceException을 Kotlin으로 마이그레이션할 때 또 다른 호환성 문제가 생겼다.
// Kotlin
class ServiceException(
val resultCode: String,
val msg: String,
cause: Throwable? = null // 기본값!
) : RuntimeException(...)
Kotlin에서는 cause를 생략할 수 있지만, Java에서는 기본값을 인식하지 못한다.
// Java
throw new ServiceException("AUTH-401", "인증 정보가 없습니다.");
// ❌ required: String, String, Throwable
// found: String, String
class ServiceException @JvmOverloads constructor(
val resultCode: String,
val msg: String,
cause: Throwable? = null
) : RuntimeException(...)
@JvmOverloads를 붙이면 Kotlin 컴파일러가 Java용 오버로딩 생성자를 자동 생성해준다:
// Java에서 둘 다 사용 가능
new ServiceException("AUTH-401", "인증 오류") // ✅
new ServiceException("AUTH-401", "인증 오류", e) // ✅
단점도 있다. 파라미터가 많으면 불필요한 오버로딩 조합이 많이 생성되고, 의도하지 않은 생성자가 외부에 노출될 수 있다. 하지만 ServiceException처럼 파라미터가 적은 경우엔 가장 깔끔한 선택이다.
점진적 마이그레이션에서 가장 중요한 것은 순서다. 잘못된 순서로 바꾸면 연쇄 충돌이 발생한다.
우리가 선택한 순서:
1단계: 독립적인 클래스 (다른 클래스를 참조하지 않는 것)
└── enum 클래스, 예외 클래스
2단계: 단순 설정/프로퍼티 클래스
└── @ConfigurationProperties, BaseEntity
3단계: 서비스/유틸 클래스
└── 단방향 의존 구조인 것부터
나중에: Spring Batch, 인증 관련
└── 팀원 코드가 많거나 안정성이 중요한 것
그리고 한 가지 중요한 원칙을 지켰다:
다른 파일 수정이 필요하면 먼저 설명하고 승인받기
처음에 이 원칙 없이 진행했다가 팀원 코드를 건드리거나 범위를 벗어나는 변경이 생겼다. 마이그레이션은 혼자 하는 작업이 아니기 때문에, 변경 범위를 명확히 하는 것이 중요하다.
Java → Kotlin 마이그레이션에서 가장 큰 복병은 kapt였다. Lombok, QueryDSL 등 어노테이션 기반 코드 생성 도구들이 Kotlin과 완벽하게 호환되지 않기 때문에, 이 부분을 먼저 이해하고 전략을 세우는 것이 핵심이다.
핵심 교훈을 정리하면:
| 상황 | 해결책 |
|---|---|
Kotlin class에 Lombok @Builder 불가 | Companion object Builder 직접 구현 |
| kapt + JDK 25 파싱 버그 | JDK 21로 빌드 또는 toolchain 고정 |
| Java에서 Kotlin 기본값 파라미터 불인식 | @JvmOverloads |
| Kotlin에서 Lombok Java 클래스 생성자 불인식 | Kotlin data class로 전환 |
점진적 마이그레이션은 느리지만 안전하다. 팀원과의 충돌을 최소화하면서 코드베이스를 개선해나가는 것, 그게 실무에서의 마이그레이션이다.
어떠세요? 분량이나 톤, 추가하고 싶은 내용이 있으면 말씀해주세요!