함수형 오류 처리

함수형 오류 처리

함수형 프로그래밍은 일련의 대수 방정식을 푸는 것 같아요. 그런데 대수에는 null 값도, 예외를 던지는 것도 없죠. 그럼 OOP 코드에서 null이나 예외를 쓰던 자리엔 무엇을 써야 할까요? 이번 장에서 Scala의 해법인 Option/Some/None 클래스들을 함께 알아볼게요.

출처: Scala 3 Book

본문

함수형 프로그래밍은 여러 개의 대수 방정식을 쓰는 것과 같아요. 그런데 대수(algebra)에는 null 값도, 예외를 던지는 행위도 없으니, FP에서는 이 기능들을 쓰지 않아요. 그러면 자연스럽게 이런 의문이 생기죠. OOP 코드에서 평소에 null 값이나 예외를 쓸 상황이 온다면, 어떻게 해야 할까요?

Scala의 답은 Option/Some/None 같은 구성을 쓰는 거예요. 이번 강의에서는 이 기법들을 쓰는 방법을 소개할게요.

본격적으로 들어가기 전에 두 가지만 짚고 갈게요.

  • SomeNone 클래스는 Option의 서브클래스예요.
  • 매번 "Option/Some/None"이라고 반복하는 대신, 앞으로는 보통 "Option" 또는 "Option 클래스들"이라고 부를게요.

첫 번째 예시 (A first example)

첫 번째 예시는 null 값을 다루진 않지만, Option 클래스를 소개하기에 좋아서 여기서 시작할게요.

문자열을 정수 값으로 변환해 주는 메서드를 만들고 싶다고 상상해 보세요. 그런데 메서드에 "1" 대신 "Hello" 같은 문자열이 들어오면 예외가 던져지는데, 그 예외를 우아하게 처리하고 싶어요. 이런 메서드의 첫 번째 초안은 이렇게 생겼을 거예요.

def makeInt(s: String): Int =
  try {
    Integer.parseInt(s.trim)
  } catch {
    case e: Exception => 0
  }
def makeInt(s: String): Int =
  try
    Integer.parseInt(s.trim)
  catch
    case e: Exception => 0

변환이 성공하면 이 메서드는 올바른 Int 값을 반환하지만, 실패하면 0을 반환해요. 목적에 따라 괜찮을 수도 있지만, 정확하진 않아요. 이 메서드가 "0"을 받았을 수도 있고, 예외를 던질 수많은 문자열 중 하나인 "foo", "bar" 같은 걸 받았을 수도 있으니까요. 이런 게 진짜 문제예요. 메서드가 정말 "0"을 받았는지, 아니면 다른 걸 받았는지 어떻게 알 수 있을까요? 답은 이 방식으로는 알 방법이 없다는 거예요.

Option/Some/None 사용하기 (Using Option/Some/None)

Scala에서 이 문제의 일반적인 해법은 Option, Some, None이라는 세 개의 클래스 묶음을 쓰는 거예요. SomeNoneOption의 서브클래스라서, 해법은 이렇게 동작해요.

  • makeIntOption 타입을 반환한다고 선언해요
  • makeIntInt로 변환할 수 있는 문자열을 받으면, 그 답을 Some 안에 감싸요
  • makeInt가 변환할 수 없는 문자열을 받으면, None을 반환해요

수정된 makeInt 버전을 볼게요.

def makeInt(s: String): Option[Int] =
  try {
    Some(Integer.parseInt(s.trim))
  } catch {
    case e: Exception => None
  }
def makeInt(s: String): Option[Int] =
  try
    Some(Integer.parseInt(s.trim))
  catch
    case e: Exception => None

이 코드는 이렇게 읽을 수 있어요. "주어진 문자열이 정수로 변환되면 IntSome 안에 감싸서 반환해요(Some(1)처럼요). 문자열을 정수로 변환할 수 없으면 예외가 던져지고 잡히며, 메서드는 None 값을 반환해요."

makeInt가 어떻게 동작하는지 보여주는 예시들이에요.

val a = makeInt("1")     // Some(1)
val b = makeInt("one")   // None

보시다시피 문자열 "1"Some(1)을 만들고, 문자열 "one"None을 만들어요. 이것이 오류 처리를 다루는 Option 방식의 핵심이에요. 보시다시피 이 기법은 메서드가 예외 대신 을 반환하게 해줘요. 다른 상황에서는 Option 값이 null 값을 대체하는 데도 쓰여요.

두 가지 참고할 점이 있어요.

  • 이 방식은 Scala 라이브러리 클래스 전반과 서드파티 Scala 라이브러리에서 두루 쓰이는 걸 발견할 수 있어요.
  • 이 예시의 핵심은 함수형 메서드가 예외를 던지지 않고, 대신 Option 같은 값을 반환한다는 점이에요.

makeInt의 소비자가 되어 보기 (Being a consumer of makeInt)

이제 여러분이 makeInt 메서드의 소비자라고 상상해 보세요. makeIntOption[Int]의 서브클래스를 반환한다는 걸 알고 있으니, 문제는 이 반환 타입들을 어떻게 다루느냐가 되죠.

여러분의 필요에 따라 두 가지 흔한 답이 있어요.

  • match 표현식 사용하기
  • for 표현식 사용하기

match 표현식 사용하기 (Using a match expression)

가능한 해법 하나는 match 표현식을 쓰는 거예요.

makeInt(x) match {
  case Some(i) => println(i)
  case None => println("That didn’t work.")
}
makeInt(x) match
  case Some(i) => println(i)
  case None => println("That didn’t work.")

이 예시에서 xInt로 변환할 수 있으면 첫 번째 case 절의 오른쪽 표현식이 평가되고, 변환할 수 없으면 두 번째 case 절의 오른쪽 표현식이 평가돼요.

