• About

펑터를 넘어서, 모나드까지

TypeScript로 이해하는 어플리케이티브 펑터와 모나드


펑터를 넘어서, 모나드까지

이번 포스팅에서는 이전에 다뤘던 펑터의 개념에 이어 모나드에 대한 설명을 이어가보려고 한다.

아무래도 모나드라고 하면 가장 먼저 떠오르는 것은 “모나드는 내부함자 범주의 모노이드 대상 어쩌고”하는 설명인데, 사실 이것은 모나드를 가장 잘 설명하는 문장이면서도 가장 설명을 못하는 문장이기도 하다.

모나드를 이해하는 순간 모나드를 설명할 수 없게 되어버린다는 모나드의 저주라고 불리는 유명한 밈이 존재할만큼, 수학을 잘 모르는 입장에서는 참 이해하기 난해한 대상이기는 하다.

이에 필자도 야심차게 모나드를 설명하기 위한 도전을 한번 해보려고 한다. (물론 실패할 수도 있다…)

이전 글에서 다뤘던 것들

일단 이전 글을 적은 것이 무려 6년 전이니, 이전 글의 내용을 간단히 복기하고 넘어가도록 하자. 자세한 내용은 이전 포스팅을 참고하면 된다.

뭐가 복잡하고 말이 많지만 결국 핵심은 간단하다. 함수형 프로그래밍의 세계에서 함수를 합성하려면 첫 번째 함수의 출력 타입과 다음 함수의 입력 타입이 일치해야 한다.

하지만 문제는 프로그래밍의 세계에는 null, undefined, 에러 같은 불확실성이나 사이드 이펙트가 존재하기 때문에 이 규칙을 지키기가 쉽지 않다는 것이다.

function getFirstLetter(s: string): string | undefined {
  return s[0];
}

function getStringLength(s: string): number {
  return s.length;
}

// getFirstLetter의 치역이 string | undefined이므로
// getStringLength와 바로 합성할 수 없다
getStringLength(getFirstLetter(''));

이 문제를 해결하기 위해 값과 사이드 이펙트를 컨테이너로 감싸는 개념을 도입했고, 그 컨테이너가 바로 함자, 영어로는 펑터(Functor)였다.

즉, 펑터는 map이라는 연산을 통해 컨테이너 안의 값을 꺼내지 않고도 안전하게 변환할 수 있는 구조체라고 보면 된다.

map: F<A> → (A → B) → F<B>

위 타입 시그니처는 F<A>라는 펑터의 map 연산에 내부 값을 변환하는 계산인 (A → B)을 넘겨주면 결과적으로 내부 값이 변경되어 F<B>가 된다는 것을 의미한다.

이때 F<A>F<B>는 내부 값이 바뀌었을지언정, 본래 가지고 있던 구조 자체가 변경되어서는 안되기 때문에 아래 두 가지 법칙을 만족해야한다.


  1. 항등 법칙: F(id)=idF(\text{id}) = \text{id}
  2. 합성 법칙: F(gf)=F(g)F(f)F(g \circ f) = F(g) \circ F(f)

idid는 인자로 받은 값을 그대로 반환하는 항등 함수이다. 이 항등 함수에 매핑을 적용하면 아무 일도 발생하지 않아야하는 것이 항등 법칙이다.

두 번째 합성 법칙은 합성된 함수를 매핑한 것과 각각의 함수를 따로 매핑하고 합성한 결과가 같아야 한다는 것을 의미한다.

이 법칙들을 지켜야하는 이유는 단순한 수학적 결벽증 때문이 아니라, 이 법칙들을 지켜줘야 펑터가 “구조를 보존하는 사상”이라는 사실을 보장하기 때문이다.

항등 법칙이 성립하면 a.map(x => x)가 정말로 아무것도 하지 않음을 신뢰할 수 있지만, 만약 이 과정에서 리스트의 순서라던가 트리의 높이와 같은 내부 구조가 변형된다면 이건 더 이상 매핑이 아니라 “재구성”이다. 즉, 항등 법칙은 펑터가 반드시 알맹이만 건드리고 껍데기는 건드리지 않는다는 신뢰의 기반이라고 할 수 있다.

그리고 합성 법칙이 성립하면 a.map(f).map(g)a.map(x => g(f(x)))로 바꿔도 동작이 동일함을 보장받는다.

이는 루프를 두 번 도는 a.map(f).map(g)a.map(x => g(f(x)))로 합쳐서 루프를 한 번만 돌아도 동작이 똑같다는 것을 보장하며, 반대로 복잡한 로직을 가독성을 위해 여러 개의 map으로 쪼개도 안전하다는 뜻이기도 하다.

하지만 이 펑터에도 한계가 존재한다. 바로 이 한계로 인해 우리가 오늘 알아볼 모나드가 등장하게 된다.

펑터의 한계

1988년, 에든버러 대학의 컴퓨터 과학자 에우제니오 모지(Eugenio Moggi)는 골치 아픈 문제와 씨름하고 있었다. 프로그램의 의미를 수학적으로 정의하는 의미론 연구를 하던 그는, 순수 람다 계산법과 현실 프로그램 사이의 간극을 어떻게 메울지 고민하고 있었다.

순수 람다 계산법에서 타입 ABA \to B는 “입력 A를 받아 B를 돌려주는 전체 함수”를 의미한다. 수학적으로는 아주 깔끔하지만 사실 현실의 프로그램은 그렇지 않다.

