Kotlin에서의 예외 처리 방법

galcyurio
7 min readMar 13, 2023
사진: UnsplashPankaj Patel

최근 몇 년간 교육 기관에서 코드 리뷰를 진행하면서 대부분의 리뷰이들이 예외 처리를 잘못 알고 구현하는 경우들을 많이 보았습니다.

예외 처리에 대해서는 그 동안 각각의 코드 리뷰에서 아래와 같이 리뷰를 주었습니다.

- 논리 오류일 때만 예외를 던지세요.
- 논리 오류가 아니면 예외를 던지지 말고 null을 반환하세요.
- 실패하는 경우가 복잡해서 null로 처리할 수 없으면 sealed class를 반환하세요.
- 일반적인 코틀린 코드에서 `try-catch`를 사용하지 마세요.

위 가이드는 Roman ElizarovKotlin and Exceptions를 기본으로 작성되었습니다.

하지만 위와 같은 리뷰를 통해서도 제대로 고쳐지지 않는 경우가 많았고 추가적인 질문을 여러 번 받기도 했습니다.
그만큼 많은 개발자들이 어려워하는 개념이라 코멘트에서의 짧은 글로는 도저히 예외처리를 제대로 설명할 수가 없기에 이번 기회에 예외 처리에 대한 글을 작성합니다.

이 글에서는 Kotlin에서의 예외 처리 방법을 설명하지만 Java에서의 예외 처리 방법 또한 포함합니다.

용어 정리

본격적인 설명을 들어가기 전에 먼저 몇 가지 용어를 알아봅니다.

예외 (Exception)

예외(Exception)는 프로그램의 오류 상황에서 던져지는 객체이며 만들어졌을 때의 stacktrace를 가지고 있습니다.
던져진 객체가 catch 되지 않은 경우에 프로그램은 충돌로 강제 종료됩니다.
throw 키워드를 통해 던질 수 있으며, 때에 따라 만들기만 하고 던지지 않을 수 있습니다.

예외 처리

말 그대로 예외(Exception)를 처리하는 기능입니다. java와 kotlin에서는 try-catch를 통해 이 기능을 지원합니다.
kotlin에서는 runCatching()을 이용하여 예외가 던져진 경우에 Result 객체를 반환하도록 만들 수 있습니다.

논리 오류

프로그램이 강제 종료되지도 않고 의도대로 동작하지도 않는 상황을 논리 오류라고 부릅니다.
예외가 던져지지 않으므로 런타임에 발생하는 RuntimeException과는 다른 상황입니다.

논리 오류일 때만 예외를 던지세요

양수를 객체로 선언한다고 가정합니다.

value class PositiveNumber(val value: Int)

이렇게 선언해두면 끝일까요?

PositiveNumber(-1)

이렇게 양수 객체를 만들 때, 음수를 넘기면 잘못된 객체가 만들어질 수 있습니다. 그러면 어떻게 양수로만 만들도록 강제할 수 있을까요?

require() 함수는 내부적으로 에러를 던집니다.

바로 위와 같이 음수가 들어오면 생성할 때 예외를 던지는 것입니다.

이렇게 만드는 이유가 뭔가요?

발생할 수 있는 에러 상황 중 논리 오류는 최악의 상황입니다. 최대한 논리 오류가 되지 않도록 만들어야 합니다. 그럼에도 불구하고 논리 오류가 발생하는 경우에는 예외를 던져야 합니다.

너무나도 당연하지만 예외를 던지면 논리 오류가 되는 상황을 원천적으로 봉쇄할 수 있습니다.

프로그램이 이상하게 동작한다는 고객 문의를 통해 알게 된다고 가정해봅시다. 그런데 예외가 던져지지 않아서 프로그램은 강제 종료되지 않았고 오류 보고로도 수집은 되지 않았고 직접 해봤더니 내 컴퓨터에서는 잘 되는 경우를 상상해보세요.

이러한 종류의 논리 오류는 에러 원인을 알 수가 없기 때문에 디버깅이 굉장히 어려습니다. 따라서 논리 오류는 최대한 만들지 않아야하고 발생할 수 있는 경우 예외를 던져야 합니다.

