Kotlin 사고방식 (7/7)

이전 편: [Kotlin] 6. DSL 설계

이 문서가 시리즈의 마지막 편입니다.

Kotlin 사고방식 시리즈 (7/7)

이전 편: 6 DSL 설계

Kotlin이 JVM 위에서 돌아가는 최대 장점은 Java 라이브러리를 그대로 쓸 수 있다는 것이다. Spring, JPA, Jackson, QueryDSL — 전부 Java로 만들어졌지만 Kotlin에서 아무 문제 없이 동작한다. 이것이 코프링이 가능한 이유다.

하지만 "문제없이 동작한다"와 "자연스럽게 어울린다"는 다른 이야기다. Java 코드에서 Kotlin 코드를 호출할 때, 또는 그 반대일 때 알아야 할 규칙들이 있다. 이 규칙을 모르면 @JvmStatic이 왜 필요한지, @field: 접두사가 왜 붙는지 이해할 수 없다.

이 문서에서는 Kotlin ↔ Java 상호운용의 핵심 규칙과 실무 패턴을 정리한다.

Kotlin → Java (Kotlin 코드를 Java에서 호출)

Kotlin은 JVM 바이트코드로 컴파일된다. Java에서 Kotlin 코드를 호출할 때는 컴파일된 바이트코드 구조를 이해해야 한다.

최상위 함수와 @JvmName

Kotlin에서 클래스 밖에 선언한 최상위 함수는 Java에서 어떻게 보일까?

// StringExtensions.kt
fun String.isValidEmail(): Boolean = contains("@") && contains(".")

이 함수는 Java에서 StringExtensionsKt.isValidEmail(str) 로 호출된다. Kotlin 컴파일러가 파일 이름 + Kt 접미사로 클래스를 자동 생성하기 때문이다.

파일 이름이 곧 클래스 이름이 되는 건 불편할 수 있다. @JvmName으로 바꿀 수 있다.

@file:JvmName("StringUtils")
package com.blog.common

fun String.isValidEmail(): Boolean = contains("@") && contains(".")

이제 Java에서 호출하면 @file:JvmName에서 지정한 이름이 클래스명으로 쓰인다.

StringUtils.isValidEmail(str);  // StringExtensionsKt 대신 StringUtils

@JvmStatic

companion object의 멤버는 Java에서 호출할 때 Companion이 중간에 끼어든다.

class Post(val title: String) {
    companion object {
        const val MAX_TITLE_LENGTH = 200

        fun of(title: String): Post = Post(title)
    }
}

이 코드를 Java에서 호출하면, companion object의 멤버는 Companion을 거쳐야 한다.

// @JvmStatic 없이
Post.Companion.of("제목");

// const val은 자동으로 static final
Post.MAX_TITLE_LENGTH;   // OK — const val은 예외

@JvmStatic을 붙이면 Java에서 자연스럽게 호출할 수 있다.

companion object {
    @JvmStatic
    fun of(title: String): Post = Post(title)
}

Java에서 Post.of("제목")으로 자연스럽게 호출할 수 있다.

Post.of("제목");  // Companion 없이 직접 호출
  • const val@JvmStatic 없이도 Java에서 직접 접근 가능하다. 컴파일 타임 상수이므로 자동으로 static final이 된다.
  • 일반 val이나 fun@JvmStatic을 붙여야 Java에서 편하게 쓸 수 있다.

@JvmField

Kotlin 프로퍼티는 Java에서 getter/setter로 접근해야 한다. @JvmField를 붙이면 getter/setter 없이 필드로 직접 접근할 수 있다.

class Config {
    @JvmField
    val maxRetry = 3
}
// @JvmField 없이
config.getMaxRetry();

// @JvmField 있으면
config.maxRetry;

테스트나 프레임워크 통합에서 필드 직접 접근이 필요할 때 쓴다. JUnit 5의 @RegisterExtension 같은 어노테이션이 @JvmField를 요구하는 대표적인 경우다.


@JvmOverloads

Kotlin의 기본값 파라미터는 Java에서 보이지 않는다. Java에서는 모든 파라미터를 넘겨야 한다.

fun createPost(
    title: String,
    content: String,
    isPublished: Boolean = true
) { /* ... */ }
// Java에서 — 기본값을 못 쓴다
createPost("제목", "내용", true);  // 세 번째 파라미터 생략 불가

@JvmOverloads를 붙이면 Java에서 쓸 수 있도록 오버로딩 메서드를 자동 생성해준다.