프로그램은 무한 루프에 빠질 수도 있고, 예외를 던질 수도 있고, 상태를 변경할 수도 있고, 파일을 읽을 수도 있기 때문이다. 이런 여러가지 “효과(effect)“를 발생시키는 프로그램을 순수 함수 ABA \to B로 보면 의미가 왜곡된다.

그래서 모지는 프로그램을 ABA \to B가 아니라 AT(B)A \to T(B)로 보자는 것에서 출발했다. 여기서 TT는 “계산의 개념”을 담는 구조를 의미하며, T(B)T(B)BB라는 값을 결과로 내는 계산이라는 뜻이다. 값 자체가 아니라 값을 만들어내는 계산을 타입으로 표현하는 것이다.

말로만 하면 어려우니 직접적인 예시를 한번 보자면, 우리에게 익숙한 계산들은 이런 것들이 있다.


  • AMaybe(B)A \to \text{Maybe}(B): 실패할 수도 있는 계산
  • AIO(B)A \to \text{IO}(B): 외부 세계와 상호작용하는 계산
  • AState(B)A \to \text{State}(B): 상태를 변경하는 계산

그런데 막상 이렇게 접근하자니 또 한 가지 문제가 발생한다. 바로 AT(B)A \to T(B) 형태의 함수들을 합성하는 것이 까다로워진 것이다. 첫 함수가 AT(B)A \to T(B)라면 두 번째 함수는 BT(C)B \to T(C)의 형태가 될텐데, 첫 번째 함수의 출력인 T(B)T(B)와 두 번째 함수의 입력 BB가 맞지 않는 문제가 발생한다.

모지는 이 합성 문제를 해결하는 구조가 카테고리 이론에서 이미 연구되어 있다는 것을 발견했고, 그것이 바로 함수형 프로그래밍에서의 모나드의 시작이었다.

이렇게 모지가 수학에서 빌려온 “모나드”라는 이름표가, 오늘날 컴퓨터 이론에서 가장 악명 높으면서도 강력한 추상화의 이름이 되었다.

왜 펑터로는 해결이 안될까?

그렇다면 모지의 고민은 왜 펑터만으로 해결이 어려운 것일까? 우리는 펑터의 map이 해결하지 못하는 두 가지 결정적인 병목 지점을 마주하게 된다.

먼저 계산의 맥락인 TT 안에 함수가 갇혀버린 경우를 생각해보자. 이런 케이스를 발생시키는 대표적 예시로는 커링이 있다.

const maybeA: Maybe<number>;
const maybeB: Maybe<number>;

const add = (a: number) => (b: number) => a + b;
const map: <A, B>(maybe: Maybe<A>, f: (a: A) => B): Maybe<B>;

이때 펑터의 map을 사용하면 maybeAadd 연산을 적용하여 다음 인자를 기다리는 함수를 만들어낼 수 있을까? 결론부터 말하자면 불가능하다. 왜냐하면 펑터의 map을 통해 적용된 연산의 결과가 그냥 함수가 아니기 때문이다.

const addA = map(maybeA, add);
// map의 타입 시그니처에 대입해보면...
map<number, (b: number) => number>(
  maybeNumber: Maybe<A>,
  add: (a: number) => (b: number) => number
): Maybe<(b: number) => number>

일반적으로 커링된 함수를 사용하면 addA(b: number) => number 타입이 되어야겠지만, 여기서는 map을 사용하여 연산을 적용했으므로, 결과적으로 Maybe<(b: number) => number>라는 타입을 얻게 된다. 즉, 함수가 계산의 맥락 안에 갇혀버렸다.

그런데 문제는 이렇게 되어버리면 쌩 함수만 인자로 받을 수 있는 펑터의 map을 사용하여 합성을 진행하는 것이 불가능해진다는 것이다. map은 맥락 밖의 함수를 맥락 안의 값에 적용할 뿐, 맥락 안에 갇힌 함수를 다른 맥락 안에 있는 값에게 전달할 방법이 없다.

이것은 기본적으로 펑터의 map이 커링된 함수를 받을 때 바깥 쪽에 있는 함수에만 연산을 적용하기 때문이다. 커링된 함수처럼 함수가 함수를 반환하는 경우에는 안 쪽의 함수까지 닿을 수가 없다.

그리고 또 다른 문제는 펑터를 반환하는 두 함수를 합성하는 상황 속에서 발생한다. 이번에도 “값이 있을 수도 있고 없을 수도 있다”를 의미하는 Maybe 펑터를 예시로 한번 살펴보자.

const findUser: (id: number): Maybe<User>;
const findTeam: (user: User): Maybe<Team>;

이 함수들은 모지가 고안해냈던 입력 AA를 받아 계산 T(B)T(B)를 반환하는 함수들을 의미한다.

findUser 함수는 유저의 아이디를 인자로 받아 User를 반환한다. 이때 이상한 아이디가 들어오면 매칭되는 유저가 없을 것이기 때문에 Maybe 펑터를 사용한 것이다. 그리고 findTeam 함수는 User를 인자로 받아 유저의 소속 팀을 반환한다. 우리는 이 두 함수를 합성해서 유저의 팀을 찾는 함수를 만드려고 한다.

문제는 findUser가 반환하는 Maybe<User>findTeam이 바로 받아들일 수 없다는 것이다.

하지만 펑터의 map을 사용하면 어찌저찌 합성을 해볼 수는 있다. 한번 합성을 진행해보자.

map(findUser(1), findTeam);

// map의 타입 시그니처에 대입해보면...
map<User, Maybe<Team>>(maybe: Maybe<User>, f: (a: User) => Maybe<Team>): Maybe<Maybe<Team>>

