크리스마스랑 사실 아무 상관 없습니다. 그냥, 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,
(+ 2)
, (* 3)
, (- 4)
같은 함수들은 ⋅
을 이용해 계속 조합할 수 있고,⋅
과 조합했을 때 아무 일도 안함
함수가 있고,(* 3) ⋅ (+ 2)
하고 (- 4)
를 적용하거나, (+ 2)
하고, (- 4) ⋅ (* 3)
를 적용하거나 같은 결과를 얻습니다.연산(함수)들이 위와 같은 조건을 만족하는 구조를, 이름부터 수학 냄새가 너무나는 모노이드라 부릅니다.
함수들이 모노이드 구조를 만족하면, 먼저 연산(함수)들을 마음대로 조합해 놨다가, 값에 적용할 수 있습니다.(값이 아니라, 연산(함수)들이 모노이드 구조를 이루고 있음을 눈여겨 보세요) 하스켈의 모든 실행 프로그램은 모노이드 구조를 따르고 있는데, 여기서는 거창한 수학 이름보다는 동작에만 집중해서 보시고, 일단 “모노이드가 되면 편하구나” 정도 믿고 넘어가겠습니다.
모노이드가 되면 (a -> d)
함수를 받는 곳에 (c -> d) ⋅ (b -> c) ⋅ (a -> b)
함수를 넘길 수 있게 됩니다.
이 번에는, 연산을 하면, 다음에 또 연산을 바로 적용할 수 있는 값만 나오지 않고, 무언가 추가적인 정보를 가진 결과가 나오는 경우를 보겠습니다. 예를 들어, 연산을 적용하면서, 연산 횟수를 기억한다든지, 더 이상 연산할 수 없는 값이 나온다든지, 어떤 연산을 하고 있는지 화면에 출력한다든지…하는 연산 이외의 어떤 작업이 일어나는 경우입니다. 값만 나오던 연산과 구별해서 다음처럼 표기하겠습니다. (추가적인 정보를 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
를 떼어내서 Effect
는 Effect
대로 계산을 하고, (+ 2)
와 (* 3)
를 조립하고, 나중에 with Effect
를 다시 붙여주는 작업을 하면 됩니다. 함수를 조립하던 ⋅
함수가 담당하면 딱인 작업입니다.
⋅
은 인자를 받아 첫 째 함수를 적용해서 결과값을 받은 후, 이 값을 두 번째 함수의 입력으로 주고, 결과를 받는 함수입니다.
g ⋅ f = \x -> g (f x)
위 함수가 Effect
를 지원하게 바꿔 보면, (구별하기 위해 ⋅
대신 ∘
를 쓰겠습니다. 의사 코드입니다.)
=
g ∘ f init ->
\Effect1 = f init
result1 with Effect2 = g result1
result2 with return (result2 with (Effect1와 Effect2 합성))
-- ---------join---------
점 점 부리토가 될 확률이 높아지고 있습니다만, 계속해 보겠습니다.
위와 같은 ∘
함수를 정의하면, 이제 다음처럼 쓸 수 있습니다.
(* 3) with Effect ∘ (+ 2) with Effect
위 합성 함수의 결과도 여전히 연산 with Effect
입니다. 다시 말해, 연속으로 다음처럼 쓸 수도 있습니다.
(- 4) with Effect ∘ (* 3) with Effect ∘ (+ 2) with Effect
⋅
대신 새로 ∘
을 정의했습니다. ∘
의 정의를 잘 보시면, 함수를 적용하며 생기는 Effect
들을 따로 모아 하나로 합치는 join
작업이 중요한 역할을 합니다.
이제, 변경 없이 그대로 쓰기 위해 필요했던 아무 일도 안함
의 동작을 보겠습니다.
= 그대로 연산 연산 ⋅ 아무 일도 안함
위와 비슷하게
Effect ∘ (아무 일도 안함) = 그대로 연산 with Effect 연산 with
가 되어야 합니다. ∘
의 동작을 보면, 안에 있는 Effect1
과 Effect2
합성은 Effect
두 개가 필요합니다. 하지만, 아무 일도 안함
은 Effect
가 없는 상태입니다.
아무 일도 안함
을 아무 일도 안함 with 기본 Effect
로 만들어 주는 return
이란 함수를 정의할 수 있다면, 다음처럼 동작할 수 있습니다. (Effect와 기본 Effect를 합성했을 때 Effect는 변하지 않습니다.)
Effect ∘ return (아무 일도 안함) = 그대로 연산 with Effect 연산 with
결합같은 경우, 나중에 실제 코드로 정의해 보면, Effect
가 없었을 때와 마찬가지로 결합에 상관없이 같은 결과가 나옴을 확인할 수 있습니다만 여기선 넘어 가겠습니다.
연산 with Effect
는 ⋅
와 아무 일도 안함
으론 모노이드가 되지 못했지만,
연산 with Effect
는 ∘
와 return
이 있으면 모노이드가 됐습니다.
모노이드가 되지 못하던 연산 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 Effect
는 m a
, a -> m b
로 표현되고,
join
은 그대로 join
,
return
도 그대로 return
,
∘
은 바인드>>=
, 혹은 >=>
로 나타납니다.
어딘가 Monad m => m
같은 모양이 보인다면, 이 구조는 Effect
두 개를 눌러 담을 수 있는(합성할 수 있는) 구조를 가지고 있구나라고 떠올릴 수 있습니다. 예를 들어
Reader
모나드라 하면, “외부 스코프값을 넣어주는 작업을, 두 번 할 걸, 한 번만 하게 바꾸는 것이다”라고 읽을 수 있고,Maybe
모나드라 하면, Just (Just a)
는 Just a
로, Just (Nothing)
은 Nothing
으로 해석해서 두 번 Nothing
인지 봐야 할 걸, 한 번만 보게 바꿔 줍니다.join
, return
을 정의할 수 있어야 모나드를 만들 수 있습니다.
위에서 아직 얘기 안했는데, 엄밀하게는 모노이드가 되기 위해 따라야 하는 법칙, 모나드가 되기 위해 따라야 하는 법칙들이 몇 개 있습니다. 이런 법칙들은 보통 하스켈의 문법으로 표현되진 않고, 프로그래머들이 “알아서 잘” 설계해야 하는 것들입니다. 여기 글이 이해가시면 추가로 찾아 보세요.
복잡한 내용을 좀 더 볼 마음이 생기신 분을 위해 덧 붙이면, Effect
합성(펑터 합성)은 join
과 return
이 알아서 모노이드로 만들어서, a -> m b
함수들을 합성하는 동안 프로그래머가 신경 쓰지 않도록, 합성 중인 체인에서 필요할 때 갖다 쓸 수 있도록 컨텍스트에 담아 유지합니다. 모나드를 바인드>>=
로 계속 엮을 때 하는 일입니다. join
과 >>=
의 차이는 여기를 참조 하세요.
아직 펑터, 엔도 펑터에 관한 얘기는 안했지만, 어슴푸레 “엔도 펑터의 모노이드”란 말이, 무슨 말일지 보일 것만 같지 않나요? 물론, 여기 설명으로 모나드를 완전히 이해하는 건 불가능합니다. 당연히 “완전한” 이해를 돕는 글을 목표로 하지 않습니다. 수학 얘기를 최대한 빼내고, 비전공자 업자들이 모나드로 가는 길에, 볼만한 글이 되는 게 목표입니다.
이 번에도 새로운 부리토를 투척한 것 같습니다. 최대한 코드와 수학을 가리기 위해 with Effect
를 써 봤는데, 오히려 혼란스러워진 것 같습니다. 계속 하스켈을 만지다 보면, 또 아는 것이 생길테니, 포기하지 않고 2025년 크리스마스에 또 시도해 보겠습니다.
2025.1 추가
아래는 현재 글에서 이어지는 게 적당하지 않을 수 있습니다. 위에 있는 내용들을 이해한 후에, Reader
모나드를 만들면서 왜 모나드가 필요한가를 보기위해 추가해 놓은 설명인데, 그리 쉽게 풀어쓰지 못한 것 같습니다. 다른 자료들을 조금 더 보다가 돌아와서 보셔도 됩니다.
위에 설명을 잘 보시면, Effect 처리는 사용자(프로그래머)가 딱히 신경 안쓰고 있습니다. m
구조에 쓸 >>=
또는 >=>
또는 join
또는 flatmap
, 그리고 return
또는 pure
등이 미리 준비되어 있고, 이를 do
표기법 같은 설탕 문법들과 섞어 쓰면 심지어 코드에 드러나지도 않습니다.
예를 들어, 나중에 환경값 env
를 받아야만 값이 되는 함수 env -> a
를 Reader env a
라 정의하겠습니다. 위 설명을 그대로 적용하면,
a -> b
함수와 b -> c
함수를 조합하고 싶으면 ⋅
을 쓰면 되는데,
a -> Reader env b
함수와 b -> Reader env c
함수를 조합해야 되는 상황을 가정 하겠습니다.
a
를 넘겨주면 b
가 되면 편한데, b
를 갖고 있긴 하지만, 추가 정보로 env
를 받아야만 b
가 되는 함수를 돌려 줍니다.
먼저 join
으로 접근해 보겠습니다.
“env
를 받아야면 a
가 되는 함수”를 값으로 보고, 이 값이 또 다시 env
를 받아야만 되는 걸, env
를 한 번만 받는 걸로 바꿀 수 있나 봐야 합니다. 타입으로 쓰면 Reader env (Reader env a) -> Reader env a
가 되도록 만들어야 합니다.
join :: Reader env (Reader env a) -> Reader env a
Reader (Reader f)) = \env -> (f env) env join (
바깥에서 env
를 받아 안에 들어 있는 함수 f
에 넣어 주면, 또 env
를 받는 상태가 됩니다. 말로 풀면, env를 두 번 받아서 넣어 주는 것과, 한 번 받아서 두 번 써먹어도 차이가 없는 상태입니다. 말이 복잡합니다만, ∘
까지 본 후 예시를 보면 좀 더 편하게 볼 수 있을 겁니다.
먼저 init -> Reader env res1
와 res1 -> Reader env res2
를 조합해 보겠습니다.
∘
코드를 보기 전에, 안에 들어 있는 함수를 꺼내 올 수 있는 runReader
를 넣어 타입 정의를 제대로 쓰면,
newtype Reader env a = Reader { runReader :: env -> a }
첫 번째 함수 init -> Reader env res1
에 init
을 넣어 주면, Reader env res1
이 되고, 여기에 env
를 넣어 주면 res1
이 됩니다. 아직은 복잡해 보이지만, 익숙해지면 지금은 모르는 init
과 env
를 외부에서 받도록 람다 헤드에 걸어 두면 됩니다.
= \init -> Reader $
g ∘ f -> runReader (g ( runReader (f init) env )) env \env
runReader
가 보이게 join
을 구현할 수도 있습니다.
= \env -> runReader (runReader r env) env join r
∘
을 join
을 써서 구현하려면, 펑터 인스턴스의 fmap
을 같이 쓰면 잘 보입니다.
fmap f (Reader r) = Reader $ \env -> f (r env)
= \env -> join $ fmap g f $ env g ∘ f
join
이나 ∘
이 준비 되었으니 이제 return
을 준비 하면 됩니다.
return a = Reader $ \_ -> a
이제 join
혹은 ∘
과 return
을 이용해 조합할 수 있고, 지금 조합하는 함수는 모두 나중에 env
를 받으면서 값이 됩니다. 조합하고 있는 함수들 각 각에 env
를 넣어 주는 건 ∘
이 담당합니다. 마치 전역 상수처럼 env
가 어디서 왔는지 신경쓰지 않고 쓰면 됩니다. 마치 물 밑에서 알아서 env
관련 작업을 하고 있고, 눈에는 b -> a ⋅ a -> b
만 보이는 것처럼 됐습니다.
순수 함수만 존재하면, 함수가 필요한 정보는 모두 인자를 통해 받아야만 해서, 매우 많은 인자가 필요하게 되고, 그 많은 인자를 매 번 넘기는 걸 신경쓰는 건 매우 까다롭습니다. 이 때, 일부 정보는 물밑에서 처리하게 하면, 프로그래머는 우아해 보이는 척 할 수 있습니다. 그래서 우아해 보이려면 모나드는 필수입니다.
예상하셨겠지만, ⋅
, ∘
는 모노이드의 이항 함수, 아무 것도 안함
은 항등원, ⋅
, ∘
의 자유로운 결합은 결합 법칙 입니다.↩︎