Java에서 Kotlin 코드 호출

이 Codelab에서는 자바 코드에서 더 원활하게 호출할 수 있도록 Kotlin 코드를 작성하거나 적용하는 방법을 알아봅니다.

학습할 내용

  • @JvmField, @JvmStatic 및 기타 주석을 사용하는 방법
  • Java 코드에서 특정 Kotlin 언어 기능에 액세스하는 데 제한이 있습니다.

이미 알고 있어야 하는 사항

이 Codelab은 프로그래머를 대상으로 하며 기본적인 Java 및 Kotlin 지식이 있다고 가정합니다.

이 Codelab에서는 Java 프로그래밍 언어로 작성된 대규모 프로젝트의 일부를 마이그레이션하여 새로운 Kotlin 코드를 통합하는 과정을 시뮬레이션합니다.

간단하게 하기 위해 기존 코드베이스를 나타내는 UseCase.java이라는 단일 .java 파일이 있다고 가정합니다.

원래 Java로 작성된 일부 기능을 Kotlin으로 작성된 새 버전으로 대체했으며 이를 통합해야 한다고 가정해 보겠습니다.

프로젝트 가져오기

프로젝트의 코드는 여기 GitHub 프로젝트에서 클론할 수 있습니다. GitHub

또는 다음 위치에서 zip 보관 파일을 다운로드하고 프로젝트를 추출할 수 있습니다.

ZIP 파일 다운로드

IntelliJ IDEA를 사용하는 경우 '프로젝트 가져오기'를 선택합니다.

Android 스튜디오를 사용하는 경우 '프로젝트 가져오기 (Gradle, Eclipse ADT 등)'를 선택합니다.

UseCase.java를 열고 표시되는 오류를 해결해 보겠습니다.

문제가 있는 첫 번째 함수는 registerGuest입니다.

public static User registerGuest(String name) {
   User guest = new User(Repository.getNextGuestId(), StringUtils.nameToLogin(name), name);
   Repository.addUser(guest);
   return guest;
}

Repository.getNextGuestId()Repository.addUser(...)의 오류는 '정적 컨텍스트에서 비정적에 액세스할 수 없습니다'로 동일합니다.

이제 Kotlin 파일 중 하나를 살펴보겠습니다. Repository.kt 파일을 엽니다.

저장소는 object 키워드를 사용하여 선언된 싱글톤입니다. 문제는 Kotlin이 이러한 항목을 정적 속성 및 메서드로 노출하는 대신 클래스 내부에 정적 인스턴스를 생성한다는 것입니다.

예를 들어 Repository.INSTANCE.getNextGuestId()를 사용하여 Repository.getNextGuestId()를 참조할 수 있지만 더 나은 방법이 있습니다.

@JvmStatic로 저장소의 공개 속성과 메서드에 주석을 달아 Kotlin이 정적 메서드와 속성을 생성하도록 할 수 있습니다.

object Repository {
   val BACKUP_PATH = "/backup/user.repo"

   private val _users = mutableListOf<User>()
   private var _nextGuestId = 1000

   @JvmStatic
   val users: List<User>
       get() = _users

   @JvmStatic
   val nextGuestId
       get() = _nextGuestId++

   init {
       _users.add(User(100, "josh", "Joshua Calvert", listOf("admin", "staff", "sys")))
       _users.add(User(101, "dahybi", "Dahybi Yadev", listOf("staff", "nodes")))
       _users.add(User(102, "sarha", "Sarha Mitcham", listOf("admin", "staff", "sys")))
       _users.add(User(103, "warlow", groups = listOf("staff", "inactive")))
   }

   @JvmStatic
   fun saveAs(path: String?):Boolean {
       val backupPath = path ?: return false

       val outputFile = File(backupPath)
       if (!outputFile.canWrite()) {
           throw FileNotFoundException("Could not write to file: $backupPath")
       }
       // Write data...
       return true
   }