for 표현식 사용하기 (Using a for expression)

또 다른 흔한 해법은 for 표현식, 즉 이 책 앞부분에서 봤던 for/yield 조합을 쓰는 거예요. 예를 들어 세 문자열을 정수 값으로 변환한 뒤 더하고 싶다고 해볼게요. for 표현식과 makeInt로 이렇게 해요.

val y = for {
  a <- makeInt(stringA)
  b <- makeInt(stringB)
  c <- makeInt(stringC)
} yield {
  a + b + c
}
val y = for
  a <- makeInt(stringA)
  b <- makeInt(stringB)
  c <- makeInt(stringC)
yield
  a + b + c

그 표현식이 실행된 뒤에 y는 둘 중 하나가 돼요.

  • 세 문자열이 모두 Int로 변환되면, ySome[Int], 즉 Some에 감싸진 정수가 돼요
  • 세 문자열 중 하나라도 Int로 변환되지 않으면, yNone이 돼요

직접 테스트해 볼 수 있어요.

val stringA = "1"
val stringB = "2"
val stringC = "3"

val y = for {
  a <- makeInt(stringA)
  b <- makeInt(stringB)
  c <- makeInt(stringC)
} yield {
  a + b + c
}
val stringA = "1"
val stringB = "2"
val stringC = "3"

val y = for 
  a <- makeInt(stringA)
  b <- makeInt(stringB)
  c <- makeInt(stringC)
yield
  a + b + c

이 샘플 데이터로는 변수 ySome(6) 값을 갖게 돼요.

실패 케이스를 보려면, 그 문자열 중 하나를 정수로 변환되지 않는 값으로 바꿔 보세요. 그러면 yNone이 되는 걸 볼 수 있어요.

y: Option[Int] = None

Option을 컨테이너로 생각하기 (Thinking of Option as a container)

정신적 모델(mental model)은 새로운 상황을 이해하는 데 자주 도움이 돼요. Option 클래스에 익숙하지 않다면, 하나의 컨테이너로 생각해 보세요.

  • Some은 항목 하나가 들어 있는 컨테이너예요
  • None은 컨테이너이되, 안에 아무것도 없어요

Option 클래스를 상자(box)로 생각하는 게 편하다면, None은 빈 상자 같아요. 뭔가 들어 있을 수도 있었지만, 실제로는 없는 거죠.

null을 대체하는 데 Option 사용하기 (Using Option to replace null)

다시 null 값으로 돌아가 볼게요. null 값이 코드에 조용히 스며들 수 있는 대표적인 지점은 이런 클래스예요.

class Address(
  var street1: String,
  var street2: String,
  var city: String,
  var state: String,
  var zip: String
)

지구상의 모든 주소에 street1 값이 있지만, street2 값은 선택적(optional)이에요. 결과적으로 street2 필드에 null 값이 할당될 수 있죠.

val santa = new Address(
  "1 Main Street",
  null,               // <-- D’oh! A null value!
  "North Pole",
  "Alaska",
  "99705"
)
val santa = Address(
  "1 Main Street",
  null,               // <-- D’oh! A null value!
  "North Pole",
  "Alaska",
  "99705"
)

역사적으로 개발자들은 이런 상황에서 빈 문자열과 null 값을 사용했어요. 둘 다 근본 문제인 "street2선택적 필드다"를 우회하는 꼼수죠. Scala를 비롯한 현대 언어에서 올바른 해법은 street2가 선택적이라고 먼저 선언하는 거예요.

class Address(
  var street1: String,
  var street2: Option[String],   // an optional value
  var city: String, 
  var state: String, 
  var zip: String
)

이제 개발자들은 이렇게 더 정확한 코드를 작성할 수 있어요.

val santa = new Address(
  "1 Main Street",
  None,           // 'street2' has no value
  "North Pole",
  "Alaska",
  "99705"
)
val santa = Address(
  "1 Main Street",
  None,           // 'street2' has no value
  "North Pole",
  "Alaska",
  "99705"
)

또는 이렇게요.

val santa = new Address(
  "123 Main Street",
  Some("Apt. 2B"),
  "Talkeetna",
  "Alaska",
  "99676"
)
val santa = Address(
  "123 Main Street",
  Some("Apt. 2B"),
  "Talkeetna",
  "Alaska",
  "99676"
)

Option만이 해법은 아니에요 (Option isn’t the only solution)

이 절이 Option 클래스에 초점을 맞추고 있지만, Scala에는 몇 가지 다른 대안도 있어요.

예를 들어 Try/Success/Failure라는 세 개의 클래스 묶음이 같은 방식으로 동작해요. 다만 이 클래스들은 (a) 주로 코드가 예외를 던질 수 있을 때 쓰고, (b) 예외 메시지에 접근할 수 있게 해주는 Failure 클래스를 쓰고 싶을 때 사용해요. 예를 들어 Try 클래스들은 파일, 데이터베이스, 인터넷 서비스와 상호작용하는 메서드를 작성할 때 흔히 쓰여요. 그런 함수들은 쉽게 예외를 던질 수 있으니까요.

빠른 복습 (A quick review)

이 절이 길었으니 빠르게 복습해 볼게요.

  • 함수형 프로그래머는 null 값을 쓰지 않아요
  • null 값의 주요 대체재는 Option 클래스를 쓰는 거예요
  • 함수형 메서드는 예외를 던지지 않고, 대신 Option, Try, Either 같은 값을 반환해요
  • Option 값을 다루는 흔한 방법은 matchfor 표현식이에요
  • Option은 항목 하나(Some)와 항목 없음(None)의 컨테이너로 생각할 수 있어요
  • Option은 선택적인 생성자/메서드 매개변수에도 쓸 수 있어요

더 알아보기