크리스마스에는 모나드지

Posted on December 24, 2024

크리스마스랑 사실 아무 상관 없습니다. 그냥, 2024년 크리스마스 이브에 일이 손에 안 잡혀 영원한 떡밥, 모나드를 건드려 봤습니다.

처음 모나드를 보는 분이 아닌, 모나드에 한 번쯤, 다리 걸려 넘어져 봐서, 위 말들에 공감하는 분들이 보면 좋을 생각 스트레칭을 위한 인문학스러운 글입니다. 높은 확률로 또 하나의 부리토가 나오겠지만, 계속 시도해 보겠습니다. 반복하다 보면, 언젠가 쓸만한 설명이 나오지 않을까요?

아래는 수학도 없고, 전혀 하스켈 코드를 내보이지도 않습니다. 하스켈스러운 코드가 보여도 그저 의사 코드일 뿐입니다.

※ 저는 함수형이 하도 어렵다가 친근하다가 해서, 가끔은 함수brother처럼 보입니다.

조합

1(+ 2)를 적용하면 1 (+ 2) = 3
위 결과 값에 다시 (* 3)을 적용하면 3 (* 3) = 9
위 결과 값에 다시 (- 4)을 적용하면 9 (- 4) = 5

위 작업을 보니, 함수들을 계속 적용하고 있습니다. 그럼 아래와 같이 뭉쳐서, 조합해서 표현할 수 있습니다.
1(- 4) ⋅ (* 3) ⋅ (+ 2)를 적용 후 결과로 5를 받습니다.

데이터에 순차적으로 연산을 적용하며, 바뀌는 데이터 상태를 끌고 다니던 것을,
함수들만 먼저 모아 놓고, 한 방에 적용하는 모양으로 바꿨습니다.

결합

((- 4) ⋅ (* 3)) ⋅ (+ 2)를 적용하거나, (- 4) ⋅ ((* 3) ⋅ (+ 2))를 적용하거나 같은 결과를 얻습니다. 순서만 지킨다면 어떤 연산을 먼저 묶어 적용하든 같은 결과를 받을 수 있습니다. 위 연산들을 모듈이라고 하면, 어떤 조합의 모듈로 만들지 자유롭게 결정할 수 있습니다.

아무 일도 안함

예를 들어, 현재 모델링하는 시스템은, 모든 데이터는 항상 세 개의 함수를 적용해서 결과를 얻어(반드시 특정 가공 절차를 거친 후), 다음에 이용한다고 가정하겠습니다. 모든 수는 항상 세 개의 함수를 적용하는데, 어떤 값은 두 개, 혹은 한 개, 아니면 아예 함수를 적용하지 않고 써야하는 값이 있는 경우도 있는 상태입니다. 이런 값은 “항상 세 개의 함수를 적용하는 절차를 거치는 값”으로 어떻게 표현할까요? 아무 일도 안함 함수가 있으면 됩니다.

예를 들어 2를 변경 없이 그대로 쓰려면, 2아무 일도 안함아무 일도 안함아무 일도 안함이란 세 번의 절차를 적용하면 됩니다.

위에 조건 세 가지를 얘기했는데, 정리하면1,

  1. (+ 2), (* 3), (- 4) 같은 함수들은 을 이용해 계속 조합할 수 있고,
  2. 과 조합했을 때 아무 일도 안함 함수가 있고,
  3. (* 3) ⋅ (+ 2)하고 (- 4)를 적용하거나, (+ 2)하고, (- 4) ⋅ (* 3)를 적용하거나 같은 결과를 얻습니다.

연산(함수)들이 위와 같은 조건을 만족하는 구조를, 이름부터 수학 냄새가 너무나는 모노이드라 부릅니다.
함수들이 모노이드 구조를 만족하면, 먼저 연산(함수)들을 마음대로 조합해 놨다가, 값에 적용할 수 있습니다.(값이 아니라, 연산(함수)들이 모노이드 구조를 이루고 있음을 눈여겨 보세요) 하스켈의 모든 실행 프로그램은 모노이드 구조를 따르고 있는데, 여기서는 거창한 수학 이름보다는 동작에만 집중해서 보시고, 일단 “모노이드가 되면 편하구나” 정도 믿고 넘어가겠습니다.

모노이드가 되면 (a -> d) 함수를 받는 곳에 (c -> d) ⋅ (b -> c) ⋅ (a -> b) 함수를 넘길 수 있게 됩니다.