   @JvmStatic
   fun addUser(user: User) {
       // Ensure the user isn't already in the collection.
       val existingUser = users.find { user.id == it.id }
       existingUser?.let { _users.remove(it) }
       // Add the user.
       _users.add(user)
   }
}

IDE를 사용하여 코드에 @JvmStatic 주석을 추가합니다.

UseCase.java로 다시 전환하면 Repository.BACKUP_PATH를 제외한 Repository의 속성과 메서드에서 더 이상 오류가 발생하지 않습니다. 이 부분은 나중에 다시 다루겠습니다.

이제 registerGuest() 메서드의 다음 오류를 수정해 보겠습니다.

문자열 작업을 위한 여러 정적 함수가 있는 StringUtils 클래스가 있다고 가정해 보겠습니다. Kotlin으로 변환할 때 메서드를 확장 함수로 변환했습니다. Java에는 확장 함수가 없으므로 Kotlin은 이러한 메서드를 정적 함수로 컴파일합니다.

UseCase.java 내의 registerGuest() 메서드를 살펴보면 뭔가 잘못된 것을 알 수 있습니다.

User guest = new User(Repository.getNextGuestId(), StringUtils.nameToLogin(name), name);

Kotlin은 이러한 '최상위' 또는 패키지 수준 함수를 파일 이름을 기반으로 하는 클래스 내부에 배치하기 때문입니다. 이 경우 파일 이름이 StringUtils.kt이므로 해당 클래스 이름은 StringUtilsKt입니다.

StringUtils의 모든 참조를 StringUtilsKt로 변경하여 이 오류를 해결할 수 있지만 다음과 같은 이유로 이상적이지 않습니다.

  • 코드에서 업데이트해야 하는 부분이 많을 수 있습니다.
  • 이름 자체가 어색합니다.

따라서 Java 코드를 리팩터링하는 대신 이러한 메서드에 다른 이름을 사용하도록 Kotlin 코드를 업데이트하겠습니다.

StringUtils.Kt을 열고 다음 패키지 선언을 찾습니다.

package com.google.example.javafriendlykotlin

@file:JvmName 주석을 사용하여 패키지 수준 메서드에 다른 이름을 사용하도록 Kotlin에 지시할 수 있습니다. 이 주석을 사용하여 클래스 이름을 StringUtils로 지정해 보겠습니다.

@file:JvmName("StringUtils")

package com.google.example.javafriendlykotlin

이제 UseCase.java를 다시 살펴보면 StringUtils.nameToLogin()의 오류가 해결되었음을 확인할 수 있습니다.

하지만 이 오류는 User의 생성자에 전달되는 매개변수에 관한 새로운 오류로 대체되었습니다. 다음 단계로 넘어가 UseCase.registerGuest()에서 마지막 오류를 수정해 보겠습니다.

Kotlin은 매개변수의 기본값을 지원합니다. Repository.ktinit 블록을 살펴보면 이러한 함수가 어떻게 사용되는지 알 수 있습니다.

Repository.kt:

_users.add(User(102, "sarha", "Sarha Mitcham", listOf("admin", "staff", "sys")))
_users.add(User(103, "warlow", groups = listOf("staff", "inactive")))

사용자 'warlow'의 경우 User.kt에 기본값이 지정되어 있으므로 displayName 값을 입력하지 않아도 됩니다.

User.kt:

data class User(
   val id: Int,
   val username: String,
   val displayName: String = username.toTitleCase(),
   val groups: List<String> = listOf("guest")
)

하지만 Java에서 메서드를 호출할 때는 동일하게 작동하지 않습니다.

UseCase.java:

User guest = new User(Repository.getNextGuestId(), StringUtils.nameToLogin(name), name);

기본값은 Java 프로그래밍 언어에서 지원되지 않습니다. 이 문제를 해결하려면 @JvmOverloads 주석을 사용하여 생성자의 오버로드를 생성하도록 Kotlin에 지시합니다.

