스칼라 함수형 프로그래밍 러닝커브의 주역!! 모나드와 주요 연산자를 소개합니다.

View: 311 1 0
작성자: 달빛제이크
카테고리: Scala Language
발행: 2024-07-07 수정 2024-07-19

안녕하세요. 달빛제이크입니다.

오늘은 스칼라 함수형 프로그래밍의 러닝 커브를 매우 가파르게 만들어 스칼라가 어렵다는 인식을 널리 퍼뜨린 바로 그 주인공 모나드에 대해 소개하겠습니다. 프로그래밍 언어를 Skill로서 사용하는 것은 경험이 쌓이면서 익숙해지지만, 그 언어가 도입한 개념에 대해 아는 것은 수학적 이해가 필요한 만큼 많이 어렵습니다. 그럼에도 불구하고 모나드에 대해 이해하려는 노력은 모나드가 순수 함수형 프로그래밍의 핵심적인 개념이기 때문입니다.

스칼라가 어렵다는 오해를 만들 의도는 전혀 없습니다만, 함수형 프로그래밍에 대해 좀 더 깊이 있는 공부를 원하시는 분들을 위해 필요한 개념들을 나열해 보았습니다.

  • 카테고리(Category)
  • 펑터 (Functor)
  • 모나드 (Monad)
  • 애플리케이티브 (Applicative)
  • 모노이드 (Monoid)
  • 세미그룹 (Semigroup)
  • 트래버서블 (Traversable)
  • 애로우 (Arrow)
  • 렌즈 (Lens)
  • 모나드 변환기 (Monad Transformer)
  • 코모나드 (Comonad)
  • 폴더블 (Foldable)

카테고리 이론의 각 개념들을 글 하나에 모두 담을 수 있는 주제가 아니기 때문에 후에 하나 씩 살펴보려고 합니다. 이 글에서 다루기로 한 모나드에 대해서도 최대한 쉽고 간단하게 소개해 드리고, 추후에 좀 더 자세하게 살펴보겠습니다.

1. 카테고리 이론 (Category Theory)

카테고리 이론(Category Theory)은 수학의 한 분야로, 객체(Object)와 객체 간의 관계를 사상(Morphism or Arrow)으로 조직화하여 이를 특정한 규칙과 성질에 따라 분류하고, 그 관계를 추상화하여 연구하는 학문입니다. 이를 프로그래밍 언어에 적용하자면, 객체 간의 관계를 나타내는 연산(함수)을 카테고리 이론의 개념들로 정의하고 추상화 할 수 있습니다. 연산(함수)을 추상화한다는 것은 파일 입출력, 상태 변경, 예외 처리 등의 부수 효과를 캡슐화 해서 순수 함수로 사용할 수 있다는 의미이기 때문에 순수 함수형 프로그래밍을 위해서 매우 중요한 개념입니다.

2. 펑터 (Functor)

모나드를 설명하기에 앞서 그보다 간단하면서도 중요한 개념인 펑터(Functor)에 대해 알아보겠습니다. 카테고리 이론에서 펑터는 한 범주의 객체와 사상을, 다른 범주의 객체와 사상에 대응시키는 함수입니다. 앞에서 설명한 것처럼 카테고리 이론의 각 개념들은 연산을 추상화한 것 입니다. 따라서 List나 Option이 펑터의 특성을 보인다고 해서 펑터라고 부르지 않습니다. List나 Option은 펑터의 인스턴스를 정의할 수 있는 타입 생성자(Type Constructor)이며, 펑터의 동작 특성을 정의한 함수가 map 메소드입니다. 일반적으로 펑터의 개념을 이해하기 위해 펑터를 타입 클래스로 정의한 후 펑터 인스턴스를 구현합니다. 펑터라는 추상화된 연산이 타입 클래스의 형태로 존재하기 때문에 직관적으로 이해하기가 수월합니다.

// Functor Type Class
trait Functor[F[_]] {
  def map[A, B](fa: F[A])(f: A => B): F[B]
}

object Functor:
  def apply[F[_]](using functor: Functor[F]): Functor[F] = functor

// List에 대한 펑터 인스턴스
given listFunctor: Functor[List] with
  def map[A, B](fa: List[A])(f: A => B): List[B] = fa.map(f)

// Option에 대한 펑터 인스턴스
given optionFunctor: Functor[Option] with
  def map[A,B](fa: Option[A])(f: A => B): Option[B] = fa.map(f)

// Functor 사용
def applyFunction[F[_]: Functor, A, B](fa: F[A])(f: A => B): F[B] =
  summon[Functor[F]].map(fa)(f)

@main def run() =
  val list = List(1, 2, 3)
  val option = Some(1)

  println(applyFunction(list)(_ + 1))    // 결과: List(2, 3, 4)
  println(applyFunction(option)(_ + 1))  // 결과: Some(2)

