Free 모나드

Posted on December 15, 2024

하스켈을 공부하며, 모나드가 무엇인지 알고 있는 분들을 위한 글입니다.

@todo: 자유 모나드라 번역할까, 아니면 음차만 해서 프리 모나드라 할까?

펑터가 정의되어 있다면, 여기에 모나드 법칙을 잘 따르게 join(혹은 bind)을 정의하고, return(혹은 pure)도 정의하면 모나드가 됩니다. 그런데, 달랑 펑터만 Free 구조에 넣으면, 모나드가 되게 할 수 있습니다. 그렇다고, 예를 들어 Readerfmap만 정의해서 펑터로 만들고, 여기에 넣는다고 Reader 모나드가 된다는 얘긴 아닙니다. 펑터가 반드시 하나의 모나드로만 구조가 확장되는 건 아닙니다. joinreturn을 어떻게 정의하냐에 따라 여러가지 모나드가 나올 수 있습니다. 펑터를 넣으면, 펑터에 따라 어떤 모나드가 되는 게 아니라, Free 모나드가 됩니다.

위 설명만 보면, 마치 공짜로 얻어서 Free 모나드 같지만, 아래와 같은 뜻이라 합니다.

여기서 Free는 공짜 맥주할 때 공짜가 아니라, 언론의 자유할 때 자유입니다.
– Free Software Foundation, What is Free Software?

추상 대수학에서 자유 무엇이라 하면, 무엇이 되기 위한 최소한의 조건만 만족하는 구조를 뜻합니다. 필수적으로 필요한 조건 말고는 그 구조를 제한하는 다른 조건이 없다는 뜻이라고 합니다. 음, 그럼 자유 대신 최소Minimal, (혹은 기반Base?) 라는 말이 낫지 않나 싶기도 합니다. 어쨌든, 그냥 얻어지는, 공짜로 얻어지는 무언가는 아니라는 뜻입니다. ※ 참고 - Introduction to Free Monads - Serokell

Free모나드 구조에 넣으면 공짜로 모나드가 되어서 Free모나드가 아니라, Free 모나드는 모나드가 되기 위한 최소한의 구조만 가진 모나드로, 모나드 성질을 갖기 위해서 꼭 필요한 조건, 동작이 아닌 것은 외부로 빼낸 모나드입니다. (Free 모나드 내부에 있는 펑터의 동작은 모나드가 되기 위한 최소한의 조건에 포함되지 않는다는 뜻으로 저는 이해하고 있습니다.)

Free 모나드 정의

-- data Free f r = Free (f (Free f r)) | Pure r
-- 하스켈은 알아서 값 생성자와 타입 생성자를 구별하니 보통 위처럼 같게 쓰는데,
-- 여기서는 혼란을 줄이기 위해 값 생성자에 `V`를 붙여 명시하겠습니다.
data Free f r = FreeV (f (Free f r)) | Pure r

위와 같이 리스트랑 비슷해 보이는 재귀적인 구조를 준비합니다. 그리고, 위 구조에 대해서 아래와 같이 펑터 인스턴스를 정의하고,

instance Functor f => Functor (Free f) where
  fmap fun (Pure x) = Pure (fun x)
  fmap fun (FreeV g) = FreeV (fmap fun <$> g) -- f의 <$>

모나드 인스턴스를 정의합니다.

instance (Functor f) => Monad (Free f) where
    return a = Pure a
    (FreeV x) >>= g = FreeV (fmap (>>= g) x)
    (Pure r) >>= g = g r

준비는 끝났습니다. 이제 Free f r에서 f 자리에 펑터를 넣어 주면, “이 펑터는 모나드 속성을 가진다고” 얘기합니다. 예를 들어 SomeFunctor는 펑터 성질만 있고, Free SomeFunctor는 모나드란 뜻입니다.

위 정의를 보면, 짧고 단순해 보이지만, 저는 무슨 암호문 같이 보입니다. 한참(몇 일이 아닙니다. 몇 년입니다. 이해했다 생각하고 사용할 줄도 알지만, 누구한테 설명하기엔 찜찜했습니다. 남한테 설명할 정도가 되어야 진짜 이해한 거라고 어디서 봤습니다.) 째려 보다가 나름 설명할 방법을 찾아 글을 남깁니다.