먼저 User.kt를 약간 업데이트해야 합니다.

User 클래스에는 단일 기본 생성자만 있고 생성자에는 주석이 포함되지 않으므로 constructor 키워드가 생략되었습니다. 이제 주석을 달고 싶으므로 constructor 키워드를 포함해야 합니다.

data class User constructor(
    val id: Int,
    val username: String,
    val displayName: String = username.toTitleCase(),
    val groups: List<String> = listOf("guest")
)

constructor 키워드가 있으면 @JvmOverloads 주석을 추가할 수 있습니다.

data class User @JvmOverloads constructor(
    val id: Int,
    val username: String,
    val displayName: String = username.toTitleCase(),
    val groups: List<String> = listOf("guest")
)

UseCase.java로 다시 전환하면 registerGuest 함수에 더 이상 오류가 없는 것을 확인할 수 있습니다.

다음 단계는 UseCase.getSystemUsers()에서 user.hasSystemAccess()에 대한 잘못된 호출을 수정하는 것입니다. 다음 단계로 진행하거나 계속 읽어 @JvmOverloads가 오류를 수정하기 위해 어떤 작업을 했는지 자세히 알아보세요.

@JvmOverloads

@JvmOverloads의 기능을 더 잘 이해하기 위해 UseCase.java에서 테스트 메서드를 만들어 보겠습니다.

private void testJvmOverloads() {
   User syrinx = new User(1001, "syrinx");
   User ione = new User(1002, "ione", "Ione Saldana");

   List<String> groups = new ArrayList<>();
   groups.add("staff");
   User beaulieu = new User(1002, "beaulieu", groups);
}

idusername의 두 매개변수만으로 User를 구성할 수 있습니다.

User syrinx = new User(1001, "syrinx");

groups의 기본값을 계속 사용하면서 displayName의 세 번째 매개변수를 포함하여 User를 구성할 수도 있습니다.

User ione = new User(1002, "ione", "Ione Saldana");

하지만 displayName을 건너뛰고 추가 코드를 작성하지 않고 groups 값만 제공할 수는 없습니다.

따라서 해당 줄을 삭제하거나'//'로 시작하여 주석 처리합니다.

Kotlin에서 기본 매개변수와 기본이 아닌 매개변수를 결합하려면 이름이 지정된 매개변수를 사용해야 합니다.

// This doesn't work...
User(104, "warlow", listOf("staff", "inactive"))
// But using named parameters, it does...
User(104, "warlow", groups = listOf("staff", "inactive"))

Kotlin은 생성자를 포함한 함수의 오버로드를 생성하지만 기본값이 있는 매개변수당 하나의 오버로드만 생성하기 때문입니다.

UseCase.java를 다시 살펴보고 다음 문제를 해결해 보겠습니다. UseCase.getSystemUsers() 메서드에서 user.hasSystemAccess()를 호출합니다.

public static List<User> getSystemUsers() {
   ArrayList<User> systemUsers = new ArrayList<>();
   for (User user : Repository.getUsers()) {
       if (user.hasSystemAccess()) {     // Now has an error!
           systemUsers.add(user);
       }
   }
   return systemUsers;
}

흥미로운 오류입니다. User 클래스에서 IDE의 자동 완성 기능을 사용하면 hasSystemAccess()의 이름이 getHasSystemAccess()로 변경된 것을 확인할 수 있습니다.

이 문제를 해결하려면 Kotlin에서 val 속성 hasSystemAccess의 이름을 다르게 생성해야 합니다. 이를 위해 @JvmName 주석을 사용할 수 있습니다. User.kt로 다시 전환하여 적용해야 할 위치를 확인해 보겠습니다.

주석을 적용하는 방법에는 두 가지가 있습니다. 첫 번째는 다음과 같이 get() 메서드에 직접 적용하는 것입니다.

