Monad는 대체 무엇인가?

정지용·2023년 11월 29일
1

FP

목록 보기
1/1
post-thumbnail

🤔 Monad란 무엇인가?

Monad는 대체 뭘까요? 함수형 프로그래밍을 공부하다 보면 한 번씩 알게 되지만 두려워서 도망가던 그 Monad를 알아보려고 해요.

이 글에서는 모나드가 무엇인지, 왜 중요한지, 어떻게 사용되는지에 대해 알아보도록 할게요.

Monad의 학술적 정의

수학에서의 Monad

위 내용은 수학에서의 Monad의 정의입니다.

Haskell에서의 Monad

적용자 m에 대해 적절한 함수 (>>=) :: m a -> (a -> m b) -> m b가 존재하여 다음 성질들을 만족할 때, m모나드(Monad)라고 한다.

  • 모든 값 x :: a와 함수 k :: a -> m b에 대해 pure x >>= k ≡ k x이다.
  • 모든 액션 u :: m a에 대해 u >>= pure ≡ u이다.
  • 모든 액션 u :: m a, 함수 k :: a -> m bh :: b -> m c에 대해 u >>= (\x -> k x >>= h) ≡ u >>= k >>= h이다.
  • 모든 액션 u :: m a와 함수 f :: a -> b에 대해 u >>= (pure . f) ≡ fmap f u이다.
  • 모든 액션 u :: m (a -> b)v :: m a에 대해 u >>= (\f -> v >>= (\x -> pure (f x))) ≡ u <*> v이다.

위 내용은 Haskell에서의 Monad의 정의입니다.

이해하기도 어렵겠지만 여기까지 이해했다고 하더라도 Monad의 의의를 알기 어렵습니다. 또한 수학과 Haskell의 Monad는 그 자체로 받아들여지기에 아주 엄격하고 난해하기 때문에 우리는 관대한 관점에서 Monad를 바라보기로 하겠습니다.

관대한 관점에서의 Monad

Monad는,
1. Context로 감싸져 있으며
2. Functor를 기반으로 구현되었고
3. FlatMap 메소드를 가지고 있어야 하는

값을 담는 일종의 컨테이너이다.

훨씬 간단하죠. 이제 Monad가 무엇인지 알아보겠습니다.

Context

Monad는 Context의 일종인데, 그럼 Context는 뭘까요?

실제로 우리가 개발을 하다 보면 순수한 값을 사용하는 경우는 많지 않죠. Array나 List와 같은 Collection을 통하여 값을 다루거나, 비동기 작업시에는 언어에 따라 Promise나 Future와 같은 구현체를 통하여 값을 다루는 경우가 많습니다.

위에서 예시로 설명한 것들은 모두 Context라고 볼 수 있는데요, Context는 값을 감싸는 무언가를 말합니다.

Context는 상자라고 볼 수 있겠구나!

Functor

Functor는 뭘까요? 한번 위키페디아에 검색을 해보면

마찬가지로 어렵지만 우리는 여기서 하나는 확인하고 넘어가야 합니다.

F(gf)=F(g)F(f)F(g\circ f)=F(g)\circ F(f)

합성함수인데요, Functor는 함수의 합성과 연관이 있다고 생각하시면 됩니다.

import java.util.funtion.Function;

interface Functor<T> {
    <R> Functor<R> map(Function<T,R> f);
}

Java에서 Functor의 정의는 위와 같은데, Functor는 map 메소드를 가진 인터페이스 그 이상 그 이하가 아니에요. 그럼 map과 함수 합성이 관련이 있겠죠?

Functor는 함수를 합성하기 위한 map과 같은 메소드를 가지고 있는 구현체입니다.

그럼 이제 우리가 알던 map과 함수형 프로그래밍에서 이야기하는 map에 어떤 차이가 있는지 알아보도록 하겠습니다.

Map()

function map(T, R)(xs: T[], f: (value: T) => R): R[] {
	const result = [];
	for (const i of xs) {
		result.push(f(i));
	}
	return result;
}

TypeScript에서 구현한 Array의 map 메소드의 모습입니다. 여기에 어떤 의미가 있는지 예제를 보며 설명하겠습니다.

보통 우리는 이 메소드를 Collection을 순회하며 mapping을 해주는 도구로 사용했는데요,
이렇게 생각하시면 이해하기 어려울 수 있어요!

