A monad is just a monoid in the category of endofunctors
실패할 수 있는 계산, 여러 답을 내는 계산, 상태를 끌고 가는 계산, 부수효과가 있는 계산 — 이 "맥락이 딸린 계산"들을 깔끔하게 이어 붙이는 단 하나의 패턴이 모나드입니다. 악명 높은 그 한 줄, "자기함자 범주의 모노이드"가 무슨 뜻인지까지 풀어 봅니다.
보통 함수는 \(A\to B\)로 깔끔하게 이어집니다(\(f\) 다음 \(g\), 즉 \(g\circ f\)). 그런데 현실의 계산은
맥락을 달고 다닙니다. 결과가 없을 수도 있고(Option), 여럿일 수도 있고(List),
상태를 바꾸고(State), 바깥 세계에 영향을 주죠(IO). 이런 함수의 타입은
\(A\to M[B]\) 꼴입니다 — 결과 \(B\)가 상자 \(M\)에 담겨 나오는.
문제는 합성입니다. \(f:A\to M[B]\) 다음에 \(g:B\to M[C]\)를 그냥 \(g\circ f\)로 잇지 못합니다. \(f\)는 상자에 든 \(B\)를 주는데 \(g\)는 맨 \(B\)를 원하니까요. 이 어긋남을 메우는 보편적 장치가 모나드입니다.
먼저 함자(functor). 타입 생성자 \(M\)이 함자라는 건, 상자를 깨지 않고 안의 값에 함수를 적용하는
map을 가졌다는 뜻입니다:
\(M[A]\)에 \(f:A\to B\)를 map하면 \(M[B]\)가 나오죠(List(1,2,3).map(_+1)). 그런데
\(f\)가 상자를 돌려주는 함수 \(A\to M[B]\)라면? map은 \(M[M[B]]\) — 상자 속 상자를
내놓습니다. 이 이중 상자를 어떻게 한 겹으로 펼 것인가, 그것이 다음 이야기입니다.
함자 \(M\)이 모나드가 되려면 두 가지 능력이 더 필요합니다.
unit·return): \(A\to M[A]\). 맨 값을 "최소한의 맥락"으로
상자에 넣기.bind, \(\gg\!=\)): \(M[A]\times(A\to M[B])\to M[B]\). 상자 속 값을 꺼내
\(A\to M[B]\)에 먹이고, 생긴 상자 속 상자를 한 겹으로 납작하게 펴기.flatMap은 사실 map으로 \(M[M[B]]\)를 만든 뒤 join(\(M[M[A]]\to M[A]\),
평탄화)으로 한 겹 벗기는 것과 같습니다. 즉 모나드를 보는 두 방식 —
"flatMap 하나" 또는 "pure + join 둘" — 은 동치죠. 이제 어긋났던 합성이
풀립니다. \(f:A\to M[B]\), \(g:B\to M[C]\)를
로 이으면 \(A\to M[C]\)가 깔끔하게 나옵니다.
아무 pure·flatMap이나 모나드인 건 아닙니다. 셋의 법칙을 지켜야 "합성이 정상"입니다.
pure(a).flatMap(f) == f(a) — 넣자마자 꺼내면 그대로.m.flatMap(pure) == m — 꺼내서 다시 넣으면 그대로.m.flatMap(f).flatMap(g) == m.flatMap(x => f(x).flatMap(g)) — 잇는 순서를 어떻게
묶든 결과가 같음.이 법칙들이 있어야 pure가 합성의 항등원 노릇을 하고, 긴 flatMap 사슬을
마음 놓고 재배열할 수 있습니다. 곧 보겠지만 이 셋은 그냥 "모노이드의 법칙"입니다.
flatMap은 중간에 None이 나오면
나머지를 통째로 건너뜁니다(단락). null 검사 지옥이 사라지죠.flatMap은 모든 선택지의 조합을 펼칩니다
(List(1,2).flatMap(x => List(x, -x)) → 네 갈래).flatMap이 상태를 몰래 실어 나릅니다.아래는 Option 모나드가 실패를 어떻게 자동으로 전파하는지 보여 줍니다. \(x\)를
\(\frac1x\to\sqrt{\cdot}\to\ln(\cdot)\)로 흘려보내는데, 한 단계라도 정의되지 않으면 그 뒤는 전부
None으로 단락됩니다.
flatMap으로 이은 사슬. 초록 상자는
Some(값), 빨강은 None, 회색은 단락되어 실행조차 안 된 단계입니다.
\(x=0\)(나눗셈)·음수(제곱근)·\(\sqrt{1/x}\le1\)(로그) 같은 데서 어떻게 무너지는지 보세요.모나드의 정체를 가장 또렷이 보여 주는 그림이 클라이슬리 범주(Kleisli category)입니다. 대상은 그대로
타입이지만, 화살표를 \(A\to M[B]\)로 새로 정의합니다("효과를 내는 함수"). 두 화살표의 합성은 바로
flatMap으로 잇는 것이고, 항등 화살표는 pure죠.
그러면 앞 장의 모나드 세 법칙이 정확히 범주의 공리(합성의 결합법칙 + 항등원)로 번역됩니다. "모나드 법칙을 지켜라"는 곧 "클라이슬리 화살표들이 진짜 범주를 이루게 하라"는 말이었던 겁니다. 모나드란 결국 효과 있는 계산을 합성하는 새로운 범주를 만드는 장치입니다.
이제 그 악명 높은 농담을 해독합니다. 모노이드란 결합적인 곱 \(\mu:M\otimes M\to M\)과 항등원 \(\eta:I\to M\)을 가진 대상입니다(정수의 덧셈, 문자열 잇기처럼). 그런데 "곱"이 꼭 숫자 곱일 필요는 없죠 — 임의의 모노이드 범주에서 정의됩니다.
한 범주의 자기함자(endofunctor)들을 모으면, 그 자체가 모노이드 범주가 됩니다. 대상은 함자, "곱 \(\otimes\)"는 함자 합성 \(\circ\), 단위 \(I\)는 항등함자죠. 이 무대에서 모노이드란 무엇일까요? 함자 \(M\)과 두 자연변환
$$ \mu : M\circ M \Rightarrow M \quad(\text{join}),\qquad \eta : \mathrm{Id}\Rightarrow M \quad(\text{pure}) $$이고, 모노이드의 결합·단위 법칙은 정확히 앞서 본 모나드 법칙입니다. 그래서 — "모나드는 자기함자 범주에서의 모노이드일 뿐". 농담이지만 한 글자도 틀리지 않은 정의죠.
현대 언어는 flatMap 사슬을 사람이 읽기 좋게 감싸 줍니다. 스칼라의
for 컴프리헨션, 하스켈의 do 표기죠. 아래 두 코드는 완전히 같습니다:
// Scala — flatMap 사슬 userOf(id).flatMap(u => accountOf(u).flatMap(a => balanceOf(a).map(b => b * 1.02))) // Scala — for 컴프리헨션 (위와 동일) for { u <- userOf(id) a <- accountOf(u) b <- balanceOf(a) } yield b * 1.02
각 <- 줄은 "상자를 열어 값을 꺼낸다"는 한 번의 flatMap입니다. 그래서 모나드를
흔히 "프로그래밍 가능한 세미콜론"이라 부릅니다 — 문장과 문장 사이에서 "그다음 무엇을 하는가"(실패 전파,
상태 전달, 비동기 대기)를 모나드가 결정해 주니까요. Option이면 단락, Future면 비동기
연결, List면 모든 조합. 같은 문법, 다른 효과.
스칼라의 cats, 하스켈의 표준 라이브러리가 이 어휘(Functor·Monad)로 짜여 있고,
이는 앞선 분배법칙 페이지에서 본 범주론이 코드의 골격으로 그대로 내려온
모습입니다.
모나드는 어렵다기보다 낯선 이름이 붙은 익숙한 패턴입니다. "맥락이 딸린 계산을, 그 맥락을 일일이 손으로 풀지 않고 이어 붙이는 법." 그 한 패턴이 실패·비결정성·상태·부수효과를 같은 문법으로 길들이고, 그 정체는 자기함자 범주의 모노이드라는 한 줄로 닫힙니다. 범주론이 추상의 구름이 아니라 실무의 연장임을, 모나드만큼 잘 보여 주는 예도 드뭅니다.