코틀린을 다루는 기술 7장을 읽고 정리한 내용입니다.
요약된 표현이 많기에 보다 자세한 설명은 책을 통해 확인하실 수 있습니다.

6장에서는 Option으로 선택적 데이터를 처리하는 방법을 배웠다. Option은 데이터가 없다는 사실을 알릴 수는 있지만, null이 발생한 이유를 제공하지 않는다는 문제점이 있다. 데이터가 없는 모든 이유를 똑같이 취급하고 그 이유를 호출한 쪽에서 추측해야만 했다.

이 장에서는 코틀린에서 오류와 예외를 처리하는 방법을 배운다. Option의 문제점을 보완하여 데이터 부재를 표현하는 Either 타입과 Result 타입을 살펴보자.

 

대부분의 예제 코드는 이 저장소에 있는 내용을 참고했다.

7.1 데이터가 없는 경우와 관련한 문제점

readLine()으로 문자열을 읽어들이는 함수를 작성한다고 가정해보자. 문자열 데이터를 사용할 수 없는 여러가지 경우의 수가 존재한다. IOException이 발생할 수도 있고, 사용자가 빈 값을 입력할 수도 있다. 어떤 문제가 발생한건지 콘솔에 메세지로 표시하려면 어떻게 해야할까?

 

이전 장에서 만든 Option을 사용한다면 Pair<Option<T>, Option<String>>을 반환 타입으로 사용할 수 있다. 하지만 이 타입은 너무 복잡하다. 오류가 발생한 경우와 정상적인 값은 서로 배타적인 관계지만, Pair로는 그런 배타성을 강제할 수 없다. 여기서 필요한 것은 합 타입(sum type) E<T, U>이다. 합 타입을 사용하면 Some(값이 존재)이나 None(값이 없음) 중 하나만 저장될 수 있고, 둘 다 저장될 수는 없다.

7.2 Either 타입

함수가 오류를 표현하는 값데이터를 표현하는 값처럼 서로 배타적인 두가지 타입의 값을 반환할 경우엔 Either를 사용할 수 있다. A, B 중 하나만 담을 수 있는 타입을 만들려면, Option을 수정해서 None 타입도 값을 저장할 수 있게 하면 된다.

sealed class Either<out E, out A> {

    internal class Left<out E, out A>(private val value: E): Either<E, A>() {

        override fun toString(): String = "Left($value)"
    }

    internal class Right<out E, out A>(private val value: A) : Either<E, A>() {

        override fun toString(): String = "Right($value)"
    }

    companion object {

        fun <E, A> left(value: E): Either<E, A> = Left(value)

        fun <E, B> right(value: B): Either<E, B> = Right(value)
    }
}

관례적으로 right 하위 클래스는 성공, left 하위 클래스는 오류를 표현한다. 최대값을 찾을 수 없을 때 String 타입으로 예외를 던지는 max를 만들어봤다.

fun max(list: List<Int>): Either<String, Int> = if (list.isEmpty()) {
    Either.left("list is empty")
} else {
    Either.right(list.foldRight(list.first()) { x, y -> if (x > y) x else y })
}

fun main(args: Array<String>) {
    println(max(listOf(1, 3, 5, 7))) // Right(7)
    println(max(listOf())) // Left(list is empty)
}

Result가 감싸고 있는 값을 꺼낼 수 있도록 getOrElse, orElse를 추가해보자.

  • getOrElse : 현재 타입이 Left라면 함수를 호출하는 쪽에서 default value를 정의할 수 있다.
  • orElse : 인자와 상관없이 현재 값에 getOrElse를 호출한다.
sealed class Either<E, out A> {

    abstract fun <B> map(f: (A) -> B): Either<E, B>

    fun getOrElse(defaultValue: () -> @UnsafeVariance A): A = when (this) {
        is Right -> this.value
        is Left  -> defaultValue()
    }

    fun orElse(defaultValue: () -> Either<E, @UnsafeVariance A>): Either<E, A> =
            map { this }.getOrElse(defaultValue)

    internal class Left<E, out A>(private val value: E): Either<E, A>() {

        override fun <B> map(f: (A) -> B): Either<E, B> = Left(value)

        override fun toString(): String = "Left($value)"
    }

    internal class Right<E, out A>(internal val value: A) : Either<E, A>() {

        override fun <B> map(f: (A) -> B): Either<E, B> = Right(f(value))

        override fun toString(): String = "Right($value)"
    }

    // ...
}

두 함수를 적용해 값을 꺼내보자.

