Functor(펑터)와 Monad(모나드) - 2

이전 포스트에서는 펑터가 무엇인지에 관해 살펴보았습니다. 펑터는 유용한 추상화이지만 약간의 문제가 존재합니다. 펑터의 map 함수가 일반적인 값을 반환하는 경우에는 문제가 되지 않지만 만약 펑터를 반환하면 어떻게 될까요? 물론 펑터 역시 값이기 때문에 상관은 없겠지만 펑터를 반환하는 경우 결과가 조금 다르게 나올 수 있습니다. 다음 예제를 통해서 살펴보겠습니다.

val functorForOption: Functor[Option] = new Functor[Option] {
  def map[A, B](fa: Option[A])(f: A => B): Option[B] = fa match {
    case None    => None
    case Some(a) => Some(f(a))
  }
}

def stringToInt(s: String): Option[Int] = {
  try {
    Some(s.toInt)
  } catch {
    case e: NumberFormatException =>
      None
  }
}

val opt: Option[Option[Int]] = {
  functorForOption.map(Some("hello"))(x => stringToInt(x))
}

일단 위의 예제 코드에는 문법적으로 이상은 없습니다. stringToInt 함수가 Int를 반환한다면 Option[Int]가 될 것입니다. 그러나 펑터의 map 함수가 Option을 리턴하기 때문에 위 예제의 opt의 타입은 Option[Option[Int]]이 됩니다. 위의 예제처럼 펑터가 펑터를 감싼 형태가 되지 때문에 합성과 체이닝을 힘들게 합니다. 한번 다음의 예제를 통해 살펴보겠습니다.

val intOpt = Option(10)
val intOptOpt = Option(Option(10))

val doubleOpt1 = intOpt.map(_.toDouble)
val doubleOpt2 = intOptOpt.map(_.toDouble) // compile error

위의 예제는 Option안에 있는 Int값을 Double로 바꾸는 방법을 보여줍니다. Int => Double로 구성된 함수만 있다면 Option[Int]의 값을 Option[Double]로 쉽게 바꿀 수 있습니다. 하지만 Option이 두 번 감싸져 있는 intOptOpt의 예제는 우리가 생각한대로 동작을 하지 않습니다. 펑터가 두 번 감싸져 있는 경우 펑터가 제 기능을 수행하기 어려워집니다. 하지만 리턴 값이 펑터인 경우는 일반적이기 때문에 이를 해결해야 합니다. 그래서 특별한 함수를 도입해서 중첩된 펑터를 납작(flat)하게 만드는 기능을 제공합니다.

val intOpt2: Option[Int] = intOptOpt.flatten

이렇게 되면 위에서 발생한 문제는 해결됩니다. 그러나 이렇게 사용되는 경우가 흔하기 때문에 펑터를 리턴하는 map함수를 사용한 다음 매번 flatten을 호출하는 것은 번거롭습니다. 그렇기 때문에 flatMap이라는 특별한 함수를 도입합니다. flatMapmap은 유사하지만 파라미터로 받는 함수가 펑터(정확히는 모나드입니다)를 반환합니다. 그러면 스칼라에서 모나드는 다음과 같이 선언합니다.

trait Monad[F[_]] extends Functor[F] {
  def unit[A](a: => A): F[A]
  def flatMap[A,B](ma: F[A])(f: A => F[B]): F[B]
}

flatMap을 사용하면 첫 번째 예제인 stringToInt을 사용하는 코드가 중첩되는 모나드의 형태가 아닌 하나의 모나드로 감싸져 있는 형태가 됩니다. 그러면 함수를 합성하거나 체이닝해서 사용하는 것이 한결 수월해 집니다. flatMap만 있다고 해서 어떤 클래스를 모나드라고 할 수 없습니다. 모나드는 특정 법칙을 만족해야 합니다. 이 모나드 법칙은 다음과 같습니다.

모나드 법칙

1) 항등법칙

