디버깅 용이성(Debuggability)

디버깅 용이성(Debuggability)

라이브러리 사용자들은 여러분의 라이브러리 기능을 바탕으로 무언가를 만들 거예요. 그리고 그들이 만든 기능에는 찾아내고 해결해야 할 오류가 있기 마련이죠. 이 오류 해결 과정은 개발 중에는 디버거 안에서, 운영 중에는 로깅과 관측성(observability) 도구를 통해 이뤄질 수 있어요. 라이브러리가 이 모범 사례들을 따른다면 디버깅이 훨씬 쉬워져요.

출처: Debuggability

본문

상태를 가진 타입에 대해 toString 메서드 제공하기

상태를 담고 있는 모든 타입에는 의미 있는 toString 구현을 제공하세요. 이 구현은 내부 타입을 포함해서, 인스턴스의 현재 내용을 이해할 수 있는 형태로 반환해야 해요.

타입의 toString 표현은 로그에 쓰이는 경우가 많으므로, 이 메서드를 구현할 때 보안을 고려해서 민감한 사용자 데이터를 반환하지 않도록 하세요.

상태를 설명하는 데 쓰는 형식은 라이브러리의 서로 다른 타입들 사이에서 최대한 일관되게 유지하세요. 이 형식이 API가 구현하는 계약(contract)의 일부라면, 명시적으로 설명하고 철저히 문서화해야 해요. toString 메서드의 출력은 가령 자동화된 테스트 스위트에서 파싱을 지원할 수도 있어요.

예를 들어 서비스 구독을 지원하는 라이브러리의 다음 타입들을 생각해 볼게요:

enum class SubscriptionResultReason {
    Success, InsufficientFunds, IncompatibleAccount
}

class SubscriptionResult(
    val result: Boolean,
    val reason: SubscriptionResultReason,
    val description: String
)

toString 메서드가 없으면 SubscriptionResult 인스턴스를 출력해도 별로 유용하지 않아요:

fun main() {
    val result = SubscriptionResult(
       false,
       IncompatibleAccount,
       "Users account does not support this type of subscription"
    )

    //prints 'org.example.SubscriptionResult@13221655'
    println(result)
}

디버거에서도 정보가 바로 표시되지 않아요.

두 경우 모두에 간단한 toString 구현만 추가해도 출력이 크게 개선돼요:

//prints 'Subscription failed (reason=IncompatibleAccount, description="Users
// account does not support this type of subscription")'
override fun toString(): String {
    val resultText = if(result) "succeeded" else "failed"
    return "Subscription $resultText (reason=$reason, description=\"$description\")"
}

toString 메서드를 자동으로 얻으려고 data class를 쓰고 싶은 유혹이 들 수 있지만, 하위 호환성 이유 때문에 권장하지 않아요. data class에 대한 자세한 내용은 API에 data class 사용 피하기 섹션에서 다룬다고 했죠. 그 부분을 참고하세요.

참고로 toString 메서드에 기술되는 상태는 문제 도메인의 정보일 필요는 없어요. 진행 중인 요청의 상태(위 예시처럼), 외부 서비스와의 연결 상태, 진행 중인 작업의 중간 상태와 관련될 수 있어요.

예를 들어 다음 빌더 타입을 봐요:

class Person(
    val name: String?,
    val age: Int?,
    val children: List<Person>
) {
    override fun toString(): String =
        "Person(name=$name, age=$age, children=$children)"
}

class PersonBuilder {
    var name: String? = null
    var age: Int? = null
    val children = arrayListOf<Person>()


    fun child(personBuilder: PersonBuilder.() -> Unit = {}) {
       children.add(person(personBuilder))
    }
    fun build(): Person = Person(name, age, children)
}

fun person(personBuilder: PersonBuilder.() -> Unit = {}): Person =
    PersonBuilder().apply(personBuilder).build()

위 이미지에 표시된 중단점(breakpoint)에서 코드를 멈추면, 표시되는 정보가 별로 도움이 되지 않아요.

간단한 toString 구현을 추가하면 훨씬 더 유용한 출력이 돼요:

override fun toString(): String =
    "PersonBuilder(name=$name, age=$age, children=$children)"

이 추가 덕분에 디버거는 이렇게 표시해요. 이렇게 하면 어떤 필드가 설정되었고 어떤 필드가 설정되지 않았는지 바로 확인할 수 있어요.

예외 처리 정책 채택하고 문서화하기

적절한 오류 처리 메커니즘 고르기(Choose appropriate error handling mechanism) 섹션에서 논의한 것처럼, 오류를 알리기 위해 라이브러리가 예외를 던지는 게 적절한 경우가 있어요. 이를 위해 라이브러리만의 예외 타입을 만들 수도 있어요.