val hasSystemAccess
   @JvmName("hasSystemAccess")
   get() = "sys" in groups

이렇게 하면 명시적으로 정의된 게터의 시그니처를 제공된 이름으로 변경하도록 Kotlin에 신호가 전송됩니다.

또는 다음과 같이 get: 접두사를 사용하여 속성에 적용할 수 있습니다.

@get:JvmName("hasSystemAccess")
val hasSystemAccess
   get() = "sys" in groups

대체 메서드는 기본적으로 정의된 게터를 사용하는 속성에 특히 유용합니다. 예를 들면 다음과 같습니다.

@get:JvmName("isActive")
val active: Boolean

이렇게 하면 getter를 명시적으로 정의하지 않고도 getter의 이름을 변경할 수 있습니다.

이러한 차이에도 불구하고 원하는 것을 사용하면 됩니다. 두 경우 모두 Kotlin에서 hasSystemAccess()이라는 이름의 getter를 생성합니다.

UseCase.java로 다시 전환하면 이제 getSystemUsers()에 오류가 없는 것을 확인할 수 있습니다.

다음 오류는 formatUser()에 있습니다. Kotlin getter 명명 규칙에 대해 자세히 알아보려면 다음 단계로 이동하기 전에 여기를 계속 읽어보세요.

Getter 및 Setter 이름 지정

Kotlin을 작성할 때는 다음과 같은 코드를 작성하는 것을 잊기 쉽습니다.

val myString = "Logged in as ${user.displayName}")

실제로 함수를 호출하여 displayName 값을 가져옵니다. 메뉴에서 Tools > Kotlin > Show Kotlin Bytecode로 이동한 다음 Decompile 버튼을 클릭하여 이를 확인할 수 있습니다.

String myString = "Logged in as " + user.getDisplayName();

Java에서 이러한 속성에 액세스하려면 getter의 이름을 명시적으로 작성해야 합니다.

대부분의 경우 Kotlin 속성의 getter의 Java 이름은 User.getHasSystemAccess()User.getDisplayName()에서 본 것처럼 get + 속성 이름입니다. 한 가지 예외는 이름이 'is'로 시작하는 속성입니다. 이 경우 getter의 Java 이름은 Kotlin 속성의 이름입니다.

예를 들어 User의 속성은 다음과 같습니다.

val isAdmin get() = //...

Java에서 다음과 같이 액세스합니다.

boolean userIsAnAdmin = user.isAdmin();

@JvmName 주석을 사용하면 Kotlin은 주석이 지정된 항목에 기본 이름이 아닌 지정된 이름이 있는 바이트 코드를 생성합니다.

생성된 이름이 항상 set + 속성 이름인 setter의 경우에도 마찬가지입니다. 예를 들어 다음 클래스를 살펴보세요.

class Color {
   var red = 0f
   var green = 0f
   var blue = 0f
}

getter는 그대로 두고 setter 이름만 setRed()에서 updateRed()로 변경한다고 가정해 보겠습니다. @set:JvmName 버전을 사용하여 이 작업을 수행할 수 있습니다.

class Color {
   @set:JvmName("updateRed")
   var red = 0f
   @set:JvmName("updateGreen")
   var green = 0f
   @set:JvmName("updateBlue")
   var blue = 0f
}

그러면 Java에서 다음을 작성할 수 있습니다.

color.updateRed(0.8f);

UseCase.formatUser()는 직접 필드 액세스를 사용하여 User 객체의 속성 값을 가져옵니다.

Kotlin에서는 일반적으로 getter와 setter를 통해 속성이 노출됩니다. 여기에는 val 속성이 포함됩니다.

@JvmField 주석을 사용하여 이 동작을 변경할 수 있습니다. 클래스의 속성에 적용하면 Kotlin에서 getter (및 var 속성의 setter) 메서드 생성을 건너뛰고 지원 필드에 직접 액세스할 수 있습니다.