모나드는 항등법칙을 만족합니다. 모나드는 모나드에 대한 항등원을 가지고 있어야 하는데 그것을 정의한 것이 바로 unit이라는 함수입니다. unit 함수의 정의는 다음과 같습니다.

def unit[A](a: => A): F[A]

임의의 모나드와 unit의 합성은 원래 그 모나드와 같습니다. 이것은 2가지 법칙으로 표현되는데 왼쪽 항등법칙오른쪽 항등법칙입니다. 간단히 이 법칙들을 flatMap으로 표현하면 다음과 같습니다.

def unit[A](a: A): Option[A] = Some(a)
def flatMap[A,B](ma: Option[A])(f: A => Option[B]): Option[B] = ma flatMap f

def f: Int => Option[Int] = a => Some(a * 2)
val x = 2

flatMap(unit(x))(f) == f(x) // true
flatMap(f(x))(unit) == f(x) // true

2) 결합법칙

모나드는 결합법칙을 만족해야 합니다. 마찬가지로 예제를 통해 살펴보겠습니다.

def unit[A](a: A): Option[A] = Some(a)
def flatMap[A,B](ma: Option[A])(f: A => Option[B]): Option[B] = ma flatMap f

val f: Int => Option[Int] = a => Some(a + 2)
val g: Int => Option[Int] = a => Some(a * 2)

val ma = Some(1)
val f: Int => Option[Int] = a => Some(1)

(ma flatMap f flatMap g) == (ma flatMap(a => f(a) flatMap g)) // true

위의 예제와 같이 모나드는 결합법칙을 만족합니다. 모나드는 어떤 일이 일어나도 그것이 결합법칙과 항등법칙을 만족한다는 것을 의미합니다.
모나드의 map은 따로 구현할 필요가 없습니다. flatMap으로 쉽게 구현할 수 있습니다.

def map[A,B](ma: F[A])(f: A => B): F[B] =
  flatMap(ma)(a => unit(f(a)))

그럼 이러한 모나드는 왜 쓰는 것일까요? 간단한 예제를 통해서 모나드의 강력함을 살펴보도록 하겠습니다.
스칼라에서는 Future 모나드라는 것이 있습니다. 이 Future는 미래의 어떤 값을 가진다는 의미입니다. 다음과 같이 정의된 함수들이 있습니다.

def loadCustomer(id: Int): Future[Customer]

def readBasket(customer: Customer): Future[Basket]

def calculateDiscount(basket: Basket, dayOfWeek: DayOfWeek): Future[Double] 

위에서 정의된 함수 시그니처만 살펴보면 어떤 작업을 수행하는지 알 수 있습니다. 각각의 함수들은 Future[A]를 리턴합니다. 그리고 그 함수는 완료하는데 일정 시간이 걸립니다. 위의 함수들을 모나드 연산자를 사용하면 마치 블로킹 호출처럼 함수들을 합성할 수 있습니다.

val discount = loadCustomer(513)
  .flatMap(readBasket)
  .flatMap(basket => calculateDiscount(basket, DayOfWeek.Friday))

모나드의 flatMap은 모나드 타입을 유지해야 하므로 flatMap의 결과가 전부 Future입니다. 이렇게 코드를 작성을 하게 되면 코드들이 완전히 비동기적으로 동작하는 장점이 생깁니다. 즉, loadCustomer를 호출하면 블로킹이 되지 않고 바로 Future를 반환합니다. 그 다음 readBasket 역시 앞의 Future 값을 통해 새로운 Future를 반환하기 됩니다. 모나드를 사용하여 간단하게 비동기 파이프라인을 구성한 것입니다.

이번 포스트에서는 함수형 프로그래밍에서 사용되는 중요한 개념인 펑터모나드에 대해 알아보았습니다. 함수형 프로그래밍 언어를 사용하면서 아마 인지하진 않았지만 펑터와 모나드를 사용하셨을 것입니다. 이번 포스트를 통해 펑터와 모나드에 대한 개념이 조금 더 친숙해졌으면 좋겠습니다.

References



comments powered by Disqus