프로그램을 개발함에 있어 에러와 사이드 이펙트(부수 효과)를 처리하는 것은 필연적이다. 아무리 꼼꼼하게 코드를 작성해도 생각하지 못한 문제는 존재하며 특히나 지속해서 발전하는 프로그램은 기술 부채와 함께 끊임없이 새로운 문제가 발생한다.

문제가 발생하는 것은 개발자 개인에게도 프로그램을 판매하는 회사에게도 끔찍한 일이다. 아무리 단순하게 해결할 수 있는 문제여도 사용자에게 피해가 간다면 금전적 손실이 발생할 수 밖에 없다. 그렇기 때문에 프로그래밍은 안전하게 프로그램을 작성할 수 있는 형태로 끊임없이 발전해왔고 개발자들은 다양한 방법을 생각해냈다. 그 중 하나가 방법론인 Railway-Oriented Programming(이하 ROP)이다.

여행을 떠나보자

사이드 이펙트

먼저 사이드 이펙트에 대해서 자세히 알아보자. 사이드 이펙트란 함수 내부(혹은 프로시저)에서 발생한 일이 함수 외부에 영향을 미치는 것을 말한다. 구체적으로 다음과 같은 경우를 말한다.

  • 함수 내부에서 외부에 있는 변수를 조작하는 경우
  • 네트워크 통신 중 잘못된 데이터를 받아 프로그램에 영향을 미치는 경우
  • 함수 내부에서 에러가 발생하여 프로그램에 문제가 발생하는 경우

위와 같은 사례 외에도 다양하게 존재할 수 있다. 요즘엔 함수 내부에서 외부 값을 참조하거나 변경하면 좋지 않다는 것이 널리 알려져 있기 때문에 보통 사이드 이펙트는 I/O로 인한 문제로 접하는 경우가 많다. 그래서 이런 문제를 해결하기 위해 많은 개발자들이 예외 처리를 하는 것에 많은 공을 들인다. 하지만 또 많은 개발자들이 간과하는 문제는 함수 내부에서 에러가 발생하여 프로그램에 문제가 발생하는 경우라고 할 수 있다. 아주 간단한 코드를 작성하더라도 이런 문제는 발생할 수 있다. 오히려 간단하기 때문에 실수하는 경우가 많다. 예를 들어, 다음과 같은 코드를 살펴보자.

// Kotlin
fun getFirstElement(list: List<Int>): Int {
  return list[0]
}

위 코드는 리스트의 첫번째 값을 찾아 불러오는 아주 간단한 함수다. 얼핏보면 큰 문제가 없어보이나 리스트가 비어있을 때 문제가 발생한다. 물론 이런 간단한 문제는 쉽게 해결할 수 있지만 막상 작성할 때 실수하는 경우가 많다.

사이드 이펙트는 프로그램의 흐름을 예측하기 어렵게 만들며, 특히나 다른 개발자가 작성한 코드를 수정할 때 사이드 이펙트를 고려하지 않으면 예상치 못한 문제가 발생할 수 있다. 이러한 문제를 해결하기 위해서 다양한 방법이 존재한다.

다양한 해결 방법

단순히 분기를 이용하는 것 외에도 사이드 이펙트를 해결 할 수 있는 방법은 다양하다. ROP에 대해 알아보기 전에 다른 방법들을 먼저 살펴보자. 사이드 이펙트를 해결하는 방법은 크게 두 가지로 나뉜다.

  • LBYL (Look Before You Leap)
  • EAFP (Easier to Ask for Forgiveness than Permission)

LBYL은 뛰기 전에 보라라는 뜻이고 EAFP는 허락보다는 용서를 구하는 것이 쉽다라는 뜻이다. 아마 Python을 공부해봤다면 이 두 방법에 대해 들어본 적이 있을 것이다. LBYL은 로직 내에 명시적으로 조건을 검사하는 것을 말한다. 예를 들어, 다음과 같은 코드를 살펴보자.

// Kotlin
fun getFirstElement(list: List<Int>): Int? {
  if (list.isEmpty()) {
    return null
  }
  return list[0]
}

위 코드는 비어있는 리스트가 매개 변수로 들어올 것을 예상하여 분기를 통해 미리 예외 처리를 한다. 반면 EAFP는 예외 처리를 통해 사이드 이펙트를 해결하는 방법이다. 예를 들어, 다음과 같은 코드를 살펴보자.

// Kotlin
fun getFirstElement(list: List<Int>): Int? {
  return try {
    list[0]
  } catch (e: Exception) {
    null
  }
}

