C 언어
Cpp 언어
Kotlin
Android App
알고리즘
Git/CI/CD
[팁] Kotlin and Exceptions
이 문서는 Java 이전 세대, Java, 그리고 Kotlin에서의 예외 처리에 대한 블로그 내용을 요약 번역한 글이다.



The Origin
Java 이전 세대의 언어들(C, C++ 등)은 에러 처리를 할 때 리턴 값 등을 통해서 처리해 왔다. 아래는 File I/O 시 에러 핸들링의 예제이다.
file = fopen("file.txt", "r");
if (file == NULL) {
    //handle error & return
}
//work with file, check for error after each file operation

개발자는 매번 File I/O나 Network 관련 로직을 구현할 때마다 이런 번거롭고 Bolierplate한 예외 처리 로직들을 구현해야 했다. 이런 방식은 메인 로직의 흐름을 파악하는데 방해가 되며 예외 처리 자체도 누락될 가능성이 있고 디버깅도 힘들다.

Java는 이를 어떻게 해결했을까?
Java에서는 Checked Exception이라는 개념을 도입하기로 했다. Java에서 File I/O API를 사용하게 되면 무조건 IOException을 처리하도록 되어 있는 것을 확인할 수 있을 것이다. 개발자는 File I/O 시 IOException 처리 로직을 구현하지 않으면 컴파일이 되지 않는다. 또한 throws를 지원함으로써 예외 처리를 외부에서 모아서 처리할 수 있도록 지원함으로써 메인 로직의 흐름을 방해하지 않고 구현할 수도 있다. 이러한 에러 핸들링 기법은 매우 우아한 방식으로 보였으며, I/O 개발 시 개발자가 예외 처리를 누락하지 않도록 도움을 주었다.
FileInputStream in = FileInputStream("file.txt"); //throws IOException

한 동안 위와 같은 구현 방식은 Boilerplate하지 않으면서도 실수를 줄일 수 있는 방법으로 여겨졌다. 그러나..




Problems
ByteArrayInputstream과 같은 Memory I/O API들은 실제로 에러날 가능성이 적음에도 불구하고, IOException을 필수로 처리해야 했다. 이외에도 사전에 예외 처리가 가능함에도 무조건 예외 처리 로직을 구현하는데 지친 개발자들은 Checked Exception API와 그 방식들을 기피하기 시작했고 점점 이에 대한 불만들이 커뮤니티에서도 전파되었다.

그러나, Checked Exception 에 있어 가장 크게 타격을 준 것은, 2014년 Java 8에 Lambda Expression과 Stream이 적용되면서부터이다. 고차 함수 안에 예외 처리를 넣는 것은 굉장히 어렵고 부담스러운 일이었고, 더 나아가서 Java 이후의 신생 언어들은 Checked Exception을 지원하지 않는 방향으로 나아가고 있었다. 즉, Checked Exception은 도태된 컨셉으로 전락하게 되었다.



Exceptions in Kotlin
Kotlin은 Java의 컨셉을 기반으로 만들어진 언어이며, JVM 라이브러리를 지원하고 있다. Kotlin 또한 다른 신생 언어들과 마찬가지로 Checked Exception의 문제점을 잘 알고 있었고, 이를 지원하지 않기로 결정했지만 여기에는 또 다른 문제가 있었다.

Java API들은 여전히 Exception을 throw하고 있어서 Kotlin Style과 차이를 보이고 있으며, 특히 Checked Exception API를 사용하면 문제는 여전하다. Kotlin에서는 어떻게 이 문제를 다루고 있으며 어떤 식으로 에러를 핸들링해야 할까?



Handling program logic errors
Kotlin에서 예외를 처리하는 방법으로는 "예외 우선 처리" 방법이 있다. 컴파일 중 확인하기 어려운 조건들이 있을 수 있다. 로직이 안정적으로 동작하기 위한 전제 조건들을 미리 체크하고 처리하는 방식이다. 예를 들어, 주문 처리를 담당하는 메서드를 구현할 때 수량이 양수가 아니면 아래와 같이 처리하는 것이다.
/** Updates order [quanity], must be positive. */
fun updateOrderQuanity(orderId: OrderId, quantity: Int) {
    require(quantity > 0) { "Quantity must be positive" }
    // proceed with update
}
이 함수를 호출할 때 수량 정보를 0이나 음수로 전달하면 Exception이 발생한다. 

