함수형 프로그래밍으로 에러 핸들링하기
::: note "스칼라로 배우는 함수형 프로그래밍 - Chapter04 예외를 이용하지 않은 오류 처리"를 읽고 정리했습니다. :::
제1장에서 예외(exception)를 던지는 것이 하나의 부수 효과임을 간단히 언급했다. 이번 장에서는 오류를 함수적으로 제기하고 처리하는 데 필요한 기본 원리들을 배울 것이다. 여기서 핵심은, 실패 상황과 예외를 보통의 값으로 표현할 수 있으며, 일반적인 오류 처리&복구 패턴을 추상화한 고차 함수를 작성할 수 있다는 것이다. 오류를 값으로 돌려준다는 함수적 해법은 더 안전하고 참조 투명성을 유지한다는 장점이 있다. 게다가 고차 함수 덕분에 예외의 주된 이점인 오류 처리 논리의 통합(consolidation of error-handling logic)도 유지된다.
4.1 예외의 장단점¶
예외가 왜 참조 투명성을 해칠까?
def failingFn(i: Int): Int = {
val y: Int = throw new Exception("fail!")
try {
val x = 42 + 5
x + y
}
catch { case e: Exception => 43 }
}
failingFn을 호출하면 예상대로 오류가 발생한다.
> failingFn(12)
java.lang.Exception: fail!
at .failingFn(<console>:8)
...
y가 참조에 투명하지 않음을 증명할 수 있다. 만일 x + y의 y를 throw new Exception("fail!")로 치환하면 그전과는 다른 결과가 나온다. 이제는 예외를 잡아서 43을 돌려주는 try 블록 안에서 예외가 발생하기 때문이다.
def failingFn2(i: Int): Int = {
try {
val x = 42 + 5
x + ((throw new Exception("fail!")): Int)
}
catch { case e: Exception => 43 }
}
> failingFn(12)
res1: Int = 43
참조 투명성에서 참조에 투명한 표현식의 의미는 문맥(context)에 의존하지 않는다라는 것을 의미하고, 지역적으로 추론할 수 있지만 참조에 투명하지 않은 표현식의 의미는 문맥에 의존적이고(context-dependent) 좀 더 전역의 추론이 필요하다는 것으로 이해해도 될 것이다.
예를 들어 참조 투명 표현식 42 + 5의 의미는 그 표현식을 포함한 더 큰 표현식에 의존하지 않는다. 그 표현식은 항상, 그리고 영원히 47과 같다. 그러나 throw new Exception("fail")
이라는 표현식의 의미는 문맥에 크게 의존한다. 방금 보았듯이, 이 표현식은 try 블록에 포함되어 있는지, 있다면 어떤 try 블록인지에 따라 달라진다.
예외의 주된 문제 두 가지는 다음과 같다.
-
방금 논의했듯이, 예외는 참조 투명성을 위반하고 문맥 의존성을 도입한다. 따라서 치환 모형의 간단한 추론이 불가능해지고 예외에 기초한 혼란스러운 코드가 만들어진다.
-
예외는 형식에 안전하지 않다. failingFn의 형식인 Int => Int만 보고는 이 함수가 예외를 던질 수 있다는 사실을 전혀 알 수 없으며, 그래서 컴파일러는 failingFn의 호출자에게 그 예외들을 처리하는 방식을 결정하라고 강제할 수 없다.
이런 단점들이 없으면서도 예외의 기본 장점인 오류 처리의 통합과 중앙집중화를 유지하는(오류 처리 논리를 코드 기반의 여기저기에 널어 놓지 않아도 되도록) 대안이 있으면 좋을 것이다. 지금부터 소개하는 대안 기법은 "예외를 던지는 대신, 예외적인 조건이 발생했음을 뜻하는 값을 돌려준다"라는 오래된 착안에 기초한다. 이 기법에서는 오류 부호를 직접 돌려주는 대신 그런 '미리 정의해 둘 수 있는 값들'을 대표하는 새로운 일반적 형식을 도입하고, 오류의 처리와 전파에 관한 공통적인 패턴들을 고차 함수들을 이용해서 캡슐화한다. 이 전략은 형식에 완전히 안전하며, 실수를 미리 발견할 수 있다.
4.2 예외의 가능한 대안들¶
다음은 목록의 평균(mean)을 계산하는 함수이다. 빈 목록에 대해서는 평균의 정의되지 않는다.
def mean(xs: Seq[Double]): Double =
if (xs.isEmpty)
throw new ArithmeticException("mean of empty list!")
else xs.sum / xs.length
mean 함수는 소위 부분 함수(partical function)의 예이다. 부분 함수란 일부 입력에 대해서는 정의되지 않는 함수를 말한다.
자신이 받아들이는 입력에 대해 입력 형식만으로는 결정되지 않는 어떤 가정을 두는 함수는 대부분 부분 함수이다. 받아들일 수 없는 입력에 대해 예외를 던질 수도 있지만, 꼭 그래야 하는 것은 아니다. 대안 몇 가지를 살펴보자.
첫 번째 대안은 Double 형식의 가짜 값을 돌려주는 것이다. 모든 경우에 그냥 xs.sum / xs.length
를 돌려준다면 빈 목록에 대해서는 0.0 / 0.0
을 돌려주게 되는데, 이는 Double.Nan이다. 아니면 다른 어떤 경계 값(sentinel value)이나 null을 돌려줄 수도 있다. 이런 부류의 접근방식은 예외 기능이 없는 언어에서 오류를 처리하는데 흔히 쓰인다. 그러나 이 책에서는 이런 접근방식을 거부한다. 이유는 여러 가지이다.
- 오류가 소리없이 전파될 수 있다. 호출자가 이런 오류 점검을 실수로 빼먹어도 컴파일러가 경고해주지 않는다.
- 실수의 여지가 많다는 점 외에, 호출하는 쪽에 호출자가 '진짜' 결과를 받았는지 점검하는 명식적 if 문들이 늘어난다.
- 다형적 코드에는 적용할 수 없다. 출력 형식에 따라서는 그 형식의 경계 값을 결정하는 것이 불가능할 수도 있다.
- 호출자에게 특별한 방침이나 호출 규약을 요구한다. mean 함수를 제대로 사용하려면 호출자가 그냥 mean을 호출해서 그 결과를 사용하는 것 이상의 작업을 수행해야한다. 이렇게 되면 모든 인수를 균일한 방식으로 처리해야 하는 고차 함수에 전달하기 어려워진다.
또 다른 대안은 함수가 입력을 처리할 수 없는 상황에 처했을 때 무엇을 해야 하는지 말해주는 인수를 호출자가 지정하는 것이다.
def mean_1(xs: IndexedSeq[Double], onEmpty: Double): Double =
if (xs.isEmpty) onEmpty
else xs.sum / xs.length
이렇게 하면 mean은 부분함수가 아닌 완전 함수(total function)가 된다. 그러나 여기에는 결과가 정의되지 않는 경우의 처리 방식을 함수의 직접적인 호출자가 알고 있어야 하고 그런 경우에도 항상 하나의 Double 값을 결과로 돌려주어야 한다는 단점이 있다.
4.3 Option 자료 형식¶
해법은, 함수가 항상 답을 내지는 못한다는 점을 반환 형식을 통해서 명시적으로 표현하는 것이다. 이를, 오류 처리 전략을 호출자에게 미루는 것으로 생각해도 된다. 이를 위해 Option이라는 새로운 형식을 도입한다. 이 형식은 스칼라 표준 라이브러리에도 존재하나, 학습을 위해 직접 만들어 본다.
sealed trait Option[+A]
case class Some[+A](get: A) extends Option[A]
case object None extends Option[Nothing]
Option에는 두 개의 경우 문이 있다. Option을 정의할 수 있는 경우에는 Some이 되고, 정의할 수 없는 경우에는 None이 된다.
이제 Option을 이용해서 mean을 구현하면 다음과 같은 코드가 된다.
def mean(xs: Seq[Double]): Option[Double] =
if (xs.isEmpty) None
else Some(xs.sum / xs.length)
이제는 이 함수의 결과가 항상 정의되지는 않는다는 사실이 함수의 반환 형식에 반영되어 있다. 함수가 항상 선언된 반환 형식(Optionp[Double])의 결과를 돌려주어야 한다는 점은 여전하므로, mean은 이제 하나의 완전 함수이다. 이 함수는 입력 형식의 모든 값에 대해 정확히 하나의 출력 형식 값을 돌려준다.
4.3.1 Option의 사용 패턴¶
부분 함수는 프로그래밍에서 흔히 볼 수 있으며, FP에서는 그런 부분성을 흔히 Option 같은 자료 형식으로 처리한다. Option이 편리한 이유는, 오류 처리의 공통 패턴을 고차 함수들을 이용해서 추출함으로써 예외 처리 코드에 흔히 수반되는 판에 박힌 코드를 작성하지 않아도 된다는 점이다.
Option에 대한 기본적인 함수들¶
Option은 최대 하나의 원소를 담을 수 있다는 점을 제외하면 List와 비슷하다. 실제로 Option에는 이전에 본 여러 List 함수에 대응되는 함수들이 있다. 이번 절에서는 함수들을 가능하면 Option 특질(trait)의 본문에 집어넣는다. 그러면 fn(obj, arg1)
대신 obj.fn(arg1)
이나 obj fn arg1
로 호출할 수 있다.
trait Option[+A] {
def map[B](f: A => B): Option[B]
def faltMap[B](f: A => Option[B]): Option[B]
def getOrElse[B >: A](default: => B): B
def orElse[B >: A](ob: => Option[B]): Option[B]
def filter(f: A => Boolean): Option[A]
}
def map[B](f: A => B): Option[B] = this match
case None => None
case Some(a) => Some(f(A))
map 함수는 Option 안의 결과(가 있다면)를 반환하는데 사용할 수 있다. 이를 요류가 발생하지 않았다는 가정하에 계산하는 것으로 생각해도될 것이다. 또한, 이는 오류 처리를 나중의 코드에 미루는 수단이기도 하다.
case class Employee(name: String, department: String)
def lookupByName(name: String): Option[Employee] = ...
val joeDepartment: Option[String] =
lookupByName("Joe").map(_.department)
이 예에서 lookUpByName("Joe")
는 Option[Employee]
를 돌려준다. 그것을 map으로 변환하면 Joe가 속한 부서의 이름을 뜻하는 Option[String]
이 나온다. 여기서 loopupByName("Joe")
의 결과를 명시적으로 점검하지 않음을 주목하기 바란다. 그냥 오류가 전혀 발생하지 않았다는 듯이 map의 인수 안에서 계산을 계속 진행한다. 만일 loopupByName("Joe")
가 None을 돌려주었다면 계산의 나머지 부분이 취소되어서 map은 _.department
함수를 전혀 호출하지 않는다.
def faltMap[B](f: A => Option[B]): Option[B] =
map(f).getOrElse(None)
flatMap을 이용하면 여러 단계로 이루어진 계산을 수행하되 어떤 단계라도 실패하면 그 즉시 나머지 모든 과정이 취소되는 방식으로 수행할 수 있다. 이는 None.flatMap(f)가 f를 실행하지 않고 즉시 None을 돌려주기 때문이다.
def getOrElse[B >: A](default: => B): B = this match
case None => default
case Some(a) => a
None일 시 default를 반환한다.
def orElse[B >: A](ob: => Option[B]): Option[B] =
map(Some(_)).getOrElse(ob)
orElse는 getOrElse와 비슷하되 첫 Option이 정의되지 않으면 다른 Option을 돌려준다는 점이 다르다. 이는 실패할 수 있는 계산이 성공하지 않으면 둘째 것을 시도하고자 할 때 유용하다.
def filter(f: A => Boolean): Option[A]
case Some(a) if f(a) => this
case _ => None
filter는 성공적인 값이 주어진 술어와 부합하지 않을 때 성공을 실패로 변환하는데 사용할 수 있다.
흔한 사용 패턴은 map, faltMap, filter의 임의의 조합을 이용해서 Option을 변환하고 제일 끝에서 getOrElse를 이용해서 오류 처리를 수행하는 것이다.
val dept: String =
loolupByName("Joe").
map(_.dept).
filter(_ != "Accounting").
getOrElse("Default Dept")
흔한 관용구로, o.getOrElse(throw new Exception("FAIL"))
은 Option의 None 경우를 예외로 처리되게 만든다. 합리적인 프로그램이라면 결코 예외를 잡을 수 없는 상황에서만 예외를 사용하면 된다. 어떤 호출자가 복구 가능한 오류로 처리할 수 있을 만한 상황이라면 예외 대신 Option을 돌려주어 호출자에게 유연성을 부여한다.
이상에서 보듯이 오류를 보통의 값으로서 돌려주면 코드를 짜기가 편해지며, 고차 함수를 사용함으로써 예외의 주된 장점인 오류 처리의 통합과 격리도 유지할 수 있다. 계산의 매 단계마다 None을 점검할 필요가 없음을 주목하기 바란다. 그냥 일련의 변환을 수행하고 나중에 원하는 장소에서 None을 점검하고 처리하면 된다. 또한 추가적인 안전성도 얻게 된다. Option[A]
는 A와는 다른 형식이므로, None일 수 있는 상황의 처리를 명시적으로 지연 또는 수행하지 않으면 컴파일러가 오류를 낸다.
4.3.2 예외 지향적 API의 Option 합성과 승급, 감싸기¶
일단 Option을 사용하기 시작하면 코드 기반 전체에 Option이 번지게 되리라는 성급한 결론을 내리는 독자도 있을 것이다. 즉 Option을 받거나 돌려주는 메서드를 호출하는 모든 코드를 Some이나 None을 처리하도록 수정해야 한다고 추측할 수 있다. 그러나 실제로는 그런 부담을 질 필요가 없다. 보통의 함수를 Option에 대해 작용하는 함수로 승급시킬(lift) 수 있기 때문이다.
예를 들어 map 함수가 있으면 Option[A]
형식의 값들을 A => B
형식의 함수를 이용해서 변환한 후 하나의 Option[B]
를 결과로 돌려주게 하는 것이 가능하다. 이를 map이 A => B
형식의 함수 f를 Option[A] => Option[B]
형식의 함수로 변환한다고 이해해도 좋을 것이다. 구체적인 방법은 다음과 같다.
def lift[A, B](f: A => B): Option[A] => Option[B] = _ map f
이러한 lift가 있으면 지금까지 나온 그 어떤 함수라도 한 Option 값의 문맥 안에서 작용하도록 변환할 수 있다. 예를 하나 보다.
자동차 보험 회사의 웹 사이트에서 사용자가 즉석 온라인 견적을 요구하는 양식을 제출하는 페이지를 위한 논리를 구현한다고 하자. 양식에 담긴 정보를 분석한 후, 결과적으로는 다음과 같은 보험료율 함수를 호출하게 될 것이다.
def insuranceRateQuote(age: Int, numberOfSpeedingTickets: Int): Double
이 함수를 호출하려면 고객의 나이와 고객이 받은 속도위반 딱지의 수를 알아야 한다. 그런데 고객이 제출한 웹 페이지의 양식에서 문자열 파싱이 실패할 수 있다. 주어진 문자열 s를 s.toInt
를 이용해서 Int로 파싱해 보았을 때 만일 문자열이 유효한 정수를 나타내지 않는다면 s.toInt
는 NumberFormatException이라는 예외를 던진다.
> "112".toInt
res0: Int = 112
> "hello".toInt
java.lang.NumberFormatException: For input string: "hello"
...
그럼 toInt의 예외 기반 API를 Option으로 변환하고 parseInsuranceRateQuote
함수를 구현해 보자. 이 함수는 나이와 속도위반 딱지 수를 받고, 두 값의 정수 파싱이 성공하면 insuranceRateQuote
를 호출한다.
def parseInsuranceRateQuote(
age: String,
numberOfSpeedingTickets: String): Option[Double] = {
val optAge: Option[Int] = Try(age.toInt)
val optTickets: Option[Int] = Try(numberOfSpeedingTickets.toInt)
insuranceRateQuote(optAge, optTickets)
}
// A 인수를 엄격하지 않는 방식으로 받아들인다. a를 평가하는 도중에 예외가 발생하면 그것을 None으로 변환할 수 있게 하기 위해서이다.
def Try[A](a: => A): Option[A] =
try Some(a)
catch { case e: Exception => None }
Try 함수는 예외 기반 API를 Option 지향적 API로 변환하는데 사용할 수 있는 범용 함수이다. 이 함수는 엄격하지 않은 또 다른 '게으른(lazy)' 인수를 사용한다. a의 형식 주해 => A가 바로 그 점을 나타낸다.
그런데 문제점이 하나 있다. optAge와 optTickets을 파싱해서 Option[Int]
를 얻은 후 insuranceRateQuote
함수를 호출해야 하는데, 그 함수는 두 개의 Int 값을 받는다는 점이다. Option[Int]
값들을 받도록 insuranceRateQuote
를 다시 작성해야할까? 그렇지 않다. insuranceRateQuote
를 변경하면 관심사들이 얽혀서 한 계산이 항상 이전 계산의 성공 여부를 신경 써야 하는 사태가 벌어진다.
insuranceRateQuote
를 수정하는 대신, 그것을 두 생략적(optional) 값들의 문맥에서 작동하도록 승급시키는 것이 바람직하다.
// 두 Option 값을 이항 함수(binary function)를 이용해서 결합하는 일반적 함수 map2를 작성하라.
// 두 Option 값 중 하나라도 None이면 map2의 결과 역시 None이어야 한다. 서명은 다음과 같다.
def map2[A, B, C](a: Option[A], b: Option[B])(f: (A, B) => C): Option[C] =
a.flatMap(aa => b.map(bb => f(aa, bb)))
다음은 이러한 map2로 parseInsuranceRateQuote
를 구현한 예이다.
def parseInsuranceRateQuote(
age: String,
numberOfSpeedingTickets: String): Option[Double] = {
val optAge: Option[Int] = Try { age.toInt } // 인수 하나를 받는 함수는 중괄호 대신 대괄호로 호출할 수 있다. Try(age.onInt)와 같다.
val optTicket: Option[Int] = Try { numberOfSpeedingTickets.toInt }
map2(optAge, optTickets)(insuranceRateQuote) // 둘 중 하나라도 파싱에 실패하면 즉시 None을 돌려준다.
}
map2 함수는 인수가 두 개인 그 어떤 함수라도 아무 수정없이 "Option에 대응하기" 만들 수 있음을 의미한다. 예전에 만들어 둔 함수라도 Option의 문맥에서 작동하도록 승급시킬 수 있다.
// Option들의 목록을 받고 그 목록에 있는 모든 Some 값으로 구성된 목록을 담은 Option을 돌려주는 함수 sequence를 작성하라. 원래의 목록에 None이 하나라도 있으면 함수의 결과도 None이어야 한다. 그렇지 않으면 원래의 목록에 있는 모든 값의 목록을 담은 Some을 돌려주어야 한다.
def sequence[A](a: List[Option[A]]): Option[List[A]] =
as match
case Nil => Some(Nil)
case h :: t => h.flatMap(hh => sequence(t).map(hh :: _))
실패할 수 있는 함수를 목록에 사상했을 때 만일 목록의 원소 중 하나라도 None을 돌려주면 전체 결과가 None이 되게 해야 할 때도 있다. 목록에 담긴 String 값들 전부가 Option[Int]로의 파싱에 성공해야 한다면 map의 결과들을 sequence로 순차 결합하면 된다.
def parseInts(a: List[String]): Option[List[Int]] =
sequence(a map (i => Try(i.toInt)))
그러나 안타깝게도 이 접근방식은 목록을 두 번 훑어야 하기 때문에 비효율적이다. 한 번은 String을 Option[Int]
로 변환하기 위해, 또 한 번은 그 Option[Int]
값들을 하나의 Option[List[Int]]
로 결합하기 위해서이다. 이러한 map의 결과들의 순차 결합은 충분히 흔한 작업이기 때문에 다음과 같은 일반적 함수 traverse를 만들어 둘 필요가 있다.
def traverse[A, B](a: List[A])(f: A => Option[B]): Option[List[B]] =
as match
case Nil => Some(Nil)
case h::t => map2(f(h), traverse(t)(f))(_ :: _)
map, lift, sequence, traverse, map2 같은 함수들이 있으면, 생략적 값을 다루기 위해 기존 함수를 수정해야 할 일이 전혀 없어야 하는 것이 정상이다.
4.4 Either 자료 형식¶
이번 장의 핵심은 실패와 예외를 보통의 값으로 표현할 수 있다는 점과 오류 처리 및 복구에 대한 공통의 패턴을 추상화하는 함수를 작성할 수 있다는 점이다. Option의 경우 예외적인 조건이 발생했을 때 무엇이 잘못되었는지에 대한 정보를 제공하지 못한다는 단점을 아마 눈치챘을 것이다. 실패 시 이 형식은 그냥 유효한 값이 없음을 뜻하는 None을 돌려줄 뿐이다. 예외가 발생한 경우 실제로 발생한 오류가 어떤 것인지 알 수 있는 무언가를 돌려주면 좋을 것이다. 이번 절에서는 Option을 간단하게 확장해서, 실패의 원인을 추적할 수 있는 Either 자료 형식을 만들어 본다.
sealed trait Either[+E, +A]
case class Left[+E](value: E) extends Either[E, Nothing]
case class Right[+A](value: A) extends Either[Nothing, A]
Option처럼 Either도 case가 두 개 뿐이다. Option과의 본질 적인 차이는, 두 경우 모두 값을 가진다는 것이다. 이 형식은 두 형식의 분리합집합이라 할 수 있다. 이 형식을 성공 또는 실패를 나타내는 데 사용할 때에는, Right 생성자를 성공을 나타내는데 사용하고 Left는 실패에 사용한다. 왼쪽 형식 매개변수의 이름으로는 error를 의미하는 E를 사용한다.
mean 예제를 다시 보자. 이번에는 실패의 경우에 String을 돌려준다.
def mean(xs: IndexedSeq[Double]): Either[String, Double] =
if (xs.isEmpty)
Left("mean of empty list!")
else
Right(xs.sum / xs.length)
오류에 대한 추가 정보, 이를테면 소스 코드에서 오류가 발생한 위치를 알 수 있는 스택 추적 정보가 있으면 편리한 경우가 종종 있다. 그런 경우 Either의 Left 쪽에서 그냥 예외를 돌려주면 된다.
def safeDiv(x: Int, y: Int): Either[Exception, Int] =
try Right(x / y)
catch { case e: Exception => Left(e) }
Option에서 했듯이, 던져진 예외를 값으로 변환한다는 이러한 공통의 패턴을 추출한 함수 Try를 작성해 보자.
def Try[A](a: => A): Either[Exception, A] =
try Right(a)
catch { case e: Exception => Left(e) }
trait Either[+E, +A] {
def map[B](f: A => B): Either[E, B] =
this match
case Right(a) => Right(f(a))
case Left(e) => Left(e)
def flatMap[EE >: E, B](f: A => Either[EE, B]): Either[EE, B] =
this match
case Left(e) => Left(e)
case Right(a) => f(a)
def orElse[EE >: E, AA >: A](b: => Either[EE, AA]): Either[EE, AA] =
this match
case Left(_) => b
case Right(a) => Right(a)
def map2[EE >: E, B, C](b: Either[EE, B])(f: (A, B) => C):
Either[EE, C] = for { a <- this; b1 <- b } yield f(a,b1)
}
이러한 정의들이 있으면 Either를 for-함축에 사용할 수 있다.
def parseInsuranceRateQuote(
age: String,
numberOfSpeedingTickets: String): Either[Exception, Double] =
for {
a <- Try { age.toInt }
tickets <- Try { numberOfSpeedingTickets.toInt }
} yield insuranceRateQuote(a, Tickets)
이제는 실패 시 그냥 None이 아니라 발생한 실제 예외에 대한 정보를 얻게 되었다.
def traverse[E,A,B](as: List[A])(f: A => Either[E, B]): Either[E, List[B]] =
as match
case Nil => Right(Nil)
case h :: t => f(h).map2(traverse(t)(f))(_ :: _)
def sequence[E,A](as: List[Either[E,A]]): Either[E,List[A]] =
traverse(as)(x => x)
함수 mkPerson은 주어진 이름과 나이의 유효성을 점검한 후 유효한 Person을 생성한다.
case class Person(name: Name, age:Age)
sealed class Name(val value: String)
sealed class Age(val value: Int)
def mkName(name: String): Either[String, Name] =
if (name == "" || name == null) Left("Name is empty.")
else Right(new Name(name))
def mkAge(age: Int): Either[String, Age] =
if (age < 0) Left("Age is out of range.")
else Right(new Age(age))
def mkPerson(name: String, age: Int): Either[String, Person] =
mkname(name).map2(mkAge(age))(Person(_, _))
4.5 요약¶
이번 장에서는 예외를 사용할 때의 문제점 몇 가지를 지적하고 순수 함수적 오류 처리의 기본 원리를 소개했다. 예외를 보통의 값으로 표현하고 고차 함수를 이용해서 오류처리 및 전파의 공통 패턴들을 캡슐화한다는 것이다. 이를 더욱 일반화하면 임의의 효과를 값으로 표현한다는 착안이 된다.
이번에 소개한 도구들이 있으면, 예외는 정말로 복구 불가능한 조건에서만 사용하면 된다.
작성자: 구민규
작성일: 2021-11-22