위 코드는 LBYL과 달리 올바른 로직을 작성한 후 예외가 발생하면 잡는 방식으로 사이드 이펙트를 해결한다. 말 그대로 먼저 처리한 후 예외에 대한 용서를 구하는 방식이다.

Python에서는 LBYL보다는 EAFP를 선호하지만 필자는 이 두 스타일에 우열은 없다고 생각한다. 상황에 따라 적절한 것이 있을 뿐이다. 이 방법들에 대한 사용 사례를 조금 더 자세히 살펴보자.

LBYL

순수 함수

외부와 상호작용 해야하는 I/O를 다루는 것이 아니라면 순수 함수로 작성하여 사이드 이펙트 문제를 해결 할 수 있다. 순수 함수는 동일한 인자를 받았을 때 항상 같은 값을 반환하는 함수를 말한다. 이말은 즉, 결과를 예측할 수 있다는 말과 동일하다. 다음과 같은 함수는 순수 함수라 할 수 있다.

fun sum(a: Int, b: Int): Int {
  return a + b
}

참고로 에러가 발생할 수 있는 예외는 처리해줘야 한다.

프로그램은 컴퓨터 시스템 위에 올라가기 때문에 수학처럼 완전하게 순수할 수는 없다. 그래서 순수 함수의 범위가 애매하게 느껴질 수 있다. 예를 들면, 부동 소수점 문제가 있다.

var num1: Double = 0.0
for (i in 0 until 10) {
  num1 += 1.0 / 3
}
val num2: Double = 1.0 / 3 * 10
println(num1 == num2) // false

위 코드를 수학적으로 생각하면 num1과 num2는 같은 값이기 때문에 true가 나와야 정상이다. 하지만 부동 소수점이라는 한계가 있기 때문에 false가 나온다. 만약 위와 같이 부동 소수점을 다루는 함수가 있다면 과연 그 함수를 순수하다고 할 수 있을까?

이를 해결하기 위해서는 프로그램 목적에 따라 구현 스펙을 정할 필요가 있다. 다시 부동 소수점을 예로 든다면 자세한 소수점이 필요 없다면 적절한 단위에서 반올림을 통해 문제를 해결하거나 정확한 계산이 필요하다면 Double 자료형을 쓰는 것이 아닌 문자열을 통해 정확한 소수점을 계산해주는 객체를 만들어 사용할 수 있다.

Guard Clause 패턴

Guard Clause 패턴은 로직의 시작 지점에 방어 조건을 먼저 작성하는 패턴이다. 패턴이라하니 복잡하다 느낄 수 있지만 실제로는 if를 이용하여 간단하게 구현할 수 있다.

// JavaScript
function authorize(user) {
  if (user.role !== 'admin') return false
  if (user.isBlocked) return false

  // 권한이 있는 사용자에게만 보여줄 로직
}

위 코드를 보다시피 매우 간단하다. Guard Clause 패턴의 핵심은 로직 상단에 방어 조건이 있는 것이며 중첩된 if를 피하는 것이다. 이를 통해 함수의 가독성을 높일 수 있다. 참고로 Swift는 언어 자체에서 Guard 문법을 지원한다.

// Swift
func authorize(user: User) throws -> Bool {
  // if와 달리 조건이 맞지 않으면 실행된다.
  guard user.role == .admin else { return false }
  guard !user.isBlocked else { return false }

  // 권한이 있는 사용자에게만 보여줄 로직
}

EAFP

try-catch 문법

순수 함수는 결과를 예측하게 해주지만 외부 I/O와 개발자가 미처 알아차리지 못한 문제는 해결해주지 않는다. 특히 요즘 제작되는 대부분의 소프트웨어는 거의 반드시 외부 I/O를 다루기 때문에 새로운 해결 방법을 찾아야 한다. 그 중 하나가 try-catch 문법이다.

try-catch 문법은 이미 오래전부터 많은 언어가 지원하고 있다. 그래서 많은 개발자들이 잘 알고있는 예외 처리 방법이기도 하다.

// Kotlin
try {
  // 예외가 발생할 수 있는 코드
} catch (e: Exception) {
  // 예외가 발생했을 때 실행할 코드
}
// JavaScript
try {
  // 예외가 발생할 수 있는 코드
} catch (e) {
  // 예외가 발생했을 때 실행할 코드
}
# Python
try:
  # 예외가 발생할 수 있는 코드
except Exception as e:
  # 예외가 발생했을 때 실행할 코드