어찌어찌 합성은 성공했지만, 결과적으로 타입이 Maybe<Maybe<Team>>이라는 슬픈 상황이 되어버렸다. 만약 여기서 팀 정보를 통해 팀장을 찾는 과정을 한 번 더 추가한다면 타입은 Maybe<Maybe<Maybe<Manager>>>와 같이 무한히 중첩될 것이다.

이 쯤에서 한번 정리해보자

우리는 모지의 문제를 따라가면서 펑터로는 해결할 수 없는 두 가지의 문제에 직면했다.

문제 원인 필요한 것
맥락 안의 함수 적용 map은 바깥의 함수만 받음 안의 함수를 안의 값에 적용하는 연산
맥락 중첩 map은 한 겹만 벗김 이중 맥락을 단일 맥락으로 펴는 연산
head 펑터로 전부 해결할 수 있을 줄 알았는데 전혀 아니었다는 슬픈 결말

괜찮다. 문제는 해결하면 되니까. 이제 우리는 이 문제를 해결하기 위한 설계도를 차근차근 그려보려고 한다.

이 중 첫 번째 문제인 맥락 안에 함수가 갇혀버린 상황은 어플리케이티브 펑터(Applicative Functor)라는 녀석으로 해결할 수 있고, 함수를 합성할 때마다 맥락이 계속 중첩되어 버린다는 문제를 바로 모나드(Monad)가 해결할 수 있다.

해결책 설계: 어플리케이티브 펑터와 모나드

이제 우리는 첫 번째 문제를 해결하기 위해 새로운 연산을 발명해야 한다. 이제부터는 앞서 언급했던 “계산의 맥락”이라는 표현이 너무 기니, 간단하게 “컨테이너”라고 부르도록 하겠다.

첫 번째 문제는 컨테이너 내부에 함수가 갇혀버린 상황이었으니, 이 함수를 다른 컨테이너 안에 있는 값에 적용할 수 있는 연산이 있으면 될 것이다.

// 컨테이너 안의 함수를 다른 컨테이너 안의 값에 적용한다
apply: T<(A → B)> → T<A> → T<B>

이 연산과 펑터의 map과 차이는 적용하려는 함수가 컨테이너 밖에 있느냐 안에 있느냐 뿐이다. 그리고 이 연산을 apply라고 부른다.

그리고 이 함수를 값에 적용한 뒤 다시 컨테이너에 담아줘야 하는 연산도 필요하다. 이 연산은 pure 연산이라고 부른다.

// 순수한 값을 컨테이너에 넣는다
pure: A → T<A>

그리고 이렇게 applypure 연산을 갖춘 펑터를 어플리케이티브 펑터라고 부른다. 이번에도 우리에게 익숙한 Maybe 컨테이너를 예시로 들어 이 연산들을 타입스크립트 타입 시그니처로 표현해보자.

const apply: <A, B>(maybe: Maybe<A>, f: Maybe<(a: A) => B>): Maybe<B>
const pure: <A>(value: A) => Maybe<A>

이제 이 어플리케이티브 펑터를 통해 앞서 우리가 겪었던 첫 번째 문제인 커링된 함수를 합성해보면 된다.

const maybeA: Maybe<number>;
const maybeB: Maybe<number>;

const add = (a: number) => (b: number) => a + b;
const maybeAddA = map(maybeA, add); // Maybe<(b: number) => number>;

apply(maybeB, maybeAddA); // Maybe<number>
// apply의 타입 시그니처에 대입해보면...
apply<number, number>(
  maybe: Maybe<number>,
  f: Maybe<(b: number) => number>
): Maybe<number>

이제 우리는 어플리케이티브 펑터의 apply라는 연산을 통해 위 예시의 maybeA, maybeB와 같은 여러 개의 컨테이너를 동시에 다루는 문제를 우아하게 해결할 수 있게 되었다.

어플리케이티브 펑터의 한계

하지만 우리가 해결한 문제에는 중요한 전제가 하나 있다. 바로 어떤 상자들을 합성할지 미리 정해져 있어야 한다는 것이다.

한번 어플리케이티브 펑터의 apply 연산 과정을 다시 떠올려보자. maybeAmaybeB는 계산이 시작되기 전부터 이미 우리 손에 들려있는 독립적인 컨테이너들이다. 즉, 계산의 구조가 값과 상관없이 고정되어있다는 것이다.

하지만 현실의 프로그램은 이보다 훨씬 동적인 경우가 많다. 이전 계산의 결과 값을 보고 나서야 다음에 어떤 컨테이너를 가져올지, 아니면 아예 컨테이너를 가져오지 않을지를 결정해야하는 상황이 훨씬 많다는 것이다.

// 유저를 찾고, 그 유저의 정보를 토대로 팀을 찾는다.
// 즉, 팀을 찾는 함수는 유저가 누구냐에 따라 다른 결과를 내놓는다.
findUser(1).fn(user => findTeam(user.teamId))

여기서 또 문제가 발생한다. findTeam이 반환하는 Maybe 컨테이너는 user라는 값에 의존하여 생성되기 때문이다. 즉, 이전 계산의 결과가 다음 계산의 맥락을 결정한다.

어플리케이티브 펑터는 이미 존재하는 컨테이너끼리만 소통시킬 수 있기 때문에 이렇게 실행 중에 동적으로 만들어지는 순차적 의존성을 표현할 수가 없다.

정적(Static) 동적(Dynamic)
“A와 B를 각각 가져와서 합쳐라” “A를 가져오고, 그 결과를 보고 B를 할지 말지 정해라”

