본문 바로가기
프로그래밍/kotlin

[kotlin] 코틀린 차곡차곡 - 15. kotlin JVM annotation 알아보기

by 사바라다 2022. 1. 22.

안녕하세요. 오늘은 kotlin JVM annotation에 대해서 알아보는 시간을 가져보도록 하겠습니다.

JVM annotation

코틀린은 컴파일 시 자바 바이트코드(.class)로 변환 되어지게 되며 다른 자바 파일들과 상호호환 되게 되고 JVM에 의해서 실행되게 되는 과정을 거칩니다. 이러한 과정 중 코틀린 파일에서 자바 바이트코드로 변경될 때좀 더 정밀한 제어를 할 수 있도록 하는 kotlin annotation이 있습니다. 이러한 어노테이션을 JVM annotation이라고 합니다.

이러한 JVM annotation의 종류는 아래와 같으며 오늘은 하나씩 차근차근 알아보는 시간을 가져보도록 하겠습니다.

  • @JvmName
  • @JvmStatic
  • @JvmField
  • @JvmDefault
  • @JvmOverloads
  • @Throws
  • @JvmWildcard
  • @JvmSuppressWildcards
  • @JvmMultifileClass
  • @JvmPackageName

이번 포스팅에서는 JvmName, JvmStatic, JvmField를 알아보고 다음시간에 나머지를 알아보도록 하겠습니다.

@JvmName

JvmName 어노테이션은 코틀린 코드에서 자바 바이트코드로 컴파일될 때 자바와의 이름을 지정하는데에 대한 차이를 매꾸어주기 위해서 사용할 수 있는 어노테이션입니다. 이 어노테이션은 코틀린 file, 함수(메서드), 변수(properties) 그리고 getter와 setter에 지정할 수 있습니다. 해당 어노테이션을 지정하게 되면 코틀린 코드 자체에는 변화가 없지만 컴파일 된 자바 바이트코드에는 변화가 있습니다.

그럼 예제를 통해서 어떻게 변화되는지 확인해보도록 합시다.

파일(file)

코틀린은 file 레벨에서 함수와 변수를 선언할 수 있습니다. 관련된 예제를 아래와 같이 하나 보도록 하겠습니다. Kotlin 파일의 이름은 VersionExtension 입니다. 그리고 그 파일 안에 아래와 같이 함수를 선언할 수 있습니다. 그리고 실제로 사용하는 방법은 해당 함수를 바로 호출하는 것이 가능합니다.

fun getDefaultVersion() : String {
    return "1.0.0"
}

// 아래는 실제 사용
val defaultVersion = getDefaultVersion()

이렇게 코드를 선언했을 때 자바코드로 디컴파일 해보면 아래와 같습니다. 파일 이름에 Kt라는 Postfix를 붙여 final class를 만들고 그 내부에 static final 함수를 만들고 있습니다. 그리고 실제로 자바 바이트 코드를 디컴파일 해보면 java의 static final 메서드를 호출하고 있습니다.

public final class VersionExtensionKt {
   @NotNull
   public static final String getDefaultVersion() {
      return "1.0.0";
   }
}

// 아래는 실제 사용
String defaultVersion = VersionExtensionKt.getDefaultVersion();

이제 이어서 file Level의 JvmName을 사용해보겠습니다. file Lvel의 JvmName은 파일 최상단에 @file:JvmName의 형식으로 사용할 수 있습니다. 이렇게 사용함에 있어서 코틀린 파일의 함수 선언 및 사용은 변경되는 부분이 없습니다.

@file:JvmName("Version")
package com.sabarada.api.service.common

fun getDefaultVersion() : String {
    return "myUserId"
}

// 아래는 실제 사용
val defaultVersion = getDefaultVersion()

그렇다면 Java 파일로 디컴파일 해보도록 하겠습니다. 그랬을때의 결과는 아래와 같습니다. 아래를 보시면 final class의 이름이 @JvmName으로 명명한 Version으로 변경된 것을 확인할 수 있습니다. 그리고 사용하는 측에서도 Version을 통해 접근한다는 사실을 알 수 있었습니다.

@JvmName(
   name = "Version"
)
public final class Version {
   @NotNull
   public static final String getDefaultVersion() {
      return "myUserId";
   }
}