User 객체는 변경할 수 없으므로 각 속성을 필드로 노출하려고 합니다. 따라서 각 속성에 @JvmField 주석을 추가합니다.

data class User @JvmOverloads constructor(
   @JvmField val id: Int,
   @JvmField val username: String,
   @JvmField val displayName: String = username.toTitleCase(),
   @JvmField val groups: List<String> = listOf("guest")
) {
   @get:JvmName("hasSystemAccess")
   val hasSystemAccess
       get() = "sys" in groups
}

이제 UseCase.formatUser()를 다시 살펴보면 오류가 수정된 것을 확인할 수 있습니다.

@JvmField 또는 const

이와 함께 UseCase.java 파일에 유사한 오류가 하나 더 있습니다.

Repository.saveAs(Repository.BACKUP_PATH);

여기에서 자동 완성을 사용하면 Repository.getBACKUP_PATH()가 표시되므로 BACKUP_PATH의 주석을 @JvmStatic에서 @JvmField로 변경하고 싶을 수 있습니다.

다음과 같이 해보세요. Repository.kt로 다시 전환하고 주석을 업데이트합니다.

object Repository {
   @JvmField
   val BACKUP_PATH = "/backup/user.repo"

이제 UseCase.java를 살펴보면 오류가 사라졌지만 BACKUP_PATH에도 메모가 있습니다.

Kotlin에서 const가 될 수 있는 유형은 int, float, String과 같은 기본 유형뿐입니다. 이 경우 BACKUP_PATH는 문자열이므로 @JvmField로 주석이 추가된 val 대신 const val를 사용하여 필드로 값에 액세스하는 기능을 유지하면서 성능을 개선할 수 있습니다.

이제 Repository.kt에서 이를 변경해 보겠습니다.

object Repository {
   const val BACKUP_PATH = "/backup/user.repo"

UseCase.java를 다시 살펴보면 오류가 하나만 남아 있습니다.

최종 오류는 Exception: 'java.io.IOException' is never thrown in the corresponding try block.입니다.

Repository.kt에서 Repository.saveAs의 코드를 살펴보면 예외가 발생합니다. 어떤 문제인가요?

Java에는 'checked exception'이라는 개념이 있습니다. 사용자가 파일 이름을 잘못 입력하거나 네트워크를 일시적으로 사용할 수 없는 경우와 같이 복구할 수 있는 예외입니다. 검사된 예외가 포착되면 개발자는 문제를 해결하는 방법에 관한 의견을 사용자에게 제공할 수 있습니다.

검사 예외는 컴파일 시간에 검사되므로 메서드의 서명에 선언합니다.

public void openFile(File file) throws FileNotFoundException {
   // ...
}

반면 Kotlin에는 검사 예외가 없으며 이것이 여기에서 문제를 일으키는 원인입니다.

해결 방법은 JVM 바이트 코드에 검사된 예외로 포함되도록 Kotlin에 발생할 수 있는 IOExceptionRepository.saveAs()의 서명에 추가하도록 요청하는 것입니다.

이는 Java/Kotlin 상호 운용성에 도움이 되는 Kotlin @Throws 주석을 사용하여 실행됩니다. Kotlin에서 예외는 Java와 유사하게 동작하지만 Java와 달리 Kotlin에는 확인되지 않은 예외만 있습니다. 따라서 Kotlin 함수가 예외를 발생시킨다는 것을 Java 코드에 알리려면 Kotlin 함수 서명에 @Throws 주석을 사용해야 합니다. Repository.kt file로 전환하고 saveAs()를 업데이트하여 새 주석을 포함하세요.

@JvmStatic
@Throws(IOException::class)
fun saveAs(path: String?) {
   val outputFile = File(path)
   if (!outputFile.canWrite()) {
       throw FileNotFoundException("Could not write to file: $path")
   }
   // Write data...
}

@Throws 주석을 사용하면 UseCase.java의 모든 컴파일러 오류가 수정된 것을 확인할 수 있습니다. 축하합니다.

이제 Kotlin에서 saveAs()를 호출할 때 trycatch 블록을 사용해야 하는지 궁금할 수 있습니다.

아닙니다. Kotlin에는 검사 예외가 없으며 메서드에 @Throws를 추가해도 이 점은 바뀌지 않습니다.

fun saveFromKotlin(path: String) {
   Repository.saveAs(path)
}

처리할 수 있는 예외를 포착하는 것은 여전히 유용하지만 Kotlin에서는 예외를 처리하도록 강제하지 않습니다.

이 Codelab에서는 직관적인 Java 코드 작성을 지원하는 Kotlin 코드를 작성하는 방법을 다루었습니다.

주석을 사용하여 Kotlin이 JVM 바이트 코드를 생성하는 방식을 변경하는 방법을 알아봤습니다.