fun main(args: Array<String>) {
    println(max(listOf(1, 3, 5, 7)).getOrElse { 0 }) // 7
    println(max(listOf(0)).orElse { Either.right(0) }) // Right(0)
}

오류를 표현하는 Left의 타입으로 무엇을 사용해야 할까?

  • 문자열은 오류 메세지를 표현할 수 있지만, 다양한 오류 상황에서는 예외가 발생한다. 또한 예외에 담겨있는 중요한 정보를 무시하고 오류 메세지에만 집중하게 된다.
  • 가급적 RuntimeException을 사용하고, 메세지가 필요한 경우 메세지를 예외로 감싸면 된다.

7.3 Result 타입

오류 또는 데이터를 표현하는 타입은, 일반적으로 실패할 가능성이 있는 계산 결과를 표현한다.

Result는 성공/실패 여부를 캡슐화된 Result 형태로 반환한다. Option과 이름이 비슷하지만, 하위 클래스 이름이 Success와 Failure라는 점이 다르다.

만약 NPE가 발생하면 즉시 Failure를 얻고, 메시지로 Failure를 만들면 Exception으로 메시지를 감싼다.

sealed class Result<out A> {

    abstract fun <B> map(f: (A) -> B): Result<B>

    abstract fun <B> flatMap(f: (A) -> Result<B>): Result<B>

    fun getOrElse(defaultValue: @UnsafeVariance A): A = when (this) {
        is Success -> this.value
        is Failure -> defaultValue
    }

    fun getOrElse(defaultValue: () -> @UnsafeVariance A): A = when (this) {
        is Success -> this.value
        is Failure -> defaultValue()
    }

    fun orElse(defaultValue: () -> Result<@UnsafeVariance A>): Result<A> =
            when (this) {
                is Success -> this
                is Failure -> try {
                    defaultValue()
                } catch (e: RuntimeException) {
                    Result.failure<A>(e)
                } catch (e: Exception) {
                    Result.failure<A>(RuntimeException(e))
                }
            }

    internal class Failure<out A>(private val exception: RuntimeException) : Result<A>() {

        override fun <B> map(f: (A) -> B): Result<B> = Failure(exception)

        override fun <B> flatMap(f: (A) -> Result<B>): Result<B> = Failure(exception)

        override fun toString(): String = "Failure(${exception.message})"
    }

    internal class Success<out A>(internal val value: A) : Result<A>() {

        override fun <B> map(f: (A) -> B): Result<B> = try {
            Success(f(value))
        } catch (e: RuntimeException) {
            Failure(e)
        } catch (e: Exception) {
            Failure(RuntimeException(e))
        }

        override fun <B> flatMap(f: (A) -> Result<B>): Result<B> = try {
            f(value)
        } catch (e: RuntimeException) {
            Failure(e)
        } catch (e: Exception) {
            Failure(RuntimeException(e))
        }

        override fun toString(): String = "Success($value)"
    }

    companion object {

        operator fun <A> invoke(a: A? = null): Result<A> = when (a) {
            null -> Failure(NullPointerException())
            else -> Success(a)
        }

        fun <A> failure(message: String): Result<A> = Failure(IllegalStateException(message))

        fun <A> failure(exception: RuntimeException): Result<A> = Failure(exception)

        fun <A> failure(exception: Exception): Result<A> = Failure(IllegalStateException(exception))
    }
}

areaName을 area로 변환하는 예제를 작성해봤다. 사용자로부터 입력이 없는 경우, 적절한 지역을 찾지 못했을 경우 등등 상황에 맞게 예외를 발생시키고 있다. 값을 사용하는 쪽에서는 Exception의 종류에 따라 default value를 다양하게 설정할 수 있다.

enum class Area(val areaName: String) {
    SEOUL("서울"), JEJU("제주"), INCHEON("인천");

    companion object {

        fun find(areaName: String): Area? = values().find { it.areaName == areaName }
    }
}

fun main(args: Array<String>) {
    val areaName = readLine()
    val area = if (areaName == null) {
        Result.failure("input stream이 파일의 끝에 도달함")
    } else {
        findArea(areaName)
    }
    println(area)
}

private fun findArea(areaName: String) = Area.find(areaName)?.run { Result(this) }
                ?: Result.failure("등록되지 않은 지역임")

7.4 Result 패턴

Result에는 값이 존재할 수도 있고, 존재하지 않을 수도 있다. Result를 사용할 때는 안에 든 값을 직접 꺼내쓰는 대신 Result에 정의된 함수로 값을 합성하는 방식을 써야한다.

선택적 데이터에 대해서는 오류 메시지가 필요 없다. 메시지가 필요하다면 그 데이터는 선택적 데이터가 아니다.