중요한 것은, 일반적인 Kotlin 코드에서 Exception을 Catch해서는 안 된다. 에러 발생했을 시 핸들링은 Framework에게 맡기고 어플리케이션을 재시작하게 하는 등 다른 오퍼레이션에 영향을 주지 않도록 하는 것이 Kotlin의 주요 목적이다. 



Dual-use APIs
특정 도메인에 국한되지 않는 일부 API들의 경우, 에러가 코드의 로직 상 에러를 의미하는지 아니면 개발자의 도움을 요청하지 않고 처리해야 하는 입력 오류를 나타내는지 여부는 명확하지 않다.(이건 무슨 말이지)

Kotlin의 String.toInt() Extension 함수를 예로 들어 보자.
val number = "123".toInt()

위의 코드에서 toInt() 함수가 Exception을 Throw할 경우, 앞에 주어진 String이 정수를 나타내지 않았다는 뜻이므로 코드의 버그임이 명확하다. 즉 Exception이 발생하는 것이 맞고 어플리케이션은 자체적으로 종료되어야 한다. 그러나 만약 String 값이 사용자의 Input Field로부터 전달된 것이면,  어떻게 바라보고 처리해야 할 것인가?

기술적으로, Java에서는 try/catch를 감싸서 처리하는 것이 이상적인 방법일 것이다. 그러나 Kotlin에서는 그렇게 하지 말아라. Kotlin에서는 매우 안 좋은 스타일로 여겨진다. String.toIntOrNull() Extension 함수가 위와 같은 케이스를 처리하기 위한 목적으로 제공되고 있다. 이 함수는 String을 Int로 변환 중 에러 발생 시 Null을 리턴하는 것을 보장하고 있다. 만약 사용자가 String을 잘못 입력한 경우, 이 메서드는 Kotline Null-Safety화 훌륭하게 상호 작용하여 예외처리를 할 수 있다.
val number = string.toIntOrNull() ?: defaultValue

더 나아가서, Kotlin 표준 라이브러리들은 전체적으로 이러한 컨셉을 기반으로 설계되었다. Collection에서 Element를 가져오는 연산자조차도 여러 특수한 경우를 대비해서 getOrNull 대응 메서드들을 지원하고 있다.
참고
https://kotlinlang.org/docs/whatsnew14.html#new-functions-for-arrays-and-collections
https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.collections/get-or-null.html 




API Design
이제 Kotlin을 통해서 개발자가 스스로 API를 설계할 때, 아래와 같은 관점으로 설계하는 것이 좋다.
논리적인 오류에 대해서만 Exception을 발생시키고, 그 외에는 Type-Safety한 result를 발생시켜라.
만약 어떤 API를 사용할 때 해당 API가 논리적 오류가 아닌 경우에도 Exception을 발생시키는 경우, Wrapper 함수나 클래스를 구현해서 Type-Safety한 result를 리턴하도록 만들고 사용하는 것이 깔끔하다. 이러한 방법을 통해서 어플리케이션 로직에서는 미리 예외 조건들을 처리하고 try/catch 없이 구현할 수 있게 된다.

일반적으로 결과가 정상적으로 처리되었는지 여부가 중요하고 실패에 대한 세부적인 원인은 필요가 없는 경우, 정상이면 결과 객체를 리턴하고 실패면 Null을 리턴하도록 구현하는 것이 적절하다. 만약 실패했을 때 세부적인 원인 확인이 필요한 경우, 결과를 sealed class로 Wrapping하여 리턴하는 방법을 추천한다. Kotlin의 경우 이런 케이스를 지원하기 위해 when이라는 강력한 문법을 지원하고 있다.

예를 들어, DateFormat.parse() 메서드는 파싱 중 에러가 발생했을 때 errorOffset 정보와 함께 ParseException을 throw하도록 되어 있다. 이런 메서드는 아래 예제와 같이 sealed class와 Wrapper 메서드로 처리하고, 실제 어플리케이션 코드에서는 Kotlin Style로 개발하면 된다.
sealed class ParsedDate {
    data class Success(val date: Date) : ParsedDate()
    data class Failure(val errorOffset: Int) : ParsedDate()
}

fun DateFormat.tryParse(text: String): ParsedDate {
    try {
        ParsedDate.Success(parse(text))
    } catch (e: ParseException) {
        ParsedDate.Failure(e.errorOffset)
    }
}

