Kotlin과 Java를 Spring Boot Maven 프로젝트에서 함께 사용하기

1. 개요

많은 팀들이 Kotlin을 사용하고 싶지만 기존 Java 코드베이스를 모두 다시 작성할 수는 없습니다. 다행히 Kotlin은 JVM에서 실행되므로 두 언어가 같은 모듈에 있을 수 있고 서로 직접 호출할 수 있습니다.

이 글에서는 Kotlin과 Java를 모두 컴파일하도록 Maven을 구성할 것입니다. 그런 다음 Kotlin이 Java를 호출할 때 nullability를 처리합니다. 그리고 Java에서 Kotlin을 쉽게 호출하기 위해 @JvmStatic, @JvmOverloads, @JvmName 어노테이션을 사용합니다. 마지막으로 Spring Boot 3.x와의 통합 방법과 일반적인 오류를 다룹니다.

2. Maven 설정

2.1. 의존성

프로젝트는 spring-boot-starter-parent 3.x와 Java 21을 사용합니다. Kotlin 표준 라이브러리와 kotlin-reflect를 추가합니다. Spring은 Kotlin 클래스의 생성자 매개변수와 기본값을 읽기 위해 kotlin-reflect가 필요합니다.

<properties>
    <java.version>21</java.version>
    <kotlin.version>2.1.0</kotlin.version>
</properties>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.jetbrains.kotlin</groupId>
        <artifactId>kotlin-stdlib</artifactId>
    </dependency>
    <dependency>
        <groupId>org.jetbrains.kotlin</groupId>
        <artifactId>kotlin-reflect</artifactId>
    </dependency>
    <dependency>
        <groupId>com.fasterxml.jackson.module</groupId>
        <artifactId>jackson-module-kotlin</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

Spring Boot는 이미 Kotlin artifact의 버전을 관리합니다. kotlin.version 속성은 기본 버전을 재정의하는 데만 사용됩니다.

2.2. 컴파일 순서

이것이 가장 중요한 부분입니다. Kotlin이 Java보다 먼저 컴파일되어야 합니다. Kotlin 컴파일러는 Java 소스 코드를 이해합니다. 이 파일은 .java 파일을 읽어 유형을 해결하지만 이 파일들을 컴파일하지 않습니다. 반대로 javac는 .kt 파일을 읽을 수 없습니다. Java는 Kotlin의 .class 파일이 이미 존재할 때만 Kotlin 클래스를 볼 수 있습니다.

<build>
    <plugins>
        <plugin>
            <groupId>org.jetbrains.kotlin</groupId>
            <artifactId>kotlin-maven-plugin</artifactId>
            <configuration>
                <jvmTarget>21</jvmTarget>
                <args>
                    <arg>-Xjsr305=strict</arg>
                </args>
                <compilerPlugins>
                    <plugin>spring</plugin>
                </compilerPlugins>
            </configuration>
            <dependencies>
                <dependency>
                    <groupId>org.jetbrains.kotlin</groupId>
                    <artifactId>kotlin-maven-allopen</artifactId>
                    <version>${kotlin.version}</version>
                </dependency>
            </dependencies>
            <executions>
                <execution>
                    <id>compile</id>
                    <goals><goal>compile</goal></goals>
                    <configuration>
                        <sourceDirs>
                            <sourceDir>${project.basedir}/src/main/kotlin</sourceDir>
                            <sourceDir>${project.basedir}/src/main/java</sourceDir>
                        </sourceDirs>
                    </configuration>
                </execution>
                <execution>
                    <id>test-compile</id>
                    <goals><goal>test-compile</goal></goals>
                    <configuration>
                        <sourceDirs>
                            <sourceDir>${project.basedir}/src/test/kotlin</sourceDir>
                            <sourceDir>${project.basedir}/src/test/java</sourceDir>
                        </sourceDirs>
                    </configuration>
                </execution>
            </executions>
        </plugin>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-compiler-plugin</artifactId>
            <executions>
                <execution>
                    <id>default-compile</id>
                    <phase>none</phase>
                </execution>
                <execution>
                    <id>default-testCompile</id>
                    <phase>none</phase>
                </execution>
                <execution>
                    <id>java-compile</id>
                    <phase>compile</phase>
                    <goals><goal>compile</goal></goals>
                </execution>
                <execution>
                    <id>java-test-compile</id>
                    <phase>test-compile</phase>
                    <goals><goal>testCompile</goal></goals>
                </execution>
            </executions>
        </plugin>
    </plugins>
</build>

