Java → Kotlin 마이그레이션 실전기

suni_develop·2026년 2월 25일

복지통합서비스

목록 보기
2/2

들어가며

팀 프로젝트에서 Java로 작성된 Spring Boot 백엔드를 Kotlin으로 점진적으로 마이그레이션하기로 했다. "한 번에 다 바꾸자"는 욕심을 버리고, entity/dto 클래스부터 시작해서 충돌을 최소화하며 전환하는 방식을 선택했다. 이 글은 그 과정에서 마주친 문제들과 선택의 이유를 기록한 글이다.


1. 첫 번째 시도: Lombok을 그냥 두면 안 될까?

처음엔 단순하게 생각했다. 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)를 기반으로 동작하도록 설계되었기 때문이다.

해결책: Companion Object Builder 패턴

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 메서드를 정적 메서드처럼 호출할 수 있다.


2. kapt + JDK 25 조합의 함정

빌드가 계속 실패했는데, 원인이 의외의 곳에 있었다.

> Error while evaluating property 'javacOptions' of task ':kaptKotlin'.
   > 25.0.1

kapt가 JDK 25 버전 문자열을 파싱하지 못하는 버그였다. 버전 번호가 두 자리(25)가 되면서 kapt 내부 파싱 로직이 실패하는 것이다.

이 문제 때문에 kapt가 실행 자체를 못하게 되니:

  • Lombok @AllArgsConstructor → 생성자 생성 ❌
  • QueryDSL @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로 실행되기 때문이다.


3. Kotlin + Java + Lombok 혼재의 구조적 한계

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 혼재 프로젝트에서 이 순서가 꼬이는 것이다.

해결책: Kotlin data class로 전환

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 없이도 생성자가 자동 생성된다.


4. Kotlin 기본값 파라미터와 Java 호환성: @JvmOverloads

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

해결책: @JvmOverloads

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처럼 파라미터가 적은 경우엔 가장 깔끔한 선택이다.


5. 마이그레이션 순서 전략: 의존성 방향을 따라가라

점진적 마이그레이션에서 가장 중요한 것은 순서다. 잘못된 순서로 바꾸면 연쇄 충돌이 발생한다.

우리가 선택한 순서:

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로 전환

점진적 마이그레이션은 느리지만 안전하다. 팀원과의 충돌을 최소화하면서 코드베이스를 개선해나가는 것, 그게 실무에서의 마이그레이션이다.


어떠세요? 분량이나 톤, 추가하고 싶은 내용이 있으면 말씀해주세요!

profile
렛츠고

0개의 댓글