const x = ['1', '2', '3', '4', '5'];

const f = element => parseInt(element, 10);
const g = element => element * 2;

console.log(x.map(f).map(g));
// [2, 4, 6, 8, 10]

실제로 적용하는 경우는 위와 같은데요, Array라는 모나드에 내장된 map이라는 메소드를 통해서 모나드와 함수를 합성시키고 있습니다.

자세하게 보면,

x.map(f)
// 값의 타입이 String -> Integer 변경
.map(g)
// 값의 타입이 Integer -> String 변경

이런식으로 타입이 한 번씩 바뀌게 되는데요, 이게 핵심입니다.
map 메소드의 인정한 의미를 알기 위해서는 T 타입의 Functor를 R 타입의 Functor로 변경하는 부분을 집중해야 합니다.

합성하는 함수에 따라서 타입이 바뀌지 않을 수 있는데요, 이상하게 생각하지 않아도 돼요.
예를 들어 결과가 Integer의 경우에는 타입이 바뀌지 않은 게 아니라 Integer에서 Integer로 변경됐다고 보는게 옳은거죠.

그럼 이 Functor와 map을 우리는 왜 쓰는걸까요? Functor는 일반적으로 모델링할 수 없는 상황을 모델링할 수 있게 합니다.

값이 없는 케이스

const x = [];

const f = element => parseInt(element, 10);
const g = element => element * 2;

console.log(x.map(f).map(g));
// []

이렇게 빈 배열에 map 함수를 적용해도 아무 일이 일어나지 않는 것을 볼 수 있습니다.

값이 미래에 준비될 것으로 예상되는 케이스

new Promise((resolve, reject) => {
    setTimeout(resolve(1), 1000)
}).then(console.log);
// 1

비동기 작업 등 값이 미래에 준비될 것으로 예상되는 경우에는 데이터를 다루기가 난감하죠. 하지만 우리는 이미 Promise를 이용하여 then ~ catch 등의 패턴으로 쉽게 다루고 있었어요.
이게 Promise가 특수한 목적을 가진 Future Monad었기 때문입니다.

then은 Promise가 fulfill된 경우에 map과 같다고 보시면 돼요.

이렇게 Functor는 안전하게 함수 합성을 하는 데에 의의가 있습니다. 즉, Functor는 값이 있는 Context이자, 함수를 합성하여도 그 Context를 유지시킬 수 있는 방법을 가지고 있는(map, then 등의 메소드) 구조를 말합니다.

이외에도 원본 데이터에 영향을 미치지 않아 사이드 이펙트가 제거되어 안정성도 확보되어 여러 이점이 있죠.

FlatMap

interface Monad<T,R extends Monad<?,?>> extends Functor<T,R> {
	R flatMap(Function<T,R> f);
}

Java에서 Monad의 정의는 위와 같습니다. 아까 봤던 Functor를 상속하여 flatMap이라는 메소드만 추가된 모습입니다.
Functor와 비슷한 구조인 거 같은데요, 한 번 다시 Functor의 정의도 확인해보겠습니다.

interface Functor<T> {
    <R> Functor<R> map(Function<T,R> f);
}

위에서 T 타입의 Functor를 R 타입의 Functor로 변경을 해주는게 map이라고 이야기를 했죠?
flatMap은 R 타입의 Functor가 아닌 R 타입을 반환한다는 차이점을 볼 수 있습니다.

flatMap은 왜 있어야 할까요? 왜 Functor에 flatMap이 있어야만 Monad가 될까요?

만약, 함수를 합성할 때 합성하고자 하는 함수가 만약 Functor를 반환한다면 어떻게 될까요? Functor를 반환하는건 합성을 진행하는 map 함수가 하는 일인데 말이에요.

// 간단하게 Monad를 정의함
class Monad {
  constructor(value) {
    this.value = value;
  }

  map(fn) {
    return this.value !== null && this.value !== undefined
      ? new Monad(fn(this.value))
      : new Monad(null);
  }

  flatMap(fn) {
    return this.map(fn).value;
  }
}

// 값을 그대로 전달하는 함수와 Monad에 감싸 전달하는 함수 정의
const tryParseInt1 = element => typeof(element) === 'string'
	? parseInt(element, 10) : null;