//In Application
val result = SimpleDateFormat("yyyy-MM-dd").tryParse("2021-05-01")
when {
    (result is ParsedDate.Success) -> println("Date : ${result.date}")
    (result is ParsedDate.Failure) -> println("Error : ${result.errorOffset}")
}
만약 Kotlin 코드에서 try/catch를 발견하게 되면 극도로 경계하고 의심해 보는 것이 좋다. 모든 try/catch는 잠재적으로 프로그래밍 버그를 뒤섞을 가능성을 가지고 있다. try/catch는 최대한 캡슐화하고 메인 로직에서 배제해라.



Input/Output
이제 가장 첫 번째에 이야기했던 I/O 관점에서의 에러 핸들링을 살펴보도록 하자. 이 경우는 아래 에러 핸들링이 좀 더 복잡하다. 하나는 에러의 원인이 로직 오류가 아니라 외부 요인에 의해 발생할 수 있기 때문이고, 다른 하나는 위에 언급한 방식으로 수정하게 되면 고전적인 C like Style이 되어 버리기 때문이다. 자칫하면 비즈니스 로직에서 I/O 실패에 대한 불필요한 코드들이 추가되어 로직의 본질이 흐려질 수 있다.

Kotlin에서 I/O에 대한 에러 처리는 기본적으로 Exception을 사용하는 것이다. 이러한 방법을 사용하면, Network 연결이 끊기는 등의 문제를 고려하지 않고 비즈니스 로직에서 I/O 함수들을 사용할 수 있다.
fun updateOrderQuanity(orderId: OrderId, quantity: Int) {
    require(quantity > 0) { "Quantity must be positive" }
    val order = loadOrder(orderId)  //network I/O
    order.quantity = quantity
    storeOrder(order)
}
I/O 에러 처리의 특별한 점은, 대부분 에러 발생 시 사용자에게 에러 내용을 보고하거나 다시 시도하기를 요구한다는 것이고 이런 로직은 중앙 집중화되어 있다는 점이다. 즉, 각 네트워크나 파일 I/O 호출 시마다 에러 처리 Boilerplate 로직을 추가하는 것이 아니라 Top Level 코드 단일 지점에서 I/O 에러 처리를 균일하게 처리하도록 담당해야 한다는 것이다. 이는 일반적으로 Low-Level 로직과 High-Level UI 로직 중간의 경계에서 수행된다.

Kotlin의 Exception은 현재 모두 Unchecked Exception이다. 따라서 이러한 Top Level 코드 단일 지점에서의 I/O 예외 처리는 개발자가 놓치기 쉬운 부분이어서 로직 오류로 잘못 보고되기도 하는 케이스이다.



Exceptions, asynchronous programming, and coroutines
이 주제에 대한 이야기는 이미 너무 많은 단어가 포함되어 있으므로 특히 비동기 코드 및 코루틴에서 예외를 사용하는 주제에 대해 간략하게 설명한다. Kotlin의 suspending function 기능은 Sequential하게 실행되므로, 엄밀히 말하자면 오류 처리 접근 방식에 일반적인 로직과 달라야 할 특별한 점이 없다.

그러나, Kotlin 코루틴의 경우 고도의 비동기 어플리케이션을 구현하는데 사용되고 있고, 비동기로 동작하는 코루틴들은 각자 실패할 가능성이 있다. Kotlin은 의 구조적 동시성은 Kotlin의 예외 처리 관점으로 설계되었다. 즉, 모든 예외는 중앙에서 처리되도록 애플리케이션의 최상위 수준에 자동으로 반영되어야 한다. 핵심 설계 원칙은, 예외를 잃어버려서는 안 되며 모든 버그는 개발자에게 보고 및 확인되어야 한다는 것이다. 

따라서, 결론적으로 코루틴에서의 예외 처리도 동일한 조언이 적용된다. 코드에서 문제가 발생했을 때 내부적으로 직접 처리해야 하는 경우 Exception 대신 적절한 결과를 리턴함으로써 일반 애플리케이션 코드에서 try / catch를 피해라. I/O 예외 처리의 경우 중앙 집중식 예외 처리 로직을 구현하여 코드의 적절한 경계에서 오류를 균일하게 처리한다.



원문 : https://elizarov.medium.com/kotlin-and-exceptions-8062f589d07 
참고 : https://betterprogramming.pub/do-you-even-try-functional-error-handling-in-kotlin-ad562b3b394f