위 설정의 몇 가지 주목할 점:

  • sourceDirs는 java 디렉토리도 선언합니다. 이덕분에 Kotlin 컴파일러는 Kotlin에서 Java 클래스를 호출할 때 이를 읽을 수 있습니다.
  • default-compile과 default-testCompile execution은 <phase>none</phase>으로 비활성화됩니다. 그 후 새 id로 다시 선언됩니다. Maven은 같은 phase의 plugin들을 선언 순서대로 실행하므로 javac는 Kotlin 이후에 실행됩니다.
  • -Xjsr305=strict는 Spring 어노테이션에 대한 엄격한 nullability 검사를 활성화합니다 (섹션 3 참고).
  • <plugin>spring</plugin>은 Spring을 위한 all-open plugin입니다 (섹션 5 참고).

디렉토리 구조는 다음과 같습니다:

src/main/java/com/nonla/shop/Customer.java
src/main/java/com/nonla/shop/CheckoutController.java
src/main/kotlin/com/nonla/shop/Order.kt
src/main/kotlin/com/nonla/shop/PaymentService.kt
src/test/java/com/nonla/shop/OrderPricingTest.java

3. Java에서 Kotlin 호출하기

3.1. Getter가 프로퍼티가 됨

이전 Java 클래스가 있다고 가정해봅시다:

package com.nonla.shop;

import org.springframework.lang.NonNull;
import org.springframework.lang.Nullable;

public class Customer {

    private final String name;
    private final String email;

    public Customer(String name, String email) {
        this.name = name;
        this.email = email;
    }

    @NonNull
    public String getName() {
        return name;
    }

    @Nullable
    public String getEmail() {
        return email;
    }
}

Kotlin에서는 getX()/setX() 쌍이 프로퍼티로 액세스됩니다:

package com.nonla.shop

fun greeting(customer: Customer): String {
    val contact = customer.email ?: "이메일 없음"
    return "안녕하세요 ${customer.name} ($contact)"
}

Kotlin은 customer.name을 호출하지만 실제로는 getName()을 호출합니다. Customer("Lan", null)의 경우 함수는 다음을 반환합니다:

안녕하세요 Lan (이메일 없음)

3.2. Platform 타입과 nullability

Java는 타입 시스템에서 nullable이라는 개념이 없습니다. 따라서 주석이 없는 Java 유형을 만날 때, Kotlin은 이를 platform 타입으로 간주하며, String!로 표기됩니다. platform 타입에서는 컴파일러가 오류를 포착하지 않습니다. 값이 null이고 이를 non-null로 사용하면 런타임에 NullPointerException이 발생합니다.

Spring의 @Nullable 및 @NonNull은 JSR-305로 meta-annotate됩니다. -Xjsr305=strict를 활성화하면 Kotlin은 getEmail()이 String?을 반환한다고 이해합니다. 다음 코드는 컴파일되지 않습니다:

val length = customer.email.length // 오류: 안전 호출(?.) 또는 non-null 단언(!!)만 허용됩니다

이를 customer.email?.length ?: 0으로 수정합니다.

참고: 각 method에 annotation을 붙이고 싶지 않으신가요? package-info.java에 Spring의 @NonNullApi를 배치하세요. 이 annotation은 패키지의 모든 매개변수와 반환값을 @Nullable이 있는 경우를 제외하고는 non-null로 간주합니다.

4. Kotlin에서 Java 호출하기

Kotlin에는 Java에 없는 많은 기능이 있습니다: top-level 함수, 기본 매개변수, companion object. 일부 annotation은 Java에서 호출할 때 Kotlin API를 더 “자연스럽게” 보이도록 합니다.

4.1. Data class와 top-level 함수

@file:JvmName("OrderPricing")
package com.nonla.shop

data class Order @JvmOverloads constructor(
    val id: String,
    val amount: Long,
    val couponCode: String? = null
)

@JvmOverloads
fun totalWithShipping(
    order: Order,
    shippingFee: Long = 30_000,
    freeShipThreshold: Long = 500_000
): Long =
    if (order.amount >= freeShipThreshold) order.amount
    else order.amount + shippingFee

각 annotation의 역할:

  • @file:JvmName("OrderPricing"): 기본적으로 Order.kt의 top-level 함수는 OrderKt 클래스로 들어갑니다. 이 annotation은 해당 클래스의 이름을 OrderPricing으로 변경합니다.
  • 생성자의 @JvmOverloads: Java는 기본 매개변수를 이해하지 못합니다. 이 annotation은 추가 overload Order(String, long)을 생성하므로 Java는 couponCode에 null을 전달할 필요가 없습니다.
  • 함수의 @JvmOverloads: 각각 1, 2, 3개의 매개변수를 받는 3개의 overload를 생성합니다.

