게임 회사에 입사했을때 처음으로 주어진 업무는

아이온이라는 MMORPG 게임에서 캐릭터를 하나 생성해서 만랩으로 만드는 것이었다.

업무시간에 점심시간에 야근으로 게임을 하더라도 아무도 뭐라 그러는 사람이 없으니 한편으론 문화충격이기도 했고..

게임에 관심이 별로 없었던 나로써는 이건 왜 해야하지라는 의문만 들었다.

 

물론 만랩에 도달하고서야 알게되었는데,

캐릭터를 일단 만랩으로 만들고 나서 시작되는 퀘스트와 할 수 있는 것들이 너무나도 많았다.

 

결국 게임 시작의 필요조건은 본인 캐릭터를 만렙으로 만드는 것이라는 것을 알았다.

----

 

Scala를 공부하면서 드는 생각은 파도파도 끝이 없는데 도대체 어디까지 가야 만랩의 시작점일까?

물론 Spark 정도를 돌려보는 수준이라면 Scala의 5% 정도만 알아도 대부분의 기능을 원활하게 쓸 수 있다.

 

그 이외의 것들을 하기위한 95%의 노력이 들어가는 영역. 외로운 싸움인 것 같다.

Scala를 시작하고 2년 정도 흘러간 현시점.

ZIO, Cats를 조금씩 쓰고 있는데 결국은 Monad 구조를 코드에 얼마나 잘 녹여 넣는가로 정리가 되어 가는 느낌이다.

Scala의 Purely Functional을 지향하는 대부분의 툴들은 Free Monad 구조가 녹아 있는 부분이 많다.

Java에 Spring Framework이 있다면 현 시점 Scala에는 ZIO가 있고 Scala에 대표 툴들이 ZIO로 통합되어가고 있는 분위기다.

 

Free Monad를 몰라도 ZIO 등의 Free Monad 친화적 구조의 툴 들을 쓰는데 큰 문제는 없지만 쓰면 쓸수록 뭔가 마음속에 답답한 짐이 커짐을 느낀다.

Free Monad의 구현방식을 정확하게 이해하지 않으면 Scala와의 여정이 시작부터 쉽지 않을 것 같다.

일단 Free Monad 구현 코드를 꼼꼼히 살펴보자.

----

 

구현 코드는 스칼라 공부하는 사람의 천사, 다니엘님의 영상과 블로그를 참고하였다.

https://blog.rockthejvm.com/free-monad/

 

Free Monad in Scala

A tutorial on the Free monad in Scala, how it works and what it’s good for.

blog.rockthejvm.com

매우 잘 정리된 글에 영상이지만 그저 놀라운 모습을 바라만 보고 있기를 반복하는데 뭔가 막히는 부분을 찾아내고 정리하는 과정이 필요하다 생각되었다.

 

우선 Free Monad로 쓸 수 있는 유용한 좋은 점중 하나가 비지니스로직과 구현로직의 분리이다.

Java나 객체지향 언어에서 사용한 Tempate Method Pattern은 상위계층에서 Function의 flow를 정의하고 구현 Class를 분리하는데, 

Scala역시 Free Monad를 이용하여 함수적으로 로직과 구현을 분리한다.

 

def myLittleProgram = for { 
  _ <- create[String]("123-456", "Daniel")
  name <- get[String]("123-456")
  _ <- create[String]("567", name.toUpperCase())
  _ <- delete("123-456")
} yield ()

위의 코드는 비지니스 로직, 즉 로직에 의한 output 데이터의 flow라고도 볼 수 있다.

데이터를 생성(create)하고, 생성된 데이터의 내부 값(get)을 변환 저장(create)하고, 기존 데이터는 삭제(delete)하는 흐름이다.

 

for comprehension 상에서 코드가 정의되었기에 create, get, delete는 개별적으로 Monad 객체라는 것을 짐작할 수 있다.