const tryParseInt2 = element => typeof(element) === 'string' 
	? new Monad(parseInt(element, 10)) : null;

const myValue = new Monad("123");

console.log(myValue.map(tryParseInt1));
console.log(myValue.map(tryParseInt2));

마지막 부분에 결과를 출력하는 부분을 한번 볼까요?

console.log(myValue.map(tryParseInt1));
// Monad {value: 123}
console.log(myValue.map(tryParseInt2));
// Monad {value: Monad}

tryParseInt2의 경우에 Monad 안에 Monad가 있는 것을 볼 수 있습니다.
Functor가 중첩된 상황이 발생하여 값을 제대로 확인하기 어려운 문제가 발생했죠.

중첩된 Functor에 함수를 결합하는 경우

const sqrt = element => element * element;

console.log(myValue.map(tryParseInt1).map(sqrt));
// Monad {value: 15129}
console.log(myValue.map(tryParseInt2).map(sqrt));
// Monad {value: NaN}

그럼 Functor가 중첩된 상황에서 함수를 결합한다면? 제대로 연산이 될 수 없습니다.
map은 전달받은 Monad를 벗겨서 그 안에 있는 값에 연산을 가했을 뿐인데요, Functor가 중첩되어 값이 아니라 Monad에 연산을 가하게 된 꼴이죠.

flatMap을 적용한 경우

console.log(myValue.map(tryParseInt1).map(sqrt));
// Monad {value: 15129}
console.log(myValue.flatMap(tryParseInt2).map(sqrt));
// Monad {value: 15129}

위에서 알아봤듯이 flatMap은 map과 다르게 Functor를 반환하지 않습니다. 연산 이후 값을 그대로 반환하게 됩니다.

이렇게 위임 함수가 Functor를 반환하는 경우에서 flatMap을 사용하여 tryParseInt2에서도 같은 결과를 만들어냈어요. 이런 식으로 적절하게 flatMap을 사용하면 함수의 합성과 체이닝을 매끄럽게 만들어 줄 수 있습니다.

즉, flatMap은 map의 단점인 Functor가 중첩된 경우에 함수의 합성과 체이닝이 저해될 수 있다는 부분을 해결하기 위해 존재하는 메소드라고 볼 수 있습니다.

요약

이렇게 Monad는 함수형 프로그래밍 패러다임에서 가장 중요한 개념인 함수의 합성을 이해하는 데 도움을 줄 수 있습니다.

Monad는 일종의 값을 담는 컨테이너로, map 메소드를 통해 안전하게 함수를 합성하여 자신의 구조를 유지하는 인터페이스이며 더 나아가 자신의 단점을 보완하는 flatMap으로 완성이 되죠. 이를 통하여 일반적이지 않은 상황을 모델링할 수 있다는 가치를 알 게 되었습니다.


마무리

Monad를 구글에 검색해주면 Monad를 설명하는 좋은 글들을 많이 볼 수 있는데요.
읽어보면 사람들마다 각자 정의하는 Monad가 조금씩 달라서 어려운 부분이 있어요. 그렇다고 Haskell과 함께 Monad를 이해하는 것은 더더욱 어렵구요.

그런데 사실 Monad는 몰라도 돼요. 우리는 이미 여러 API들을 통해 Monad를 접해왔고 개념 또한 어렴풋이 알고 있어요. 그래서 Monad를 이해하지 못 한다고 우리가 코딩을 못하거나 그런 건 아니죠.

그렇기에 이 글을 읽는 분은 애써 Monad 이해하려고 노력하기 보다는 Monad를 통해서 어떤 실리를 추구할 수 있을지 생각해보는 게 도움이 될 거 같아요.
이해하기 어려운 Monad Laws같은 이론을 바라보며 고통받지 말구요.

참고 자료

[Youtube] NAVER D2 - Monad란 무엇인가?
[Youtube] NAVER D2 - 함수형 프로그래밍과 ES6+
-> JS 개발자라면 위 영상을 한 번쯤 보는걸 추천드려요
[Blog] 당신은 이미 펑터Functor를 알고 있다
[Blog] JS개발자는 아직도 모나드를 모르겠어요
[Blog] Monad Programming with Scala Future

profile
전설의 시작

0개의 댓글