Java 코드가 Kotlin을 다음과 같이 호출합니다:

var order = new Order("ORD-1", 200_000L);
long total = OrderPricing.totalWithShipping(order);
System.out.println(order.getAmount() + " -> " + total);
System.out.println(order);
200000 -> 230000
Order(id=ORD-1, amount=200000, couponCode=null)

Kotlin의 val amount 프로퍼티는 Java에서 getAmount()로 나타납니다. data class의 toString(), equals(), hashCode() 메서드는 정상적으로 작동합니다. Order는 immutable입니다: Java에는 getter만 있고 setter는 없습니다.

4.2. Companion object와 @JvmStatic

package com.nonla.shop

import java.util.concurrent.atomic.AtomicLong

class OrderIdGenerator {
    companion object {
        const val PREFIX = "ORD-"
        private val counter = AtomicLong()

        @JvmStatic
        fun next(): String = PREFIX + counter.incrementAndGet()
    }
}

Java에서:

String id = OrderIdGenerator.next();      // "ORD-1"
String prefix = OrderIdGenerator.PREFIX;  // const 덕분에 static 상수

@JvmStatic이 없으면 Java는 OrderIdGenerator.Companion.next()를 호출해야 합니다. 이 호출 방식은 작동하지만 길고 구현 세부사항을 드러냅니다. const가 아닌 프로퍼티의 경우 @JvmField를 사용하여 public static 필드로 노출합니다.

4.3. Checked exception과 @Throws

Kotlin에는 checked exception이 없습니다. 선언하지 않은 IOException을 throw하는 Kotlin 함수가 있으면 javac이 해당 exception이 절대 throw되지 않는다고 보고하므로 Java는 catch (IOException e)를 할 수 없습니다. 이를 해결하기 위해 @Throws를 사용합니다:

@Throws(java.io.IOException::class)
fun loadInvoice(path: String): String = java.io.File(path).readText()

5. Spring Boot와의 통합

Kotlin의 클래스와 메서드는 기본적으로 final입니다. 한편, Spring은 @Transactional, @Async, @Cacheable 또는 @Configuration이 있는 bean들을 위해 proxy (CGLIB)를 만듭니다. CGLIB proxy는 원래 클래스를 상속해야 합니다. 섹션 2에서 선언한 spring plugin은 @Component, @Service, @Transactional… 등으로 표시된 모든 클래스와 해당 메서드를 자동으로 open합니다.

package com.nonla.shop

import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional

@Service
class PaymentService {

    @Transactional
    fun pay(order: Order, customer: Customer): String {
        val total = totalWithShipping(order)
        return "${customer.name}를 위해 ${order.id}에 대해 $total VND를 청구했습니다"
    }
}

Java controller는 생성자를 통해 이 Kotlin bean을 정상적으로 inject할 수 있습니다:

package com.nonla.shop;

import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class CheckoutController {

    private final PaymentService paymentService;

    public CheckoutController(PaymentService paymentService) {
        this.paymentService = paymentService;
    }

    @PostMapping("/checkout")
    public String checkout() {
        var order = new Order(OrderIdGenerator.next(), 600_000L);
        return paymentService.pay(order, new Customer("Lan", null));
    }
}

POST /checkout을 호출할 때의 결과:

Lan를 위해 ORD-1에 대해 600000 VND를 청구했습니다

주문이 500,000 임계값을 초과하므로 배송료가 면제됩니다.

plugin spring이 없으면 Kotlin으로 작성된 @Configuration 클래스가 있을 경우 애플리케이션이 시작되지 않습니다. 다른 bean의 경우 @Transactional은 경고 없이 무시될 수 있습니다.

6. 테스트 작성

Java로 작성된 테스트에서 Kotlin 코드를 호출하는 것은 Kotlin API가 Java 친화적인지 확인하는 좋은 방법입니다. 테스트를 작성하기 어려우면 Java 사용자도 어려움을 겪을 것입니다.

package com.nonla.shop;

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

class OrderPricingTest {

    @Test
    void addsDefaultShippingFeeBelowThreshold() {
        var order = new Order("ORD-1", 200_000L);

        assertThat(OrderPricing.totalWithShipping(order)).isEqualTo(230_000L);
    }

    @Test
    void freeShippingWhenAmountReachesThreshold() {
        var order = new Order("ORD-2", 500_000L);

        assertThat(OrderPricing.totalWithShipping(order)).isEqualTo(500_000L);
    }