결국 동적으로 만들어지는 순차적 의존성을 해결하려면 이전 계산의 결과물로 다음 계산(컨테이너)을 생성해야 하고, 이 과정에서 컨테이너가 중첩되는 것은 피할 수 없는 숙명이 된다.

oh 오 그렇다면 중첩된 컨테이너를 펴주는 연산이 있다면 어떨까?

어플리케이티브 펑터는 apply라는 새로운 연산을 통해 컨테이너 안의 함수를 실행했다. 하지만 만약 우리가 중첩을 평평하게 펴주는 도구를 갖게 된다면, 굳이 새로운 컨테이너 내부의 함수를 실행하는 번거로운 짓을 하지 않고도 이 문제를 해결할 수 있다.

const maybeA: Maybe<number>;
const maybeB: Maybe<number>;
const add = (a: number) => (b: number) => a + b;
// 중첩을 펴주는 녀석이 있다면 map만으로도 문제를 해결할 수 있다!

// 1. 커링된 함수를 매핑하면 컨테이너에 담긴 함수가 나온다
const maybePartialFn = map(maybeA, add);
// 결과: Maybe<(b: number) => number>

// 2. map을 사용하여 컨테이너 안에 담긴 함수에 접근하고 계산 수행
const nested = map(maybePartialFn, fn => map(maybeB, fn));
// 결과: Maybe<Maybe<number>>

// 3. 그리고 중첩을 제거하면?
const result = 펴주는녀석(nested);
// 결과: Maybe<number>

즉, 일단 합성해서 중첩시켜버리고 나중에 중첩을 펴버리면 되는 것이다.

모나드, 중첩을 펴는 연산의 발명

결국 모나드는 이러한 문제를 해결하기 위해 등장했다. 다시 말하자면 모나드는 컨테이너의 중첩을 펴는 연산을 가진 무언가이다.

그리고 이렇게 중첩을 펴는 연산을 join 또는 flatten이라고 부른다.

join: T<T<A>> → T<A>
const join: <A>(maybe: Maybe<Maybe<A>>) => Maybe<A>

즉, join 연산은 Maybe<Maybe<A>>Maybe<A>로 만든다. 수학적으로는 ”TT를 두 번 적용한 것을 TT 한 번으로 줄이는 것”이다.

사실 이러한 연산은 우리 주변에서도 쉽게 찾아볼 수 있는데, 바로 JavaScript의 Array.prototype.flat이 바로 이러한 역할을 한다.

그리고 어플리케이티브 펑터와 마찬가지로 순수한 값을 컨테이너에 넣어주는 연산도 필요하다. 이 연산은 어플리케이티브 펑터와 마찬가지로 pure라고 부르거나, of라고 부르기도 한다.

pure: A → T<A>
const pure: <A>(value: A) => Maybe<A>

하지만 실제 프로그래밍에서 join을 직접 쓰는 일은 거의 없다. 앞선 예시에서 보았듯이 모나드는 map을 사용하여 컨테이너 안에 담긴 무언가에 접근하여 합성을 진행하고 join을 이용해서 중첩을 다시 펴는 것이 한 세트로 작동하기 때문이다.

1. 컨테이너 안의 값에 함수를 적용하고 싶다 = map을 쓴다
2. 그런데 그 함수가 결과를 컨테이너에 담아서 반환한다 = 이중 포장이 된다
3. 이중 포장을 벗겨야 한다 = join을 쓴다

그래서 우리는 이 두 가지 연산을 하나로 합쳐 좀 더 편리하게 사용하려고 한다.

flatMap = map + join

매번 map 다음에 join을 부르는 것이 번거로우니, 이 두 단계를 하나로 합친 것이 flatMap이다. 이번에도 마찬가지로 우리에게 친숙한 Maybe 컨테이너를 받는 flatMap이 어떤 타입 시그니처로 표현되는지 알아보도록 하자.

map:     T<A> → (A → B)    → T<B>     // 일반 함수를 적용
flatMap: T<A> → (A → T<B>) → T<B>     // 컨테이너 반환 함수를 적용 + 펴기
const map: <A, B>(maybe: Maybe<A>, f: (a: A) => B): Maybe<B>;
const flatMap: <A, B>(maybe: Maybe<A>, f: (a: A) => Maybe<B>): Maybe<B>

mapflatMap의 차이점은 두 번째 인자에 있다. map은 단순히 A => B 형태의 함수를 받지만, flatMap(A => Maybe<B>) 형태의 함수를 받는다.

타입시그니처로는 표현되지 않았지만 내부적으로 flatMapjoin의 역할까지 하고 있기 때문에 결과 타입은 Maybe<Maybe<B>>와 같은 슬픈 형태가 아니라 Maybe<B>가 되는 것이다.

이제 우리는 이 flatMap을 사용하여 계산들 간의 순차적 의존성을 표현할 수 있게 되었다.

// map을 쓰면 결과 타입이 중첩됨
findUser(1).map(user => findDepartment(user.deptId));
// Maybe<Maybe<Department>>

// flatMap을 쓰면 해결
findUser(1).flatMap(user => findDepartment(user.deptId));
// Maybe<Department>

여기까지 펑터의 map, 어플리케이티브 펑터의 apply, 모나드의 flatMap에 대해서 알아보았다. 분량이 워낙 많았으니 한번 다시 정리하고 넘어가보자.

// 펑터의 map
const map: <A, B>(maybe: Maybe<A>, f: (a: A) => B): Maybe<B>;

// 어플리케이티브 펑터의 apply
const apply: <A, B>(maybe: Maybe<A>, f: Maybe<(a: A) => B>): Maybe<B>