Free 모나드는 최소한의 모나드 구조를 제공한다고 말합니다. 모나드는 펑터 구조를 가지고 있고, join(혹은 바인드>>=), return을 제공하고, 모나드 법칙을 따라야 모나드 구조가 완성 됩니다. 이런 것들을 다 정의하지 않아도, Free 구조에 펑터를 넣으면, 모나드 성질을 갖게 됩니다. 이렇게 얘기하면 마치 펑터의 성질에 따른 어떤 모나드가 될 것 같지만, 임의의 모나드 성질이 아니라 Free 모나드 성질만 갖습니다. Free 모나드 성질이 뭘까요? 이를 알아 보기 위해 Free 모나드가 어떤 이펙트를 다루는지 봐야 합니다. 그런데, 이펙트 생성은 펑터에서 이루어지고, 이렇게 생성된 이펙트를 합성할 때 모나드 구조가 개입할텐데, Free 모나드 설계는 철저하게 펑터의 구조에는 의존하지 않게 설계하고, 나중에 펑터를 받는 구조입니다. 이펙트가 뭔지도 모르는데, 합성할 줄 아는 모나드가 필요합니다.

어떻게 이펙트를 몰라도 합성할 수 있을까?

이펙트를 볼 땐, >>=보단 join이 좀 더 잘 보이니, join을 뜯어 보겠습니다.

1 Reader 모나드의 join, 2 State 모나드의 join, 3 Maybe 모나드의 join

Free 모나드의 join도 어떤 이펙트 작업이 두 번 일어난 걸, 한 번만 하는 걸로 보이게 바꾸어 줄텐데, 뭘 하는지 잘 보이지 않습니다.

join (Pure x)  = x
join (Free fx) = Free (fmap join fx)
--                       |    |
--                       |  Free의 join
--                       | 
--                    외부 펑터의 fmap

몇 겹의 Free로 쌓여 있든, Pure가 나올 때까지 몇 단계든 들어가서 x만 남게 만듭니다. 각 단계마다 외부에서 받은 펑터의 fmap이 동작합니다. 여러 겹을 한 겹으로 만드는 동안 외부 펑터 fmap이 쌓여 있는 만큼 동작한다는 것 말고 특별한 작업은 없습니다. (만일, 펑터로 Maybe같이 계산을 끊어버리는 펑터가 들어가면 물론 Pure까지 도달하지 않을 수 있습니다.)

