코틀린(Kotlin) - 예외(Exception) 대신 값으로 오류 처리하기

Either를 활용한 함수형 오류 처리


오류 처리에 대한 고민

회사에서 코틀린(Kotlin)을 이용해 서버 애플리케이션을 개발하고 있는데 프로젝트 초기부터 오류 처리에 대한 고민이 있었다. 프로젝트의 오류 처리 방식이 통일되지 않았기 때문이다. 어떤 로직에서는 성공과 실패에 대한 결괏값을 객체로 감싸 오류를 처리하고 또 다른 로직에서는 예외를 사용해 오류를 처리하는 식이었다.

시간이 지나자 결괏값을 감싼 객체들도 비슷해 보이지만 조금씩 다르게(중복인 듯 중복 아닌 듯...) 여러 개가 존재하는 상황이 되었다. 그뿐만 아니라 로직을 작성하는 입장에서 어떤 상황에서 예외를 던져야 하는지, 어떨 때 객체로 값을 반환해야 하는지에 대한 기준이 없어 오류 처리 방식을 고민하는 데 불필요한 시간을 소모하기도 하였다.

통일된 오류 처리 방식이 필요하다는 생각이 들었지만 마땅한 해결책을 찾지 못했었다. 그러다 최근에 '자바에서 코틀린으로(Java to Kotlin)'라는 책을 구매해 읽다가 좋은 오류 처리 방식을 알게 되어 팀에 제안한 뒤 도입을 시작했다.

자바와 코틀린에서의 예외(Exception)

자바에서 예외는 크게 'Checked Exception'과 'Unchecked Exception' 두 가지로 나뉜다.

'Checked Exception'은 컴파일러가 예외가 처리되었는지 검사를 하므로 코드를 작성하면서 예외에 대한 처리 로직을 반드시 작성해야 한다. 이에 반해 'Unchecked Exception'은 런타임에 발생하는 예외라 컴파일러가 검사할 수 없어 오류 처리를 강제하지 않는다.

시간이 흐름에 따라 자바에서 'Checked Exception'의 사용은 점점 줄어들었고 람다가 도입되면서 IOException을 제외하면 'Checked Exception'은 실무에서 거의 사용되지 않는 지경에 이르렀다. 코틀린은 이런 흐름을 반영해서인지 'Checked Exception'을 특별하게 취급하지 않는다.

'Unchecked Exception' 사용의 문제

'Unchecked Exception'의 문제는 어떤 함수를 호출함에 있어 그 함수가 예외를 던지는지, 던진다면 어떤 예외를 던지는지, 그 예외에는 어떤 값이 들어있는지 등의 오류와 관련된 정보를 함수의 시그니처만 보고선 알 수 없다는 데 있다. 즉, 예외는 함수의 참조 투명성을 해친다.

fun String.toInt(): Int 예시

문자열을 정수로 파싱하는 fun String.toInt(): Int 함수를 살펴보자. 아래와 같이 정수를 나타내는 문자열의 경우 함수의 시그니처대로 정수를 잘 반환한다.

  • 코드
val number = "123".toInt()
println(number)
  • 결과
123

그러나, 문자열이 정수를 나타내는 형태가 아니라면 NumberFormatException을 던진다.

  • 코드
val number = "abc".toInt()
println(number)
  • 결과
Exception in thread "main" java.lang.NumberFormatException: For input string: "abc"
 at java.lang.NumberFormatException.forInputString (:-1) 
 at java.lang.Integer.parseInt (:-1) 
 at java.lang.Integer.parseInt (:-1) 

이는 함수의 시그니처에서 확인할 수 없는 정보다. 함수를 호출하는 쪽에선 뜬금없는 결과일 수 있는 것이다. API 문서에 이러한 정보가 명시가 되어 있다면 그나마 다행이지만 그렇지 않다면 직접 해당 함수의 구현부를 확인하며 어떤 예외를 던지는지 직접 파악해야만 한다.

반환값을 Nullable로?

위 상황에 대한 해결책으로 반환값을 fun String.toInt(): Int?와 같이 Nullable로 변경하고 오류에 대한 반환값으로 null을 사용하면 해결될 것으로 생각할 수도 있다. 그러나, null이라는 값은 오류의 원인을 명확히 나타낼 수 없다는 한계를 갖고 있다.

위의 예시처럼 입력값인 문자열이 정수 형태가 아닐 때 오류가 발생할 수도 있지만 Int로 표현할 수 없는 크기의 정수인 경우에도 오류가 발생할 수 있을 것이다. null 만으로는 이 두 오류를 구분할 수 없다.

함수형 오류 처리

