가독성(Readability)

가독성(Readability)

읽기 좋은 API를 만드는 것은 단순히 깔끔한 코드를 쓰는 것 이상이에요. 통합과 사용을 단순화하는 사려 깊은 설계가 필요하죠. 이 절에서는 라이브러리를 조합성(composability)을 고려해 구조화하고, 간결하고 표현력 있는 설정을 위해 도메인 특화 언어(DSL)를 활용하며, 명확하고 유지보수하기 쉬운 코드를 위해 확장 함수와 확장 프로퍼티를 사용함으로써 API 가독성을 높일 수 있는 방법을 살펴볼 거예요.

명시적 조합성(Explicit Composability) 선호하기

라이브러리는 종종 사용자 지정을 허용하는 고급 연산자를 제공해요. 예를 들어 어떤 연산은 사용자가 자신만의 데이터 구조, 네트워킹 채널, 타이머, 라이프사이클 관찰자를 제공하도록 허용할 수 있어요. 하지만 이러한 사용자 지정 옵션을 추가 함수 매개변수로 도입하면 API의 복잡성이 크게 증가할 수 있어요.

사용자 지정을 위해 매개변수를 더 추가하는 대신, 서로 다른 동작을 조합할 수 있도록 API를 설계하는 것이 더 효과적이에요. 예를 들어 코루틴 Flows API에서는 버퍼링과 병합(conflation) 둘 다 별도의 함수로 구현돼 있어요. 이들은 각 기본 연산이 버퍼링과 병합을 제어하는 매개변수를 받는 대신, filtermap 같은 더 기본적인 연산과 함께 체이닝될 수 있어요.

또 다른 예는 Jetpack Compose의 Modifiers API예요. 이 API를 사용하면 Composable 컴포넌트가 패딩, 크기, 배경색 같은 일반적인 사용자 지정 옵션을 처리하는 단일 Modifier 매개변수를 받을 수 있어요. 이 방식은 각 Composable이 이런 사용자 지정을 위한 별도의 매개변수를 받을 필요를 없애서 API를 간소화하고 복잡성을 줄여줘요.

Box(
    modifier = Modifier
        .padding(10.dp)
        .onClick { println("Box clicked!") }
        .fillMaxWidth()
        .fillMaxHeight()
        .verticalScroll(rememberScrollState())
        .horizontalScroll(rememberScrollState())
) {
    // Box content goes here
}

DSL 사용하기

Kotlin 라이브러리는 빌더 DSL을 제공함으로써 가독성을 크게 향상시킬 수 있어요. DSL을 사용하면 도메인별 데이터 선언을 간결하게 반복할 수 있거든요. 예를 들어 다음은 Ktor 기반 서버 애플리케이션의 샘플이에요:

fun Application.module() {
    install(ContentNegotiation) {
        json(Json {
            prettyPrint = true
            isLenient = true
        })
    }
    routing {
        post("/article") {
            call.respond<String>(HttpStatusCode.Created, ...)
        }
        get("/article/list") {
            call.respond<List<CreateArticle>>(...)
        }
        get("/article/{id}") {
            call.respond<Article>(...)
        }
    }
}

이 코드는 Json 직렬화를 사용하도록 구성된 ContentNegotiation 플러그인을 설치하고, 다양한 /article 엔드포인트의 요청에 응답하도록 라우팅을 설정하는 애플리케이션을 구성해요.

DSL 생성에 대한 자세한 설명은 Type-safe builders를 참고하세요. 라이브러리를 만드는 맥락에서 주목할 만한 점들은 다음과 같아요:

  • DSL에서 사용되는 함수는 빌더 함수로, 리시버가 있는 람다를 마지막 매개변수로 받아요. 이 설계 덕분에 괄호 없이 호출할 수 있어 문법이 더 명확해져요. 전달되는 람다는 생성 중인 엔티티를 구성하는 데 사용될 수 있어요. 위 예제에서 routing 함수에 전달된 람다는 라우팅의 세부 사항을 구성하는 데 사용돼요.
  • 클래스 인스턴스를 만드는 팩토리 함수는 반환 타입과 같은 이름을 가져야 하고 대문자로 시작해야 해요. 위 샘플에서 Json 인스턴스를 만드는 부분에서 확인할 수 있어요. 이런 함수는 여전히 구성을 위해 람다 매개변수를 받을 수 있어요. 자세한 내용은 Coding conventions을 참고하세요.
  • 빌더 함수에 제공된 람다 안에서 필수 프로퍼티가 설정됐는지 컴파일 시점에 보장할 수 없으므로, 필수 값을 함수 매개변수로 전달할 것을 권장해요.

DSL로 객체를 만드는 것은 가독성뿐 아니라 하위 호환성도 향상시키고 문서화 과정도 단순화해요. 예를 들어 다음 함수를 봐요:

fun Json(prettyPrint: Boolean, isLenient: Boolean): Json

이 함수는 Json{} DSL 빌더를 대체할 수 있어요. 하지만 DSL 방식에는 눈에 띄는 이점이 있어요:

  • 하위 호환성을 유지하는 것이 이 함수보다 DSL 빌더로 더 쉽게 가능해요. 새 구성 옵션을 추가하는 것은 단순히 새 프로퍼티(또는 다른 예에서는 새 함수)를 추가하는 것뿐이기 때문이에요. 이는 기존 함수의 매개변수 목록을 바꾸는 것과 달리 하위 호환되는 변경이에요.
  • 문서를 만들고 유지하기도 더 쉬워요. 함수의 많은 매개변수를 한곳에서 모두 문서화하는 대신, 각 프로퍼티를 선언된 위치에서 개별적으로 문서화할 수 있어요.

