And Brain said,
함수적인, 너무나 함수적인 - 5. 모나드(Monad) 본문
Index
0. 함수형 프로그래밍
1. 람다 대수 (Lambda Calculus)와 합성함수
2. 범주론 (Category Theory)
3. 모노이드 (Monoide)
4. 펑터 (Functor)와 엔도펑터(Endo Functor)
5. 모나드 (Monad)
6 함수형 프로그래밍의 고급 기법
모나드는 엔도펑터 범주에서의 모노이드다.
모나드와 그를 이해하기 위해 설명하는 단어들이 주는 혼란 덕분에 해외 프로그래머 커뮤니티에서는 모나드가 하나의 밈화가 된 적이 있었습니다. 모나드가 뭔지 알려고 했더니, 모노이드며, 엔도펑터며 여러가지로 점점 깊은 수렁에 빠지게 되었던 것이지요. 하지만, 우리는 상황이 다릅니다. 우리는 (충실히 따라오셨다면) 엔도펑터가 무엇인지도 알고, 범주가 무엇인지도, 모노이드 또한, 알고 있습니다.
이제 함수적인, 너무나 함수적인 프로그래밍 시리즈를 시작하게 된 계기였던 모나드를 배워보도록 합시다. 여기까지 오신 여러분들은 이제 모나드를 이해할 준비가 되었습니다. 여태까지 배운 것들은 오로지 이 모나드를 설명하기 위함이었습니다. 지금까지 잘 이해하셨다면, 모나드도 충분히 이해하실 수 있으실 겁니다. 여러분들의 이해의 결실을 맺을 준비가 되셨나요?
자, 그러면 함수형 프로그래밍의 정수, 모나드를 시작합시다.
모나드 (Monad)
먼저, 모노이드와 엔도펑터에 대해 다시 한 번 상기시켜봅시다.
모노이드는 집합(Set)과 이항 연산(Binary operation)을 가지며, 결합법칙(Associativity)과 항등원(Identity element)을 만족하는 단일 형태의 대수 구조였습니다.
엔도펑터는 펑터의 특별한 경우로 범주 C의 대상과 사상을 동일한 범주 C 내에서 대응시키는, 자기 자신으로의 펑터였습니다.
즉, 모나드는 간단히 말해 엔도펑터(자기 자신으로의 펑터)를 대상으로 하는 범주에서 모노이드 구조를 만족하는 개념입니다. 정리해봅시다.
(대상) 엔도펑터 (Endofunctor) : 범주 C의 대상과 사상을 동일한 범주 C 내에서 대응시키는 펑터를 말합니다.
(사상) 이항 연산 (Binary operation): 엔도펑터의 합성 (Functor Composition), 두 엔도펑터를 입력받아 새로운 엔도펑터를 반환하는 연산입니다. 이 연산은 범주 C 내에서만 이루어집니다.
(사상으로서 만족해야 하는 요구 사항들)
결합법칙 (Associativity): 모든 엔도펑터 F, G, H에 대해 (F ∘ G) ∘ H = F ∘ (G ∘ H)가 성립해야 합니다. 즉, 엔도펑터의 합성 순서가 결과에 영향을 주지 않습니다.
항등원 (Identity element): 항등원은 엔도펑터의 합성에 있어서 특별한 역할을 하는 엔도펑터로, 범주 C의 모든 엔도펑터 F와 합성을 수행할 때 결과가 F 자신이 되는 엔도펑터를 의미합니다. 즉, 범주 C의 엔도펑터 I에 대해 I ∘ F = F ∘ I = F가 성립해야 합니다.
참 간단하지 않나요? 여태 배운 모든 것들의 융합일 뿐입니다. 이제 이를 프로그래밍에 접목시켜 봅시다.
모나드는 함수형 프로그래밍에서 중요한 개념으로, 프로그램의 상태 변화를 추상화하여 순수한 함수로 작성할 수 있도록 도와줍니다. 일반적으로, 함수형 프로그래밍은 부작용(side-effects)이 없고, 상태를 변경하지 않는(pure) 함수를 사용합니다. 그러나 현실적인 프로그램은 상태를 다루고 부작용을 발생시킬 필요가 있습니다.
모나드는 이러한 문제를 해결하기 위해 사용됩니다. 모나드는 값을 감싸는 컨테이너 역할을 하며, 이를 통해 순수한 함수와 상태 변화를 조합할 수 있습니다. 모나드는 다음과 같은 세 가지 구성 요소로 이루어져 있습니다.
타입 생성자 (Type constructor): 모나드에 포함될 값의 타입을 정의합니다.
바인드 함수 (Bind function): 모나드를 받아들이고, 모나드에 포함된 값을 다루는 함수를 받아들여서, 새로운 모나드를 반환합니다.
리턴 함수 (Return function): 일반 값을 모나드로 변환합니다.
모나드를 사용함으로써, 프로그래머는 상태 변경이나 부작용을 내포한 코드를 작성하면서도, 순수한 함수형 프로그래밍 스타일을 유지할 수 있습니다. 이는 프로그램의 가독성, 유지 보수성을 향상시키고 에러 처리에 도움이 됩니다. 몇 가지 유명한 모나드의 예시들을 통해 모나드에 대해 이해해봅시다.
먼저 Maybe<T> 타입이라는 모나드의 구현체로 예시를 들어보겠습니다.
// 대상: 타입 T에 대한 Maybe<T> 타입
type Maybe<T> = { type: "Just"; value: T } | { type: "Nothing" };
// 사상: 값을 Maybe<T>로 감싸는 함수 return_
function return_<T>(value: T): Maybe<T> {
return { type: "Just", value };
}
// 사상: Maybe<T>를 받아서 f를 적용한 새로운 Maybe<U>를 반환하는 함수 bind (이항 연산)
function bind<T, U>(maybe: Maybe<T>, f: (value: T) => Maybe<U>): Maybe<U> {
if (maybe.type === "Just") {
return f(maybe.value);
}
return { type: "Nothing" };
}
// 항등원: Maybe 모나드에 대한 항등원은 return_ 함수입니다.
// 결합법칙: bind 함수는 결합법칙을 만족합니다.
// bind(bind(m, f), g) === bind(m, (x) => bind(f(x), g))
// 모노이드: Maybe<T> 타입과 함께 bind 함수가 모노이드를 형성합니다.
// 엔도펑터: Maybe 타입은 엔도펑터입니다. 같은 범주의 대상인 T를 Maybe<T>로 변환합니다.
// 사용 예제:
// 안전한 나눗셈 함수. 0으로 나눌 경우 Nothing을 반환합니다.
function safeDivide(a: number, b: number): Maybe<number> {
if (b === 0) {
return { type: "Nothing" };
}
return { type: "Just", value: a / b };
}
// 결과 1: 6을 2로 나눈 값을 다시 3으로 나눕니다.
const result1 = bind(bind(safeDivide(6, 2), (value1) => safeDivide(value1, 3)), (value2) => return_(value2));
if (result1.type === "Just") {
console.log("Result:", result1.value); // Result: 1
} else {
console.log("Error: division by zero");
}
// 결과 2: 6을 0으로 나눈 값을 다시 3으로 나눕니다. (0으로 나누는 과정에서 Nothing이 반환됩니다.)
const result2 = bind(bind(safeDivide(6, 0), (value1) => safeDivide(value1, 3)), (value2) => return_(value2));
if (result2.type === "Just") {
console.log("Result:", result2.value);
} else {
console.log("Error: division by zero"); // Error: division by zero
}
다음은, Either<L, R> 타입의 모나드 구현체를 살펴볼까요?
// 대상: 타입 L과 R에 대한 Either<L, R> 타입
type Either<L, R> = { type: "Left"; value: L } | { type: "Right"; value: R };
// 사상: 값을 Either<L, R>의 Right로 감싸는 함수 return_
function return_<L, R>(value: R): Either<L, R> {
return { type: "Right", value };
}
// 사상: Either<L, R>를 받아서 f를 적용한 새로운 Either<L, S>를 반환하는 함수 bind (이항 연산)
function bind<L, R, S>(either: Either<L, R>, f: (value: R) => Either<L, S>): Either<L, S> {
if (either.type === "Right") {
return f(either.value);
}
return { type: "Left", value: either.value };
}
// 항등원: Either 모나드에 대한 항등원은 return_ 함수입니다.
// 결합법칙: bind 함수는 결합법칙을 만족합니다.
// bind(bind(e, f), g) === bind(e, (x) => bind(f(x), g))
// 모노이드: Either<L, R> 타입과 함께 bind 함수가 모노이드를 형성합니다.
// 엔도펑터: Either 타입은 엔도펑터입니다. 같은 범주의 대상인 R을 Either<L, R>로 변환합니다.
// 사용 예제:
// 문자열을 숫자로 변환하는 함수. 변환에 실패할 경우 에러 메시지를 반환합니다.
function parseNumber(str: string): Either<string, number> {
const num = parseFloat(str);
if (isNaN(num)) {
return { type: "Left", value: "Error: not a valid number" };
}
return { type: "Right", value: num };
}
// 두 문자열을 숫자로 변환한 후 더하는 함수
function addStrings(str1: string, str2: string): Either<string, number> {
return bind(parseNumber(str1), (num1) =>
bind(parseNumber(str2), (num2) => return_<string, number>(num1 + num2))
);
}
// 결과 1: "3"과 "5"를 숫자로 변환한 후 더합니다.
const result1 = addStrings("3", "5");
if (result1.type === "Right") {
console.log("Result:", result1.value); // Result: 8
} else {
console.log(result1.value); // 에러 메시지 출력
}
// 결과 2: "3"과 "five"를 숫자로 변환한 후 더합니다.
const result2 = addStrings("3", "five");
if (result2.type === "Right") {
console.log("Result:", result2.value);
} else {
console.log(result2.value); // Error: not a valid number
}
이번에는, List<T> 타입의 모나드 구현체를 살펴봅시다.
// 대상: 타입 T에 대한 List<T> 타입
type List<T> = T[];
// 사상: 값을 List<T>로 감싸는 함수 return_
function return_<T>(value: T): List<T> {
return [value];
}
// 사상: List<T>를 받아서 f를 적용한 새로운 List<U>를 반환하는 함수 bind (이항 연산)
function bind<T, U>(list: List<T>, f: (value: T) => List<U>): List<U> {
return list.flatMap(f);
}
// 항등원: List 모나드에 대한 항등원은 return_ 함수입니다.
// 결합법칙: bind 함수는 결합법칙을 만족합니다.
// bind(bind(l, f), g) === bind(l, (x) => bind(f(x), g))
// 모노이드: List<T> 타입과 함께 bind 함수가 모노이드를 형성합니다.
// 엔도펑터: List 타입은 엔도펑터입니다. 같은 범주의 대상인 T를 List<T>로 변환합니다.
// 사용 예제: 두 개의 리스트에서 가능한 모든 조합을 생성합니다.
function cartesianProduct<T, U>(list1: List<T>, list2: List<U>): List<[T, U]> {
return bind(list1, (value1) => bind(list2, (value2) => return_([value1, value2])));
}
const listA = [1, 2, 3];
const listB = ["a", "b", "c"];
const result = cartesianProduct(listA, listB);
console.log(result);
// 출력: [ [ 1, 'a' ], [ 1, 'b' ], [ 1, 'c' ], [ 2, 'a' ], [ 2, 'b' ], [ 2, 'c' ], [ 3, 'a' ], [ 3, 'b' ], [ 3, 'c' ] ]
이번에는 사용자로부터의 입력을 받고 출력하는 IO 모나드를 알아봅시다.
// 대상: 타입 T에 대한 IO<T> 타입
class IO<T> {
constructor(public unsafePerformIO: () => T) {}
// 사상: 값을 IO<T>로 감싸는 함수 return_
static return_<T>(value: T): IO<T> {
return new IO(() => value);
}
// 사상: IO<T>를 받아서 f를 적용한 새로운 IO<U>를 반환하는 함수 bind (이항 연산)
bind<U>(f: (value: T) => IO<U>): IO<U> {
return new IO(() => f(this.unsafePerformIO()).unsafePerformIO());
}
}
// 항등원: IO 모나드에 대한 항등원은 return_ 함수입니다.
// 결합법칙: bind 함수는 결합법칙을 만족합니다.
// bind(bind(io, f), g) === bind(io, (x) => bind(f(x), g))
// 모노이드: IO<T> 타입과 함께 bind 함수가 모노이드를 형성합니다.
// 엔도펑터: IO 타입은 엔도펑터입니다. 같은 범주의 대상인 T를 IO<T>로 변환합니다.
// 사용 예제: 숫자를 입력받아 2배로 만드는 과정을 IO 모나드로 표현합니다.
// readNumber 함수: 사용자로부터 숫자를 입력받아 IO<number> 타입으로 반환합니다.
function readNumber(): IO<number> {
return new IO(() => {
const input = prompt("Enter a number: ");
return parseFloat(input);
});
}
// doubleNumber 함수: 입력된 숫자를 두 배로 만든 후 IO<number> 타입으로 반환합니다.
function doubleNumber(num: number): IO<number> {
return IO.return_(num * 2);
}
// printResult 함수: 입력된 숫자를 콘솔에 출력하고 IO<void> 타입을 반환합니다.
function printResult(result: number): IO<void> {
return new IO(() => {
console.log("Result:", result);
});
}
// 실행 흐름을 정의합니다. 실행은 아직 이루어지지 않았습니다.
// readNumber를 호출하여 숫자를 입력받고, 그 결과를 doubleNumber에 전달합니다.
// doubleNumber의 결과는 두 배로 만들어진 숫자이며, 이를 printResult 함수에 전달하여 출력합니다.
const program = readNumber()
.bind(doubleNumber)
.bind(printResult);
// 실제로 실행하기 위해서는 unsafePerformIO를 호출합니다.
// 사용자의 입력을 받고, 결과를 출력합니다.
program.unsafePerformIO();
마지막으로, 여러분들도 익히 알고 사용해왔음에도 이것이 모나드인지 몰랐던, Promise를 알아봅시다.
// 대상: 타입 T에 대한 Promise<T> 타입
type Async<T> = Promise<T>;
// 사상: 값을 Promise<T>로 감싸는 함수 return_
function return_<T>(value: T): Async<T> {
return Promise.resolve(value);
}
// 사상: Promise<T>를 받아서 f를 적용한 새로운 Promise<U>를 반환하는 함수 bind (이항 연산)
function bind<T, U>(promise: Async<T>, f: (value: T) => Async<U>): Async<U> {
return promise.then(f);
}
// 항등원: Promise 모나드에 대한 항등원은 return_ 함수입니다.
// 결합법칙: bind 함수는 결합법칙을 만족합니다.
// bind(bind(p, f), g) === bind(p, (x) => bind(f(x), g))
// 모노이드: Promise<T> 타입과 함께 bind 함수가 모노이드를 형성합니다.
// 엔도펑터: Promise 타입은 엔도펑터입니다. 같은 범주의 대상인 T를 Promise<T>로 변환합니다.
// 사용 예제: 비동기 작업을 순차적으로 실행하고 그 결과를 처리합니다.
// fetchData 함수: id를 인수로 받아 비동기 작업을 통해 문자열 데이터를 가져오는 함수입니다.
function fetchData(id: number): Async<string> {
return new Promise((resolve) => {
setTimeout(() => {
resolve(`Fetched data for ID: ${id}`);
}, 1000);
});
}
// processData 함수: 문자열 데이터를 인수로 받아 비동기 작업을 통해 숫자 데이터를 반환하는 함수입니다.
function processData(data: string): Async<number> {
return new Promise((resolve) => {
setTimeout(() => {
resolve(data.length);
}, 1000);
});
}
// 비동기 작업 실행 예제: fetchData 함수를 호출하여 비동기 작업을 시작하고, 그 결과를 processData 함수에 전달하여 처리합니다.
bind(fetchData(42), (data) => {
console.log(data); // 출력: Fetched data for ID: 42 (fetchData에서 반환된 결과)
return bind(processData(data), (processedData) => {
console.log(processedData); // 출력: 20 (processData에서 반환된 결과)
return return_(processedData); // 처리된 데이터를 다음 작업에 전달합니다.
});
});
여기까지 유명한 모나드의 예시들을 통해 함수적인 프로그래밍의 모나드란 무엇인가까지 알아보았습니다.
자, 드디어 끝났습니다! 마지막으로 정리해볼까요?
모나드는 엔도펑터 범주에서의 모노이드다.
이제, 여러분들이 이 글을 읽은 후 모나드의 정의를 다시 봤을때 그렇지! 하고 넘어갈 수 있다면, 더 할 나위없이 기쁠 것 같습니다. 이번 포스팅에서는 모나드에 대해, 모노이드와 엔도펑터를 통해 설명하였습니다. 또한, Maybe, Either, List, IO 그리고 Promise와 같은 프로그래밍에서 사용되는 모나드 구현체들을 예시로 들어 설명하였습니다.
여러분들은 이제, 함수형 프로그래밍의 정수인 모나드까지 이해하셨습니다. 그렇지만, 언제나 배울 것은 더 많습니다. 지금까지 배운 것들을 바탕으로 함수형 프로그래밍을 더 깊이있게 학습해보시고 실제로 사용해보시길 바랍니다. 여태까지 정말로 고생 많으셨습니다. 저도 마찬가지로 여러분들에게 설명하기 위해 많은 공부를 하게되었습니다. 여러분들 덕분에 제가 또 한층 성장할 수 있었습니다.
이렇게 얘기했지만, 아직 이 시리즈가 끝난 것은 아닙니다. 다음은 함수형 프로그래밍의 고급 기법들을 소개하는 글을 쓸 것입니다. 물론, 오늘 배운 모나드까지가 이 시리즈의 핵심입니다. 다음 글은 가벼운 마음으로 보셔도 됩니다.
언제나,
Thanks for watching, Have a nice day.
References
https://wikidocs.net/7056
https://pnurep.github.io/functional%20programming/functors-applicatives-monads/#
https://saengmotmi.netlify.app/javascript-study/2022-05-13-monad/#2-%EB%AA%A8%EB%82%98%EB%93%9Cmonad
https://teamdable.github.io/techblog/Moand-and-Functional-Architecture
https://koreascience.kr/article/JAKO200111921537481.pdf
https://blog.juho.kim/posts/2021-07-26_Journey-To-Monad/
https://kpug.github.io/fp-gitbook/Chapter3.html
https://dev.to/ingun37/monad-monoid-40if
https://overcurried.com/3%EB%B6%84%20%EB%AA%A8%EB%82%98%EB%93%9C/
'IT > 함수적인, 너무나 함수적인' 카테고리의 다른 글
함수적인, 너무나 함수적인 - 6. 함수형 프로그래밍의 고급 기법 (2) | 2023.05.01 |
---|---|
함수적인, 너무나 함수적인 - 4. 펑터 (Functor)와 엔도펑터(Endo Functor) (4) | 2023.04.22 |
함수적인, 너무나 함수적인 - 3. 모노이드(Monoid) (0) | 2023.04.17 |
함수적인, 너무나 함수적인 - 2. 범주론 (Category Theory) (0) | 2023.04.16 |
함수적인, 너무나 함수적인 - 1. 람다 대수 (Lambda Calculus)와 합성 함수 (0) | 2023.04.16 |