앞서 서술한 이유로 인해 함수형에서는 오류 처리에 예외 대신에 값을 사용한다. 두 가지 타입을 가질 수 있는 Either라는 타입을 활용해 성공과 실패 결괏값을 표현한다. Either는 두 가지 타입을 가질 수 있지만 어느 한 순간에는 한 가지 타입만 가질 수 있다. 관습적으로 Right를 성공의 결과, Left를 오류의 결과로 사용한다.

sealed class Either<out L, out R>
data class Left<out L>(val l: L) : Either<L, Nothing>()
data class Right<out R>(val r: R) : Either<Nothing, R>()

Either를 사용해 fun String.toInt(): Int 함수를 다음과 같이 감쌀 수 있다.

fun String.parseInt(): Either<String, Int> = try {
    Right(this.toInt())
} catch (exception: Exception) {
    Left(exception.message ?: "정수로 표현할 수 없습니다")
}

새롭게 정의한 parseInt() 함수는 Either를 반환하기에 오류 발생 시 String을 출력한다는 것을 함수 시그니처에서 확인할 수 있고 이를 통해 호출하는 입장에선 예상 가능하게 오류를 처리할 수 있다.

결과값 사용

when

Eithersealed class로 선언되었기 때문에 결과값에 대한 처리를 when으로 깔끔하게 할 수 있다. else 분기가 필요 없고 IDE에서도 LeftRight 케이스 처리 분기를 자동으로 완성해주기 때문이다. 다음은 when을 사용하여 결과값을 다루는 예시다.

val result: Either<String, Int> = "123".parseInt()
val number = when (result) {
    is Right -> 10 * result.r
    is Left -> {
        println(result.l)
        0
    }
}
println(number)

map() 등의 고차함수 활용

when을 사용하는 방식도 깔끔하긴 하지만 매번 이렇게 결과값을 가져오는 것은 번거로울 것이다. 반복되는 패턴이기에 이를 고차함수로 추상화시킬 수 있다.

  • 성공 값에 대해 연산을 수행해 새로운 성공 값으로 변환하는 mapRight()
inline fun <L, R1, R2> Either<L, R1>.mapRight(block: (R1) -> R2): Either<L, R2> = when (this) {
    is Right -> Right(block(this.r))
    is Left -> this
}
  • 실패 값에 대해 연산을 수행해 새로운 실패 값으로 변환하는 mapLeft()
inline fun <L1, L2, R> Either<L1, R>.mapLeft(block: (L1) -> L2): Either<L2, R> = when (this) {
    is Right -> this
    is Left -> Left(block(this.l))
}
  • Either에 담긴 값을 꺼내는 get()
fun <T> Either<T, T>.get() = when (this) {
    is Right<T> -> r
    is Left<T> -> l
}

이렇게 정의한 함수를 이용해 when을 사용해 결과값을 다루었던 로직을 아래와 같이 리팩터링할 수 있다.

val number = "123".parseInt()
    .mapRight { 10 * it }
    .mapLeft {
        println(it)
        0
    }.get()
println(number)

이 외에도 다양한 고차함수를 정의해 Either를 다루는 데 사용할 수 있을 것이다. 또한 Either라는 명칭이 마음에 들지 않는다면 Result와 같은 명칭으로 바꾸고 Right, Left는 각각 Success, Failure로 정의해 가독성을 높일 수도 있다.

이 모든 것을 직접 개발해서 사용할 수도 있겠지만 기존에 있는 라이브러리를 활용하는 것도 방법일 것이다. 예시로 '자바에서 코틀린으로' 책 저자가 개발한 Result4K라는 라이브러리가 있다.

코틀린의 Result와는 무엇이 다를까?

코틀린에서는 Result라는 내장 타입을 제공한다. 코루틴을 위해 설계되었다고 하는데 Result는 실패값으로 예외(Exception)만 사용할 수 있는 제약이 있다. 또한, Either는 타입 단에서 성공과 실패가 구분되는 반면 Result는 메서드 호출을 통해 성공과 실패를 확인해야 하는 번거로움이 존재한다.

언제 사용하는 것이 좋을까

개인적인 의견으로는 오류 처리에 Either를 사용하는 것을 기본으로 하고 예외(Exception)는 애플리케이션에 치명적인(복구 불가능한) 오류에 대해서만 사용하는 것이 좋다고 생각한다. 즉 논리적으로 처리 가능한 케이스라면 Exception 보다는 Either를 사용하는 것이 좋아 보인다.

또한, 웹 애플리케이션 개발 시 Spring을 프레임워크로 사용하고 있다면 @Transactional 등의 AOP를 활용하는 수준에서만 예외(Exception)을 사용하는 것이 좋을 것 같다.

참고문헌

덩컨 맥그레거, 냇 프라이스. 자바에서 코틀린으로. 한빛미디어, 2022.