trait DBOps[A]
case class Create[A](key: String, value: A) extends DBOps[Unit]
case class Read[A](key: String) extends DBOps[A]
case class Update[A](key: String, value: A) extends DBOps[A]
case class Delete(key: String) extends DBOps[Unit]

def create[A](key: String, value: A): Free[DBOps, Unit] =
Free.liftM[DBOps, Unit](Create(key, value))

def get[A](key: String): Free[DBOps, A] =
Free.liftM[DBOps, A](Read[A](key))

def update[A](key: String, value: A): Free[DBOps, A] =
Free.liftM[DBOps, A](Update[A](key, value))

def delete(key: String): Free[DBOps, Unit] =
Free.liftM(Delete(key))

모든 함수는 Free[_,_] 의 인스턴스를 리턴한다. Free[_,_] 자체가 flatMap, pure 함수를 포함하는 Monad라는 것을 짐작할 수 있다.

다만 우리가 알고 있던 모나드의 모습은 M[_]인데 type 인자가 하나 더 있는 더 복잡한 구조라는 것을 짐작할 수 있다.(겁부터 먹는다)

 

일단 Free의 구조를 살펴보자.

trait Free[M[_], A] {
    import Free.*
    def flatMap[B](f: A => Free[M, B]): Free[M, B] = FlatMap(this, f)
    def map[B](f: A => B): Free[M, B] = flatMap(a => pure(f(a)))
    def foldMap[G[_]: Monad](natTrans: M ~> G): G[A] = this match {
      case Pure(a) => Monad[G].pure(a)
      case Suspend(ma) => natTrans.apply(ma)
      case FlatMap(fa, f) => // need a G[B] --> fa = DBMonad = Suspend, f = Next DBMonad = Suspend
        Monad[G].flatMap(fa.foldMap(natTrans))(a => f(a).foldMap(natTrans))
    }
}
  
object Free {
    def pure[M[_], A](a: A): Free[M, A] = Pure(a)
    def liftM[M[_], A](ma: M[A]): Free[M, A] = Suspend(ma)

    case class Pure[M[_], A](a: A) extends Free[M, A]
    case class FlatMap[M[_],A,B](fa: Free[M, A], f: A => Free[M, B]) extends Free[M, B]
    case class Suspend[M[_], A](ma: M[A]) extends Free[M, A]
}

짧은 지식이지만 Monad에는 flatMap, pure 함수가 있다는 걸 알았고 map은 보너스라는 건 알고 있다.

새롭게 보이는 function이 liftM과 foldMap 이다.

 

def liftM[M[_], A](ma: M[A]): Free[M, A] = Suspend(ma)

// 사용 예제
def create[A](key: String, value: A): Free[DBOps, Unit] =
	Free.liftM[DBOps, Unit](Create(key, value))

// 참고
case class Create[A](key: String, value: A) extends DBOps[Unit]

liftM의 정의와 구현은 위와 같은데 뭔가 심오하다. 

일단은 M[A] 를 받아서 Free[M,A]로 변환된 객체를 리턴하는 구조로 보면 되는데, DBOps[Unit] -> Free[DBOps, Unit] 의 형태로 변환하는 pure의 Free Monad 함수로 보면 될 것 같다.

 

그리고 Free Monad의 핵심 foldMap인데 우선 어떻게 사용되는지 살펴보자

val dbOps2IO: DBOps ~> IO = new (DBOps ~> IO) {
  override def apply[A](fa: DBOps[A]): IO[A] = fa match {
    case Create(key, value) => IO.create { // actual code that uses the database
      println(s"insert into people(id, name) values ($key, $value)")
      myDB += (key -> serialize(value))
      ()
    }
    case Read(key) => IO.create {
      println(s"select * from people where id=$key limit 1")
      deserialize(myDB(key))
    }
    case Update(key, value) => IO.create {
      println(s"update people(name=$value) where id=$key")
      val oldValue = myDB(key)
      myDB += (key -> serialize(value))
      deserialize(oldValue)
    }
    case Delete(key) => IO.create {
      println(s"delete from people where id=$key")
      ()
    }
  }
}