with Effect

이 번에는, 연산을 하면, 다음에 또 연산을 바로 적용할 수 있는 값만 나오지 않고, 무언가 추가적인 정보를 가진 결과가 나오는 경우를 보겠습니다. 예를 들어, 연산을 적용하면서, 연산 횟수를 기억한다든지, 더 이상 연산할 수 없는 값이 나온다든지, 어떤 연산을 하고 있는지 화면에 출력한다든지…하는 연산 이외의 어떤 작업이 일어나는 경우입니다. 값만 나오던 연산과 구별해서 다음처럼 표기하겠습니다. (추가적인 정보를 Effect라 부르겠습니다. 왜 Effect라 부르는지는 나중에 여기를 참고하세요.)

(+ 2) with Effect, (* 3) with Effect, (- 4) with Effect
(조합 중인 연산의 with Effect는, 모두 같은 동작을 대상으로 합니다. 예를 들어, 횟수 기록을 한다면, 조합 중인 각 연산에 있는 Effect는 모두 횟수 기록 동작을 의미 합니다.)

1(+ 2)를 적용 후 나온 3(* 3)을 적용하면 좋겠는데,
1(+ 2) with Effect를 적용하면 3 with Effect가 나오고, 이를 숫자만 오길 기다리고 있는 (* 3) with Effect에 넣어 주지 못하는 상황입니다. 연산 with Effect을 써서 (* 3) with Effect ⋅ (+ 2) with Effect 처럼 묶어 놓을 수 없다는 얘기입니다. 다른 말로 하면, 모노이드로 만들지 못하니, 먼저 묶어 놨다가 적용하는 식으로 쓸 수 없습니다. 이렇게 되면 활용도가 훅 떨어집니다. 아래 해결책을 보기 전에, 먼저 해결책을 상상해 보시기 바랍니다.

어떻게 하면 (* 3) with Effect ⋅ (+ 2) with Effect 처럼 쓸 수 있을까요?

누군가가 with Effect를 떼어내서 EffectEffect대로 계산을 하고, (+ 2)(* 3)를 조립하고, 나중에 with Effect를 다시 붙여주는 작업을 하면 됩니다. 함수를 조립하던 함수가 담당하면 딱인 작업입니다.

은 인자를 받아 첫 째 함수를 적용해서 결과값을 받은 후, 이 값을 두 번째 함수의 입력으로 주고, 결과를 받는 함수입니다.

g ⋅ f = \x -> g (f x)

join

위 함수가 Effect를 지원하게 바꿔 보면, (구별하기 위해 대신 를 쓰겠습니다. 의사 코드입니다.)

g ∘ f =
\init -> 
  result1 with Effect1 = f init 
  result2 with Effect2 = g result1
  return (result2 with (Effect1Effect2 합성))
--                      ---------join---------

점 점 부리토가 될 확률이 높아지고 있습니다만, 계속해 보겠습니다.

위와 같은 함수를 정의하면, 이제 다음처럼 쓸 수 있습니다.

(* 3) with Effect ∘ (+ 2) with Effect

위 합성 함수의 결과도 여전히 연산 with Effect입니다. 다시 말해, 연속으로 다음처럼 쓸 수도 있습니다.

(- 4) with Effect ∘ (* 3) with Effect ∘ (+ 2) with Effect

대신 새로 을 정의했습니다. 의 정의를 잘 보시면, 함수를 적용하며 생기는 Effect들을 따로 모아 하나로 합치는 join 작업이 중요한 역할을 합니다.

return

이제, 변경 없이 그대로 쓰기 위해 필요했던 아무 일도 안함의 동작을 보겠습니다.

연산 ⋅ 아무 일도 안함 = 그대로 연산

위와 비슷하게

연산 with Effect ∘ (아무 일도 안함) = 그대로 연산 with Effect

가 되어야 합니다. 의 동작을 보면, 안에 있는 Effect1Effect2 합성은 Effect 두 개가 필요합니다. 하지만, 아무 일도 안함Effect가 없는 상태입니다. 아무 일도 안함아무 일도 안함 with 기본 Effect로 만들어 주는 return이란 함수를 정의할 수 있다면, 다음처럼 동작할 수 있습니다. (Effect와 기본 Effect를 합성했을 때 Effect는 변하지 않습니다.)

