Kotlin

[Kotlin] 예외 처리

sh1mj1 2023. 12. 28. 01:43

Kotlin in Action 을 공부하고 Effective kotlin 의 내용을 조금 참조하여 정리한 글입니다.

 

이미지 출처    https://commons.wikimedia.org/wiki/File:Kotlin_Icon.svg

 

코틀린의 exception 처리는 자바나 다른 언어의 예외 처리와 비슷하다. 

정상적인 종료가 아닐 때 예외를 던질 수 있고(`throw`), 함수 호출부에서는 그 예외를 잡아서 처리할 수 있다. 

발생한 예외를 함수 호출부에서 처리하지 않으면 함수 호출 스택을 거슬러 올라가면서 예외를 처리하는 부분이 나올 때까지 예외를 다시 던진다(`rethrow`).

 

코틀린의 기본 예외 처리 코드 예시를 보자. 자바와 비슷하다.

if (percentage !in 1..100) {
    throw IllegalArgumentException(
        "A percentage value must be between 0 and 100: $percentage"
    )
}

또한 코틀린의 `throw` 는 자바와 달리 식 이므로 다른 식에 포함될 수 있다. 아래처럼 말이다.

val percentage = if (number in 0..100) {
    number
} else throw IllegalArgumentException(
    "A percentage value must be between 0 and 100: $number"
)

try, catch, finally

자바와 마찬가지로 코틀린에서도 예외 처리 시 `try` 와 `catch`, `finally` 절을 함께 사용한다.

 

아래에서 파일의 각 줄을 읽고 수로 변환하는, 만약 그 줄이 수 타입이 아니면 `null` 을 리턴하는 예제 코드를 보자.

fun readNumber(reader: BufferedReader): Int? {
    try {
        val line = reader.readLine()
        return Integer.parseInt(line)
    } catch (e: NumberFormatException) {
        return null
    } finally {
        reader.close()
    }
}

메서드에 `throws` 절이 코드에 붙어있지 않다.

자바에서는 입력을 받는 함수를 작성할 때 함수 선언 뒤에 `throws IOException` 을 붙여야 한다.

왜냐하면 `IOException` 이 checked exception(체크 예외)이기 때문이다. 자바에서는 체크 예외는 명시적으로 처리해야 한다.

어떤 함수가 던질 가능성이 있는 예외는 모두 `catch` 로 처리해야 하며, 처리하지 않은 예외는 `throws` 절에 명시해야 한다.

 

예를 들어 위 `readNumber` 라는 함수를  Java 로 작성한다고 하면

