FP - 예외 다루기(with Option, Either)

Keunjae Song·2020년 3월 15일
1

fp

목록 보기
6/6

스칼라로 배우는 함수형 프로그래밍을 읽고 정리한 글입니다.

서론

분명 FP를 배우면서 부수 효과(side effect)가 없는 순수 함수(pure function)을 작성하는 것을 지향한다고 알게 되었다.
그런데, 이 부수 효과 중에는 예외를 던지는 것도 포함이 되는데, 그렇다면 FP로 프로그램 코드를 작성할 때는 예외를 던질 수 없는 것일까?
만약, 예외를 던지지 않는다면, 어떻게 예외 상황을 다루어야 할까?

예외를 던지는 함수

어떤 자료구조의 평균을 구하는 함수 mean이 있다고 가정하자.

def mean(xs: Seq[Double]): Double =
  if (xs.isEmpty)
    throw new ArithmeticException("mean of empty list")
  else
    xs.sum / xs.length

mean 함수는 비어있는 Seq에 대해서는 평균을 내놓지 않고 예외를 던진다.
이렇게, mean 함수와 같이 일부 입력에 대해서는 출력이 정의되지 않는 함수를 부분 함수(partial function)이라고 한다.

예외 던지기 이외에 일반적인 예외 핸들링 방법

그렇다면, 이 mean 함수가 일부 입력에 대해서 예외를 던지는 것을 어떻게 바꿀 수 있을까?
FP적으로 말고, 일반적인 방법에서 예외를 던지지 않고 이를 해결하려 했다면 아래와 같이 해결하려 했을 것이다.
첫 번째, error code를 반환한다.(혹은 인자를 추가하여 해당 인자를 반환한다.)
(나는 C를 조금 다뤄봤던 사람으로서 이 방법이 되게 익숙했다.)

def mean(xs: Seq[Double], onEmpty: Double): Double =
  if (xs.isEmpty) onEmpty
  else xs.sum / xs.length

onEmpty라는 인자를 새로 받아서, xs가 비어있을 경우 onEmpty를 돌려준다.
그러나, 이 방법은 onEmpty라는 인자가 추가되었을 뿐만 아니라 이 함수를 호출하는 쪽에서 매번 mean이 잘 수행되었는지 확인하기 위해 출력 결과가 onEmpty가 아닌지 확인하는 if문이 추가되어야 하는 번거러움이 생긴다.

두 번째, null을 반환한다.
null은 오직 기본 타입이 아닌 경우에만 return 값으로서 용이한데, 지금과 같은 mean 함수는 Double을 return하므로 맞지 않다.

그럼 어떤 좋은 방법이 있을까?

첫번째, Option 사용하기

Option을 이용해 함수가 항상 모든 입력에 대해서 정의할 수 없다는 것을 반환 타입에 명시한다.
Option에는 두 가지 경우의 수가 있는데, 결과를 정의할 수 있는 경우에는 Some, 결과를 정의할 수 없는 경우에는 None을 반환하도록 한다.

def mean(xs: Seq[Double]): Option[Double] =
  if (xs.isEmpty) None
  else Some(xs.sum / xs.length)

이제 mean 함수를 보면 항상 해당하는 반환 형식(Option[Double])을 결과로서 돌려준다는 것은 자명하므로, 완전 함수라고 볼 수 있다.

이 Option의 장점은 고차 함수(Higher-order function)을 사용할 때 빛을 발한다.

아래와 같은 예제를 살펴보자.

case class Employee(name: String, department: String)
def lookupByName(name: String): Option[Employee] = ...
val joeDepartmant: Option[String] = 
  lookupByName("joe").map(_.departmant)

lookupByName("joe")를 하면 Option[Employee]가 반환된다.
또 이것을 map으로 변환하면 Joe의 departmant가 Option[String]으로 나오게 된다.

여기서 주목해야 할 점은 lookupByName("joe")의 결과를 체크하지 않았다는 점이다.

만약, lookupByName("joe")가 에러 상황에 대해 예외를 던지거나, error code를 반환하도록 구현되어 있었다면, 무조건 이에 대해 핸들링하는 코드(ex: if문)가 있어야 할 것이다.

그러나 Option을 반환 타입으로 선택한 순간부터 이에 대한 처리가 쉬워졌다.
만약 lookupByName("joe")가 None을 반환했다면 map_.department 부분을 알아서 호출되지 않는다.