@JvmOverloads
fun createPost(
    title: String,
    content: String,
    isPublished: Boolean = true
) { /* ... */ }
// Java에서 — 오버로딩이 자동 생성됨
createPost("제목", "내용");        // isPublished = true
createPost("제목", "내용", false);  // 세 파라미터 다 전달
생성자에 @JvmOverloads

Spring의 @RequestMapping이나 Jackson의 역직렬화에서 기본 생성자가 필요한 경우, 주 생성자에 @JvmOverloads를 붙이면 해결되는 경우가 있다. 하지만 코프링에서는 kotlin-jpa 플러그인이 이 역할을 대신하므로, JPA 엔티티에는 @JvmOverloads보다 플러그인을 쓰는 게 맞다.


Java → Kotlin (Java 코드를 Kotlin에서 호출)

대부분의 Java 코드는 Kotlin에서 자연스럽게 호출된다. 하지만 주의해야 할 지점이 있다.

플랫폼 타입

5 Null Safety 완전 정복에서 다뤘던 플랫폼 타입이 상호운용의 가장 큰 함정이다. Java 메서드의 반환 타입은 Kotlin에서 nullable인지 아닌지 알 수 없다. 받는 즉시 타입을 명시하는 것이 핵심 방어 전략이다.


SAM 변환

Java의 함수형 인터페이스(메서드가 하나인 인터페이스)는 Kotlin에서 람다로 바로 전달할 수 있다. 이것을 SAM(Single Abstract Method) 변환이라 한다.

// Java 인터페이스
public interface OnClickListener {
    void onClick(View view);
}

public void setOnClickListener(OnClickListener listener) { ... }

이 Java 인터페이스를 Kotlin에서 호출할 때, 람다를 직접 넘길 수 있다. 컴파일러가 자동으로 SAM 변환을 수행한다.

button.setOnClickListener { view ->
    println("클릭: $view")
}

Java의 Runnable, Callable, Comparator, Predicate 등을 Kotlin에서 쓸 때 전부 이 방식이 적용된다.

// Thread에 Runnable 전달
Thread { println("백그라운드 작업") }.start()

// Collections.sort에 Comparator 전달
Collections.sort(list) { a, b -> a.compareTo(b) }
Kotlin 인터페이스의 SAM 변환

Java 인터페이스는 자동으로 SAM 변환이 되지만, Kotlin 인터페이스는 안 된다. Kotlin 인터페이스에서 SAM 변환을 쓰려면 fun interface로 선언해야 한다.


Getter/Setter와 프로퍼티 접근

Java 클래스의 getter/setter는 Kotlin에서 프로퍼티 문법으로 접근할 수 있다.

// Java 클래스
public class User {
    private String name;
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
}

Kotlin에서는 이 getter/setter를 프로퍼티 문법으로 접근할 수 있다.

val user = User()
user.name = "관리자"       // setName() 호출
println(user.name)         // getName() 호출

Java의 getName()/setName()이 Kotlin에서 name 프로퍼티로 자동 매핑된다. is 접두사도 마찬가지다. isActive()isActive 프로퍼티가 된다.


어노테이션 사용 타겟

Kotlin의 주 생성자 파라미터는 생성자 파라미터이자 프로퍼티이자 필드다. 어노테이션을 붙일 때 이 중 어디에 붙을지 모호해진다.

data class PostCreateRequest(
    @NotBlank val title: String    // @NotBlank이 어디에 붙지?
)

기본적으로 어노테이션은 파라미터에 붙는다. 하지만 Bean Validation은 필드에 붙어야 동작한다. 그래서 @field: 접두사가 필요하다.

data class PostCreateRequest(
    @field:NotBlank(message = "제목은 필수입니다")
    val title: String,

    @field:Size(max = 50000)
    val content: String
)

사용 가능한 타겟은 이렇다.

  • @field: — Java 필드에 붙는다. Validation, JPA 어노테이션에 주로 사용.
  • @get: — getter에 붙는다. Jackson의 @JsonProperty 등.
  • @set: — setter에 붙는다.
  • @param: — 생성자 파라미터에 붙는다.
  • @property: — Kotlin 프로퍼티에 붙는다 (리플렉션에서만 보임).
코프링에서 가장 흔한 실수

@field:를 빠뜨려서 Validation이 안 먹는 것이 코프링에서 가장 흔한 실수다. @NotBlank, @Size, @Email 등 Bean Validation 어노테이션은 반드시 @field: 접두사를 붙여야 한다.


심화 분석