언어마다 조금씩 문법은 조금씩 다르지만 형태는 거의 같다. try-catch 문법은 예외가 발생할 수 있는 코드를 try 블록에 작성하고 예외가 발생했을 때 실행할 코드를 catch 블록에 작성한다. 이렇게 작성하면 예외가 발생했을 때 catch 블록의 코드가 실행된다. try-catch는 어디에서 사용되어야 할까? 대체로 함수를 사용하는 상위 로직에서 사용한다. 그리고 사용 당하는 함수는 에러만을 던진다. 이는 개발자가 미리 알고 의도한 에러든 예상치 못한 에러든 상관없다.

// Kotlin
fun authorize(user: User) {
  if (user.role != Role.ADMIN) {
    throw RuntimeException("권한이 없습니다.")
  }
}

fun login() {
  try {
    authorize(User(name = "kciter", role = Role.USER))
  } catch (e: Exception) {
    println(e.message)
  }
}

try-catch 문법은 크게 문제가 없어보이지만 가독성에 조금 문제가 있다. try-catch는 순차적으로 흐르지 않는다. 에러가 발생하면 catch 절로 이동하고 finally와 같은 문법을 사용할 경우 try에서 처리된 후 온 것인지 catch에서 처리된 후 온 것인지 확인이 필요하다. 그렇기 때문에 프로그램을 그대로 종료할 것이 아니라면 어떤 절로 로직이 마무리 되더라도 문제없이 진행될 수 있도록 개발자가 신경써야 한다.

또한, 해당 함수가 어떤 에러를 반환하는지 개발자가 미리 알아야 한다는 문제가 있다. 특히 사용자 지정 에러가 많은 경우 생산성에 문제가 될 수 있다.

하지만 try-catch가 나쁘다는 의미는 아니다. 서버 프로그램과 같이 절대로 패닉이 발생해선 안되는 프로그램에서는 try-catch 문법은 매우 유용하다.

fun main() {
  val server = ServerSocket(8080)
  println("Server is running on port ${server.localPort}")

  while (true) {
    val socket = server.accept()
    val reader = Scanner(socket.getInputStream())
    val writer = socket.getOutputStream()
    println("Client connected: ${socket.inetAddress.hostAddress}")

    thread {
      while (true) {
        try {
          val text = reader.nextLine()
          writer.write(text.toByteArray(Charset.defaultCharset()))
        } catch (e: Exception) {
          println(e.message)
          socket.close()
          break
        }
      }
    }
  }
}
서버는 신뢰성을 위해 최대한 살아있어야 한다

Functor와 Monad

펑터와 모나드는 함수형 프로그래밍을 접하면 자주 들을 수 있는 개념이다. 일반적으로 처음 프로그래밍을 접하며 배운 내용들과는 이질적이기도 하고 설명에 수학적인 내용이 들어가는 경우도 있어서 어렵게 느껴질 수 있다. 하지만 하나씩 살펴보면 그리 어려운 개념은 아니다. 함수형 프로그래밍에 대한 설명은 이 글의 범위를 벗어나므로 어려운 개념은 생략하고 간단하게 펑터와 모나드를 살펴볼 것이다.

먼저 펑터와 모나드를 이해하기 전에 타입에 대해 살펴볼 필요가 있다. 함수형 프로그래밍에서 타입은 함수 합성을 하기 위한 중요한 개념이다. 수학적 정의와 마찬가지로 프로그래밍 세계의 함수도 정의역과 치역으로 이루어져 있다.

정의역과 치역 그리고 공역

함수의 정의역과 치역은 집합이다. 그리고 프로그래밍 언어에선 이를 타입으로 표현한다.

Boolean = {true, false}
Short = {-32768, ..., 0, ..., 32767}
Int = {-2147383647, ..., 0, ..., 2147483647}
...

그렇다면 다음 함수는 Int 집합에서 Double 집합으로 변형하는 함수라고 볼 수 있다. 즉, 정의역은 매개 변수의 타입이고 치역은 반환 타입이다.

fun divide(a: Int, b: Int): Double = a.toDouble() / b.toDouble()

하지만 위 함수는 b가 0일 경우 DivideByZero 에러가 발생하기 때문에 순수 함수가 아니다. 이 경우 치역을 온전한 Double이라고 말할 수는 없다. 분기를 이용한 예외 처리로 이를 해결할 수 있지만 만약 에러마저도 치역에 포함시키고 싶다면 다른 방법을 사용해야 한다. 이 문제를 해결하는 것이 어렵게 느껴질 수 있지만 사실 상당히 간단한 일이다. Double이라는 집합으로는 에러를 담아낼 수 없기 때문에 새로운 집합이 필요하다. 즉, 새로운 타입을 만들어내면 되는 것이다.