    @Test
    void customShippingFeeViaOverload() {
        var order = new Order("ORD-3", 100_000L, "TET2026");

        assertThat(OrderPricing.totalWithShipping(order, 15_000L)).isEqualTo(115_000L);
        assertThat(order.getCouponCode()).isEqualTo("TET2026");
    }

    @Test
    void dataClassEqualityWorksFromJava() {
        assertThat(new Order("ORD-4", 1L)).isEqualTo(new Order("ORD-4", 1L));
    }
}

mvn test을 실행하세요. 로그에서 Kotlin이 Java보다 먼저 컴파일되는 것을 볼 수 있습니다:

[INFO] --- kotlin:2.1.0:compile (compile) @ shop ---
[INFO] --- compiler:3.13.0:compile (java-compile) @ shop ---
[INFO] --- kotlin:2.1.0:test-compile (test-compile) @ shop ---
[INFO] --- compiler:3.13.0:testCompile (java-test-compile) @ shop ---
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0

7. 일반적인 오류

Java가 Kotlin 클래스에 대해 cannot find symbol을 보고합니다. 원인은 거의 항상 javac이 Kotlin 컴파일러보다 먼저 실행되기 때문입니다. 섹션 2.2에서 default-compile을 비활성화하고 plugin 선언 순서를 확인하세요.

Kotlin이 Java를 호출할 때 NullPointerException이 발생합니다. 이는 platform 타입의 결과입니다. Java 코드에 @Nullable/@NonNull (또는 @NonNullApi)을 추가하고 -Xjsr305=strict를 활성화해야 합니다. 다른 방법은 경계에서 명시적으로 유형을 선언하는 것입니다. 예: val email: String? = customer.email.

Kotlin이 Lombok이 생성한 getter를 보지 못합니다. Kotlin 컴파일러는 Lombok이 실행되기 전에 Java 소스 코드를 읽습니다. 따라서 @Getter 또는 @Data가 어떤 메서드를 생성할지 모릅니다. Kotlin용 compiler plugin lombok을 추가할 수 있습니다 (artifact kotlin-maven-lombok). 더 간단한 방법은 Kotlin이 필요로 하는 Lombok 클래스를 Java record 또는 Kotlin data class로 변환하는 것입니다.

Kotlin으로 작성된 JPA entity입니다. Hibernate는 매개변수가 없는 생성자와 final이 아닌 entity가 필요합니다. spring plugin과 함께 jpa plugin (no-arg, artifact kotlin-maven-noarg)을 추가합니다. 또한 entity에는 data class를 사용하면 안 됩니다. 왜냐하면 자동으로 생성된 equals/hashCode가 lazy loading에 문제를 일으킬 수 있기 때문입니다.

참고: 언제 두 언어를 섞지 말아야 합니까? 팀에 Kotlin을 아는 한두 명만 있으면 review와 유지보수 비용이 증가합니다. 두 개의 컴파일러가 있기 때문에 빌드 시간도 더 깁니다. 또 다른 옵션은 Kotlin을 별도의 Maven 모듈로 분리하는 것입니다. 이 경우 두 언어 사이의 경계가 명확해지고 .kt과 .java 파일 간의 순환 의존성을 피할 수 있습니다.

실제 규칙: DTO, 유틸리티 함수, 테스트와 같은 종속성이 적은 Kotlin 코드로 시작하세요. 핵심 비즈니스 로직을 다시 작성하는 것으로 시작하지 마세요.

8. 결론

Kotlin과 Java는 하나의 Maven 모듈에서 함께 존재할 수 있습니다. 필요한 것은 한 가지뿐입니다: Kotlin 컴파일러가 javac보다 먼저 실행되어야 하고 두 소스 디렉토리를 모두 읽을 수 있어야 합니다. Kotlin이 Java를 호출할 때는 platform 타입에 주의하고 -Xjsr305=strict와 함께 nullability annotation을 사용하세요. Java에서 Kotlin을 호출할 때는 @JvmName, @JvmOverloads, @JvmStatic, @Throws이 API를 훨씬 더 쉽게 사용할 수 있게 도와줍니다. Spring Boot에서는 proxy가 올바르게 작동하려면 spring plugin (all-open)이 필수입니다. 마지막으로, Kotlin 코드에 대한 Java 테스트를 작성하는 것이 API가 실제로 Java 친화적인지 알아내는 가장 빠른 방법입니다.

kotlinjavaspring-bootmaveninterop