이전 편: 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); // 세 파라미터 다 전달
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) }
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을 도입할 때의 현실적인 전략은 이렇다.
- 새 코드부터 Kotlin으로 — 기존 Java 코드를 건드리지 않고, 새로 작성하는 클래스를 Kotlin으로 만든다.
- 테스트부터 — 프로덕션 코드는 Java를 유지하고, 테스트를 Kotlin으로 작성한다. 리스크가 가장 낮다.
- 점진적 전환 — 수정이 필요한 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. 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: 접두사로 타겟을 명시해야 동작합니다.