묶어서 하나의 타입

위와 같은 개념이 펑터라고 할 수 있다. 지금부터 프로그래밍 세계에서 펑터를 어떻게 구현할 수 있을지 알아보자.

Functor

펑터라는 개념을 이용하면 타입을 확장하여 새로운 타입을 만들어낼 수 있다. 이를 통해 에러를 담아낼 수 있다. 코드를 살펴보기 전에 먼저 개념적인 이미지부터 살펴보자.

위 이미지를 보면 펑터는 박스와 같다는 것을 알 수 있다. 박스 안에는 값이 들어있고 이를 꺼내서(unwrap value) 함수를 적용(apply function)한다. 그리고 다시 박스에 집어넣는다(rewrap value). 왜 이런 번거로운 짓을 하는 걸까? 그 이유는 값에 함수를 적용할 때 발생하는 문제를 해결하기 위함이다. 다시 다음 이미지를 살펴보자.

이번엔 펑터에 0을 나누는 함수를 적용했다. 이 경우 당연히 에러가 발생한다. 여기서 개발자는 적절한 로직을 통해 예외 처리를 할 수 있다. 이때 예외 처리를 통해 얻은 에러 객체를 펑터에 넣어주면 된다.

이를 코드로 구현하면 다음과 같다. 여기선 예제로 Kotlin을 사용하겠다.

class Functor<T>(private val value: T) {
  fun <R> map(f: (T) -> R): Functor<R> =
    Functor(f(this.value))
}

펑터에서 함수를 받아 값을 변형하는 함수를 보통 map이라고 한다. 어디서 많이본 함수 아닌가? 그렇다. 우리는 이미 펑터라는 개념을 자주 써왔다! 그럼 이번엔 위 Functor 클래스를 이용하는 코드를 살펴보자.

class Functor<T>(private val value: T) {
  fun <R> map(f: (T) -> R): Functor<R> =
    Functor(f(this.value))

  override fun toString(): String =
    "Functor($value)"
}

fun main() {
  val functor = Functor(1)
  val result = functor.map { it + 1 }
  println(result) // Functor(2)
}

아주 간단한 코드다. 펑터는 1이라는 값을 가지고 있고 이를 map 함수를 통해 1을 더한 값을 반환한다. 이를 통해 펑터는 값을 변형하는 함수를 적용할 수 있다는 것을 알 수 있다. 이제 펑터를 이용하여 조금 더 복잡한 것을 만들어보자. 이번에는 값이 null인지 알 수 있는 펑터를 구현해볼 것이다.

sealed class Option<out T> {
  data class Some<T>(val value: T): Option<T>()
  object None: Option<Nothing>()

  companion object {
    fun <T> of(value: T?): Option<T> = when (value) {
      null -> None
      else -> Some(value)
    }
  }

  override fun toString(): String =
    when (this) {
      is Some -> "Some($value)"
      is None -> "None"
    }
}

fun <T, R> Option<T>.map(f: (T) -> R): Option<R> =
  when (this) {
    is Option.Some -> Option.of(f(this.value))
    is Option.None -> Option.None
  }

fun main() {
  val option = Option.of("Hello, World!")
  val result1 = option.map { it.toIntOrNull() }
  val result2 = option.map { it.length }

  println(result1) // None
  println(result2) // Some
}

Option이라는 값이 적용될 때 null인지 아닌지 판단하여 null이라면 None을 값이 있다면 Some으로 타입을 분류하는 펑터를 구현했다. 이를 이용하여 NullPointerException과 같은 문제를 예방할 수 있다. 그리고 패턴 매칭이 지원되는 언어라면 다음과 같이 더 안전하게 사용이 가능하다.

fun main() {
  val option = Option.of("Hello, World!")
  val result = when (option) {
    is Some -> option.value
    is None -> "None"
  }

  // result는 null이 아님을 보장한다.
  println(result) // Hello, World!
}

만약 펑터를 이용하여 null을 판단하는 것이 아니라 에러를 판단한다면 어떨까? 이번에는 에러를 판단하는 펑터를 구현해보자.

sealed class Result<out V, out E> {
  data class Success<out V>(val value: V): Result<V, Nothing>()
  data class Failure<out E>(val error: E): Result<Nothing, E>()

  companion object {
    fun <V> of(f: () -> V): Result<V, Throwable> = try {
      Success(f())
    } catch (e: Throwable) {
      Failure(e)
    }
  }

  override fun toString(): String =
    when (this) {
      is Success -> "Success($value)"
      is Failure -> "Failure($error)"
    }
}