확장 함수와 확장 프로퍼티 사용하기

가독성을 높이기 위해 확장 함수와 확장 프로퍼티를 사용할 것을 권장해요.

클래스와 인터페이스는 타입의 핵심 개념을 정의해야 해요. 추가적인 기능과 정보는 확장 함수와 확장 프로퍼티로 작성해야 해요. 이렇게 하면 독자에게 추가 기능이 핵심 개념 위에 구현될 수 있고, 추가 정보가 타입의 데이터로부터 계산될 수 있다는 것이 분명해져요.

예를 들어 ( String도 구현하는) CharSequence 타입은 그 내용에 접근하는 가장 기본적인 정보와 연산자만 포함해요:

interface CharSequence {
    val length: Int
    operator fun get(index: Int): Char
    fun subSequence(startIndex: Int, endIndex: Int): CharSequence
}

문자열과 흔히 연관되는 기능은 대부분 확장 함수로 정의돼 있어요. 이 확장 함수들은 모두 타입의 핵심 개념과 기본 API 위에 구현할 수 있어요:

inline fun CharSequence.isEmpty(): Boolean = length == 0
inline fun CharSequence.isNotEmpty(): Boolean = length > 0

inline fun CharSequence.trimStart(predicate: (Char) -> Boolean): CharSequence {
    for (index in this.indices)
        if (!predicate(this[index]))
           return subSequence(index, length)
    return ""
}

계산된 프로퍼티와 일반 메서드는 확장으로 선언하는 것을 고려해 보세요. 기본적으로 멤버로 선언해야 하는 것은 일반 프로퍼티, 오버라이드, 오버로드된 연산자뿐이에요.

boolean 타입을 인자로 쓰지 않기

다음 함수를 고려해 보세요:

fun doWork(optimizeForSpeed: Boolean) { ... }

이 함수를 API에 제공한다면 다음과 같이 호출될 수 있어요:

doWork(true)
doWork(optimizeForSpeed=true)

첫 번째 호출에서는 Parameter Name Hints가 켜진 IDE에서 코드를 읽지 않는 한, boolean 인자가 무엇을 위한 것인지 추론하는 것이 불가능해요. 명명된 인자를 사용하면 의도가 분명해지지만, 사용자에게 이 스타일을 강제할 방법은 없어요. 따라서 가독성을 높이려면 코드에서 boolean 타입을 인자로 쓰지 않아야 해요.

대안으로, API는 boolean 인자가 제어하는 작업을 위한 별도의 함수를 만들 수 있어요. 이 함수는 무엇을 하는지 나타내는 설명적인 이름을 가져야 해요.

예를 들어 Iterable 인터페이스에는 다음과 같은 확장이 있어요:

fun <T, R> Iterable<T>.map(transform: (T) -> R): List<R>
fun <T, R : Any> Iterable<T>.mapNotNull(
    transform: (T) -> R?
): List<R>

다음의 단일 메서드 대신에 말이죠:

fun <T, R> Iterable<T>.map(
    includeNullResults: Boolean = true, 
    transform: (T) -> R
): List<R>

또 다른 좋은 접근 방식은 다른 연산 모드를 정의하기 위해 enum 클래스를 사용하는 거예요. 이 방식은 여러 동작 모드가 있거나, 시간이 지나면서 그 모드들이 바뀔 것으로 예상될 때 유용해요.

숫자 타입을 적절히 사용하기

Kotlin은 API의 일부로 사용할 수 있는 여러 숫자 타입을 정의해요. 이를 적절히 사용하는 방법은 다음과 같아요:

  • Int, Long, Double 타입을 산술 타입으로 사용하세요. 이들은 계산이 수행되는 값을 나타내요.
  • 비산술 엔티티에 산술 타입을 쓰지 마세요. 예를 들어 ID를 Long으로 표현하면 사용자들이 순서대로 할당됐다고 가정하고 ID를 비교하고 싶어질 수 있어요. 이는 신뢰할 수 없거나 의미 없는 결과를 낳거나, 경고 없이 바뀔 수 있는 구현에 의존하게 만들 수 있어요. 더 나은 전략은 ID 추상화를 위한 전용 클래스를 정의하는 거예요. Inline value classes를 사용하면 성능에 영향을 주지 않고 그런 추상화를 만들 수 있어요. 예시는 Duration 클래스를 참고하세요.
  • Byte, Float, Short 타입은 메모리 배치 타입이에요. 이들은 캐시에 값을 저장하거나 네트워크로 데이터를 전송할 때처럼 값 저장에 사용할 수 있는 메모리 양을 제한하는 데 사용돼요. 이 타입들은 기본 데이터가 그 타입 안에 안정적으로 들어맞고 계산이 필요 없을 때만 사용해야 해요.
  • 부호 없는 정수 타입 UByte, UShort, UInt, ULong은 주어진 형식에서 사용 가능한 양수 값의 전체 범위를 활용하기 위해 사용해야 해요. 부호 있는 타입의 범위를 넘어서는 값이 필요한 시나리오나 네이티브 라이브러리와의 상호 운용성에 적합해요. 다만 도메인이 음수가 아닌 정수만 필요로 하는 상황에서는 사용을 피하세요.

더 알아보기

이 가이드의 다음 부분에서는 일관성(Consistency)에 대해 배워요.