// 모나드의 flatMap
const flatMap: <A, B>(maybe: Maybe<A>, f: (a: A) => Maybe<B>): Maybe<B>

먼저 map은 컨테이너 안에 있는 값에 일반 함수를 적용하는 녀석이다. 하지만 이것만으로는 컨테이너 내부에 갇힌 함수는 사용할 수 없다는 문제가 발생한다.

그래서 apply가 등장했다. apply는 컨테이너 내부에 있는 함수에 접근할 수 있도록 만들어줘서 이런 상황일 때도 계산의 합성이 가능하도록 만들어주었다. 하지만 결국 순차적 의존성을 가진 동적인 계산을 진행하면 컨테이너가 중첩된다는 문제가 발생했다.

이를 해결하기 위해 중첩된 연산을 펴주는 join과 펑터의 map이 합쳐진 flatMap이 등장했다. 우리는 이제 이 연산을 통해 앞 결과에 의해 다음 계산을 결정하는 순차적 의존성을 가진 계산도 자유롭게 합성할 수 있게 되었다.

법칙은 리팩토링에 대한 증명

그렇다면 flatMap은 정말 우리가 함수를 안전하게 합성할 수 있게 만들어주는 마법인걸까? 글쎄, 이것을 자신있게 이야기하기 위해서는 flatMap이 지켜줘야하는 몇 가지 법칙이 존재한다.

세 가지 법칙

우리가 flatMap을 자유롭게 사용하기 위해 이 연산은 아래 법칙을 지켜준다는 것이 보장되어야 한다.

결합 법칙: 어느 층부터 합쳐도 같아야 한다

가장 먼저 결합 법칙이다. 즉, 중첩을 펼 때, 어느 쪽부터 펴든 결과가 같아야 한다는 것을 의미한다. 한번 Maybe<Maybe<Maybe<A>>>처럼 삼중의 계산으로 감싸진 컨테이너가 있다고 생각해보자.

우리는 이제 이 컨테이터를 T(A)T(A)형태로 만들기 위해 중첩을 펴내야 한다.

T(T(T(A)))T(T(A))T(A)T(T(T(A))) \to T(T(A)) \to T(A)

이 중첩을 펴서 한 겹으로 만드는 방법은 두 가지다. 첫 번째 방법은 안쪽 두 개의 TT를 먼저 합친 다음, 바깥 쪽에 남은 TT를 벗겨내 T(A)T(A)로 만드는 방법이다. 그리고 두 번째 방법은 바깥에 있는 두 개의 TT를 합친 다음, 안 쪽에 남은 TT를 벗기는 방법이다.

이 중 어떤 순서로 연산을 수행하던 T(A)T(A)라는 최종 결과는 같아야 한다는 것이 바로 결합법칙이다.

μTμ=μμT\mu \circ T\mu = \mu \circ \mu T
m.flatMap(f).flatMap(g) === m.flatMap(x => f(x).flatMap(g))

위 수식에서 μ\mu(뮤)는 join을 의미한다.

여기서 TμT\mu는 “안쪽 두 겹”을 합치는 것이고, μT\mu T는 “바깥쪽 두 겹”을 합치는 것이라고 보면 된다. 즉, 3층 마트료시카를 2층으로 줄일 때, 안쪽 인형들을 먼저 합치느냐 바깥쪽 인형들을 먼저 합치느냐의 차이가 없어야 한다는 것이다.

좌단위 법칙: 입구에서 장난치지 말자

두 번째는 좌단위 법칙이다. 이 법칙은 넣었다가 바로 벗기면 아무 일도 없었던 것과 동일한 상태여야한다는 것을 의미한다.

μTη=id\mu \circ T\eta = \text{id}
pure(a).flatMap(f) === f(a)

위 수식에서 η\eta(에타)는 pure를 의미한다.

여기서 특히 주목해야 할 부분은 η\eta 앞에 붙은 TT(TηT\eta)인데, 이는 상자 바깥이 아니라 이미 상자에 담겨 있는 내부의 값에 pure를 적용하라는 뜻이다.

즉, T(A)T(A)라는 컨테이너가 있을 때 내부의 알맹이 AApure로 감싸서 T(T(A))T(T(A))라는 이중 구조를 만든 뒤, 이를 다시 join(μ\mu)으로 펴내면 원래의 상태(id\text{id})로 돌아와야 한다는 법칙이다.

이 법칙이 보장되어야 우리는 컨테이너 내부에서 일어나는 계산의 최소 단위인 pure 연산을 신뢰할 수 있다. 만약 이 좌단위 법칙이 깨진다는 것은 pure가 단순히 값을 감싸는 것을 넘어 내부적으로 로직을 변형시키고 있다는 것을 의미하기 때문이다.

우단위 법칙: 포장지만 바꿨다면 알맹이는 그대로

세 번째는 우단위 법칙이다. 이는 좌단위 법칙과 유사하지만, 컨테이너를 씌우는 순서가 반대다.

μηT=id\mu \circ \eta T = \text{id}
m.flatMap(pure) === m

여기서 ηT\eta T는 이미 존재하는 컨테이너 T(A)T(A) 자체를 하나의 값으로 간주하고, 그 바깥을 pure(η\eta)로 한 번 더 감싸는 행위를 의미한다. 결과적으로 η(T(A))\eta(T(A))가 되어 T(T(A))T(T(A))라는 이중 구조가 만들어지는 것이다.

