Table of contents
Open Table of contents
오류를 더 잘 처리하기
우리의 목표는 오류를 가능한 빨리 감지하여 복구하거나, 실패한 경우 문제를 해결하는 데 필요한 모든 정보를 클라이언트에게 보고하고 로그에 남기는 것이다.
좀 더 자세한 오류 메시지가 필요한 경우가 여럿 있다.
- 서로 다른 결과를 초래하는 각각의 실패를 구분해야 한다.
- 디버깅을 위해 복잡한 계산이 어떤 단계에서 실패했는지 세부 정보를 기록해야 한다.
null
은 계산의 유효한 결과에 포함되기 때문에 오류 표시로null
을 사용할 수 없다.
널로 오류 처리하기
일반적으로 오류가 발생할 때마다 null
을 반환하는 것은 오류의 원인에 대해 크게 신경 쓰지 않을 때 잘 작동한다.
오류 반환하기
오류 세부 정보를 계산 결과와 함께 반환할 수 있다. 이런방식은 C나 고랭(Go) 같은 언어에서 흔히 사용하는 관습이다.
fun readTextFile(fileName: String): Pair<String, List<String>?> =
File(fileName).let {
if (it.exists()) {
{ "" to it.readLines() }
} else {
"File not found" to null
}
}
}
여기에서는 널이 될 수 있는 결과와 함께 오류 메시지를 반환한다. 암묵적인 약속은 결과가 null
인 경우 오류 메시지에 이유를 설명한다는 것이다.
암묵적인 약속은 결과가 null인 경우 오류 메시지에 이유를 설명한다는 것이다.
이 코드는 함수의 전체성을 보존하고 오류에 대한 자세한 정보도 제공한다. 이런 성질은 큰 장점이지만 두 가지 단점이 있다.
- 이런 식으로 모든 코드를 작성하는 것은 매우 장황하고 오류가 발생하기 쉽다. 매번
Pair
를 반환해야하며, 결과를 사용하기 전에 널 가능성을 확인해야 한다. - 가능한 모든 오류를 문자열(또는 선택한 다른 타입)로 표현해야 하는데, 이는 복잡한 타입으로 작업할 때, 상당히 제한적이다. 예를 들어 잘못된
Request
를 오류에 추가하고 싶다면 이를 문제열로 변환하거나 혹은 (오류 정보에 문자열 대신) 더 복잡한 다른 오류 타입을 사용해야 하는데 이런 복잡한 타입을 쓰는 것은 대부분의 경우 낭비다. 달리 말하면,String
으로 오류를 표현하는 것은 유연성이 너무 떨어진다.
예외를 예외적으로 유지하기
함수형 프로그래밍에서 오류를 처리하기 위해 예외를 사용하는 것이 왜 문제가 되는지 살펴보자.
예외가 함수의 깔끔한 연쇄를 끈었다는 사실과 문제가 생기면 명령형 코드로 돌아가야 한다.
함수의 전체성을 보존하는 것은 중요하다. 전체성이 성립할 때 이런 함수들을 안전하게 합성할 수 있기 때문이다.
함수형 프로그래밍에서 예외가 맡을 역할이 없다는 뜻은 아니다.
복구할 방법이 없는 상황이라면 예외를 던지고 프로그래밍을 종료하거나 HTTP
호출을 중단할 것이다.
함수형 오류 처리
함수형 프로그래밍 관점에서 볼 때, 이 문제에 대한 해결책은 오류를 인식하는 함수를 결합하는 대수이다. 다행히도 이러한 대수를 만들 수 있는 수학적 도구, 즉 앞에서 이야기한 마법의 화살표인 펑터(functor) 가 있다.
우리는 대수를 몇몇 데이터 타입에 대해 작동하는 함수의 컬렉션과 이런 함수들 사이의 관계를 지정하는 법칙의 집합을 의미한다고 정의했다.
펑터와 카테고리 배우기
카테고리는 최대한 추상적인 방식으로 관계를 정의하기 위해 도입한 수학적 개념이다.
카테고리는점과 화살표들로 이루어져 있다. 이때 화살표는 점과 점을 연결한다. 점과 화살표는 무엇이든 표현할 수 있는 추상적인 표현이다.
다이어그램에서 중요한 점은 화살표에 방향이 있다는 점, 화살표를 서로 합성할 수 있다는 점이다.
카테고리를 함성하려면 몇 가지 간단한 규칙을 검증해야 한다.
- 두 화살표를 항상 합성할 수 있다 (Sol에서 CP로 가는 화살표와 CP에서 ESB로 가는 화살표의 합은 SoL에서 ESB로 가는 화살표와 같다.)
- 화살표를 합성하는 순서는 중요하지 않다.
- 각 점마다 자기 자신을 가리키는 항등 화살표가 있다(ESB에서 ESB로 가는 화살표)
엄밀히 말해 화살표는 사상(morphism) 과 대상(object) 으로 구성된다.
카테고리의 개념은 매우 추상적이다. 결합할 수 있는 관계라면 무엇이든 카테고리롤 다룰 수 있다.
카테고리를 언어 연구부터 아원자 물리학에 이르기까지 다양하게 써먹을 수 있다.
펑터는 두 카테고리를 함께 매핑한다. 더 정확히 말하면 다음과 같다.
펑터는 카레고리 사이의 매핑이다. C와 D라는 두 개의 카테고리가 있을 때 펑터 F는 C의 대상을 D의 대상으로 매핑한다. 즉, 펑터는 대상에 대한 함수다. 만약 a가 C안의 대상이라면, D에 있는 a의 이미지를 F a로 표기한다. 하지만 카테고리는 단순히 대상만을 의미하지 않고 대상과 사상(대상을 연결함)을 함께 말한다. 펑터도 사상을 매핑하며, 사상에 대한 함수다. 하지만 펑터는 아무렇게나 매핑하지 않고 연결을 유지한다. 따라서 C 안의 사상f가 대상 a를 대상 b에 연결하면, D에서 f의 이미지는 a의 이미지(F a)와 b의 이미지(F b)를 연결한다.
코드에서 카테고리 정의하기
fun <T> identity(x: T): T = x
infix fun <A, B, C> ((A) -> B).then(f: (B) -> C): (A) -> C = { a: A -> f(this(a)) }
첫 번째 규칙 (두 화살표를 항상 합성할 수 있음) 두 화살표를 따라 순서대로 값을 변환한 결과가 두 화살표를 합성해 만들어진 새로운 화살표로 값을 변환한 결과가 항상 같은지 검증한다.
val l = anyString.length()
val h = half(l) // 2로 나눔
val halfLength = ::length then ::half
halfLength(anyString) shouldBe h
두 번째 규칙 (화살표를 합성하는 순서는 중요하지 않음) 세 가지 함수를 두 가지 다른 순서로 합성해도 여전히 결과가 같은지 본다.
val healfLengthStr1 = (::length then ::half) then ::toString
val healfLengthStr2 = ::length then (::half then ::toString)
healfLengthStr1(anyString) shouldBe healfLengthStr2(anyString)
세 번째 규칙 (각 점마다 자기 자신을 가리키는 항등 화살표가 있다)
임의의 문자열에 indentity
를 적용한 결과는 원래의 문자열임을 증명한다.
val anyString = randomString()
identity(anyString) shouldBe anyString
코드에서 펑터 정의하기
어떤 타입의 집합을 다른 타입들로 변환하면서 관계를 보존하는 방법을 찾아야 한다. 정의할 펑터는 다음 작업을 수행해야 한다.
Int
,String
이나 카테고리에 포함된 모든 타입을 기반으로 새 타입을 만든다.- 새 타입에 대해서도 여전히 함수를 계속 사용하도록 해준다.
코틀린에서는 타입으로부터 다른 타입으로 만들어낼 수 있는 방법이 있다. 이를 제네릭 프로그래밍이라고 하며, 줄여서 제네릭스라고도 부른다.
펑터는 제네릭스와 같지 않다. 하지만 제네릭스는 타입 빌더고 타입 빌더를 사용하면 코드에서 펑터를 구현할 수 있다. 다른 이유로 제네릭스를 사용할 수 도 있다. (Comparable 인터페이스) 또는 제네릭스를 사용하지 않고 몇 가지 타입에서만 작동하는 펑터를 만들 수도 있다. 하지만 이런 식으로 펑터를 만들면 그리 유용하지 않다. 펑터를 뒷받침 하는 근본적 아이디어는 변환이다.
펑터를 이용해 오류 처리하기
유니언 타입으로 오류 처리하기
sealed class Result<out T>
data class Success<out T>(val value: T) : Result<T>()
data class Failure(val error: Throwable) : Result<Nothing>()
Result
는 널이 될 수 있는 타입을 포함하는 모든 타입의 값을 감쌀 수 있다.- 실제 값은
Success
인 경우만 존재한다. - 실패인 경우에는 결과 타입에 신경쓰지 않을 것이므로, 실패 시 타입을 지정하지 않도록
Nothing
을 사용한다.
더 정확한 오류
Result
타입에는 성공적인 결과를 표현하는 하나의 제네릭 타입 파라미터만 있었다.
하지만 이 타입은 발생할 수 있는 오류에 대해 거의 정보를 제공하지 않는다.
interface ResultError {
val message: String
}
sealed class Result<out T, out E : ResultError>
data class Success<out T> internal constructor(val value: T) : Result<T, Nothing>()
data class Failure <out E : ResultError> internal constructor(val error: E) : Result<Nothing, E>()
fun <E : ResultError> T.asFailure(): Result<Nothing, E> = Failure(this)
fun <T> T.asSuccess(): Result<T, Nothing> = Success(this)
- 오류 타입으로 일반적인 인터페이스를 사용한다. 각 결과는 이 인터페이스를 구현하는 더 구체적인 타입을 정의해 사용할 수 있다.
- 이제 값 타입과 오류 타입이라는 두 가지 제네릭 파라미터를 받는다. 이 두 타입은 클래스에서 메서드의 반환값으로 사용되기 때문에 out 위치다.
transform
의 로직은 동일하게 유지되지만 더 자세한 오류를 허용한다.- 이제 실패 시에도 오류를 나타내는 파라미터가 필요하다.