Java에서 Kotlin 호출하기
Java에서 Kotlin 호출하기
Kotlin 코드는 Java에서 쉽게 호출할 수 있어요. 예를 들어 Kotlin 클래스의 인스턴스는 Java 메서드에서 매끄럽게 생성하고 조작할 수 있습니다. 하지만 Kotlin 코드를 Java에 통합할 때 주의해야 할 몇 가지 Java와 Kotlin의 차이점이 있어요. 이 페이지에서는 Kotlin 코드와 그 Java 클라이언트의 상호운용을 맞춤 구성하는 방법을 설명할게요.
본문
프로퍼티(Properties)
Kotlin 프로퍼티는 다음 Java 요소로 컴파일됩니다:
get접두사를 붙여 이름을 계산한 getter 메서드.set접두사를 붙여 이름을 계산한 setter 메서드(var프로퍼티에만 해당).- 프로퍼티 이름과 같은 이름의 private 필드(backing field가 있는 프로퍼티에만 해당).
예를 들어, var firstName: String은 다음 Java 선언으로 컴파일돼요:
private String firstName;
public String getFirstName() {
return firstName;
}
public void setFirstName(String firstName) {
this.firstName = firstName;
}
프로퍼티 이름이 is로 시작하면, 다른 이름 매핑 규칙이 사용됩니다. getter의 이름은 프로퍼티 이름과 같고, setter의 이름은 is를 set으로 바꿔 얻습니다. 예를 들어 프로퍼티 isOpen의 getter는 isOpen()이고 setter는 setOpen()이라고 불러요. 이 규칙은 Boolean뿐만 아니라 모든 타입의 프로퍼티에 적용됩니다.
패키지 수준 함수(Package-level functions)
org.example 패키지의 app.kt 파일에 선언된 모든 함수와 프로퍼티(확장 함수 포함)는 org.example.AppKt라는 Java 클래스의 정적 메서드로 컴파일됩니다.
// app.kt
package org.example
class Util
fun getTime() { /*...*/ }
// Java
new org.example.Util();
org.example.AppKt.getTime();
생성된 Java 클래스에 사용자 지정 이름을 설정하려면 @JvmName 애너테이션을 사용하세요:
@file:JvmName("DemoUtils")
package org.example
class Util
fun getTime() { /*...*/ }
// Java
new org.example.Util();
org.example.DemoUtils.getTime();
생성된 Java 클래스 이름이 같은(같은 패키지에 같은 이름을 가지거나 같은 @JvmName 애너테이션을 가진) 여러 파일이 있으면 보통 오류가 돼요. 하지만 컴파일러는 지정된 이름을 가진 단일 Java 퍼사드(facade) 클래스를 생성할 수 있으며, 이 클래스는 그 이름을 가진 모든 파일의 모든 선언을 포함합니다. 이러한 퍼사드 생성을 활성화하려면 그러한 모든 파일에 @JvmMultifileClass 애너테이션을 사용하세요.
// oldutils.kt
@file:JvmName("Utils")
@file:JvmMultifileClass
package org.example
fun getTime() { /*...*/ }
// newutils.kt
@file:JvmName("Utils")
@file:JvmMultifileClass
package org.example
fun getDate() { /*...*/ }
// Java
org.example.Utils.getTime();
org.example.Utils.getDate();
인스턴스 필드(Instance fields)
Kotlin 프로퍼티를 Java에서 필드로 노출해야 한다면 @JvmField 애너테이션으로 표시하세요. 필드는 기본 프로퍼티와 같은 가시성을 가져요. 다음 조건을 만족하면 @JvmField로 프로퍼티를 표시할 수 있습니다:
- backing field가 있고
private이 아니고open,override,const수정자가 없고- 위임 프로퍼티(delegated property)가 아닌 경우
class User(id: String) {
@JvmField val ID = id
}
// Java
class JavaClient {
public String getID(User user) {
return user.ID;
}
}
지연 초기화(late-initialized) 프로퍼티도 필드로 노출됩니다. 필드의 가시성은 lateinit 프로퍼티 setter의 가시성과 같아요.
정적 필드(Static fields)
이름 있는 객체(named object)나 동반 객체(companion object)에 선언된 Kotlin 프로퍼티는 그 이름 있는 객체나 동반 객체를 포함하는 클래스에 정적 backing field를 가져요.
보통 이 필드들은 private이지만, 다음 중 한 가지 방식으로 노출할 수 있습니다:
@JvmField애너테이션lateinit수정자const수정자
이러한 프로퍼티를 @JvmField로 표시하면 프로퍼티 자체와 같은 가시성의 정적 필드가 돼요.
class Key(val value: Int) {
companion object {
@JvmField
val COMPARATOR: Comparator = compareBy { it.value }
}
}
// Java
Key.COMPARATOR.compare(key1, key2);
// Key 클래스의 public static final 필드
객체나 동반 객체의 지연 초기화 프로퍼티는 프로퍼티 setter와 같은 가시성의 정적 backing field를 가집니다.
object Singleton {
lateinit var provider: Provider
}
// Java
Singleton.provider = new Provider();
// Singleton 클래스의 public static non-final 필드
const로 선언된 프로퍼티(클래스 안뿐만 아니라 최상위에서도)는 Java에서 정적 필드로 변환됩니다:
// file example.kt
object Obj {
const val CONST = 1
}
class C {
companion object {
const val VERSION = 9
}
}
const val MAX = 239
Java에서는:
int constant = Obj.CONST;
int max = ExampleKt.MAX;
int version = C.VERSION;
정적 메서드(Static methods)
Kotlin은 패키지 수준 함수를 정적 메서드로 표현해요. 이름 있는 객체나 동반 객체에 정의된 함수를 @JvmStatic 애너테이션으로 표시하면 그 함수에 대한 정적 메서드도 생성할 수 있습니다.
동반 객체의 함수에 @JvmStatic을 사용하면, 컴파일러는 둘러싸는 클래스에 정적 메서드와 동반 객체에 인스턴스 메서드를 모두 생성해요:
// Kotlin
class C {
companion object {
@JvmStatic fun callStatic() {}
fun callNonStatic() {}
}
}
Java에서는 둘러싸는 클래스와 동반 객체 모두에서 callStatic()을 호출할 수 있지만, callNonStatic()은 동반 객체를 통해서만 사용할 수 있어요:
// Java
C.callStatic(); // Success
C.callNonStatic(); // Error: not a static method
C.Companion.callStatic(); // Instance method remains
C.Companion.callNonStatic(); // Success
이름 있는 객체(싱글턴)의 경우 @JvmStatic은 함수를 객체의 클래스의 정적 메서드로 바꾸지만, 별도의 인스턴스 메서드는 생성하지 않아요:
// Kotlin
object Obj {
@JvmStatic fun callStatic() {}
fun callNonStatic() {}
}
Java에서는 이름 있는 객체에서 callStatic() 메서드를 호출할 수 있고, callNonStatic()은 싱글턴 인스턴스를 통해서만 사용할 수 있습니다:
// Java
Obj.callStatic(); // Success
Obj.callNonStatic(); // Error: not a static method
Obj.INSTANCE.callNonStatic(); // Success, the call passed through the singleton instance
인터페이스의 동반 객체에 있는 함수에도 @JvmStatic을 적용할 수 있어요. 이러한 함수는 인터페이스의 정적 메서드로 컴파일됩니다:
interface ChatBot {
companion object {
@JvmStatic fun greet(username: String) {
println("Hello, $username")
}
}
}
객체나 동반 객체의 프로퍼티에도 @JvmStatic 애너테이션을 적용해, 그 getter와 setter 메서드를 해당 객체 또는 동반 객체를 포함하는 클래스의 정적 멤버로 만들 수 있어요.
인터페이스의 기본 메서드(Default methods)
JVM을 대상으로 할 때, Kotlin은 달리 구성하지 않는 한 인터페이스에 선언된 함수를 기본 메서드(default method)로 컴파일해요. 이는 Java 클래스가 재구현 없이 직접 상속할 수 있는 인터페이스의 구체적인 메서드입니다.
다음은 기본 메서드가 있는 Kotlin 인터페이스의 예시예요:
interface Robot {
fun move() { println("~walking~") } // will be default in the Java interface
fun speak(): Unit
}
기본 구현은 인터페이스를 구현하는 Java 클래스에서 사용할 수 있어요.
//Java implementation
public class C3PO implements Robot {
// move() implementation from Robot is available implicitly
@Override
public void speak() {
System.out.println("I beg your pardon, sir");
}
}
C3PO c3po = new C3PO();
c3po.move(); // default implementation from the Robot interface
c3po.speak();
인터페이스의 구현체는 기본 메서드를 오버라이드할 수 있어요.
//Java
public class BB8 implements Robot {
//own implementation of the default method
@Override
public void move() {
System.out.println("~rolling~");
}
@Override
public void speak() {
System.out.println("Beep-beep");
}
}
기본 메서드용 호환성 모드
Kotlin은 인터페이스의 함수가 JVM 기본 메서드로 컴파일되는 방식을 제어하는 세 가지 모드를 제공해요. 이 모드들은 컴파일러가 DefaultImpls 클래스에 호환성 브리지(compatibility bridge)와 정적 메서드를 생성할지 여부를 결정합니다.
이 동작은 -jvm-default 컴파일러 옵션으로 제어할 수 있어요. 참고로 -jvm-default 컴파일러 옵션은 더 이상 사용되지 않는(deprecated) -Xjvm-default 옵션을 대체합니다.
호환성 모드에 대해 자세히 알아볼게요:
- enable — 기본 동작입니다. 인터페이스에 기본 구현을 생성하고 호환성 브리지와
DefaultImpls클래스를 포함합니다. 이 모드는 이전에 컴파일된 Kotlin 코드와의 호환성을 유지해요. - no-compatibility — 인터페이스에 기본 구현만 생성합니다. 호환성 브리지와
DefaultImpls클래스는 건너뜁니다.DefaultImpls클래스에 의존하는 코드와 상호작용하지 않는 새 코드베이스에 이 모드를 사용하세요. 이는 더 오래된 Kotlin 코드와의 이진 호환성을 깨뜨릴 수 있어요. 인터페이스 위임(interface delegation)을 사용하면 모든 인터페이스 메서드가 위임됩니다. - disable — 인터페이스의 기본 구현을 비활성화합니다. 호환성 브리지와
DefaultImpls클래스만 생성됩니다.
가시성(Visibility)
Kotlin은 가시성 수정자를 Java에 다음과 같이 매핑해요:
private멤버는private으로 유지됩니다.private최상위 선언은 Java에서private최상위 선언이 됩니다. 클래스 안에서 접근된다면 패키지-전용(private) 접근자도 포함됩니다.protected멤버는protected로 유지됩니다. 참고로 Java는 같은 패키지의 다른 클래스에서protected멤버에 접근하는 것을 허용하지만, Kotlin은 허용하지 않아요.internal선언은 Java에서public이 됩니다. Kotlin 컴파일러는 바이트코드에서internal멤버의 이름을 맹글링(mangle)합니다. 이는 예를 들어 Java에서 Kotlin 클래스를 확장할 때 모듈 간 우발적인 오버라이드를 막고, 같은 시그니처의 멤버에 대한 오버로딩을 허용합니다. 참고로internal클래스의 public 멤버 이름은 맹글링되지 않으며 Java에서 계속 호출할 수 있어요.public멤버는public으로 유지됩니다.
KClass
때로는 KClass 타입의 파라미터를 가진 Kotlin 메서드를 호출해야 할 때가 있어요. Class에서 KClass로의 자동 변환은 없으므로, Class<T>.kotlin 확장 프로퍼티에 해당하는 것을 직접 호출해 수동으로 변환해야 합니다:
kotlin.jvm.JvmClassMappingKt.getKotlinClass(MainView.class)
@JvmName으로 시그니처 충돌 처리하기
때로는 Kotlin에 이름이 있는 함수에 대해 바이트코드에서 다른 JVM 이름이 필요한 경우가 있어요. 가장 대표적인 예는 타입 소거(type erasure) 때문에 발생합니다:
fun List<Int>.filterValid(): List<Int>
fun List<String>.filterValid(): List<String>
이 두 함수는 JVM 시그니처가 같기 때문에(filterValid(Ljava/util/List;)Ljava/util/List;) 나란히 정의할 수 없어요. Kotlin에서 정말 같은 이름을 유지하고 싶다면, 둘 중 하나(또는 둘 다)에 @JvmName을 붙이고 인자로 다른 이름을 지정하면 됩니다:
fun List<String>.filterValid(): List<String>
@JvmName("filterValidInt")
fun List<Int>.filterValid(): List<Int>
Kotlin에서는 filterValid라는 같은 이름으로 접근 가능하지만, Java에서는 filterValid와 filterValidInt로 접근해요.
프로퍼티 x와 함수 getX()가 함께 필요한 경우에도 같은 방법을 쓸 수 있습니다:
val x: Int
@JvmName("getX_prop")
get() = 15
fun getX() = 10
명시적으로 구현된 getter와 setter가 없는 프로퍼티의 생성된 접근자 메서드 이름을 바꾸려면 @get:JvmName과 @set:JvmName을 사용할 수 있어요:
@get:JvmName("x")
@set:JvmName("changeX")
var x: Int = 23
오버로드 생성(Overloads)
보통 기본 파라미터 값이 있는 Kotlin 함수를 작성하면, Java에서는 모든 파라미터가 있는 전체 시그니처로만 보여요.
선택 파라미터에 대한 오버로드를 생성하려면 @IntroducedAt 애너테이션이나 @JvmOverloads 애너테이션을 사용할 수 있습니다.
공개(public) API에 새로운 선택 파라미터를 추가할 때 @IntroducedAt을 사용하세요. 그러면 생성된 오버로드가 각 파라미터가 도입된 버전을 반영하게 됩니다. 컴파일러는 이 정보를 사용해 해당하는 숨겨진 오버로드를 자동으로 생성해요.
이는 버전 기반 오버로드 생성을 제공하고, 라이브러리의 이전 버전에 대해 컴파일된 호출자의 이진 호환성을 보존하는 데 도움이 됩니다.
@IntroducedAt 애너테이션은 실험적(Experimental)이에요. 사용하려면 @OptIn(ExperimentalVersionOverloading::class) 애너테이션으로 옵트인하세요.
다음은 Button() 함수가 여러 API 버전에 걸쳐 여러 선택 파라미터를 받는 예시예요:
@OptIn(ExperimentalVersionOverloading::class)
fun Button(
label: String = "",
color: Color = DefaultColor,
@IntroducedAt("1.1") borderColor: Color = DefaultBorderColor,
@IntroducedAt("1.2") borderStyle: Style = DefaultBorderStyle,
@IntroducedAt("1.2") borderWidth: Int = 1,
onClick: () -> Unit
) {
// Function body
}
이 버전들을 바탕으로 컴파일러는 원래 API와, 새로운 선택 파라미터를 도입한 각 API 버전에 대해 숨겨진 오버로드를 생성해요:
// Original API
Button(
label: String,
color: Color,
onClick: () -> Unit
)
// Version 1.1
Button(
label: String,
color: Color,
borderColor: Color,
onClick: () -> Unit
)
// Version 1.2
Button(
label: String,
color: Color,
borderColor: Color,
borderStyle: Style,
borderWidth: Int,
onClick: () -> Unit
)
Java 호출자에게 여러 오버로드를 노출하고 싶다면 @JvmOverloads 애너테이션도 사용할 수 있어요.
이 애너테이션은 생성자, 정적 메서드 등에도 동작합니다. 인터페이스에 정의된 메서드를 포함한 추상 메서드에는 사용할 수 없어요. 예를 들어 기본 파라미터 값이 있는 Circle 클래스를 생각해 보아요:
class Circle @JvmOverloads constructor(centerX: Int, centerY: Int, radius: Double = 1.0) {
@JvmOverloads fun draw(label: String, lineWidth: Int = 1, color: String = "red") { /*...*/ }
}
기본값이 있는 각 파라미터에 대해, 이 파라미터와 파라미터 목록에서 그 오른쪽에 있는 모든 파라미터를 제거한 오버로드가 하나씩 추가로 생성됩니다. 이 예시에서는 다음이 생성돼요:
// Constructors:
Circle(int centerX, int centerY, double radius)
Circle(int centerX, int centerY)
// Methods
void draw(String label, int lineWidth, String color) { }
void draw(String label, int lineWidth) { }
void draw(String label) { }
@IntroducedAt과 @JvmOverloads 애너테이션은 모두 오버로드를 생성하므로, 함께 사용하면 충돌하는 오버로드가 생길 수 있어요. 두 애너테이션을 모두 사용하면 컴파일러가 경고를 보고합니다. 경고를 억제하면, 컴파일러는 @IntroducedAt 애너테이션에서 생성된 오버로드를 우선시해요.
보조 생성자(Secondary constructors)에서 설명한 대로, 클래스가 모든 생성자 파라미터에 기본값을 가지면 인자가 없는 public 생성자가 생성된다는 점을 기억하세요. 이는 @JvmOverloads 애너테이션을 지정하지 않아도 동작합니다.
확인된 예외(Checked exceptions)
Kotlin에는 확인된 예외(checked exception)가 없어요. 그래서 보통 Kotlin 함수의 Java 시그니처는 던져지는 예외를 선언하지 않습니다. 따라서 Kotlin에 다음과 같은 함수가 있다고 해 보아요:
// example.kt
package demo
fun writeToFile() {
/*...*/
throw IOException()
}
Java에서 이 함수를 호출하고 예외를 잡고 싶다면:
// Java
try {
demo.Example.writeToFile();
} catch (IOException e) {
// error: writeToFile() does not declare IOException in the throws list
// ...
}
writeToFile()이 IOException을 선언하지 않으므로 Java 컴파일러에서 오류 메시지를 받게 돼요. 이 문제를 해결하려면 Kotlin에서 @Throws 애너테이션을 사용하세요:
@Throws(IOException::class)
fun writeToFile() {
/*...*/
throw IOException()
}
널 안전성(Null-safety)
Java에서 Kotlin 함수를 호출할 때, non-nullable 파라미터에 null을 전달하는 것을 막을 수 없어요. 그래서 Kotlin은 non-null을 기대하는 모든 public 함수에 대해 런타임 검사를 생성합니다. 이렇게 하면 Java 코드에서 즉시 NullPointerException을 얻게 돼요.
변성 제네릭(Variant generics)
Kotlin 클래스가 선언처 변성(declaration-site variance)을 사용하면, Java 코드에서 그 사용이 어떻게 보이는지에 대한 두 가지 옵션이 있어요. 예를 들어 다음 클래스와 이 클래스를 사용하는 두 함수가 있다고 해 보아요:
class Box<out T>(val value: T)
interface Base
class Derived : Base
fun boxDerived(value: Derived): Box<Derived> = Box(value)
fun unboxBase(box: Box<Base>): Base = box.value
이 함수들을 Java로 번역하는 단순한 방법은 다음과 같아요:
Box<Derived> boxDerived(Derived value) { ... }
Base unboxBase(Box<Base> box) { ... }
문제는 Kotlin에서 unboxBase(boxDerived(Derived()))를 쓸 수 있지만, Java에서는 클래스 Box가 파라미터 T에서 불변(invariant)이므로 Box<Derived>가 Box<Base>의 하위 타입이 아니라는 점이에요. Java에서 이게 동작하게 하려면 unboxBase를 다음과 같이 정의해야 합니다:
Base unboxBase(Box<? extends Base> box) { ... }
이 선언은 Java가 가진 전부인 use-site 변성을 통해 선언처 변성을 에뮬레이트하기 위해 Java의 와일드카드 타입(? extends Base)을 사용해요.
Kotlin API가 Java에서 동작하도록, 컴파일러는 공변(covariant)으로 정의된 Box가 파라미터로 나타날 때 Box<Super>를 Box<? extends Super>로 생성해요(반변(contravariant)으로 정의된 Foo는 Foo<? super Bar>). 반환 값일 때는 와일드카드를 생성하지 않는데, 그렇지 않으면 Java 클라이언트가 이를 처리해야 하고(일반적인 Java 코딩 스타일에 어긋남) 그렇기 때문이에요. 따라서 우리 예시의 함수는 실제로 다음과 같이 번역됩니다:
// return type - no wildcards
Box<Derived> boxDerived(Derived value) { ... }
// parameter - wildcards
Base unboxBase(Box<? extends Base> box) { ... }
인자 타입이 final이면 보통 와일드카드를 생성할 이유가 없으므로, Box<String>은 어떤 위치에 있든 항상 Box<String>이에요.
기본적으로 와일드카드가 생성되지 않는 곳에 와일드카드가 필요하다면 @JvmWildcard 애너테이션을 사용하세요:
fun boxDerived(value: Derived): Box<Derived> = Box(value)
// is translated to
// Box<? extends Derived> boxDerived(Derived value) { ... }
반대로, 와일드카드가 생성되는 곳에 와일드카드가 필요 없다면 @JvmSuppressWildcards를 사용하세요:
fun unboxBase(box: Box<@JvmSuppressWildcards Base>): Base = box.value
// is translated to
// Base unboxBase(Box<Base> box) { ... }
@JvmSuppressWildcards는 개별 타입 인자뿐만 아니라 함수나 클래스 같은 전체 선언에도 사용할 수 있어서, 그 안의 모든 와일드카드를 억제해요.
타입 Nothing의 번역
타입 Nothing은 Java에 자연스러운 대응물이 없기 때문에 특별해요. 실제로 java.lang.Void를 포함한 모든 Java 참조 타입은 null을 값으로 받아들이지만, Nothing은 그것조차 받아들이지 않습니다. 그래서 이 타입은 Java 세계에서 정확하게 표현할 수 없어요. 그래서 Kotlin은 Nothing 타입의 인자가 사용되는 곳에 raw 타입을 생성합니다:
fun emptyList(): List<Nothing> = listOf()
// is translated to
// List emptyList() { ... }
인라인 값 클래스(Inline value classes)
Java 코드가 Kotlin의 인라인 값 클래스와 원활하게 동작하길 원한다면 @JvmExposeBoxed 애너테이션이나 -Xjvm-expose-boxed 컴파일러 옵션을 사용할 수 있어요. 이 접근 방식들은 Java 상호운용을 위해 Kotlin이 필요한 박스형(boxed) 표현을 생성하도록 보장합니다.
기본적으로 Kotlin은 인라인 값 클래스를 언박스형(unboxed) 표현으로 컴파일하며, 이는 Java에서 접근할 수 없는 경우가 많아요. 예를 들어 Java에서 MyInt 클래스의 생성자를 호출할 수 없습니다:
@JvmInline
value class MyInt(val value: Int)
그래서 다음 Java 코드는 실패해요:
MyInt input = new MyInt(5);
@JvmExposeBoxed 애너테이션을 사용하면 Kotlin이 Java에서 직접 호출할 수 있는 public 생성자를 생성해요. 애너테이션을 다음 수준에 적용해 Java에 무엇을 노출할지 세밀하게 제어할 수 있습니다:
- 클래스(Class)
- 생성자(Constructor)
- 함수(Function)
코드에서 @JvmExposeBoxed 애너테이션을 사용하기 전에 @OptIn(ExperimentalStdlibApi::class)로 옵트인해야 해요. 예를 들면:
@OptIn(ExperimentalStdlibApi::class)
@JvmExposeBoxed
@JvmInline
value class MyInt(val value: Int)
@OptIn(ExperimentalStdlibApi::class)
@JvmExposeBoxed
fun MyInt.timesTwoBoxed(): MyInt = MyInt(this.value * 2)
이 애너테이션들로 Kotlin은 MyInt 클래스에 대한 Java 접근 가능한 생성자와, 값 클래스의 박스형 형태를 사용하는 확장 함수용 변형을 생성해요. 그래서 다음 Java 코드는 성공적으로 실행됩니다:
MyInt input = new MyInt(5);
MyInt output = ExampleKt.timesTwoBoxed(input);
이 동작을 모듈 안의 모든 인라인 값 클래스와 이를 사용하는 함수에 적용하려면 -Xjvm-expose-boxed 옵션으로 컴파일하세요. 이 옵션으로 컴파일하는 것은 모듈의 모든 선언에 @JvmExposeBoxed 애너테이션이 있는 것과 같은 효과를 가져요.
상속된 함수
@JvmExposeBoxed 애너테이션은 상속된 함수에 대해 자동으로 박스형 표현을 생성하지 않아요.
상속된 함수에 필요한 표현을 생성하려면, 구현하거나 확장하는 클래스에서 이를 오버라이드하세요:
interface IdTransformer {
fun transformId(rawId: UInt): UInt = rawId
}
// Doesn't generate a boxed representation for the transformId() function
@OptIn(ExperimentalStdlibApi::class)
@JvmExposeBoxed
class LightweightTransformer : IdTransformer
// Generates a boxed representation for the transformId() function
@OptIn(ExperimentalStdlibApi::class)
@JvmExposeBoxed
class DefaultTransformer : IdTransformer {
override fun transformId(rawId: UInt): UInt = super.transformId(rawId)
}
Kotlin에서 상속이 어떻게 작동하고 super 키워드로 상위 클래스 구현을 호출하는 방법을 배우려면 상속(Inheritance)을 참고하세요.