코틀린을 다루는 기술 7장 정리 - 오류와 예외 처리하기
코틀린을 다루는 기술 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
: 데이터나 오류 중 하나를 표현해야하는 타입이 필요할 때 유용
'if (study) > Java & Kotlin' 카테고리의 다른 글
[Kotlin] 두개의 List에서 중복되는 원소의 개수 구하기 (교집합 찾기) (0) | 2020.08.05 |
---|---|
[Kotlin] 가변 인자(vararg) 사용하기, 배열을 가변 인자로 넘기기 (0) | 2020.07.31 |
코틀린을 다루는 기술 6장 정리 - 선택적 데이터 처리하기 (0) | 2020.07.24 |