우단위 법칙이 말하고자 하는 핵심은 명확하다. 상자 바깥을 pure로 한 겹 더 감싼 뒤(ηT\eta T), 다시 join(μ\mu)으로 그 겉껍데기를 벗겨내면 결국 처음의 상태(id\text{id})와 동일해야 한다는 것이다.

앞서 언급했듯이 이 단위 법칙들이 중요한 이유는 pure라는 연산이 맥락을 왜곡하지 않는 항등원임을 보장하기 때문이다.

마치 우리가 숫자에 00을 더해도 값이 변하지 않듯, 어떤 컨테이너를 pure로 감싸고 다시 펴는 행위는 그 컨테이너가 가진 원래의 정보나 상태에 아무런 영향을 주지 않아야 한다.

다시 “내부함자 범주의 모노이드 대상”으로

자, 그렇다면 이것이 위키에 나와있는 모나드의 정의인 “내부함자 범주의 모노이드 대상”과 무슨 관련이 있는 것일까?

여기서부터는 추상적이고 수학적인 이야기가 많이 나오니, 실무적인 수준으로 모나드를 이해하는 것만으로도 충분한 독자들은 건너뛰어도 된다.

hard 하지만 운동 많이 될테니 그래도 한번 읽어보는 것을 추천한다

사실 앞서 말했듯이 모나드는 처음부터 수학자들이 “이런 걸 써라”하고 던져준 것이 아니다. 오히려 공학적인 필요에 의해 안전하게 함수를 합성하는 방법을 찾다 보니, 아래와 같은 도구들이 필연적으로 발명된 것에 가깝다.

발명된 것 설명 예시
TT 타입을 받아서 새로운 타입을 만드는 타입 생성자 Maybe<A>, Array<A>, …
η\eta(에타) 값을 컨테이너에 넣는 연산 pure, Promise.resolve, …
μ\mu(뮤) 중첩된 컨테이너를 단일 컨테이너로 펴는 연산 join, flatten, …

그리고 이 연산들을 우리가 안전하게 사용하기 위해서는 앞서 살펴본 세 가지 법칙(결합, 좌단위, 우단위)을 지켜야 한다고 했다. 이제 이 구조를 수학적으로 분석해 보면 아주 흥미로운 지점에 도달하게 된다.

범주(Category): 대상과 화살표의 세계

범주(Category)는 대상(Object)과 대상 사이의 사상(Morphism, 화살표)으로 이루어진 구조다. 필자는 개인적으로 범주보다는 카테고리라는 단어가 더 익숙하므로, 앞으로 카테고리라고 부르도록 하겠다.

우선 TypeScript 관점에서 가장 친숙한 카테고리는 타입의 카테고리다. 타입의 카테고리에서 대상은 number, string과 같은 타입들이고 사상은 (a: number) => string과 같이 한 대상에서 다른 대상으로 나아가는 계산, 함수이다.

카테고리와 함자(Functor)에 대한 자세한 내용은 이전 포스팅에 수록되어있으므로, 한번 읽고 오는 것을 추천한다.

내부함자(Endofunctor): 같은 세계 안에서 도는 펑터

그렇다면 모나드의 정의 중 “내부함자 범주의…”라는 것은 결국 내부함자(Endofunctor)로 이루어진 카테고리라는 의미이다.

이전 포스팅에 간단하게 적어놓았지만 함자, 즉 펑터는 어떤 카테고리를 다른 카테고리로 나아가게 만들어주는 사상이다. 일반적인 펑터는 카테고리 CC에서 다른 카테고리 DD로 매핑하지만, 내부함자, 엔도펑터는 출발지와 도착지가 같은 범주인 T:CCT: C \to C라는 것을 의미한다.

왜 모나드는 “내부함자 카테고리”의 대상인 것일까? 그 이유는 우리가 프로그래밍 세계 안에서 사용하는 펑터는 결국 프로그래밍 세계에서만 돌고 있기 때문이다. 예를 들어 펑터의 map 연산을 한번 생각해보자.

const map: <A, B>(maybe: Maybe<A>, f: (a: A) => B): Maybe<B>;

위 타입 시그니처를 보면 map 함수는 A를 받아 B로 나아가는 것을 볼 수 있다. 중요한 점은 A도 결국 타입스크립트의 타입 시스템에 있는 타입이고, 결과 타입인 Maybe<B>도 타입스크립트의 타입 시스템에 있는 타입이라는 것이다.

즉, 타입의 세계에서 타입의 세계로 향한다. 이것이 프로그래밍에서 사용하는 펑터가 엔도펑터(내부함자)인 이유이다. 다른 세계로 가는 것이 아니라 같은 세계 안에서 변환하기 때문이다.

내부함자 범주: 펑터들 자체가 대상인 세계

자 그럼 이제 추상화를 한 단계 올려보자. 프로그래밍에서 사용하는 펑터들이 엔도펑터라는 사실을 알았다면 이제는 엔도펑터들 자체를 대상으로 놓는 새로운 카테고리를 생각할 수 있다.

일반 타입 카테고리 엔도펑터 카테고리
대상 number, string, … Maybe, Array, Promise, …
사상 (a: A) => B 펑터 → 펑터

카테고리의 사상은 어떠한 대상에서 다른 대상으로 나아가는 것이니, 엔도펑터 카테고리에서의 사상은 펑터에서 다른 펑터로 나아가는 것이라고 생각해볼 수 있다.

그리고 이렇게 펑터를 다른 펑터로 바꾸는 사상을 우리는 자연 변환(Natural Transformation)이라고 부른다.

모노이드(Monoid) 대상: 합치기의 대수학

