예측 가능성(Predictability)

예측 가능성(Predictability)

견고하고 사용자 친화적인 Kotlin 라이브러리를 설계하려면 일반적인 사용 사례를 예상하고, 확장성을 허용하며, 올바른 사용을 강제하는 것이 필수적이에요. 기본 설정, 오류 처리, 상태 관리에 대한 모범 사례를 따르면 라이브러리의 무결성과 품질을 유지하면서 사용자에게 매끄러운 경험을 제공할 수 있어요.

기본적으로 올바른 일을 하기

라이브러리는 각 사용 사례의 "행복한 경로(happy path)"를 예상하고 그에 맞는 기본 설정을 제공해야 해요. 사용자가 라이브러리가 올바르게 동작하도록 기본 값을 직접 제공할 필요가 없어야 해요.

예를 들어 Ktor HttpClient를 사용할 때 가장 흔한 사용 사례는 서버에 GET 요청을 보내는 것이에요. 이것은 필수 정보만 지정하면 되는 아래 코드로 수행할 수 있어요:

val client = HttpClient(CIO)
val response: HttpResponse = client.get("https://ktor.io/")

필수 HTTP 헤더에 대한 값을 제공하거나, 응답의 가능한 상태 코드에 대한 사용자 지정 이벤트 핸들러를 제공할 필요는 없어요.

사용 사례에 명확한 "행복한 경로"가 없거나, 매개변수가 기본 값을 가져야 하는데 논란의 여지가 없는 옵션이 없다면, 그것은 요구사항 분석에 결함이 있음을 나타낼 가능성이 높아요.

확장의 기회 허용하기

올바른 선택을 예상할 수 없을 때는 사용자가 자신이 선호하는 방식을 지정하도록 허용하세요. 라이브러리는 사용자가 자신만의 접근 방식을 제공하거나 서드파티 확장을 사용할 수도 있게 해야 해요.

예를 들어 Ktor HttpClient에서는 사용자가 클라이언트를 구성할 때 콘텐츠 협상 지원을 설치하고, 선호하는 직렬화 형식을 지정하도록 권장돼요:

val client = HttpClient(CIO) {
    install(ContentNegotiation) {
        json(Json {
            prettyPrint = true
            isLenient = true
        })
    }
}

사용자는 설치할 플러그인을 선택하거나, 클라이언트 플러그인 정의를 위한 별도 API로 자신만의 플러그인을 만들 수 있어요.

또한 사용자는 라이브러리의 타입에 대한 확장 함수와 확장 프로퍼티를 정의할 수 있어요. 라이브러리 저자로서 확장을 염두에 둔 설계를 하고, 라이브러리의 타입이 명확한 핵심 개념을 갖도록 보장함으로써 이를 더 쉽게 만들 수 있어요.

원치 않는 확장과 유효하지 않은 확장 막기

사용자는 라이브러리의 원래 설계를 위반하거나, 문제 도메인의 규칙 안에서 불가능한 방식으로 라이브러리를 확장할 수 없어야 해요.

예를 들어 데이터를 JSON으로 직렬화·역직렬화할 때 출력 형식에서 지원되는 타입은 여섯 개뿐이에요: object, array, number, string, boolean, null.

JsonElement라는 open 클래스나 인터페이스를 만들면 사용자는 JsonDate 같은 유효하지 않은 파생 타입을 만들 수 있어요. 대신 JsonElement 인터페이스를 sealed로 만들고 각 타입에 대한 구현을 제공할 수 있어요:

sealed interface JsonElement

class JsonNumber(val value: Number) : JsonElement
class JsonObject(val values: Map<String, JsonElement>) : JsonElement
class JsonArray(val values: List<JsonElement>) : JsonElement
class JsonBoolean(val value: Boolean) : JsonElement
class JsonString(val value: String) : JsonElement
object JsonNull : JsonElement

