자바에서 Kotlin 코드 호출

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

학습할 내용

  • @JvmField, @JvmStatic 및 기타 주석을 사용하는 방법
  • 자바 코드에서 특정 Kotlin 언어 기능에 액세스할 때의 제한사항

기본 요건

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

이 Codelab에서는 새로운 프로그래밍 코드를 통합하기 위해 자바 프로그래밍 언어로 작성된 대규모 프로젝트의 일부를 이전하는 것을 시뮬레이션합니다.

작업을 단순화하기 위해 기존 코드베이스를 나타내는 UseCase.java라는 단일 .java 파일을 사용합니다.

자바로 작성된 일부 기능을 Kotlin으로 작성된 새 버전으로 대체했으며, 앞으로 통합을 통합해야 한다고 상상해 보겠습니다.

프로젝트 가져오기

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

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

ZIP 파일 다운로드

IntelliJ IDEA를 사용하는 경우 'Import Project'를 선택합니다.

Android 스튜디오를 사용 중인 경우 'Import Project (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 파일을 엽니다.

Repository는 객체 키워드를 사용하여 선언한 싱글톤입니다. Kotlin은 이러한 클래스를 정적 속성 및 메서드로 노출하지 않고 클래스 내에서 정적 인스턴스를 생성한다는 점입니다.

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

Kotlin에서 Get이 포함된 공개 메서드 및 메서드에 @JvmStatic 주석을 달아 정적 메서드와 속성을 생성할 수 있습니다.

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의 속성 및 메서드에서 더 이상 오류가 발생하지 않습니다. 단, Repository.BACKUP_PATH은 예외입니다. 나중에 다시 확인하겠습니다.

지금은 registerGuest() 메서드의 다음 오류를 수정합니다.

문자열 작업에 몇 가지 정적 함수가 포함된 StringUtils 클래스가 있다고 가정하겠습니다. Kotlin으로 변환할 때 메서드를 확장 함수로 변환했습니다. 자바에는 확장 함수가 없으므로 Kotlin에서는 이러한 메서드를 정적 함수로 컴파일합니다.

안타깝게도 UseCase.java 내부의 registerGuest() 메서드를 살펴보면 문제가 있음을 알 수 있습니다.

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

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

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

  • 코드에 업데이트해야 할 장소가 많이 있을 수 있습니다.
  • 이름 자체가 어색합니다.

따라서 자바 코드를 리팩터링하는 대신 이 메서드에 다른 이름을 사용하도록 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")))

사용자 기본 설정이 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")
)

아쉽게도 이는 자바에서 메서드를 호출할 때 동일하게 작동하지 않습니다.

UseCase.java:

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

기본값은 자바 프로그래밍 언어에서 지원되지 않습니다. 이 문제를 해결하려면 @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");

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

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

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

이 경우 해당 행을 삭제하거나 앞에 ////#99;를 붙이도록 하세요.

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

명시적으로 정의된 getter의 서명을 제공된 이름으로 변경하라고 Kotlin에 알립니다.

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

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

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

@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();

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

대부분의 경우 Kotlin 속성의 getter의 자바 이름은 단순히 get + 속성 이름입니다. 이는 User.getHasSystemAccess()User.getDisplayName()에서 봤기 때문입니다. 한 가지 예외는 이름이 "is"로 시작하는 속성입니다. 이 경우 getter의 자바 이름이 Kotlin 속성의 이름입니다.

User의 속성. 예:

val isAdmin get() = //...

다음을 통해 자바에서 액세스합니다.

boolean userIsAnAdmin = user.isAdmin();

Kotlin은 @JvmName 주석을 사용하여 주석이 달린 항목의 기본 이름이 아닌 지정된 이름을 가진 바이트 코드를 생성합니다.

생성된 이름이 항상 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
}

그러면 자바에서 다음과 같이 작성할 수 있습니다.

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.ktRepository.saveAs 코드를 살펴보면 예외가 발생하는 것을 확인할 수 있습니다. 왜 그런가요?

자바에는 '확인된 예외'라는 개념이 있습니다. 사용자가 파일 이름을 잘못 입력했거나 네트워크가 일시적으로 사용할 수 없게 된 상황 등 복구가 가능한 예외사항이 있습니다. 확인된 예외가 발견되면 개발자는 사용자에게 문제 해결 방법에 관한 의견을 제공할 수 있습니다.

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

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

반면에 Kotlin은 예외를 확인하지 않았으며 이로 인해 문제가 발생합니다.

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

이를 위해 자바/Kotlin 상호 운용성에 도움이 되는 Kotlin @Throws 주석을 사용합니다. Kotlin에서 예외는 자바와 유사하게 작동하지만 자바와 달리 Kotlin에는 선택되지 않은 예외만 있습니다. 따라서 Kotlin 함수에서 예외가 발생한다고 자바 코드에 알리려면 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에서는 관용적인 자바 코드 작성을 지원하는 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
}