여기까지 모나드의 정의 중 “내부함자 범주의…”라는 내용을 살펴봤다면, 이제 “모노이드 대상이다”라는 말이 어떤 의미인지 살펴보자.

수학에서 모노이드라는 것은 다음 세 가지를 갖춘 구조를 의미한다.


  1. 집합 또는 대상들의 모임
  2. 이항 연산: 두 원소를 합쳐서 같은 집합 안에 들어있는 원소를 만든다. 반드시 결합 법칙을 만족해야한다.
  3. 항등원: 어떤 원소와 연산해도 그 원소를 그대로 반환하는 특별한 원소.

개념이 워낙 추상적이라 조금 어렵게 느껴질 수 있지만 막상 예시를 그렇게 복잡하지 않다. 가장 대표적인 모노이드는 정수와 덧셈의 관계이다.

정수와 덧셈에서 덧셈은 정수 집합의 두 원소를 뽑아와 연산하면 정수 집합의 원소를 다시 반환한다. 마치 1+2=31 + 2 = 3처럼 말이다. 그리고 이미 독자 분들도 알다시피 덧셈은 결합 법칙을 만족한다. 그리고 마지막으로 어떤 정수와 더해도 그 정수를 그대로 반환하는 녀석인 항등원은 00이다.

이러한 이유로 정수와 덧셈을 묶은 세트는 “모노이드”라고 부를 수 있는 것이고 수학적으로는 정수 집합 Z\mathbb{Z}과 덧셈 기호를 묶어 (Z,+)(\mathbb{Z}, +)라고 표기한다. (정확하게는 덧셈에 대한 정수군에는 역원도 존재하지만, 이 설명에서 중요한 것은 아니니 넘어가겠다)

연결점: 모나드는 내부함자 범주의 모노이드 대상

자 모노이드가 무엇인지 이해했다면 이제 드디어 “내부함자 범주의 모노이드 대상”이 무슨 뜻인지를 이해할 수 있게 된다.

일단 내부함자(Endofunctor) 범주는 Maybe, Promise와 같은 프로그래밍에서의 펑터들로 이루어진 카테고리를 의미하니, 이 친구들이 모노이드 구조를 갖춘 대상이 맞는지를 살펴보면 될 것 같다.

앞서 언급했던 대표적인 모노이드인 정수 집합과 덧셈의 관계, 그리고 엔도펑터와 합성 연산 간의 관계를 비교해보자.

모노이드 요소 정수 덧셈 내부함자 범주
이항 연산 + (1+21 + 2) 합성 (TTT \circ T)
연산의 결과 정수 내부함자
항등원 0 항등 함자 Id

이렇게 비교해보니 얼추 비슷해보이긴 한다. 하지만 문제가 하나 있는데, 바로 엔도펑터들의 이항 연산인 TTT \circ T의 결과는 T(A)T(A)가 아니라 T(T(A))T(T(A))처럼 중첩된 결과라는 것이다. 즉, 같은 집합 안에 있는 원소가 아니다.

여기서 바로 아까 정의한 모나드의 join(μ\mu)과 pure(η\eta)가 등장한다.

연산 표현 설명
join (μ\mu) TTTT \circ T \Rightarrow T 두 겹의 T를 하나의 T로 합치는 자연변환
pure (η\eta) IdT\text{Id} \Rightarrow T 항등 함자에서 T로 가는 자연변환

자연변환이란 펑터 사이의 “구조를 보존하는 변환”이다. pure가 단순히 값을 컨테이너에 넣는 녀석이 아니라 자연변환이라는 점이 중요하다. 이는 pure가 어떤 타입 AA에 대해서든 일관된 방식으로 동작해야 함을 의미한다. 즉, 타입에 따라 동작이 달라지면 안 된다.

그리고 앞서 살펴봤던 모나드의 법칙들을 다시 살펴보면, 이 법칙들이 모노이드가 요구하는 결합 법칙과 항등원에 대한 법칙을 만족한다는 사실을 알 수 있다.

법칙 표현 설명
결합 법칙 μTμ=μμT\mu \circ T\mu = \mu \circ \mu T 어떤 순서로 합치든 같다
단위 법칙 μTη=id=μηT\mu \circ T\eta = id = \mu \circ \eta T 넣었다 빼면 원래대로 돌아온다. 즉, η\eta는 항등원이다.

이처럼 우리가 프로그래밍에서 연산의 결과를 일관되게 보장하기 위해 세웠던 법칙들이 모노이드가 요구하는 것들과 정확하게 일치하는 것을 볼 수 있다.

정수의 모노이드 내부함자 범주의 모노이드 (= 모나드)
대상 정수 내부함자 (Maybe, Array, …)
이항 연산 ++ join (μ\mu) (TTTT \circ T \Rightarrow T)
항등원 00 pure (η\eta) (IdT\text{Id} \Rightarrow T)
결합 법칙 (a+b)+c=a+(b+c)(a+b)+c = a+(b+c) μTμ=μμT\mu \circ T\mu = \mu \circ \mu T
단위 법칙 0+n=n=n+00+n = n = n+0 μηT=id=μTη\mu \circ \eta T = \text{id} = \mu \circ T\eta

즉 이러한 이유들로 인해 우리가 프로그래밍에서 사용하는 모나드를 “내부함자 범주의 모노이드 대상이다”라고 말할 수 있는 것이다.

하지만 앞서 언급했듯이 이 정의는 우리가 프로그래밍에서 모나드를 사용하게 된 이유가 아니다. 우리는 그저 “효과가 있는 계산을 순차적으로 합성하고 싶다”라는 실용적이고 공학적인 필요성에서 출발해서 joinpure를 발명했을 뿐인데, 그것이 마침 수학자들이 이미 알고 있던 “모노이드”라는 구조와 정확하게 일치했던 것이다.