val ioProgram: IO[Unit] = myLittleProgram.foldMap(dbOps2IO)

ioProgram.unsafeRun()

우선 사전 정의되었던 myLittlePrograme의 foldMap(dbOps2IO) 함수를 호출하여 DBOps 기반의 데이터를 IO 형태로 변환하고, 

IO에서 제공하는 unsafeRun() 함수를 호출하여 실제 로직의 프로그램을 실행시킨다 (놀랍다)

 

즉 비지니스로직상의 전체 data flow와 세부 action의 로직을 foldMap() 함수가 연결시켜주는 역할을 한다.

처음으로 생각나는 Scala 문법 구조상 이해가 되지 않는 의문은 for comprehesion이다.

def myLittleProgram: Free[DBOps, Unit] = for { // monadic
  _ <- create[String]("123-456", "Daniel")
  name <- get[String]("123-456")
  _ <- create[String]("567", name.toUpperCase())
  _ <- delete("123-456")
} yield ()

myLittleProgram의 최종 return type은 위의 코드에서 보듯이 Free[DBOps, Unit] 이다. 

 

여기서 foldMap의 동작 원리를 이해하기 위해서 for comprehension의 동작 방식에 대한 의문이 든다.

 

우리가 알고 있는 for comprehension은 각 Monad 객체 flatMap 연산을 chain형태로 순차적으로 호출하고 최종 결과값을 return하는 구조로 알고 있다.

 

 chain과 같은 정방향의 실행구조 (a.flatMap(a1 => b.flatMap(b1 => c.flatMap(c1 => c1.map(.. ))) 를 가지고 최종 결과값에 대한 접근만 myLittleProgram 함수명을 통하여 가능하다. 즉 myLittleProgram은 아래와 같이 풀어 쓸 수 있다.

val myLittleProgram2: Free[DBOps, Unit] = create[String]("123-456", "Daniel").flatMap(a1 => {
  get[String]("123-456").flatMap(name => {
    create[String]("567", name.toUpperCase()).flatMap(a3 => {
      delete("123-456").map(a4 => ())
    })
  })
})

for comprehension의 표현을 단순히 풀어 쓴 코드라 myLittleProgram2의 return type 역시 Free[DBOps, Unit]으로 동일하다. 

foldMap 구현시 첫번째로 해야될 일은 DBOps[String], 즉 Free[DBOps, String]을 IO[String]으로 변환하는 로직일 것이다.

 

그런데 다시 create[String]에 어떻게 접근 혹은 진입을 할 수 있을까?

 

결론부터 얘기를 하면 우리가 알고 있던 flatMap과 map은 뭔가 연산을 하고 결과값을 던져주는 함수인데,

여기서 사용되는 flatMap은 단순히 원본의 데이터를 IO 형태로 변환시켜주는 역할만을 한다.

println("MyLittleProgram => " + myLittleProgram)

// output
MyLittleProgram => FlatMap(Suspend(Create(123-456,Daniel)),c.y.study.FreeMonadSample$$$Lambda$17/0x0000000800096040@319b92f3)

위의 코드는 놀랍게도 for comprehension의 실행 결과가 값이 아닌 변환된 데이터 타입을 보여준다. 변환된 데이터 타입을 풀어쓰면 아래와 같다. 

def myLittleProgram = FlatMap(Suspend(Create(123-456,Daniel)),
	FlatMap(Suspend(Read("123-456")), 
		FlatMap(Suspend(Create(Read(..)), 
        		FlatMap(Suspend(Delete("123-456")), Pure(Unit))))

최상 root 객체가 FlatMap이고 chain 혹은 Linked List와 같은 형태의 구조적인 데이터 구조를 이루고 있다.

즉, myLittleProgram의 참조 객체는 FlatMap이 된다.

 

실제로 foldMap() 함수를 호출하였을때 실행되는 순서는 아래와 같다.

[FM] FlatMap: Suspend(Create(123-456,Daniel)), f =>c.y.study.FreeMonadSample$$$Lambda$17/0x0000000800096040@77846d2c
[FM] Suspend: Create(123-456,Daniel)
insert into people(id, name) values (123-456, Daniel)
[FM] FlatMap: Suspend(Read(123-456)), f =>c.y.study.FreeMonadSample$$$Lambda$23/0x0000000800091840@3b94d659
[FM] Suspend: Read(123-456)
select * from people where id=123-456 limit 1
[FM] FlatMap: Suspend(Create(567,DANIEL)), f =>c.y.study.FreeMonadSample$$$Lambda$25/0x0000000800118040@103f852
[FM] Suspend: Create(567,DANIEL)
insert into people(id, name) values (567, DANIEL)
[FM] FlatMap: Suspend(Delete(123-456)), f =>c.y.study.FreeMonadSample$Free$$Lambda$27/0x000000080011a040@71623278
[FM] Suspend: Delete(123-456)
delete from people where id=123-456
[FM] Pure :()

데이터의 비지니스로직을 정의한 순서에 따라 순회하여 데이터를 참조 변환이 가능한 구조가 된다. 아래의 코드가 실질적인 그 역할을 하고 있다.

trait Free[M[_], A] {
// ...

    def foldMap[G[_]: Monad](natTrans: M ~> G): G[A] = this match {
      case Pure(a) => Monad[G].pure(a)
      case Suspend(ma) => natTrans.apply(ma)
      case FlatMap(fa, f) => // need a G[B] --> fa = DBMonad = Suspend, f = Next DBMonad = Suspend
        Monad[G].flatMap(fa.foldMap(natTrans))(a => f(a).foldMap(natTrans) )
    }
}

// 참고1
def flatMap[B](f: A => Free[M, B]): Free[M, B] = FlatMap(this, f)

// 참고2
case class FlatMap[M[_],A,B](fa: Free[M, A], f: A => Free[M, B]) extends Free[M, B]

// 참고3
given ioMonad: Monad[IO] with {
  override def pure[A](a: A) = IO(() => a)

  override def flatMap[A, B](ma: IO[A])(f: A => IO[B]) =
    IO(() => f(ma.unsafeRun()).unsafeRun())
}

데이터 구조(리스트라 하자)를 순회하며 기존 데이터를 Free[DBOps, A] --> IO[A] 형태로 변환하며, 비지니스로직의 실제 구현체가 호출(unsafeRun())되는 구조를 가진다.

 

foldMap은 FlatMap을 발견하면 내부의 본래 데이터로직(Suspend)을 실행하고, 실행 결과를 다음 로직(FlatMap)이 참조하여 실행될 수 있도록 실행하는 구조를 가진다.

 

즉, FlatMap은 내부 데이터를 추출 검증(실행)하고, 다음 로직을 실행하는 역할, Suspend는 실제 로직을 실행(() => a)하는 역할을  한다 보면된다.

 

---

굉장히 복잡해 보일 수 있는데  FreeMonad는 단순하게 비지니스로직을 정의하고 실제 구현로직은 IO로 정의하여 순차적으로 구현로직이 비지니스 로직의 정의에 따라 실행될 수 있도록 내부적으로 복잡한 변환 로직을 가지고 있다.

물론 로직의 실행 흐름은 Monad의 for comprension에 정의된 흐름의 틀에 어느정도 의존성을 가진다 볼 수 있다.

 

ZIO, Cats 등 Purely Fuctional을 지향하는 모든 툴에서 위와 유사한 구조의 코드를 볼 수 있다.

 

---

즉, 위의 Free Monad 코드와 개념, 활용을 정확히 이해하는 것이 Scala 시작을 위한 준비라 볼 수 있겠다 -_-;;

 

+ Recent posts