당연히 map 뿐만 아니라 filter 등 다양한 고차 함수와도 사용할 수 있다.
똑같이 None인 경우, 괄호 안에 있는 연산(위 같은 경우 _.department)을 수행하지 않는다.

Option의 장점

오류를 Option이란 하나의 값(정확히는 None)으로서 반환한다면, 고차 함수를 사용하며 계산할 때 중간 중간에 None인지 아닌지 검사할 필요가 없어진다.
코드를 볼 때도, 그냥 일련의 과정처럼 고차 함수들을 하나 하나 읽어나가면 된다.
그리고 여러 고차 함수(map이나 filter) 등을 거치고 나서 실제로 데이터를 사용하려 할 때 getOrElse 같은 함수로 Some인 경우 실제 데이터를 취득하고 None인 경우는 처리해주면 된다.

또한, None일 수 있는 경우에 처리를 지연하거나 수행하지 않으면 컴파일러가 오류를 낸다.(컴파일 타임에 발생 가능한 에러를 체크할 수 있다.)
고차 함수는 None인 경우에 처리를 수행하지 않는다.
그렇다면 만약 예로, Option[String] 데이터를 String으로서 가져올 때 getOrElse를 수행하지 않는다면 어떻게 될까?

이는 REPL을 통해서도 확인해 볼 수 있다.

scala> val str: Option[String] = None
str: Option[String] = None

scala> val str2: String = str
error: type mismatch;
found: Option[String]
required: String

이와 같이 None일 수 있는 값을 그냥 사용하려 한다면, 오류를 낸다.
따라서, 마지막에 Option 타입에서 실제로 데이터를 가져오려는 경우에는 getOrElse와 같은 함수로 처리해주어야 한다.

두 번째, Either 사용하기

scala에는 Either라는 타입도 제공하는데, 영어 단어 뜻 그대로 뒤에 따라오는 두 개의 타입 중 하나를 택해 사용하는 것이다.
Option이 많이 쓰이고 좋은 예외 핸들링 방법이지만, 무엇이 잘못되었는지, 예외 메세지는 무엇인지 알 수 없는 게 단점이다.

Either를 사용하므로써, 예외 메세지 등을 String으로 돌려줄 수 있다.

백문이 불여일견이라고, 예제를 살펴보자.

def mean(xs: Seq[Double]): Either[String, Double] = 
  if (xs.isEmpty)
    Left("mean of empty list!")
  else
    Right(xs.sum / xs.length)

mean 함수의 반환 타입은 Either[String, Double]이다.
이는 반환값이 String, Double 둘 중 하나라는 것이며 Left와 Right 생성자로서 해당 타입을 생성할 수 있다.
scala 관례로서는, Right는 정상을 나타낼 때, Left는 실패를 나타내는 것으로 사용한다.
따라서, 현재 mean 함수는 실패한 경우에 Left 생성자를 이용해 예외를 나타내고(String으로 에러 메세지를 돌려줌), 정상인 경우 Right 생성자를 이용해 평균(Double)을 반환한다.

이것도 좋은 예제지만, 가끔은 단순히 String이 아니라 오류에 대한 추가 정보가 필요한 경우가 있다.
이럴 때는 그냥 Either의 Left에 Exception을 담도록 하면 된다.

def safeDiv(x: Int, y: Int): Either[Exception, Int] = 
  try Right(x/ y)
  catch { case e: Exception => Left(e) }

결론

결국, Either나 Option을 통해 예외를 하나의 값으로서 처리한다는 것이 핵심이다.

java나 c++에서와 같이 오류 상황일 때 예외를 throw하는 게 아닌, 예외 자체를 하나의 값으로 여겨 함수를 부분 함수가 아닌, 모든 입력에 대해 정의된 출력을 내놓는 완전 함수로 구현하는 것이 핵심이다.

그리고 FP를 배우게 되면 제일 먼저 배우게 되는 개념인 "부수 효과(side effect)가 없는 순수 함수로 프로그램 코드를 작성한다"에도 적용되는 얘기이다.

이 글에서 리팩토링한 mean 함수는 더 이상 예외를 던지지 않는, 부수 효과가 없는 순수 함수이다.

0개의 댓글