연산 with Effectreturn (아무 일도 안함) = 그대로 연산 with Effect

결합같은 경우, 나중에 실제 코드로 정의해 보면, Effect가 없었을 때와 마찬가지로 결합에 상관없이 같은 결과가 나옴을 확인할 수 있습니다만 여기선 넘어 가겠습니다.

모나드

연산 with Effect아무 일도 안함으론 모노이드가 되지 못했지만,
연산 with Effectreturn이 있으면 모노이드가 됐습니다.

모노이드가 되지 못하던 연산 with Effect 모양이 (join을 갖고 있는 )과 return으로 모노이드가 됩니다. 이런 구조를 모나드라 부릅니다.

조금 더 복잡하게 얘기하면, Effect가, Effect 두 번을 적용하는 동작(펑터 합성)과, 이를 하나로 만드는 join과, 기본 Effect를 만들어내는 return이 모노이드 구조를 만들어내고, 이 덕분에 연산 with Effect가 모노이드 구조가 됩니다. 하스켈에서 얘기하면 펑터를 두 번 적용한 것을 join이 있어 한 번 적용하는 것으로 만들고, 이 join이 별다른 동작을 안하게 하는 return이 있으면 모나드라 부릅니다.

이제 (a -> m d) 함수를 받는 곳에 (c -> m d) ∘ (b -> m c) ∘ (a -> m b) 함수를 넘길 수 있게 됐습니다.

마치며

하스켈에서

Effect는 펑터 m,(m이 문법적으로 의미가 있는 건 아니고, 관습적으로 이펙트가 있는 구조에 m을 붙입니다.)
연산 with Effectm a, a -> m b로 표현되고,
join은 그대로 join,
return도 그대로 return,
은 바인드>>=, 혹은 >=>로 나타납니다.

어딘가 Monad m => m 같은 모양이 보인다면, 이 구조는 Effect 두 개를 눌러 담을 수 있는(합성할 수 있는) 구조를 가지고 있구나라고 떠올릴 수 있습니다. 예를 들어

join, return을 정의할 수 있어야 모나드를 만들 수 있습니다.

위에서 아직 얘기 안했는데, 엄밀하게는 모노이드가 되기 위해 따라야 하는 법칙, 모나드가 되기 위해 따라야 하는 법칙들이 몇 개 있습니다. 이런 법칙들은 보통 하스켈의 문법으로 표현되진 않고, 프로그래머들이 “알아서 잘” 설계해야 하는 것들입니다. 여기 글이 이해가시면 추가로 찾아 보세요.

복잡한 내용을 좀 더 볼 마음이 생기신 분을 위해 덧 붙이면, Effect 합성(펑터 합성)은 joinreturn이 알아서 모노이드로 만들어서, a -> m b 함수들을 합성하는 동안 프로그래머가 신경 쓰지 않도록, 합성 중인 체인에서 필요할 때 갖다 쓸 수 있도록 컨텍스트에 담아 유지합니다. 모나드를 바인드>>=로 계속 엮을 때 하는 일입니다. join>>=의 차이는 여기를 참조 하세요.

아직 펑터, 엔도 펑터에 관한 얘기는 안했지만, 어슴푸레 “엔도 펑터의 모노이드”란 말이, 무슨 말일지 보일 것만 같지 않나요? 물론, 여기 설명으로 모나드를 완전히 이해하는 건 불가능합니다. 당연히 “완전한” 이해를 돕는 글을 목표로 하지 않습니다. 수학 얘기를 최대한 빼내고, 비전공자 업자들이 모나드로 가는 길에, 볼만한 글이 되는 게 목표입니다.

이 번에도 새로운 부리토를 투척한 것 같습니다. 최대한 코드와 수학을 가리기 위해 with Effect를 써 봤는데, 오히려 혼란스러워진 것 같습니다. 계속 하스켈을 만지다 보면, 또 아는 것이 생길테니, 포기하지 않고 2025년 크리스마스에 또 시도해 보겠습니다.

그래서, 왜 모나드가 대단한 취급을 받지?

2025.1 추가
아래는 현재 글에서 이어지는 게 적당하지 않을 수 있습니다. 위에 있는 내용들을 이해한 후에, Reader 모나드를 만들면서 왜 모나드가 필요한가를 보기위해 추가해 놓은 설명인데, 그리 쉽게 풀어쓰지 못한 것 같습니다. 다른 자료들을 조금 더 보다가 돌아와서 보셔도 됩니다.

