역호환성 (Backward compatibility)
이 장에는 역호환성에 대한 고려 사항이 포함되어 있습니다. 다음은 "하지 말아야 할" 권장 사항입니다:
- 기존 API 함수에 인자 추가하지 마세요.
- API에서 데이터 클래스를 사용하지 마세요.
- 반환 타입을 더 좁게 만들지 마세요.
다음을 고려하세요:
- @PublishedApi 주석
- @RequiresOptIn 주석
- 명시적 API 모드
역호환성을 강제하는 도구에 대해 알아보세요.
역호환성이란? (Definition of backward compatibility)
좋은 API의 기본 요소 중 하나는 역호환성(Backward compatibility)입니다. 역호환성을 갖춘 코드는 새로운 API 버전의 클라이언트가 이전 API 버전과 동일한 API 코드를 사용할 수 있게 해줍니다. 이 섹션에서는 API를 역호환성을 갖출 수 있도록 고려해야 할 주요 포인트를 설명합니다.
API에 대해 말할 때 적어도 세 가지 유형의 호환성이 있습니다:
- 소스 호환성
- 동작 호환성
- 바이너리 호환성
소스 호환성을 성립시키는 것은 새로운 라이브러리 버전이 클라이언트 애플리케이션을 더 높은 버전의 라이브러리와 올바르게 다시 컴파일될 것임을 확신할 때 가능합니다. 보통 이러한 변경이 사소하지 않을 경우 자동으로 구현하고 확인하기 어렵습니다. 모든 API에는 특정 변경으로 인해 소스 호환성이 깨질 수 있는 극히 드물고 복잡한 경우가 항상 존재합니다.
동작 호환성은 새로운 코드가 원래 코드의 의미를 변경하지 않고 버그를 수정하는 것을 보장합니다.
이전에 컴파일된 라이브러리 버전을 바꿀 수 있는 바이너리 역호환 버전은 이전 버전의 라이브러리로 컴파일된 소프트웨어가 여전히 올바르게 작동해야 함을 보장합니다.
소스 호환성을 깨지 않으면서 바이너리 호환성을 깨트릴 수도 있으며, 그 반대도 가능합니다.
바이너리 역호환성을 유지하는 몇 가지 원칙은 명백합니다. 공개 API의 일부를 그냥 제거하지 말고 대신 사용을 폐기하세요. 아래 섹션에서는 잘 알려지지 않은 원칙들을 다루고 있습니다.
"하지 말아야 할" 권장 사항들 ("Don't do" recommendations)
기존 API 함수에 인자를 추가하지 마세요 (Don't add arguments to existing API functions)
공개 API에 기본값이 아닌 인자를 추가하는 것은 역호환성을 깨뜨리는 변경입니다. 기존 코드는 업데이트된 메서드를 호출하기 위한 충분한 정보를 가지고 있지 않을 것입니다. 기본값이 있는 인자를 추가하는 것도 사용자의 코드를 깨뜨릴 수 있습니다.
아래 예시에서는 역호환성을 깨뜨리는 방법을 보여주기 위해 두 개의 클래스를 사용합니다: "라이브러리"를 나타내는 lib.kt와 이 "라이브러리"의 "클라이언트"를 나타내는 client.kt입니다. 이러한 라이브러리와 그 클라이언트의 구성은 실제 응용 프로그램에서 흔한 패턴입니다. 이 예시에서 "라이브러리"에는 피보나치 수열의 다섯 번째 항을 계산하는 함수가 하나 있습니다. lib.kt 파일에는 다음과 같은 내용이 있습니다:
fun fib() = … // Returns the fifth element
이 함수를 다른 파일인 client.kt에서 호출해 봅시다:
fun main() {
println(fib()) // Returns 3
}
이 클래스들을 컴파일해 봅시다:
kotlinc lib.kt client.kt
컴파일 결과 두 개의 파일이 생성됩니다: LibKt.class와 ClientKt.class.
이 작동하는지 확인하기 위해 클라이언트를 호출해 보겠습니다:
$ kotlin ClientKt.class
3
이 디자인은 완벽하지 않으며 학습 목적을 위해 하드코딩되었습니다. 얻고자 하는 수열의 요소를 미리 정의하며, 이는 올바르지 않으며 깔끔한 코드 원칙을 따르지 않습니다. 이제 동일한 기본 동작을 유지하면서 다시 작성해 보겠습니다: 기본적으로 다섯 번째 요소를 반환하지만 가져올 요소 번호를 지정할 수 있도록 만들겠습니다.
lib.kt:
fun fib(numberOfElement: Int = 5) = … // Returns requested member
"라이브러리"만 다시 컴파일해 보겠습니다: kotlinc lib.kt.
그리고 "클라이언트"를 실행해 보겠습니다:
$ kotlin ClientKt.class
결과:
Exception in thread "main" java.lang.NoSuchMethodError: 'int LibKt.fib()'
at LibKt.main(fib.kt:2)
at LibKt.main(fib.kt)
…
fib() 함수의 시그니처가 컴파일 후 변경되었기 때문에 NoSuchMethodError가 발생합니다.
만약 client.kt를 다시 컴파일하면 새로운 시그니처를 인식하기 때문에 다시 작동할 것입니다. 이 예제에서는 소스 호환성은 유지하면서 바이너리 호환성이 깨졌습니다.
복원(decompilation)의 도움을 받아 더 자세히 알아보세요
이 설명은 JVM에 특화되어 있습니다.
변경 전에 LibKt 클래스에 javap를 호출해 보겠습니다:
❯ javap LibKt
Compiled from "lib.kt"
public final class LibKt {
public static final int fib();
}
변경 후에는 다음과 같습니다:
❯ javap LibKt
Compiled from "lib.kt"
public final class LibKt {
public static final int fib(int);
public static int fib$default(int, int, java.lang.Object);
}
시그니처가 public static final int fib()인 메서드가 public static final int fib(int) 시그니처의 새로운 메서드로 대체되었습니다. 동시에, 프록시 메서드인 fib$default가 fib(int)로 실행을 위임합니다. JVM에서는 이를 해결하기 위해 @JvmOverloads 주석을 추가해야 합니다. 다중 플랫폼 프로젝트의 경우 해결책이 없습니다.
API에서 데이터 클래스를 사용하지 마세요 (Don't use data classes in an API)
데이터 클래스는 간결하고 요약되며 기본적으로 좋은 기능을 제공하기 때문에 사용하고 싶은 유혹이 있을 수 있습니다. 그러나 데이터 클래스 작동 방식의 특성 때문에 라이브러리 API에서는 사용하지 않는 것이 좋습니다. 거의 모든 변경은 API를 역호환성을 유지하지 못하게 만듭니다.
일반적으로 시간이 지남에 따라 클래스를 어떻게 변경해야 할지 예측하기 어렵습니다. 오늘 자체 포함된다고 생각하더라도 미래에 당신의 필요성이 변경될 확률은 없다는 보장이 없습니다. 따라서 데이터 클래스와 관련된 모든 문제는 이러한 클래스를 변경하기로 결정했을 때만 발생합니다.
먼저, 이전 섹션 "기존 API 함수에 인자 추가하지 마세요"에서 고려한 내용은 생성자에도 적용됩니다. 생성자 역시 메서드이기 때문입니다. 두 번째로, 보조 생성자를 추가하더라도 호환성 문제를 해결하지 못합니다. 다음 데이터 클래스를 살펴보겠습니다:
data class User(
val name: String,
val email: String
)
예를 들어 시간이 지남에 따라 사용자들이 활성화 절차를 거쳐야 한다는 것을 이해하게 되고, "active"라는 새로운 필드를 기본값 "true"로 추가하고자 할 수 있습니다. 이 새로운 필드는 기존 코드가 대부분 변경 없이 작동하도록 해야 합니다.
위의 섹션에서 이미 논의한 것처럼 새로운 필드를 이렇게 간단히 추가할 수는 없습니다:
data class User(
val name: String,
val email: String,
val active: Boolean = true
)
이 변경은 바이너리 호환성을 유지하지 않습니다.
세 개의 인자를 받는 기본 생성자를 호출하는 두 개의 인자만 받는 새로운 생성자를 추가해 보겠습니다:
data class User(
val name: String,
val email: String,
val active: Boolean = true
) {
constructor(name: String, email: String) :
this(name, email, active = true)
}
이번에는 두 개의 생성자가 있으며 그 중 하나의 시그니처가 변경 전의 클래스 생성자와 일치합니다:
public User(java.lang.String, java.lang.String);
하지만 문제는 생성자가 아닙니다. 문제는 copy 함수에 있습니다. 그 시그니처가 변경되었습니다:
public final User copy(java.lang.String, java.lang.String);
다음과 같이 변경되었습니다:
public final User copy(java.lang.String, java.lang.String, boolean);
그리고 이로 인해 코드가 바이너리 호환성을 갖지 못하게 되었습니다.
물론 데이터 클래스 내에 프로퍼티를 추가하는 것만으로 해결할 수 있지만, 그렇게 하면 데이터 클래스의 모든 장점이 사라집니다. 따라서 거의 모든 변경은 데이터 클래스 내에서 소스, 바이너리 또는 동작 호환성을 깨뜨립니다.
어떤 이유에서든 데이터 클래스를 사용해야 한다면 생성자와 copy() 메서드를 오버라이드해야 합니다. 또한 클래스 내에 필드를 추가하면 hashCode() 및 equals() 메서드도 오버라이드해야 합니다.
인자의 순서를 바꾸는 것은 항상 호환성이 없는 변경입니다. componentX() 메서드 때문에 소스 호환성이 깨지며 아마도 바이너리 호환성도 깨질 것입니다.
반환 타입을 더 좁게 만들지 마세요 (Don't make return types narrower)
가끔은 특히 명시적 API 모드를 사용하지 않을 때, 반환 타입 선언이 암묵적으로 변경될 수 있습니다. 그렇지 않더라도 시그니처를 좁게 만들고 싶을 수 있습니다. 예를 들어, 컬렉션의 요소에 대한 인덱스 액세스가 필요하다고 판단하여 반환 타입을 Collection에서 List로 변경하고 싶을 수 있습니다. 반환 타입을 확장하는 것은 일반적으로 소스 호환성을 깨뜨립니다. 예를 들어, List에서 Collection으로 변환하는 것은 인덱스 액세스를 사용하는 모든 코드를 깨뜨립니다. 반환 타입을 좁히는 것은 일반적으로 소스 호환성을 유지하지만 바이너리 호환성을 깨뜨리며, 이 섹션에서 설명합니다.
라이브러리 파일인 library.kt에 있는 라이브러리 함수를 고려해 보겠습니다:
public fun x(): Number = 3
그리고 이 함수를 사용하는 client.kt 파일의 예시:
fun main() {
println(x()) // Prints 3
}
kotlinc library.kt client.kt로 컴파일하고 작동하는지 확인해 보겠습니다:
$ kotlin ClientKt
3
"라이브러리" 함수 x()의 반환 타입을 Number에서 Int로 변경해 보겠습니다:
fun x(): Int = 3
그리고 클라이언트만 다시 컴파일하겠습니다: kotlinc client.kt. ClientKt는 더 이상 예상대로 작동하지 않습니다. 3을 출력하지 않고 대신 예외를 던집니다:
Exception in thread "main" java.lang.NoSuchMethodError: 'java.lang.Number Library.x()'
at ClientKt.main(call.kt:2)
at ClientKt.main(call.kt)
…
이는 바이트코드의 다음 줄 때문에 발생합니다:
0: invokestatic #12 // Method Library.x:()Ljava/lang/Number;
이 줄은 x()라는 정적 메서드를 호출하며 이 메서드는 Number 타입을 반환합니다. 그러나 더 이상 그런 메서드가 없기 때문에 바이너리 호환성이 깨졌습니다.
@PublishedApi 어노테이션 (The @PublishedApi annotation)
가끔 인라인 함수를 구현하기 위해 내부 API의 일부를 사용해야 할 수도 있습니다. @PublishedApi 주석을 사용하여 이를 수행할 수 있습니다. @PublishedApi로 주석이 달린 코드 일부를 공개 API의 일부로 취급하고, 따라서 역호환성에 대해 주의해야 합니다.
@RequiresOptIn 어노테이션 (The @RequiresOptIn annotation)
때로는 API를 실험해 보고 싶을 수도 있습니다. 코틀린에서는 @RequiresOptIn 주석을 사용하여 어떤 API가 불안정하다고 정의하는 멋진 방법이 있습니다. 그러나 다음 사항을 유의하세요:
- 오랫동안 API의 일부를 변경하지 않았으며 안정적이라면 @RequiresOptIn 주석을 사용하는 것을 다시 고려해 보아야 합니다.
- @RequiresOptIn 주석을 사용하여 API의 다른 부분에 다른 보증을 정의할 수 있습니다: 미리보기, 실험적, 내부, 민감, 또는 알파, 베타, RC.
- 각 수준이 무엇을 의미하는지 명시적으로 정의하고 KDoc 주석을 작성하고 경고 메시지를 추가해야 합니다.
옵트인이 필요한 API에 의존한다면 @OptIn 주석을 사용하지 마세요. 대신 @RequiresOptIn 주석을 사용하여 사용자가 어떤 API를 사용하고 어떤 것을 사용하지 않을지를 의도적으로 선택할 수 있도록 하세요.
@RequiresOptIn의 또 다른 예시는 특정 API의 사용에 대해 사용자에게 명시적으로 경고하려는 경우입니다. 예를 들어 코틀린 리플렉션을 활용하는 라이브러리를 유지 관리한다면 해당 라이브러리의 클래스에 @RequiresFullKotlinReflection을 붙일 수 있습니다.
명시적 API 모드 (Explicit API mode)
API를 가능한 한 투명하게 유지하려고 노력해야 합니다. API를 강제로 투명하게 만들려면 명시적 API 모드를 사용하세요.
Kotlin은 코드를 작성하는 방법에 대해 광범위한 자유를 제공합니다. 타입 정의, 가시성 선언 또는 문서 작성을 생략할 수 있습니다. 명시적 API 모드는 암시적인 것을 명시적으로 만들도록 개발자로 하여금 강제합니다. 위의 링크에서 활성화하는 방법을 알아볼 수 있습니다. 이것이 왜 필요한지 이해해 보겠습니다:
1. 명시적 API가 없으면 역호환성을 깨뜨리기 쉽습니다:
// version 1
fun getToken() = 1
// version 1.1
fun getToken() = "1"
getToken() 함수의 반환 타입이 변경되었지만 사용자의 코드를 업데이트할 필요조차 없이도 깨집니다. 사용자는 Int를 기대하지만 String을 받게 됩니다.
2. 가시성에도 동일한 원칙이 적용됩니다. getToken() 함수가 private인 경우에는 역호환성이 깨지지 않습니다. 그러나 명시적인 가시성 선언 없이는 API 사용자가 이 함수에 접근할 수 있는지 여부가 불분명합니다. 접근할 수 있어야 한다면 public으로 선언하고 문서화되어야 합니다. 이 경우 변경으로 인해 역호환성이 깨집니다. 그렇지 않다면 private 또는 internal로 정의되어야 하며, 이 변경은 역호환성을 깨뜨리지 않을 것입니다.
역호환성을 유지하기 위해 설계된 도구들 (Tools designed to enforce backward compatibility)
역호환성을 유지하는 것은 소프트웨어 개발의 중요한 측면입니다. 이를 통해 라이브러리나 프레임워크의 새 버전을 기존 코드와 함께 사용할 때 문제가 발생하지 않습니다. 역호환성을 유지하는 것은 대규모 코드베이스나 복잡한 API를 다룰 때 어려운 작업이며 시간이 많이 걸릴 수 있습니다. 이를 수동으로 제어하는 것은 어려우며, 개발자들은 종종 변경 사항이 기존 코드를 깨뜨리지 않도록 보장하기 위해 테스트와 수동 검사에 의존해야 합니다. 이 문제를 해결하기 위해 JetBrains는 Binary 호환성 검증기를 개발하였으며, 또 다른 해결책인 japicmp도 있습니다.
현재로서 두 도구 모두 JVM에만 작동합니다.
두 도구 모두 장단점이 있습니다. japicmp는 모든 JVM 언어에서 작동하며 CLI 도구와 빌드 시스템 플러그인 모두 제공합니다. 그러나 이전 및 새로운 애플리케이션을 JAR 파일로 패키징해야 합니다. 라이브러리의 이전 빌드에 액세스할 수 없는 경우 사용하기가 그리 쉽지 않습니다. 또한 japicmp는 Kotlin 메타데이터의 변경 내용을 제공하는데, 이는 필요하지 않을 수 있습니다 (메타데이터 형식이 지정되지 않으며 Kotlin 내부용으로만 사용된다고 간주됩니다).
Binary 호환성 검증기는 Gradle 플러그인으로만 작동하며 알파 안정성 수준입니다. JAR 파일 액세스가 필요하지 않습니다. 이전 API와 현재 API의 특정 덤프만 필요합니다. 이러한 덤프를 자체적으로 수집할 수 있습니다. 이러한 도구에 대해 자세히 알아보세요.
Binary 호환성 검증기 (Binary compatibility validator)
Binary 호환성 검증기는 API의 역호환성을 자동으로 감지하고 보고하여 라이브러리와 프레임워크의 역호환성을 보장하는 도구입니다. 이 도구는 변경 사항을 만든 후에 라이브러리의 바이트 코드를 분석하고 이전 및 변경 후의 두 버전을 비교하여 기존 코드를 깨뜨릴 수 있는 변경 사항을 식별합니다. 이를 통해 사용자들에게 문제가 되기 전에 문제를 식별하고 해결할 수 있습니다.
이 도구는 수동 테스트와 검사에 들였을 것 같은 상당한 시간과 노력을 절약해 줄 수 있습니다. 또한 API의 변경으로 인해 발생할 수 있는 문제를 사전에 방지하는 데 도움이 됩니다. 이로 인해 사용자들은 라이브러리나 프레임워크의 안정성과 호환성을 신뢰할 수 있게 되며, 결국 더 나은 사용자 경험을 제공할 수 있습니다.
japicmp
JVM 플랫폼을 타겟으로 한다면 japicmp도 사용할 수 있습니다. japicmp는 Binary 호환성 검증기와는 다른 수준에서 작동합니다. 이 도구는 이전 및 새로운 JAR 파일 두 개를 비교하고 그들 사이의 비호환성을 보고합니다.
japicmp는 호환성을 깨뜨리는 사항뿐만 아니라 사용자에게 어떤 영향을 미치지 않는 변경 사항도 보고합니다. 예를 들어, 다음 코드를 고려해 보겠습니다:
class Calculator {
fun add(a: Int, b: Int): Int = a + b
fun multiply(a: Int, b: Int): Int = a * b
}
새로운 메서드를 다음과 같이 추가하여 호환성을 깨뜨리지 않으면:
class Calculator {
fun add(a: Int, b: Int): Int = a + b
fun multiply(a: Int, b: Int): Int = a * b
fun divide(a: Int, b: Int): Int = a / b
}
그럼 japicmp는 다음과 같은 변경 사항을 보고합니다:
이것은 @Metadata 주석의 변경으로, 크게 흥미로운 것은 아닙니다. 그러나 japicmp는 JVM 언어에 중립적이며 보이는 모든 것을 보고해야 합니다.
원문
https://kotlinlang.org/docs/jvm-api-guidelines-backward-compatibility.html
'Kotlin' 카테고리의 다른 글
[Kotlin] Kotlin 공식 문서 번역 - 시퀀스 (Sequence) (73) | 2023.09.26 |
---|---|
[Kotlin] data class가 애플리케이션 성능에 미치는 영향 (0) | 2023.08.27 |
[Kotlin] Kotlin 공식 문서 번역 - 데이터 클래스 (Data classes) (0) | 2023.08.26 |
[Kotlin] Kotlin 공식 문서 번역 - 클래스 (Classes) (0) | 2023.08.26 |
[Kotlin] inline 사용법 (4) - crossinline 사용법 (1) | 2023.08.16 |
댓글