// 아래는 실제 사용
String defaultVersion = Version.getDefaultVersion();

함수(Function)

JvmName을 함수에 사용하는 케이스를 알아보도록 하겠습니다. 아래와 같은 코틀린 클래스가 있습니다. 이 클래스는 컴파일 되지 않습니다. val name 변수에서 getName이라는 메서드를 자동으로 생성해주기때문에 Platform declaration clash: The following declarations have the same JVM signature라는 에러가 발생하며 컴파일 에러가 발생하게 되는 것입니다.

class Version {

    val name = "sabarada"

    fun getName(): String {
        return name
    }
}

이럴 경우 우리는 JvmName 어노테이션으로 이러한 문제를 해결해볼 수 있습니다. kotlin의 명시된 getName 메서드에 JvmName으로 이름을 변경해 주는것입니다. 이렇게 했을 때 컴파일은 실패하지 않습니다.

class Version {

    val name = "sabarada"

    @JvmName("getName2")
    fun getName(): String {
        return name
    }
}

실패하지 않는 이유를 확인하기 위해서 자바 디컴파일을 해봅시다. 아래와 같이 나오는 것을 알 수 있습니다. 여기서 우리가 선언했던 getName 변수가 getName2 라는 이름을 가지고 있기때문에 네이밍 충돌이 발생하지 않았다는 사실을 알 수 있었습니다.

public final class Version {
   @NotNull
   private final String name = "sabarada";

   @NotNull
   public final String getName() {
      return this.name;
   }

   @JvmName(
      name = "getName2"
   )
   @NotNull
   public final String getName2() {
      return this.name;
   }
}

이러한 결과로 kotlin에서 해당 클래스를 사용할 때 getName()name이 다른 결과를 가져오도록 할 수 있습니다.

version.name      // name의 getter
version.getName() // getName 함수

함수 2번째 케이스

JvmName을 사용할 수 있는 2번째 케이스는 제네릭 Type erasure에 의한 충돌을 방지하기 위해서 사용할 수 있는 방법입니다. 아래코드는 메서드 오버라이딩이 될것임을 상정하고 만든 코드 입니다. 하지만 코드는 컴파일 되지 않습니다. 왜냐하면 자바의 제네릭 타입의 특징때문입니다. 제네릭 타입은 아시다시피 컴파일시 사라지게 됩니다. 이게 제네릭의 Type Erasure 때문입니다. 그래서 아래와 같은 제네릭 타입만 다르게 파라미터를 가져오는 코드는 오버라이딩이 제공되지 않습니다. 하지만 코틀린에서는 지원이 됩니다. 이러한 차이를 매꿀수 있는 방법으로도 JvmName 어노테이션을 사용할 수 있습니다. (Type Erasure에 대한 내용은 [java] 배열(Array)과 컬렉션 제네릭의 차이를 참고해주세요.)

class Audio {

    private var listenerIds: List<Long> = emptyList()

    fun setListenerIds(receiverNames : List<String>) { // 컴파일 불가
        listenerIds = receiverNames.map { it.toLong() }.toList()
    }

    fun setListenerIds(receiverNames : List<Int>) {
        listenerIds = receiverNames.map { it.toLong() }.toList()
    }
}

아래는 JvmName을 통해 자바 바이트 코드에 대해서 별도의 이름을 정해줌으로써 코틀린에서 오버라이딩이 가능해지도록 해준 코드입니다.

class Audio {

    private var listenerIds: List<Long> = emptyList()

    @JvmName("setListenerIds2")
    fun setListenerIds(receiverNames : List<String>) {
        listenerIds = receiverNames.map { it.toLong() }.toList()
    }

    @JvmName("setListenerIds3")
    fun setListenerIds(receiverNames : List<Int>) {
        listenerIds = receiverNames.map { it.toLong() }.toList()
    }
}

코틀린 코드를 위와같이 작성하면 아래처럼 코틀린 코드를 오버라이딩해서 사용할 수 있습니다.

audio.setListenerIds(listOf(1, 2, 3))
audio.setListenerIds(listOf("1", "2", "3"))

@JvmStatic과 @JvmField