fun <V, R, E> Result<V, E>.map(f: (V) -> R): Result<R, E> =
  when (this) {
    is Result.Success -> Result.of { f(value) }
    is Result.Failure -> this
  }

Option과 거의 비슷하다. 다만 null을 판단하는 것이 아니라 try-catch를 이용하여 Throwable을 판단한다는 것이 다르다. 이를 이용하여 다음과 같이 사용할 수 있다.

fun main() {
  val result = Result.of { 1 + 2 }
    .map { it / 0 }
  println(result) // Failure(error=java.lang.ArithmeticException: / by zero)
}

of 메서드를 통해 에러가 발생한다면 Failure 타입이 반환되고 발생하지 않는다면 Success 타입이 반환된다. 마찬가지로 map을 이용해 값을 변형할 때 에러가 발생한다면 Failure를 반환하고 발생하지 않는다면 Success를 반환한다. 이를 통해 에러를 안전하게 처리할 수 있다. 여기서 Option때와 마찬가지로 패턴 매칭을 사용한다면 다음과 같이 사용할 수 있다.

fun main() {
  val result = Result.of { 1 + 2 }
    .map { it / 0 }
    .map { it * 2 }

  when (result) {
    is Result.Success -> println(result.value)
    is Result.Failure -> println(result.error)
  }
}

여기까지는 아무런 문제가 없지만 다음과 같은 상황이 있을 수 있다.

fun sum(a: Int, b: Int): Result<Int, Throwable> = Result.of { a + b }
fun divide(a: Int, b: Int): Result<Int, Throwable> = Result.of { a / b }

fun main() {
  val result = Result.of { 5 }
    .map { sum(it, 10) } // Result<Result<Int, Throwable>, Throwable>
    .map { divide(it, 0) } // 타입이 맞지 않아 컴파일 에러가 발생한다.

  when (result) {
    is Result.Success -> println(result.value)
    is Result.Failure -> println(result.error)
  } // java.lang.ArithmeticException: / by zero
}

위 코드와 같이 함수마다 에러를 판단하기 위해 Result라는 펑터 타입을 사용한다면 map을 이용할 때 박스를 다시 박스로 감싸는 문제가 발생하게 된다. 이를 해결하기 위해서는 박스로 다시 감싸지 않고 값을 변형하는 해야한다. 프로그래밍 세계에서 이를 구현하기 위해 모나드라는 개념을 이용할 수 있다.

Monad

모나드는 굉장히 어렵다라는 소문이 자자한 개념이다. 그러다보니 모나드 괴담이라는 자료까지 생겨나곤했다. 하지만 이론적인 내용을 배제하고 하나씩 살펴보면 그다지 어렵지 않다는 것을 알 수 있다.

일단 수학 용어는 치워보자

앞서 모나드는 펑터의 중첩을 해결 할 수 있다고 말했다. 실제로 프로그래밍 세계의 모나드는 이를 위해 탄생했다. 게다가 심지어 많은 개발자가 이미 모나드를 사용하고 있다. 다음 코드를 살펴보자.

val list = listOf(1, 2, 3, 4, 5)
val result = list
  .flatMap {
    listOf(it, it + 1) // listOf 함수는 List<T> 타입을 반환한다.
  }

flatMap이라는 함수에 대해 다뤄본적이 있다면 위 코드는 익숙할 것이다. 만약 리스트를 변형하던 중 다시 리스트 타입을 반환해야 한다면 flatMap을 사용한다. 만약 map 함수였다면 List<Int> 타입을 List<List<Int>> 타입으로 변형했겠지만 flatMapList<Int> 타입으로 변형할 수 있다. 간단하게 표현하자면 flatMap 함수가 반환한 것을 값으로 그대로 사용하는 것이라 볼 수 있다. 이것이 모나드다.

다시 정리하면 모나드는 중첩을 해결한다. 이제 Result 펑터를 이용하여 모나드를 구현해보자.

sealed class Result<out V, out E> {
  data class Success<out V>(val value: V): Result<V, Nothing>()
  data class Failure<out E>(val error: E): Result<Nothing, E>()

  companion object {
    fun <V> of(f: () -> V): Result<V, Throwable> = try {
      Success(f())
    } catch (e: Throwable) {
      Failure(e)
    }
  }

  override fun toString(): String =
    when (this) {
      is Success -> "Success($value)"
      is Failure -> "Failure($error)"
    }
}

fun <V, R, E> Result<V, E>.map(f: (V) -> R): Result<R, E> =
  when (this) {
    is Result.Success -> Result.of { f(value) }
    is Result.Failure -> this
  }