저수준 API를 추상화하고 단순화하는 라이브러리는 의존성에서 던져지는 예외도 처리해야 해요. 라이브러리는 예외를 억제하거나, 그대로 전달하거나, 다른 타입의 예외로 변환하거나, 다른 방식으로 사용자에게 오류를 알리는 것 중에서 선택할 수 있어요.

이 옵션들은 맥락에 따라 모두 타당할 수 있어요. 예를 들어:

  • 사용자가 라이브러리 B를 단순화하는 편의를 위해 라이브러리 A를 채택했다면, 라이브러리 A가 라이브러리 B가 생성하는 예외를 수정 없이 다시 던지는 것이 적절할 수 있어요.
  • 라이브러리 A가 라이브러리 B를 순수한 내부 구현 세부 사항으로만 채택했다면, 라이브러리 B가 던지는 라이브러리 특유의 예외는 라이브러리 A 사용자에게 노출되어서는 안 돼요.

일관된 예외 처리 방식을 채택하고 문서화해서 사용자가 라이브러리를 생산적으로 쓸 수 있게 해야 해요. 특히 디버깅에서 중요해요. 라이브러리 사용자는 디버거와 로그에서 예외가 라이브러리에서 비롯된 것임을 인지할 수 있어야 해요.

예외의 타입은 오류의 유형을 나타내야 하고, 예외의 데이터는 사용자가 문제의 근본 원인을 찾는 데 도움이 되어야 해요. 흔한 패턴은 저수준 예외를 라이브러리 특유의 예외로 감싸고, 원래 예외를 cause로 접근할 수 있게 하는 거예요.

커스텀 예외에 대한 코루틴 스택 트레이스 복구 지원하기

라이브러리의 커스텀 예외 타입에 코루틴 스택 트레이스 복구(stack trace recovery) 지원을 추가하면 디버깅이 쉬워져요. 이는 라이브러리의 kotlinx.coroutines 라이브러리 및 Kotlin의 다른 비동기 런타임 지원을 개선해요.

코루틴이 중단 함수(suspending function)를 통해 다른 코루틴에서 예외를 받으면, 스택 트레이스 복구는 그 함수 호출로 이어지는 스택 프레임과 함께 예외의 복사본을 만들어요.

kotlinx.coroutines 라이브러리는 예외 메시지, cause, 둘 다, 또는 아무 인자도 받지 않는 생성자를 가진 예외에 대해 스택 트레이스 복구를 자동으로 수행해요. 라이브러리의 예외 타입이 줄 번호나 오류 코드 같은 추가 생성자 인자를 요구한다면, StackTraceRecoverable 인터페이스를 구현하세요.

인터페이스를 구현하려면 copyForStackTraceRecovery() 함수를 오버라이드하면 돼요. 오버라이드에서 스택 트레이스 복구용 새 예외 인스턴스를 반환하거나, kotlinx.coroutines 라이브러리가 예외를 복사하지 않길 원하면 null을 반환해요.

StackTraceRecoverable 인터페이스는 Kotlin 표준 라이브러리의 일부라서, 이를 구현해도 kotlinx.coroutines 라이브러리에 대한 의존성이 추가되지 않아요.

스택 트레이스 복구용 새 인스턴스를 만들 때 line 프로퍼티를 보존하는 커스텀 예외의 예시를 볼게요:

import kotlin.coroutines.ExperimentalStdlibCoroutineSupportApi
import kotlin.coroutines.debug.StackTraceRecoverable

@OptIn(ExperimentalStdlibCoroutineSupportApi::class)
class FileEditException
// The implementation requires a private constructor
// to pass the cause to the IllegalStateException constructor
private constructor(
    val line: Int,
    private val detail: String,
    cause: Throwable?,
) : IllegalStateException("When editing line $line: $detail", cause),
    // Implements StackTraceRecoverable for stack trace recovery
    StackTraceRecoverable<FileEditException> {

    constructor(line: Int, detail: String) : this(line, detail, null)

    // Copies the line number and message details
    override fun copyForStackTraceRecovery(): FileEditException =
        FileEditException(line, detail, this)
    }

fun main() {
    val original = FileEditException(15, "Unexpected token")

    // Normally, you don't need to call this function directly unless you're testing its behavior
    // The kotlinx.coroutines library invokes it automatically during stack trace recovery
    val copy = original.copyForStackTraceRecovery()

    println(copy.message)
    // When editing line 15: Unexpected token

    println(copy.cause == original)
    // true
}

더 알아보기