사실 모나드는 박스가 아니다

우리는 지금까지 Maybe라는 구체적인 예시를 통해 모나드의 원리를 파헤쳤다. 하지만 실무에서 모나드를 다룰 때는 단순히 코드를 구현하는 것보다, 이 도구가 담고 있는 맥락을 이해하고 기존 도구들과의 차이를 인지하는 것이 훨씬 중요하다.

지금까지 많은 모나드 설명 포스팅들이 펑터와 모나드를 박스에 비유해서 설명했고 필자도 그렇게 설명을 했었지만, 사실 이 비유는 직관적이기는 하나 모나드의 정체성을 절반만 설명할 뿐이다.

만약 Promise와 같은 녀석을 단순히 “미래의 값이 담긴 박스”로만 본다면, 왜 then이 순차적으로 실행되어야 하는지 설명하기 어렵다. 그래서 펑터나 모나드는 단순한 박스라기보다 “특정 효과가 수반되는 계산의 맥락”이라고 표현하는 것이 적합하다.

따라서 모나드의 flatMap이 하는 일도 단순히 박스를 까서 펼치는 것이 아니라, 서로 다른 맥락을 가진 계산들을 안전하게 이어 붙이는 것에 가깝다.

모나드 담고 있는 맥락(Effect)
Maybe 값이 없을 수도 있다는 맥락
Result 실패의 이유(에러)를 포함한 맥락
Promise 시간이 걸리는 비동기 계산의 맥락
Array 여러 개의 결과가 존재할 수 있는 비결정적 맥락

추가적으로 한 가지 더 짚고 넘어가자면 우리는 Promise에 대해 다시 바라볼 필요가 있다. 코드 상에서 Promise는 매우 모나딕하게 작동하지만 엄밀한 수학적 잣대를 들이대면 모나드가 아니다.

모나드는 구조를 보존하는 map과 구조를 펴는 flatMap이 엄격히 구분되어야 하는데, Promise의 then은 반환값에 따라 이 둘을 적당히 섞어서 처리해버리기 때문이다.

또한 수학적 모나드는 이중으로 겹쳐진 TTAT\langle T\langle A \rangle \rangle 상태가 존재해야 하지만, Promise는 런타임 수준에서 이를 허용하지 않고 즉시 단일 계층으로 뭉쳐버린다. 물론 이러한 설계가 실무적인 편의성을 주긴 하지만, 수학적 엄밀함이 주는 예측 가능성과는 어느 정도 거리가 있는 셈이다.

따라서 Promise는 엄밀하게 이야기하자면 모나드라고 할 수 없다.

마치며

이렇게 펑터부터 시작해서 어플리케이티브 펑터, 모나드까지 긴 여정을 마쳤다.

돌이켜보면 이 긴 여정은 “어떻게 하면 안전하게 함수를 합성할 수 있을까?”라는 지극히 공학적인 질문에서 시작되었다. 우리는 펑터의 한계를 넘기 위해 apply를 만났고, 중첩되는 맥락을 해결하기 위해 joinflatMap을 발명했다. 그리고 우리가 만든 이 도구들이 사실 수학자들이 수백 년 전부터 연구해온 ‘내부함자 범주의 모노이드 대상’이라는 견고한 구조와 일치한다는 사실도 발견할 수 있었다.

이러한 발견이 우리에게 주는 진짜 가치는 바로 우리가 작성하는 코드들의 합성 가능성에 대한 수학적 확신이다. 결합 법칙과 단위 법칙을 통해 맥락에 진입하는 입구가 중립적임을 신뢰할 수 있고, 결합 법칙을 통해서는 어떤 레이어에서 리팩토링을 하던 결과가 같다는 것을 보장받을 수 있다. 그리고 이러한 신뢰가 쌓여야 우리가 작은 맥락 조각들을 이어붙여가며 거대하고 복잡한 비즈니스 로직을 구축해나갈 수 있다.

결국 모나드를 이해한다는 것은 추상적인 맥락을 다루는 법을 배우고 우리가 작성하는 소프트웨어에 수학적인 질서를 부여하여 코드에 대한 확신을 얻는 과정이라고 볼 수 있다.

필자도 나름 모나드를 설명해보겠다고 발버둥을 쳐봤는데, 솔직히 이 글의 난이도가 쉬운 것인지 어려운 것인지 전혀 가늠이 안된다.

혹시라도 추가적인 궁금증이 있는 분들은 필자의 이메일을 통해 질문을 남겨주시면 최대한 설명을 해드릴테니 많은 함수형 프로그래밍 러버들의 관심과 사랑을 부탁드린다.

이상으로 펑터를 넘어서, 모나드까지 포스팅을 마친다.

관련 포스팅 보러가기

Jan 27, 2020

어떻게 하면 안전하게 함수를 합성할 수 있을까?

프로그래밍/아키텍처
Dec 25, 2024

[번역] 프로그래머를 위한 카테고리 이론 - 11. 선언적 프로그래밍

프로그래밍
Jun 01, 2024

[번역] 프로그래머를 위한 카테고리 이론 - 10. 자연 변환

프로그래밍
Apr 18, 2024

[번역] 프로그래머를 위한 카테고리 이론 - 9. 함수 타입

프로그래밍
Apr 02, 2024

[번역] 프로그래머를 위한 카테고리 이론 - 8. 펑터의 특성

프로그래밍