// flatMap은 결과값을 그대로 사용한다.
fun <V, R, E> Result<V, E>.flatMap(f: (V) -> Result<R, E>): Result<R, E> =
  when (this) {
    is Result.Success -> f(this.value)
    is Result.Failure -> this
  }

위 코드는 Result 펑터를 구현한 코드이다. map 함수는 펑터의 특징을 그대로 따르고 있고 flatMap 함수는 모나드의 특징을 그대로 따르고 있다. flatMap 함수는 결과값을 그대로 사용한다는 특징을 가지고 있다. 이제 모나드를 이용하여 펑터 쪽 예제에서 불가능했던 문제를 해결해보자.

fun sum(a: Int, b: Int): Result<Int, Throwable> = Result.of { a + b }
fun divide(a: Int, b: Int): Result<Int, Throwable> = Result.of { a / b }

fun main() {
  val result = Result.of { 5 }
    .flatMap { sum(it, 10) }
    .flatMap { divide(it, 0) } // 타입이 일치한다!

  when (result) {
    is Result.Success -> println(result.value)
    is Result.Failure -> println(result.error)
  } // java.lang.ArithmeticException: / by zero
}

이제 문제가 해결된 것을 볼 수 있다. 모나드에 대해서는 이론적인 내용이 많이 있지만 실용적인 것만 따진다면 이렇게 간단하게 구현할 수 있다. 이제 펑터와 모나드를 이용하면 기존과는 다른 방식으로도 예외 처리를 할 수 있다는 것을 알았을 것이다. 이제 본격적으로 ROP에 대해서 알아보자.

Railway-Oriented Programming

ROP는 사이드 이펙트를 제어하기 위한 함수형 패러다임 기반 방법론이다. ROP라는 방법론은 널리 알려지진 않았지만 Rust는 try-catch 문법을 지원하지 않는대신 ROP 철학을 일부 따르고 있다. 즉, 배워둬서 나쁠건 없다고 생각한다.

// Rust 예제
use std::fs::File;

fn main() {
  let f = File::open("hello.txt"); // Result 객체를 반환한다.

  let f = match f {
    Ok(file) => file, // 파일이 정상적으로 열렸다면 파일 객체를 반환한다.
    Err(error) => {
      panic!("There was a problem opening the file: {:?}", error) // 에러를 처리한다.
    },
  };
}

이어서 설명하면 ROP는 굉장히 단순하다. 간단하게 요약하면 로직은 성공 혹은 실패로 나뉘고 그에 따라 새로운 선로를 설치해서 신뢰할 수 있는 소프트웨어를 구축한다는 방법론이다.

성공 혹은 실패

이를 위해 기본적으로 위에서 구현한 Result라는 모나드 객체를 사용하고 에러를 체크하는 것은 어떤 방법을 사용하더라도 상관없다. 중요한 것은 ROP라는 방법론의 철학을 이해하는 것이다. ROP는 다음과 같은 철학을 따른다.

  • 모든 기능은 순차적으로 실행된다.
  • 모든 기능은 성공 혹은 실패로 나뉜다.
  • 프로그램은 패닉이 발생하면 안된다.

이렇게 간단한 철학을 따르면서도 ROP는 사고적으로 굉장히 강력한 방법론이다. 우리는 프로그래밍을 할 때 항상 기능에 대한 추상화를 한다. ROP에선 기능을 선로에 빗대어 추상화하며 선로를 구성하는 기능들은 모두 성공 혹은 실패로 나눈다. 이렇게 추상화를 하면 기능의 단위를 성공과 실패로 나눌 수 있는 적절한 크기로 나누게 되므로 구현과 리팩토링 하는 것이 편해진다.

또한, 모든 기능을 순차적으로 실행하기 때문에 프로그램의 흐름을 이해하기 쉬워지고 가독성이 좋아진다. 이러한 장점들을 통해 ROP는 신뢰할 수 있는 소프트웨어를 구축하는데 도움을 준다. 이제 Result에 대한 추가적인 내용에 대해 알아보자.

복구 선로

이미 Result를 구현한 시점에서 ROP의 설명은 거의 끝났다고 볼 수 있다. 하지만 앞서 설명하지 않은 내용 중 복구라는 개념이 있다. ROP는 세 가지 선로로 분류된다.

  • 성공 선로
  • 실패 선로
  • 복구 선로