논리 오류일 때 던진 예외는 어떻게 처리하나요?

이 부분은 아래 섹션을 참고해주세요. (일반적인 코틀린 코드에서 try-catch를 사용하지 마세요)

논리 오류가 아니면 예외를 던지지 말고 null을 반환하세요

방금전에 만들었던 PositiveNumber를 다시 한 번 살펴봅시다.

value class PositiveNumber(val value: Int) {
init {
require(value >= 0) { "0과 음수는 허용되지 않습니다. value: $value" }
}
}

0이 되는 경우 예외를 던져서 논리 오류를 막고 있습니다. 하지만 애초부터 논리 오류가 발생하지 않도록 만들어보면 어떨까요?

생성자를 사용할 수 없도록 private으로 숨기고 from() 팩토리 함수를 추가하였습니다.

이렇게 하면 외부에서는 반드시 from() 함수를 통해서만 PositiveNumber를 만들어야 하고 만약 음수라면 null이 반환됩니다. 이 방식에서는 음수로 양수(PositiveNumber)를 만든다는 논리 오류 자체가 발생할 수가 없습니다.

실패하는 경우가 복잡해서 null로 처리할 수 없으면 sealed class를 반환하세요

성공하는 경우는 보통 1가지 경우겠지만 실패하는 경우는 여러가지 사례가 있을 수 있습니다. 주민번호를 입력받는 기능이라면 앞자리가 비어있을 수 있고 뒷자리가 비어있을 수 있습니다. 이러한 성공, 실패들을 모두 sealed class로 선언해보세요.

이를 통해 우리는 성공한 경우는 물론 실패한 경우는 앞자리, 뒷자리가 모자란 경우 모두에 대해서 처리할 수 있습니다.

일반적인 코틀린 코드에서 try-catch를 사용하지 마세요

피드백 자체에 일반적인 코틀린 코드라고 되어 있지만 이 점은 자바에서도 동일합니다.

예외는 반드시 예외적인 상황에서만 던져야 합니다.

여기서 try-catch를 사용하지 말라는 것은 runCatching() 또한 사용하지 말라는 것과 같습니다.

코드 리뷰를 진행하다보면 피드백이 잘못 반영되는 사례 중 하나가 논리 오류일 때 던져진 Exceptioncatch해서 프로그램의 요구사항을 구현하는 것이었습니다. 프로그램의 정상적인 흐름을 Exception을 던지고 try-catch로 구현한 코드는 유지보수에 심각한 영향을 주고 가독성도 좋지 않습니다.

그러면 예외는 어떻게 처리해야 하나요?

우리가 논리 오류일 때, 던지는 예외를 포함해서 프로그램 전체적으로 발생하는 예외들을 전역적으로 처리해주는 예외 처리기를 통해 보고해야 합니다.

대표적으로 catch되지 않은 모든 Exception을 처리하는 Thread.setDefaultUncaughtExceptionHandler()를 이용한 방법이 있습니다.

@JvmInline
value class PositiveNumber(val value: Int) {
init {
require(value >= 0) { "0과 음수는 허용되지 않습니다. value: $value" }
}
}

fun main() {
Thread.setDefaultUncaughtExceptionHandler { _, exception ->
println("TODO: 예외 수집 도구로 에러를 전송")
exception.printStackTrace()
}
PositiveNumber(-1)
}

중요한 점은 예외가 발생한 경우 반드시 예외 수집 도구로 에러를 전송해서 추후 디버깅할 수 있도록 만들어줘야 한다는 점입니다.

위에서는 순수 JVM에 대해서만 이야기했습니다. 비동기 라이브러리를 쓰는 경우 에러 처리 방식이 달라질 수 있습니다.
예를 들어, RxJava에서는 RxJavaErrorHandler를 사용한다거나 Coroutines에서는 CoroutineExceptionHandler을 사용하기 때문에 각 개발 환경에 따라 별도의 전역적으로 에러를 처리할 수 있는 방법을 찾아보세요.

--

--