위에 설명을 잘 보시면, Effect 처리는 사용자(프로그래머)가 딱히 신경 안쓰고 있습니다. m구조에 쓸 >>= 또는 >=> 또는 join 또는 flatmap, 그리고 return 또는 pure 등이 미리 준비되어 있고, 이를 do 표기법 같은 설탕 문법들과 섞어 쓰면 심지어 코드에 드러나지도 않습니다.

Reader

예를 들어, 나중에 환경값 env를 받아야만 값이 되는 함수 env -> aReader env a라 정의하겠습니다. 위 설명을 그대로 적용하면,
a -> b 함수와 b -> c 함수를 조합하고 싶으면 을 쓰면 되는데,
a -> Reader env b 함수와 b -> Reader env c 함수를 조합해야 되는 상황을 가정 하겠습니다.
a를 넘겨주면 b가 되면 편한데, b를 갖고 있긴 하지만, 추가 정보env를 받아야만 b가 되는 함수를 돌려 줍니다.

join

먼저 join으로 접근해 보겠습니다.
env를 받아야면 a가 되는 함수”를 값으로 보고, 이 값이 또 다시 env를 받아야만 되는 걸, env를 한 번만 받는 걸로 바꿀 수 있나 봐야 합니다. 타입으로 쓰면 Reader env (Reader env a) -> Reader env a가 되도록 만들어야 합니다.

join :: Reader env (Reader env a) -> Reader env a
join (Reader (Reader f)) = \env -> (f env) env 

바깥에서 env를 받아 안에 들어 있는 함수 f에 넣어 주면, 또 env를 받는 상태가 됩니다. 말로 풀면, env를 두 번 받아서 넣어 주는 것과, 한 번 받아서 두 번 써먹어도 차이가 없는 상태입니다. 말이 복잡합니다만, 까지 본 후 예시를 보면 좀 더 편하게 볼 수 있을 겁니다.

먼저 init -> Reader env res1res1 -> Reader env res2를 조합해 보겠습니다.
코드를 보기 전에, 안에 들어 있는 함수를 꺼내 올 수 있는 runReader를 넣어 타입 정의를 제대로 쓰면,

newtype Reader env a = Reader { runReader :: env -> a }

첫 번째 함수 init -> Reader env res1init을 넣어 주면, Reader env res1이 되고, 여기에 env를 넣어 주면 res1이 됩니다. 아직은 복잡해 보이지만, 익숙해지면 지금은 모르는 initenv를 외부에서 받도록 람다 헤드에 걸어 두면 됩니다.

g ∘ f = \init -> Reader $
            \env -> runReader (g (   runReader (f init) env  )) env 

runReader가 보이게 join을 구현할 수도 있습니다.

join r = \env -> runReader (runReader r env) env 

join을 써서 구현하려면, 펑터 인스턴스의 fmap을 같이 쓰면 잘 보입니다.

fmap f (Reader r) = Reader $ \env -> f (r env)
g ∘ f = \env -> join $ fmap g f $ env

return 또는 pure

join이나 이 준비 되었으니 이제 return을 준비 하면 됩니다.

return a = Reader $ \_ -> a

이제 join 혹은 return을 이용해 조합할 수 있고, 지금 조합하는 함수는 모두 나중에 env를 받으면서 값이 됩니다. 조합하고 있는 함수들 각 각에 env를 넣어 주는 건 이 담당합니다. 마치 전역 상수처럼 env가 어디서 왔는지 신경쓰지 않고 쓰면 됩니다. 마치 물 밑에서 알아서 env 관련 작업을 하고 있고, 눈에는 b -> a ⋅ a -> b만 보이는 것처럼 됐습니다.

순수 함수만 존재하면, 함수가 필요한 정보는 모두 인자를 통해 받아야만 해서, 매우 많은 인자가 필요하게 되고, 그 많은 인자를 매 번 넘기는 걸 신경쓰는 건 매우 까다롭습니다. 이 때, 일부 정보는 물밑에서 처리하게 하면, 프로그래머는 우아해 보이는 척 할 수 있습니다. 그래서 우아해 보이려면 모나드는 필수입니다.

join

  1. 예상하셨겠지만, , 는 모노이드의 이항 함수, 아무 것도 안함은 항등원, , 의 자유로운 결합은 결합 법칙 입니다.↩︎

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