성공 선로는 간단하다. 우리가 생각했던 베스트 케이스대로 로직이 구성되는 것이다. 반면 실패 선로는 각각의 선로를 지나가던 중(함수를 실행하는 도중) 문제가 발생하여 실패하는 경우다. 복구 선로는 실패 선로를 지나가던 중 문제가 발생했지만 복구할 수 있는 경우이다. 그러면 다시 성공 선로로 이동한다. 이미 펑터와 모나드를 설명하며 성공 선로와 실패 선로를 구축하는 것은 Result를 구현하며 보았기 때문에 복구하는 방법을 살펴보자.

복구 선로를 만드는 함수는 rescue 혹은 recover라는 이름으로 구현된다. 어떤 이름을 사용하던 상관은 없다. 다음 코드를 살펴보자.

sealed class Result<out V, out E> {
  data class Success<out V>(val value: V): Result<V, Nothing>()
  data class Failure<out E>(val error: E): Result<Nothing, E>()
}

// Other functions...

fun <V, E> Result<V, E>.recover(f: (E) -> V): Result.Success<V> {
  return when (this) {
    is Result.Success -> this
    is Result.Failure -> Result.Success(f(error))
  }
}

fun main() {
  val result = sum(it, 10)
    .flatMap { divide(it, 0) }
    .recover { 0 } // 복구 선로 후에는 무조건 Success다.

  println(result.value) // 0
}

필자는 recover라는 함수를 구현하여 실패에 대한 처리를 할 수 있도록 구현했다. 위 코드를 보다시피 매우 간단하다.

에러 타입 제한

try-catch의 경우 어떤 에러가 발생할지 알기 어렵다는 점이 있다. 그래서 사용할 함수 내부를 파악하고 사용하는 쪽 throw에서 분기 혹은 타입 패턴 매칭을 사용하는 경우가 많다. 하지만 Result를 사용하면 에러를 구분하여 처리할 수 있다. 다음 코드를 살펴보자.

sealed class NumberException: RuntimeException() {
  data class DivideByZero(override val message: String): NumberException()
  data class TooBig(override val message: String): NumberException()
  data class TooSmall(override val message: String): NumberException()
}

fun sum(a: Int, b: Int): Result<Int, NumberException> {
  val result = a + b
  if (result > 100) return Result.Failure(NumberException.TooBig("Too Big"))
  if (result < 0) return Result.Failure(NumberException.TooSmall("Too Small"))

  return Result.Success(result)
}

fun divide(a: Int, b: Int): Result<Int, NumberException> {
  if (b == 0) return Result.Failure(NumberException.DivideByZero("Divide By Zero"))
  return Result.Success(a / b)
}

fun main() {
  val result = sum(5, 10)
    .flatMap { divide(it, 0) }
    .recover {
      when (it) {
        is NumberException.DivideByZero -> -1
        is NumberException.TooBig -> 100
        is NumberException.TooSmall -> 0
      }
    }

  println(result.value) // -1
}

위 코드에서 recoverwhen 부분을 보자. sealed clas를 통해 제한된 타입을 패턴 매칭을 통해 안전하게 처리하는 것을 볼 수 있다. 이를 통해 더욱 더 안전하게 예외를 관리할 수 있다.

Monad Comprehension

flatMap을 사용하여 박스를 중첩하지 않아도 된다는 것을 앞서 배웠다. 웬만하면 flatMap만으로 깔끔하게 코드를 작성하는 것이 가능하지만 다음과 같은 경우가 있을 수 있다.

fun main() {
  val result = getUserById(1)
    .flatMap { user ->
      getAllPosts()
        .map { posts ->
          posts.filter { it.userId == user.id } // user가 필요하다.
        }
    }

  when (result) {
    is Result.Success -> println(result.value)
    is Result.Failure -> println(result.error)
  }
}

위 코드를 보면 flatMap을 사용하여도 점점 Nested 되기 때문에 코드가 복잡하다. 위 코드처럼 선행된 값을 알아야하기 때문에 Nested한 코드를 어쩔 수 없이 작성하는 경우가 많다. 이를 해결하기 위해 Monad Comprehension이라는 것을 사용할 수 있다. 다만, 이 글에서 예제 코드를 위해 전반적으로 사용하는 Kotlin에선 Monad Comprehension을 지원하지 않는다. 이 기능을 제공하는 언어는 대표적으로 Scala와 Haskell이 있다. 여기서는 Scala 예제를 통해 Monad Comprehension을 살펴보자.

def getUserById(id: Int): Either[Exception, User] = {
  // ...
}

def getAllPosts(): Either[Exception, List[Post]] = {
  // ...
}

def main(args: Array[String]) = {
  val result = for {
    user <- getUserById(1)
    posts <- getAllPosts().map(_.filter(_.userId == user.id))
  } yield posts.map(_.title)

  result match {
    case Right(posts) => println(posts)
    case Left(e) => println(e)
  }
}