코틀린은 자바의 static 또는 싱글톤 클래스를 대체하기 위하여 companion과 object 클래스를 지원하고 있습니다. 그런데 이런 companion과 object는 사실 우리가 일반적으로 자바에서 사용하고 있는 방식과는 조금 다르게 내부에서 이루어지고 있습니다. 코드를 한번보고 이어서 이야기해보도록 하겠습니다. 먼저 아래코드는 코틀린의 object 클래스입니다. 싱글톤 클래스라고 생각하시면 됩니다.

object Version {

    var value = "1.0.0"

    fun resetVersion() { }
}

위 코드는 자바로 디컴파일을 진행하면 아래와 같은 코드로 변경됩니다. 싱글톤 클래스 이기 때문에 INSTANCE가 존재하며 private으로 생성자가 생성되어 있는것을 확인할 수 있습니다. 그리고 static block을 통해 초기화를 하고 있는것도 확인할 수 있습니다. 그리고 getter와 setter가 변수에 선언되어있어 해당 메서드를 통해 접근하는 것을 확인할 수 있습니다. 실제로 접근을 하면 Version.INSTANCE.xxx 형태로 접근하게 됩니다.

public final class Version {

   private Version() {
   }

   @NotNull
   public static final Version INSTANCE;

   @NotNull
   private static String value;

   @NotNull
   public final String getValue() {
      return value;
   }

   public final void setValue(@NotNull String var1) {
      Intrinsics.checkNotNullParameter(var1, "<set-?>");
      value = var1;
   }

   public final void resetVersion() {
   }

   static {
      Version var0 = new Version();
      INSTANCE = var0;
      value = "1.0.0";
   }
}

변수를 접근할 때 INSTANCE를 통해서 접근한다는 이러한 방법은 static 변수와 함수에 접근하는 일반적인 방법과는 조금 상이합니다. 이런 부분을 맞춰주기 위해서 @JvmStatic과 @JvmField를 사용할 수 있습니다. 차이를 확인하기 위해서 아래와 같은 코드로 구성하였습니다.

object Version {

    var value = "1.0.0"

    @JvmStatic
    var value2 = "1.0.0"

    @JvmField
    var value3 = "1.0.0"

    fun resetVersion() { }

    @JvmStatic
    fun resetVersion2() { }
}

위 코드를 자바로 디컴파일 했을 때 나오는 코드가 아래입니다. 중복되는 value는 제거하고 어노테이션을 붙인 변수만 확인을 해보겠습니다. @JvmStatic을 변수에 붙였을 때는 private static 변수로 getter와 setter로 접근합니다. 그리고 @JvmField를 붙였을 때느 public static 변수로 직접 접근할 수 있다는 사실을 알 수 있었습니다. 둘 모두 INSTNACE를 거치지 않고 접근할 수 있게 되었다는것을 확인할 수 있었습니다. 추가로 함수에 @JvmStatic을 붙일 수 있는데 이때 역시 메서드에 static이 붙어 직접접근할 수 있다는 사실까지 확인이 가능했습니다.

public final class Version {

   @NotNull
   private static String value2;

   @JvmField
   @NotNull
   public static String value3;

   @NotNull
   public static final Version INSTANCE;


   /** @deprecated */
   // $FF: synthetic method
   @JvmStatic
   public static void getValue2$annotations() {
   }

   @NotNull
   public static final String getValue2() {
      return value2;
   }

   public static final void setValue2(@NotNull String var0) {
      Intrinsics.checkNotNullParameter(var0, "<set-?>");
      value2 = var0;
   }

   public final void resetVersion() {
   }

   @JvmStatic
   public static final void resetVersion2() {
   }

   private Version() {
   }

   static {
      Version var0 = new Version();
      INSTANCE = var0;
      value = "1.0.0";
      value2 = "1.0.0";
      value3 = "1.0.0";
   }
}

마무리

오늘은 이렇게 코틀린 코드를 좀 더 JVM 친화적으로 사용할 수 있는 JVM Annotation에 대해서 알아보았습니다.

다음 시간에도 이어서 오늘 미처 못알아보았던 어노테이션에 대해서 알아보도록 하겠습니다.

감사합니다.

참조

kotlinlang_kotlin.jvm

baeldung_jvm-annotations

codechacha_kotlin-annotations

댓글