7.5 고급 Result 처리

  • (A) → Boolean 타입의 함수를 인자로 받아서 true면 Success, false면 Failure를 반환하는 함수를 만들어보자.

        fun filter(p: (A) -> Boolean, message: String = "Condition not matched"): Result<A> = flatMap {
            if (p(it)) this else failure(message)
        }
  • (A) → Boolean 타입의 함수를 인자로 받아서 Result가 감싼 value가 조건을 만족하는지 확인하는 함수를 만들어보자.

      fun exists(p: (A) -> Boolean): Boolean = map(p).getOrElse(false)

7.6 Failure 매핑하기

메시지를 더 적합한 메시지로 바꾸거나, 사용자가 문제 원인을 파악하기 쉽게 정보를 추가하는 등의 상황에서 Failure를 다른 값으로 바꾸는 것이 유용할 수 있다. 오류 메시지를 다른 Failure로 변환해주는 mapFailure 함수를 추가해보자.

abstract fun mapFailure(message: String): Result<A>

Failure의 구현은 기존 예외를 새로운 예외로 감싸면 된다.

override fun mapFailure(message: String): Result<A> = Failure(RuntimeException(message, exception))

Empty와 Success에선 아무 것도 할 필요가 없으므로 this를 반환하면 된다.

override fun mapFailure(message: String): Result<A> = this

7.7 팩토리 함수 추가하기

이미 null이거나 오류가 생긴 값을 인자로 받았을 때, 구체적인 오류 메시지를 제공할 수 있도록 invoke 함수를 오버로딩 해보자. 오류 메시지가 없으면 Empty를, 아니라면 Failure를 반환하면 된다.

sealed class Result<out A> {

    // ...

    companion object {

        operator fun <A> invoke(a: A? = null): Result<A> = when (a) {
            null -> Failure(NullPointerException())
            else -> Success(a)
        }

        operator fun <A> invoke(a: A? = null, p: (A) -> Boolean): Result<A> = when (a) {
            null -> Failure(NullPointerException())
            else -> when {
                p(a) -> Success(a)
                else -> Empty
            }
        }

        operator fun <A> invoke(a: A? = null, message: String, p: (A) -> Boolean): Result<A> = when (a) {
            null -> Failure(NullPointerException())
            else -> when {
                p(a) -> Success(a)
                else -> Failure(IllegalArgumentException("Argument $a does not match condition: $message"))
            }
        }

        // ...
    }
}

7.8 효과 적용하기

Result에 감싸인 값에 효과를 적용하려면 getOrElse로 값을 직접 꺼내야만 한다. (여기서 효과는 콘솔, 파일에 로그를 남기거나, 외부 네트워크로 메시지를 보내는 등 외부 세계에 영향을 끼치는 모든 것을 의미) Result에서 값을 추출하는 대신, Result 내부에 효과를 넘기는 방법으로 더 안전한 처리가 가능하다.

  • 효과를 받아서 Result가 감싸고 있는 값에 적용하는 함수를 만들어보자.

    • 하위 타입에서 구현할 추상 메서드의 시그니처는 다음과 같다.

        abstract fun forEach(
                onSuccess: (A) -> Unit = {},
                onFailure: (RuntimeException) -> Unit = {},
                onEmpty: () -> Unit = {}
            )
    • Empty에서의 구현

        override fun forEach(
                    onSuccess: (Nothing) -> Unit,
                    onFailure: (RuntimeException) -> Unit,
                    onEmpty: () -> Unit
                ) {
                    onEmpty()
                }
    • Success에서의 구현

        override fun forEach(
                    onSuccess: (A) -> Unit,
                    onFailure: (RuntimeException) -> Unit,
                    onEmpty: () -> Unit
                ) {
                    onSuccess(exception)
                }
    • Failure에서의 구현

        override fun forEach(
                    onSuccess: (A) -> Unit,
                    onFailure: (RuntimeException) -> Unit,
                    onEmpty: () -> Unit
                ) {
                    onFailure(exception)
                }

forEach는 이런 식으로 사용할 수 있다.

val person = getName().flatMap(toons::getResult).flatMap(Person::email)
person.forEach(::println, ::println, ::println)

요약

오류 때문에 값이 없다는 사실을 표현하기 위해 Result 타입을 만들었다. 그리고 Either 타입으로 둘 중 한 타입의 값이 들어있는 데이터를 표현했다.

  • Either : 두가지 다른 타입의 값을 반환하는 함수 작성 시 유용
  • Result : 데이터나 오류 중 하나를 표현해야하는 타입이 필요할 때 유용