public static Integer readNumber(BufferedReader reader) throws IOException {
    try {
        String line = reader.readLine();
        return Integer.parseInt(line);
    } catch (NumberFormatException e) {
        return null;
    } finally {
        try {
            reader.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

코틀린에서는 다른 최신 JVM 언어처럼 체크 예외와 unchecked exception(언체크 예외)를 구별하지 않는다. 

또한 함수가 던지는 예외를 지정하지 않고 발생한 예외를 잡아도 되고, 잡아내지 않아도 된다.

 

이렇게 코틀린을 이렇게 설계한 이유가 있다.

자바에서는 체크 예외 처리를 강제한다고  했다.

하지만 프로그래머들이 의미없이 예외를 다시 전디고, 예외를 잡되 처리하지는 않고서 무시하는 코드를 작성하는 경우가 흔하다.

결국 예외 처리 규칙이 실제로는 오류 발생을 방지하지 못하는 경우가 자주 있었다.

 

위에서 `NumberFormatException` 은 체크 예외가 아니다.

즉, 자바 컴파일러는 `NumberFormatException` 을 잡도록 강제하지 않는다.

그래서 런타임에 `NumberFormatException` 이 발생하는 경우는 흔하다.

입력값이 잘못되는 경우는 흔하기 때문에 이런 문제 발생 시 다음 단계로 부드럽게 넘어가도록 프로그램을 설계해야 한다는 것은 불행한 일이다.

 

위에 `BufferedReader.close` 는 `IOException` 을 던질 수 있고, 이 예외는 체크예외이므로 자바에서 반드시 처리해야 한다.

하지만 스트림을 닫다가 실패하는 경우 스트림을 사용하는 클라이언트 프로그램이 할 수 있는 의미있는 동작은 없다. 

즉, 이 경우 `IOException` 을 잡아내는 코드는 불필요하다.

try 를 식으로 사용하기

코틀린의 `try` 키워드도 `if` 나 `when` 처럼 식이다.

위에서 구현한 `readNumber` 메서드에 `finally` 절을 없애며 코드를 아래처럼 수정해보자.

fun readNumber(reader: BufferedReader) {
    val number = try {
        Integer.parseInt(reader.readLine())
    } catch (e: NumberFormatException) {
        return
    }
    println(number)
}

이 코드에서 `catch` 블록 안에서는 `return` 이 있다.

예외 발생 시 `catch` 블록 다음 코드는 실행되지 않는다.

 

이 코드를 아래처럼 수정해보자.

fun readNumber(reader: BufferedReader) {
    val number = try {
        Integer.parseInt(reader.readLine())
    } catch (e: NumberFormatException) {
        null
    }
    println(number)
}

이 경우, `try` 코드 블록의 실행이 정상적으로 끝나면 그 블록의 마지막 식의 값이 결과이고,

예외가 발생하고 잡히면 그 예외에 해당하는 `catch` 블록의 값이 결과가 된다.

 

여기부터 아래는 약간의 심화 내용이다.

예외를 활용해 코드에 제한을 걸자

확실하게 특정 형태로 동작해야 하는 코드가 있다면, 예외를 활용해서 제한을 걸어주는 게 좋다.

코틀린에서는 크게 아래 4가지 방법을 사용한다.

  • `require`: argument 를 제한한다.
  • `check`: 상태와 관련된 동작을 제한한다.
  • `assert`: 어떤 것이 `true` 인지 확인한다. (테스트 용도)
  • `return` 또는 `thorw` 와 함께 사용하는 Elvis 연산자

이렇게 제한을 걸었을 때 얻을 수 있는 장점은

  • 제한을 걸면 개발 문서를 읽지 않은 개발자도 문제를 쉽게 확인할 수 있다.
  • 문제가 있을 때 함수가 예상치 못한 동작을 하지 않고 예외를 throw 한다.
    (예상치 못한 동작이 예외를 던지는 것보다 더 위험하고 상태 관리가 힘듦)
    이러한 제한으로 문제를 놓치지 않고 코드가 더 안정적으로 작동하게 한다.
  • 코드가 어느 정도 자체적으로 검사된다. 따라서 관련 단위 테스트를 줄일 수 있다.
  • smart cast 기능을 활용할 수 있데 되어 casting 을 적게 할 수 있다.

이러한 제한들에 대해 차례대로 조금 더 자세히 알아보자.

require 함수: Argument

클래스나 함수를 정의할 때 타입 시스템을 활용하여 argument 에 제한을 자주 건다. 예시는

  • 숫자를 아규먼트로 받아서 팩토리얼을 계산한다면 숫자는 양의 정수여야 한다.
  • 사용자로부터 이메일 주소를 입력받을 때는 값이 입력되어 있는지, 이메일 형식이 올바른지 확인한다.

이러한 경우 `require` 함수를 사용한다.

fun factorial(n: Int): Long {
    require(n >= 0)
    return if (n <= 1) 1 else factorial(n - 1) * n
}

fun sendEmail(user: User, message: String) {
    requireNotNull(user.email)
    require(isValidEmail(user.email))
    // ...
}

 

클래스의 `init` 블록에서도 많이 사용한다.

data class User(val email: String){
    init {
        require(email.startsWith("x"))
    }
}

 

`require` 함수는 조건 불만족시 항상 `IllegalArgumentException` 을 발생시킨다. 

또 람다를 활용해서 lazy message(지연 메시지)를 정의할 수도 있다.

fun factorial(n: Int): Long {
    require(n >= 0) {"Cannot calculate factorial of  $n " +
            "because it is smaller than 0"}
    return if (n <= 1) 1 else factorial(n - 1) * n
}

 

check 함수: State

어떤 구체적인 조건을 만족할 때만 함수를 사용할 수 있게 해야 할 때 사용한다. 예를 들어

  • 어떤 객체가 미리 초기화되어 있어야만 처리를 하는 함수
  • 사용자가 로그인했을 때만 처리를 하는 함수
  • 객체를 사용할 수 있는 시점에만 사용하는 함수

이러한 경우 `check` 함수를 사용한다.

fun speak(text: String) {
    check(isInitialized)
    // ...
}

fun getUserInfo(): UserInfo {
    checkNotNull(token)
    // ....
}

fun next(): T {
    check(isOpen)
    // .....
}

 

`check` 함수는 `require` 와 비슷하지만 지정된 에측이 불만족일 때 `IllegalStateException` 을 던진다. 

`require` 과 마찬가지로 지연 메시지를 사용하여 예외 메시지를 설정할 수 있다.

 

일반적으로 `require` 블록보다 더 뒤에, 즉, `check` 를 더 나중에 한다.

Assert 계열 함수

단위 함수가 올바르게 구현되었는지를 확실하게 알아내도록 하기 위해서는 단위 테스트가 필요하다. 

보통은 assert 계열 함수들은 프로덕션 코드가 아닌 테스트 코드에서 사용된다. 하지만 프로덕션 환경에서도 assert 를 사용할 수 있는 경우도 있다. (코틀린/JVM 에서 -ea JVM 옵션 활성화 시)

다만 프로덕션 환경에서는 실제로는 오류가 발생하지 않고 테스트할 때만 활성화된다.(테스트 코드로 프로덕션 코드의 함수를 실행시켰을 때 활성화됨) 

단위 테스트 대신 함수에서 assert 를 사용하면

  • Assert 계열의 함수는 코드를 자체 점검하며, 더 효율적 테스트가 가능해짐.
  • 특정 상황이 아닌 모든 상황에 대한 테스트가 가능하다.
  • 런타임에 정확히 어떻게 되는지 확인이 가능하다.
  • 실제 코드가 더 빠른 시점에 실패하게 만든다. 따라서 예상하지 못한 동작이 언제 어디서 실행되었는지 쉽게 찾을 수 있다.

사실 이런 assert 는 파이썬에서 굉장히 많이 사용되고, 자바에서는 거의 사용되지는 않는다.

nullability 와 smart casting

코틀린에서는 `require` 와 `check` 블록으로 어떤 조건을 확인해서 `true` 가 나왔다면 해당 조건은 이후도 `true` 로 가정한다.

즉, 이를 활용해서 타입 비교를 하면, 스마트 캐스트가 작동한다.

fun changeDress(person: Person) {
    require(person.outfit is Dress)
    val dress: Dress = person.outfit
    // ...
}

interface Outfit
class Dress : Outfit
class Jacket : Outfit

위에서 `person` 의 복장이 `Dress` 여야 코드가 정상적으로 진행된다.

따라서 `outfit` 프로퍼티가 불변이라면 `outfit` 프로퍼티가 `Dress` 로 스마트 캐스트된다.

 

이것은 어떤 대상이 `null` 인지 확인할 때 굉장히 유용하다.

fun sendEmail(person: Person) {
    requireNotNull(person.email)
    val email: String = person.email
    println(email)
    // .......
}

`nullability` 를 목적으로 오른쪽에 `throw` 또는 `return` 을 두고 Elvis 연산자를 활용하는 경우도 많다. 

이런 코드는 가독성도 좋고 유연하게 사용이 가능하다. (Elvis 연산자 설명이 있는 글)

 

아래에서는 만약 `email` 이 `null` 이라면 오류를 발생시키지 않고 단순히 함수를 중지시키고 있다.

fun sendEmail(person: Person, text: String) {
    val email: String = person.email ?: return
    println(email + text)
    // ...
}

프로퍼티에 문제가 있어서 `null` 일 때 여러 처리를 해야할 때도 `return`/`throw` 와 `run` 함수를 조합해서 활용하면 된다.

함수가 중지된 이유를 로그에 출력해야 할 때 사용할 수 있다.

fun sendEmail(person: Person, text: String) {
    val email: String = person.email ?: run { 
        println("Email not sent, no email address")
        return
    }
    println(email + text)
    // ...
}

이렇게 `return`  과 `throw` 를 활용한 Elvis 연산자는 nullable 을 확인할 때 자주 사용된다.