보통의 모나드의 joinm (m a) -> m a 타입으로, 예를 들어 join Just (Just (Just 1))Just (Just 1)이 됩니다. 하지만, Freejoin은 마치, 세겹 이상 쌓여 있는, Free(f (Free(f (Free(f (Pure r))))) 같은 것들도 Free(f (Pure r))로 만들 것처럼 보입니다.

샌드위치처럼 Free와 외부 펑터 f가 몇 겹으로 번갈아 있는 걸 Free(f (Pure r))로 만드는 것처럼 보이는데, join (Pure x) = x가 걸립니다. 마치 Pure가 사라지고 Free(f r)이 될 것만 같은 join의 동작입니다. 그러면, Free 구조가 망가지니 그러면 안될텐데요. (전 여기가 Free 모나드를 이해하는 힌트였습니다.)

이제부터 상상입니다. 주의해서 보세요.

둘을 모으기만

모나드는 두 번의 이펙트를 모아서, 하나로 만든다고 얘기합니다. 마치 reduce가 바로 바로 일어나서 하나가 되는 느낌입니다만, 다른 모나드의 join들을 유심히 살펴 보시면, 첫 째, 둘을 모아서 외부에서 보면 하나처럼 보이게 만들고, (밀가루 반죽 두 개를 뭉치듯이) 둘 째, 다시는 하나 하나로 돌아오지 못하게 합성하는 작업 둘로 나눌 수 있습니다. 그런데, 이 두 단계 중 둘을 모으는 첫 번째 단계는 합성하는 방법을 몰라도 할 수 있습니다. 다르게 얘기하면 이펙트 합성 방법을 몰라도, 즉 펑터의 동작은 나중에 받을거야 하고, 작업을 진행할 수 있습니다. ※ 참고 - 모노이드 이항 연산의 일반화

“Free 모나드는 이펙트 둘을 모아 놓기만 하고, 합성까지는 하지 않습니다.”

@todo: 어디까지를 합성이라 볼까? 합성이란 낱말보다 더 적당한 말이 뭐가 있을까?

이펙트 하나와, 다른 이펙트 하나를 합친다고 할 때, 이펙트 하나는 진짜 하나가 아닌, 여러 개를 합쳐 놓은 하나 같은 것(모노이드)일 수 있습니다. Free타입의 구조를 유심히 보시면, 이펙트를 받아 리스트에 담아 두듯, 반복되는 Free 생성자 사이 사이에 꽂아서 보관만 합니다. 즉, 모아 놓기만 합니다. Free 모나드의 join

Free(f (Free(f (Pure r))))Free(f (Pure r))로 만드는 것이 아니라,
Free(f (Free(f ...(Pure r))))rFree(f (Free(f ...(Pure r))))을 넣어 붙이는 게 목적입니다.

※ 다른 문서들을 읽을 때, 값 생성자와 타입 생성자를 잘 구별해서 봐야 합니다. 위 Free는 모두 값 생성자입니다. m (m a) -> m a가 보이도록 타입 생성자와 값 생성자가 구별 되게 바꾸면, Free 모나드의 joinFreeV f (FreeT f r)을 받아 FreeV f r로 만드는 함수입니다.

모아 놓은 이펙트들을 합치는 건 나중 일입니다.

어디다 쓰지?

선언형으로(혹은 DSL로) 첫 번째 할 일이 (A,B,C)이고, 두 번째 할 일이 (D,E)라고 선언만 해 둔 걸 합쳐서 (A,B,C,D,E)로 만드는데 딱입니다. 이 작업 진행서같은 선언 목록만 읽어 들이며, 각 각의 작업이 어떤 일을 실제로 할지 해석하는 인터프리터를 따로 두고 싶을 때 유용하게 쓸 수 있습니다.

선언과 인터프리터로 분리한다는 말은, 모노이드 이항 연산의 일반화와 같은 얘기입니다.

※ 참고 - Free 모나드 패턴

리스트 모나드와 비슷

Free 모나드와 리스트를 비교해 봅시다.

data Free f r = Free (f (Free f r)) | Pure r
data List   a = Cons  a (List   a)  | Empty

비슷한 듯 다른 듯 보입니다. 금방 눈에 보이는 건, 둘 다 재귀 구조이며, 끝을 나타내는 값 생성자가 있습니다. 리스트와 리스트를 (<>)를 이용해 붙이듯, Free 모나드의 join은 각 각 몇 겹으로 쌓인 Free값 둘을 이어 붙입니다. 다른 말로 하면, [[1,2],[3,4]][1,2,3,4]로 평평flat하게 만드는 리스트 모나드의 동작이 아니라 [1,2] <> [3,4][1,2,3,4]로 만드는 동작과 비슷합니다.

Q. 그럼, Free 모나드가 필요한 곳에 그냥 리스트를 쓰면 되지 않을까요?
A. Free 모나드는, 한 겹 파고 들어갈 때마다 펑터의 fmap이 동작합니다. 예를 들어 Maybe를 넣었다면, 중간에 Nothing을 만나면 바로 끝나는 동작을 합니다.

※ 프리 모노이드도 여기서 얘기하는 이항 연산 일반화의 “둘을 모으는 작업”만 하는 모노이드입니다. 프리 모노이드는 리스트로 표현할 수 있습니다.

외부 펑터들을 넣은 예시

실제 동작을 보면 Free 펑터의 동작이 잘 보이니, 예를 들어 보겠습니다.
머릿속에서 모나드 인스턴스는 지우고, 펑터만 구현되어 있는 리스트, Maybe를 Free 모나드에 넣어 보겠습니다.

Free값은 항상 Pure로 끝납니다. Free(f (Pure r))이거나, Free(f (Free(f (Free (f (Pure r))))))같은 모양입니다. 첫 번째에서 r에 또다시 Free(f (Pure r))을 넣어 준다면, Free(f ( Pure (Free(f (Pure r)))))이 됩니다. 여기에 join을 돌리면, join (Pure x) = x 구현에 따라 중간에 들어가 있는 Pure는 사라지고, Free(f (Free(f (Pure r))))이 됩니다.

리스트 펑터

Free 모나드 Free f r에서 f에 리스트 펑터를 넣어 주면 어떤 동작을 하는지 보겠습니다.

data List a = Cons a (List a) | Empty
Free List a 의 값은
Free (Empty)
Free (Cons (Pure r))
Free (Cons (Free (Empty)))
Free (Cons (Free (Cons (Pure r))))
...

이런 값들이 있는데, Free (Cons (Pure r))rFree (Cons (Free (Cons Pure r)))을 넣으면,
Free (Cons (Pure (Free (Cons (Free (Cons Pure r))))))이 되고 join을 돌리면
Free (Cons (Free (Cons (Free (Cons Pure r)))))이 됩니다.

리스트 모나드처럼 이펙트를 합성하는 모습은 없습니다.

Maybe 펑터

data Maybe a = Just a | Nothing
Free Maybe a의 값은
Free (Nothing)
Free (Just (Pure r))
Free (Just (Free (Nothing)))
Free (Just (Free (Just (Pure r))))
...

Free (Just (Pure r))에서 rFree (Just (Free (Just (Pure r))))을 넣으면,
Free (Just (Pure (Free (Just (Free (Just (Pure r)))))))이 되고 join을 돌리면
Free (Just (Free (Just (Free (Just (Pure r))))))이 됩니다.

Maybe 모나드처럼 이펙트를 합성하는 모습은 없습니다.

마치며

DSL을 만들 때, 더 퍼포먼스가 좋다는 tagless final 같은 대안이 있긴 하지만, 나름 장단점이 있어 자주 쓰입니다. 그래서, 잊을만하면 어디선가 Free모나드를 만나곤 합니다. Free 모나드 패턴에 대해선 몇 년 전에 정리 해 뒀지만, Free 모나드 자체에 대해선 정리가 안되고 걸리적 거리는 부분이 있어 미루고 있었습니다. (신기하게도 하스켈은 모르고도 잘 쓰는 것들이 많습니다. 여러 자료들을 보면 저만 그런 건 아닌가 봅니다.) 몇 년 지나면서 모나드에 대해 지식이 조금 더 생겨 덤벼 봤습니다. Free 모나드가 왜 공짜 모나드가 아닌지 보는 것을 넘어, 모나드의 본질에 대한 생각을 넓히는데 도움이 되는 것 같습니다. 실용 프로그래밍에 모나드를 쓰는 건 그리 오래 걸리지 않지만, 모나드는 한 방에 완벽하게 알아 먹긴 힘든 놈 같습니다.

제 글들에 지겹도록 반복해서 주의문을 달고 있는데, 늘 혼자 상상으로 풀어 나가니 이번에도 어쩔 수 없습니다. 틀린 내용이 있을 수 있습니다.


  1. Reader 모나드의 join

    join rra = Reader $ \r -> runReader (runReader rra r) r

    rrar을 넣어 주고 받은 결과값에 또 r을 넣어 주는 작업을, 바깥에서 보면 r을 한 번만 받는 작업으로 보이게 해 줍니다.↩︎

  2. State 모나드의 join

    join st = State $ \s ->
                 let (st', s') = runState st s 
                     (a, s'')  = runState st' s' 
                 in  (a, s'')

    s를 넣어주고 계산해서 바뀐 s'을 넣어 주는 작업을, 바깥에서 보면 s를 한 번만 받는 작업으로 보이게 해 줍니다.↩︎

  3. Maybe 모나드의 join

    join Nothing = Nothing
    join (Just Nothing) = Nothing
    join (Just (Just x)) = Just x

    Just인지, Nothing인지 판단하는 작업을 한 번만 하도록 바꿉니다.↩︎

Github 계정이 없는 분은 메일로 보내주세요. lionhairdino at gmail.com