예제에서 먼저 Functor 타입 클래스를 정의하고, List와 Option에 대한 펑터 인스턴스를 정의했습니다. listFunctor와 optionFunctor에서 map 메소드를 구현하기 위해 사용한 몸체 내 map 함수는 List와 Option 자체에 정의된 메소드입니다. 스칼라의 List와 Option에서는 이미 map 메소드가 정의되어 있기 때문에 Functor 타입 클래스 없이 이미 펑터의 역할을 수행하고 있습니다. 예제에서 Functor 타입 클래스를 명시적으로 작성한 것은 펑터에 대한 이해를 돕기 위함입니다. applyFunction 메소드에서 사용한 summon이라는 함수는 주어진 타입 클래스의 인스턴스를 암시적으로 가져오는 유틸리티 함수로 표준 라이브러리 scala.Predef에 정의되어 있으며, 주어진 타입에 대해 현재 범위에서 사용 가능한 암시적 인스턴스를 찾습니다.

3. 모나드 (Monad)

어떤 타입 M에 대해 pure와 bind가 존재할 때 M을 모나드라고 합니다. 계속 말씀드리는 것이 카테고리 이론에서 나오는 개념들은 모두 연산을 추상화한 개념입니다. pure와 bind로 연산을 추상화한 타입 M이 모나드입니다. 따라서, 모나드를 이해하기 위해서는 pure와 bind의 동작을 알아야 합니다.

// pure의 정의
def pure[A](a: A): M[A]

// bind의 정의. Scala에서는 flatMap으로 구현합니다.
def flatMap[A, B](ma: M[A])(f: A => M[B]): M[B]

예제에서 처럼 pure는 타입 A를 모나드 컨텍스트 M[A]로 변환하는 함수입니다. 따라서 bind의 동작을 수행할 수 있습니다. 그리고 bind는 모나드 값을 받아서 f의 연산을 통해 새로운 모나드 값을 반환하는 동작을 수행합니다. purebind는 모나드의 개념을 설명하는 데 사용되는 일반적인 용어이며, 스칼라에서는 List, Option, Future와 같은 타입이 pure를 통해 반환되는 모나드 컨텍스트로서의 특성을 가지며, bind에 대한 동작을 flatMap으로 표현할 수 있습니다. 아래의 예제는 이해를 돕기 위해 Monad 타입 클래스를 정의하고 Option 타입을 Monad로 구현하였습니다.

// 펑터 타입 클래스 정의
trait Functor[F[_]] {
  def map[A, B](fa: F[A])(f: A => B): F[B]
}

// 모나드 타입클래스 정의
trait Monad[M[_]] extends Functor[M] {
  // 머나드의 동작 특성을 pure와 flatMap으로 정의
  def pure[A](a: A): M[A]
  def flatMap[A, B](ma: M[A])(f: A => M[B]): M[B]

  // Functor의 map을 flatMap과 pure를 사용하여 구현
  def map[A, B](ma: M[A])(f: A => B): M[B] = flatMap(ma)(a => pure(f(a)))
}

// Option 타입을 Monad로 구현한 예
given optionMonad: Monad[Option] with {
  def pure[A](a: A): Option[A] = Some(a)
  def flatMap[A, B](ma: Option[A])(f: A => Option[B]): Option[B] = ma match {
    case Some(a) => f(a)
    case None => None
  }
}

// Monad 사용 예
val maybeIntMonad: Option[Int] = Some(3)
val maybeStringMonad: Option[String] = optionMonad.flatMap(maybeIntMonad)(a => Some(a.toString))

@main def runExample(): Unit = {
  println(maybeStringMonad) // Some("3")
}

예제에서 flatMap과 map의 정의에서 확인할 수 있듯이 모나드(Monad)는 펑터(Functor)의 확장된 개념으로, 중복된 연산에 대한 한계를 극복하기 위해 연속적인 연산을 가능하게 하는 추가적인 구조를 제공합니다. 아래 예제를 통해 다시 확인해보겠습니다.

// 펑터 예제 (중첩된 컨테이너 문제)
val maybeInt: Option[Int] = Some(3)
val maybeNestedOption: Option[Option[String]] = maybeInt.map(x => Some(x.toString))    // Some(Some("3"))

// 모나드 예제 (중첩된 컨테이너 문제 해결)
val maybeInt: Option[Int] = Some(3)
val maybeString: Option[String] = maybeInt.flatMap(x => Some(x.toString))    // Some("3")

예제에서 확인할 수 있듯이 펑터에서는 중복된 연산에 대해 중복된 타입의 값을 반환하기 때문에 `Some(Some("3"))이라는 값을 반환합니다. 이는 펑터의 동작을 구현한 map의 특성이며, 이를 모나드의 flatMap에서 중복된 연산을 평평하게 만들어 단일 타입 값인 Some("3")을 반환하는 것으로 중복된 연산 문제를 해결하고 있습니다.

지금까지 카테고리 이론을 시작으로 펑터와 모나드에 대해 간단히 알아 봤습니다. 카테고리 이론을 통해 부수효과를 추상화 할 수 있고, 펑터와 모나드를 통해 연산을 체이닝하고 데이터를 변환하는 강력한 기능을 제공합니다. Cats, Scalaz, Zio와 같이 순수 함수형 프로그래밍을 지향하는 라이브러리들은 이러한 개념들을 사용하여 안정적이고 유지 보수가 쉬운 코드를 작성할 수 있도록 도와줍니다.

다음 글에서는, 비동기적으로 연산을 수행하는 Future 타입과 Future 타입에서 펑터와 모나드가 어떻게 활용되는지 알아보겠습니다. 감사합니다.

comments 0