sealed 타입은 또한 컴파일러가 when 표현식이 else 문 없이도 철저하게(exhaustive) 처리되도록 보장하게 해줘서, 가독성과 일관성을 향상시켜요.

변경 가능한 상태 노출 피하기

여러 값을 다룰 때 API는 가능하면 읽기 전용 컬렉션을 받거나 반환해야 해요. 변경 가능한 컬렉션은 스레드 안전하지 않고 라이브러리에 복잡성과 예측 불가능성을 도입해요.

예를 들어 사용자가 API 진입점에서 반환된 변경 가능한 컬렉션을 수정한다면, 구현의 구조를 수정하는 것인지 복사본을 수정하는 것인지 불분명해져요. 마찬가지로 사용자가 컬렉션을 라이브러리에 전달한 후 그 안의 값을 수정할 수 있다면, 이것이 구현에 영향을 주는지 불분명해져요.

배열은 변경 가능한 컬렉션이므로 API에서 사용을 피하세요. 배열을 꼭 사용해야 한다면, 데이터를 사용자와 공유하기 전에 방어적 복사(defensive copy)를 만들어야 해요. 이렇게 하면 데이터 구조가 수정되지 않은 채 유지돼요.

이 방어적 복사 정책은 vararg 인자에 대해 컴파일러가 자동으로 수행해요. 전개 연산자(spread operator)로 기존 배열을 vararg 인자가 기대되는 곳에 전달하면 배열의 복사본이 자동으로 만들어져요.

이 동작은 다음 예제에서 확인할 수 있어요:

fun main() {
    fun demo(vararg input: String): Array<out String> = input

    val originalArray = arrayOf("one", "two", "three", "four")
    val newArray = demo(*originalArray)

    originalArray[1] = "ten"

    //prints "one, ten, three, four"
    println(originalArray.joinToString())

    //prints "one, two, three, four"
    println(newArray.joinToString())
}

입력과 상태 검증하기

구현을 진행하기 전에 입력과 기존 상태를 검증해서 라이브러리가 올바르게 사용되도록 보장하세요. 입력을 검증하려면 require 함수를, 기존 상태를 검증하려면 check 함수를 사용해요.

require 함수는 조건이 false이면 IllegalArgumentException을 던져서, 적절한 오류 메시지와 함께 함수가 즉시 실패하도록 해요:

fun saveUser(username: String, password: String) {
    require(username.isNotBlank()) { "Username should not be blank" }
    require(username.all { it.isLetterOrDigit() }) {
        "Username can only contain letters and digits, was: $username"
    }
    require(password.isNotBlank()) { "Password should not be blank" }
    require(password.length >= 7) {
        "Password must contain at least 7 characters"
    }

    /* Implementation can proceed */
}

오류 메시지에는 사용자가 실패 원인을 파악하는 데 도움이 되는 관련 입력이 포함되어야 해요. 위 예제에서 잘못된 문자를 포함한 사용자 이름에 대한 오류 메시지가 잘못된 사용자 이름을 포함하는 것처럼요. 이 관행의 예외는 오류 메시지에 값을 포함하는 것이 보안 공격의 일부로 악의적으로 사용될 수 있는 정보를 노출할 때예요. 그래서 비밀번호 길이에 대한 오류 메시지는 비밀번호 입력을 포함하지 않는 거예요.

마찬가지로 check 함수는 조건이 false이면 IllegalStateException을 던져요. 아래 예제처럼 인스턴스의 상태를 검증할 때 이 함수를 사용해요:

class ShoppingCart {
    private val contents = mutableListOf<Item>()

    fun addItem(item: Item) {
       contents.add(item)
    }

    fun purchase(): Amount {
       check(contents.isNotEmpty()) {
           "Cannot purchase an empty cart"
       }
       // Calculate and return amount
    }
}

더 알아보기

이 가이드의 다음 부분에서는 디버깅 용이성(Debuggability)에 대해 배워요.