  • @JvmStatic를 사용하여 정적 멤버와 메서드를 생성합니다.
  • @JvmOverloads를 사용하여 기본값이 있는 함수의 오버로드된 메서드를 생성합니다.
  • @JvmName를 사용하여 getter 및 setter의 이름을 변경합니다.
  • @JvmField를 사용하여 getter 및 setter를 통하지 않고 속성을 필드로 직접 노출합니다.
  • @Throws를 사용하여 확인된 예외를 선언합니다.

파일의 최종 콘텐츠는 다음과 같습니다.

User.kt

data class User @JvmOverloads constructor(
   @JvmField val id: Int,
   @JvmField val username: String,
   @JvmField val displayName: String = username.toTitleCase(),
   @JvmField val groups: List<String> = listOf("guest")
) {
   val hasSystemAccess
       @JvmName("hasSystemAccess")
       get() = "sys" in groups
}

Repository.kt

object Repository {
   const val BACKUP_PATH = "/backup/user.repo"

   private val _users = mutableListOf<User>()
   private var _nextGuestId = 1000

   @JvmStatic
   val users: List<User>
       get() = _users

   @JvmStatic
   val nextGuestId
       get() = _nextGuestId++

   init {
       _users.add(User(100, "josh", "Joshua Calvert", listOf("admin", "staff", "sys")))
       _users.add(User(101, "dahybi", "Dahybi Yadev", listOf("staff", "nodes")))
       _users.add(User(102, "sarha", "Sarha Mitcham", listOf("admin", "staff", "sys")))
       _users.add(User(103, "warlow", groups = listOf("staff", "inactive")))
   }

   @JvmStatic
   @Throws(IOException::class)
   fun saveAs(path: String?):Boolean {
       val backupPath = path ?: return false

       val outputFile = File(backupPath)
       if (!outputFile.canWrite()) {
           throw FileNotFoundException("Could not write to file: $backupPath")
       }
       // Write data...
       return true
   }

   @JvmStatic
   fun addUser(user: User) {
       // Ensure the user isn't already in the collection.
       val existingUser = users.find { user.id == it.id }
       existingUser?.let { _users.remove(it) }
       // Add the user.
       _users.add(user)
   }
}

StringUtils.kt

@file:JvmName("StringUtils")

package com.google.example.javafriendlykotlin

fun String.toTitleCase(): String {
   if (isNullOrBlank()) {
       return this
   }

   return split(" ").map { word ->
       word.foldIndexed("") { index, working, char ->
           val nextChar = if (index == 0) char.toUpperCase() else char.toLowerCase()
           "$working$nextChar"
       }
   }.reduceIndexed { index, working, word ->
       if (index > 0) "$working $word" else word
   }
}

fun String.nameToLogin(): String {
   if (isNullOrBlank()) {
       return this
   }
   var working = ""
   toCharArray().forEach { char ->
       if (char.isLetterOrDigit()) {
           working += char.toLowerCase()
       } else if (char.isWhitespace() and !working.endsWith(".")) {
           working += "."
       }
   }
   return working
}