Kotlin 컴파일 결과 확인

Kotlin 코드가 Java로 어떻게 변환되는지 확인하는 방법이 있다. IntelliJ에서 Tools > Kotlin > Show Kotlin Bytecode 를 열고, Decompile 버튼을 누르면 Java 코드로 디컴파일된 결과를 볼 수 있다.

이걸로 확인할 수 있는 것들은 다음과 같다.

  • data class가 어떤 메서드들을 생성하는지
  • inline 함수가 실제로 인라인되는지
  • companion object가 어떤 구조로 컴파일되는지
  • 프로퍼티의 getter/setter가 어떻게 만들어지는지

상호운용에서 문제가 생기면, 바이트코드를 확인하는 것이 가장 확실한 디버깅 방법이다.

혼합 코드베이스 전략

기존 Java 프로젝트에 Kotlin을 도입할 때의 현실적인 전략은 이렇다.

  1. 새 코드부터 Kotlin으로 — 기존 Java 코드를 건드리지 않고, 새로 작성하는 클래스를 Kotlin으로 만든다.
  2. 테스트부터 — 프로덕션 코드는 Java를 유지하고, 테스트를 Kotlin으로 작성한다. 리스크가 가장 낮다.
  3. 점진적 전환 — 수정이 필요한 Java 파일을 건드릴 때 Kotlin으로 변환한다. IntelliJ의 자동 변환(Ctrl+Shift+Alt+K) + 이디엄 적용.

한 번에 전환하려 하지 말고, 새 코드부터 Kotlin, 기존 코드는 건드릴 때마다 점진적으로 가 가장 현실적이다.


자주 하는 실수

@JvmStatic 빠뜨리기

class ApiConstants {
    companion object {
        val BASE_URL = "https://api.blog.com"  // @JvmStatic 없음
    }
}

Java에서 ApiConstants.BASE_URL로 접근하면 안 되고, ApiConstants.Companion.getBASE_URL()로 써야 한다. 혼합 코드베이스에서는 Java에서 호출할 가능성이 있는 companion object 멤버에 @JvmStatic이나 const val을 붙이자.

Java의 void 반환 메서드에 대한 오해

Java에서 void를 반환하는 메서드는 Kotlin에서 Unit을 반환한다. 대부분 문제없지만, Java의 Void 클래스(대문자 V)와 혼동하지 말아야 한다.

// Java의 Callable<Void> 구현
val callable: Callable<Void> = Callable {
    doSomething()
    null   // Void는 null만 가능
}

// Java의 void 메서드 오버라이드
override fun onComplete(): Unit {
    // Unit은 생략 가능
}

Kotlin 키워드와 Java 이름 충돌

Java에서는 합법적이지만 Kotlin에서는 키워드인 이름이 있다. when, in, is, object 등.

// Java 메서드 이름이 Kotlin 키워드와 충돌할 때
javaObject.`when`()     // 백틱으로 감싸야 한다
javaObject.`in`()
javaObject.`is`()

백틱으로 감싸면 키워드를 식별자로 쓸 수 있다. 이 상황은 주로 Java 라이브러리를 호출할 때 발생한다.


면접 Q&A

Q. Kotlin의 companion object와 Java의 static의 차이는?

companion object는 실제 객체입니다. 그래서 인터페이스를 구현할 수 있고, 변수에 대입할 수 있고, 확장 함수를 붙일 수 있습니다. Java의 static은 클래스에 속한 것이지 객체가 아니므로 이런 것들이 불가능합니다. 바이트코드 레벨에서는 내부에 Companion이라는 싱글톤 클래스가 생성됩니다.

Q. Kotlin에서 Java 라이브러리를 쓸 때 가장 주의할 점은?

플랫폼 타입입니다. Java 메서드의 반환값은 Kotlin에서 nullable인지 non-null인지 알 수 없습니다. Java 코드와의 경계에서 반드시 타입을 명시적으로 선언해야 합니다. Spring API는 @Nullable/@NonNull 어노테이션이 잘 붙어 있어서 상대적으로 안전하지만, 서드파티 라이브러리는 그렇지 않을 수 있습니다.

Q. @field: 접두사는 왜 필요한가요?

Kotlin의 주 생성자 파라미터는 생성자 파라미터, 프로퍼티, 필드 세 가지 역할을 합니다. 어노테이션의 기본 타겟이 생성자 파라미터이기 때문에, Bean Validation처럼 필드에 붙어야 하는 어노테이션은 @field: 접두사로 타겟을 명시해야 동작합니다.