위 코드에서 for ~ yield 부분이 For Comprehension이라 부르는 문법으로 Monad Comprehension을 쉽게 사용할 수 있게 해주는 Syntactic Sugar 문법이다. 이런식으로 Nested를 제거할 수 있다. 참고로 Kotlin에서 이 문법 흉내내기 위해서 Context Receiver라는 것을 사용할 수 있다.

fun main() {
  val result: Result<List<String>, Throwable> = binding {
    val user = getUserById(1).bind()
    val posts = getAllPosts().bind()
    posts.filter { it.userId == user.id }.map { it.title }
  }

  when (result) {
    is Result.Success -> println(result.value)
    is Result.Failure -> println(result.error)
  }
}

여기서 Context Receiver를 이용한 구현은 이 글의 범위를 벗어나기 때문에 생략한다. 만약 이에 대해 궁금하다면 ArrowKt 공식 홈페이지를 참고하길 바란다.

중첩 컨테이너 문제

만약 Result를 다른 모나드와 함께 사용하고 싶다면 어떻게 해야할까? 예를 들면, 위에서 만든 Option 모나드를 함께 사용하고 싶을 수도 있다. 다음 코드를 살펴보자.

fun getUserById(id: Int): Result<Option<User>, Throwable> {
  // ...
}

fun getPostByUserId(userId: Int): Result<Post, Throwable> {
  // ...
}

fun main() {
  val result = getUserById(1)
    .flatMap { user ->
      when (user) { // user는 Option<User> 타입이다.
        is Option.Some -> {
          getPostsByUserId(user.value.id)
            .map { posts -> 
              posts.map { it.title } 
            }
        }
        is Option.None -> Result.Failure(Throwable("User not found"))
      }
    }

  when (result) {
    is Result.Success -> println(result.value)
    is Result.Failure -> println(result.error)
  }
}

위 코드를 보면 getUserById 함수가 Result<Option<User>, Throwable> 타입이기 때문에 중간에 패턴 매칭을 통해 박스를 벗겨내는 것을 볼 수 있다. 여기서는 Option을 Nullable로 대체하면 해결할 수 있지만 실제로 여러 모나드를 사용할 경우 점점 코드가 복잡해질 수 있다. 이처럼 이미 다른 모나드를 주력으로 사용하고 있는 상황이라면 문제가 될 수 있다. 예를 들어, Spring 환경에서 Reactive Programming을 위해 Mono, Flux 등을 사용하는 경우 혹은 Rx 계열 라이브러리를 사용하는 경우를 예시로 들 수 있다.

이를 해결하기 위해서는 Higher-Kinded Type(이하 HKT)이라는 개념이 필요하다. 다만, 안타깝게도 몇 언어를 제외하면 HKT를 제공하는 언어는 드물기 때문에 이 문제를 해결하기는 쉽지 않다. 이 글 전반적으로 사용되는 Kotlin에선 해당 기능을 제공하지 않는다.

Scala에선 HKT를 지원한다. 이를 통해 Monad Transfomer이라는 것을 구현할 수 있는데, 이를 통해 문제를 해결할 수 있다.

def getUserById(id: Int): Either[Exception, Option[User]] = {
  Right(Option(User(1, 30)))
}

def getPostsByUserId(userId: Int): Either[Exception, List[Post]] = {
  Right(List(Post("A"), Post("B")))
}

def main(args: Array[String]): Unit = {
  val result = for {
    // OptionT 타입은 cats 라이브러리를 통해 사용했다.
    user <- OptionT(getUserById(1))
    posts <- OptionT.liftF(getPostsByUserId(user.id))
  } yield posts.map(_.title)

  result.value match {
    case Right(posts) => println(posts)
    case Left(e) => println(e)
  }
}

위 코드를 보다시피 Nested한 코드가 제거되어 조금 더 깔끔한 모습을 볼 수 있다. 아쉽게도 이를 지원하지 않는 언어는 사용이 불가능하다. 따라서 ROP를 도입하고 싶은 개발자는 자신의 환경을 고려할 필요가 있다.

마치며

ROP를 사용한다면 조금 더 안전하고 직관적인 코딩을 할 수 있다. 다만, 환경에 따라 사용하기 힘들 수 있으니 이를 고려하여 사용하도록 하자. 또한, ROP를 사용하더라도 모든 함수에 대해 Result를 사용하는 것은 권장하지 않는다. 이는 코드의 가독성을 떨어뜨릴 수 있기 때문이다. 따라서, 필요한 함수에 대해서만 사용하는 것이 좋다.