💡 모나드 패턴이란 함수형 프로그래밍을 기반으로 둔 프로그래밍 패턴이다. 먼저 우리가 모나드 패턴을 알기 전 알아야 할 지식이 있다. 그건 바로 '함수형 프로그래밍'이다.
프로그래밍 패러다임 중 하나로, 순수 함수와 불변성을 강조하며, 프로그램의 상태 변경을 최소화하는 것을 목표로 합니다. 이를 통해 코드의 간결성, 모듈성 및 예측 가능성을 높일 수 있습니다.
출처
함수형 프로그래밍에선 순수 함수(Pure Function)들이 사용됩니다. 순수 함수란 인자에만 의존하고 부수 효과가 없는 함수로 인자 외에 외부 어떤 값에도 영향을 받거나 주지 않습니다. 즉, 언제 실행되든, 몇 번이고 실행되든 같은 입력값에 대해 같은 결과값을 반환하는 함수를 말합니다.
“함수형 프로그래밍은 순수 함수와 선언형 프로그래밍의 토대 위에 함수 조합(function composition)과 모나드 조합(monadic composition)으로 코드를 설계하고 구현하는 기법입니다.”
출처: <Do it 타입스크립트 프로그래밍>, 전예홍
함수형 프로그래밍 정의를 찾다보면 순수 함수
, 불변성
, 선언형
라는 단어를 자주 찾아볼 수 있다.
💡 함수형 프로그래밍은 순수 함수들을 조합하여 선언적이고 예측 가능한 안전한 코드를 작성할 수 있다.
: 같은 입력값에 대해 항상 같은 결과값을 반환하므로, 코드의 예측 가능성이 높아지고 테스트가 용이해진다.
function fAdd(a, b) {
return a + b
}
fAdd(1, 2); // 3
: 함수형 프로그래밍에서는 데이터 원본을 수정하지 않아 예상치 못한 오류를 방지한다. (사본을 생성해 수정하는 방식)
const arr = [1, 2, 3]
// arr.push(4) ❌
const newArr = [...arr, 4] // 🙆♀️
const a = { b: '1', c: 2 }
const newA = { ...a, c: 3 }
-> 실행 흔적을 남기지 않으므로, 함수형 코드는 예측과 테스트에 용이하고 변경 이력 관리나 캐싱 작업 등에서도 유리하다.
: 어떻게 할건지(How)를 나타내기보다 무엇(What)을 할 건지를 설명하는 방식
<-> 명령형 함수 : 무엇(What)을 할 것인지 나타내기보다 어떻게(How) 할 건지를 설명하는 방식
// 선언형 함수 (What)
const numbers = [1, 2, 3, 4, 5];
const doubled = numbers.map(num => num * 2);
console.log(doubled); // [2, 4, 6, 8, 10]
// 명령형 함수 (How)
const numbers = [1, 2, 3, 4, 5];
const doubled = [];
for(let i = 0; i < numbers.length; i++) {
doubled.push(numbers[i] * 2);
}
console.log(doubled); // [2, 4, 6, 8, 10]
🫸 선언형 코드는 "무엇을 할 것인가"에 집중하여, 코드의 의도를 명확하게 표현한다. map 함수를 사용하여 배열의 각 요소를 변환하는 작업을 한 줄로 표현할 수 있어, 가독성이 높아진다. 또한, 상태 변화가 없으므로 코드가 안정적이고, 오류 방지에 좋다.
🫷명령형 코드는 상태 변화와 명령어의 순서에 의존하여 코드가 동작한다. 코드의 의도를 파악하기 위해서는 각 단계를 이해해야 하며, 상태 변화로 인해 디버깅이 복잡해질 수 있다.
: 함수형 코드의 호출문은 그 반환값으로 대체할 수 있다. 예시에서 fAdd
가 순수 함수이므로, 해당 함수의 실행이 외부에 끼치는 변화가 없기 때문(코드의 가독성과 디버깅이 쉬워짐)
😄 함수형 코드 예시
const fAdd = (a, b) => a + b
const fAdd_5_5 = fAdd(5, 5)
console.log(fAdd_5_5)
console.log(fAdd_5_5 === fAdd(5, 5))
console.log(
fAdd(fAdd(5, 5), 5) === fAdd(10, 5)
);
// 10
// true
// true
🥹 명령형 코드 예시
-> 부수 효과로 인한 호출문이 반환값으로 단순히 대체될 수 없음을 보여준다.
let total = 0;
function addToTotal(value) {
total += value; // 상태 변경 (부수 효과)
return total;
}
const result1 = addToTotal(5);
console.log(result1); // 5
const result2 = addToTotal(10);
console.log(result2); // 15
console.log(result1 === addToTotal(5)); // false
console.log(addToTotal(5) === addToTotal(5)); // false
고차함수는 다른 함수를 매개변수로 받거나(콜백함수: 다른 함수에 인자로 들어가는 함수) 반환값으로 내놓는 함수를 말한다. 고차 함수는 코드의 재사용성을 높이고, 복잡한 연산을 간단하게 표현할 수 있으며 코드의 간결함을 유지할 수 있다.
const anonOperation = (a, b, callback) => callback(a, b)
// 콜백함수
const fAdd = (a, b) => a + b
const res = anonOperation(6, 4, fAdd)
console.log(res)
// 10
장점에 대해 들었을 때는 잘 와닿지 않는다. 위와 같은 이점들로 작성해본 간단한 코드 예제를 보자.
const composeFunctions = (func1, func2) => value => func1(func2(value));
const addNumber = number => value => value + number;
const powerOf = exponent => value => value ** exponent;
const addNum = addNumber(3);
const squareNumber = powerOf(2);
const addThreeThenSquare = composeFunctions(squareNumber, addNum);
console.log(addTwoThenSquare(10)); // 169
위 코드는 각 함수를 쪼개 기능을 나누었기 때문에 일단 재사용성에 좋다. 코드의 가독성 또한 좋고,, 예를 들어, addNum
을 addNumber(5)
로 쉽게 교체할 수 있다.
또, 유지보수면에서도 좋다. 절차형의 경우 기존 함수에 계속해서 추가하고 수정해야 하는데 이때 해당 함수를 사용하는 모든 곳에서 문제가 발생할 가능성이 높다. 반면 함수형의 경우 새로운 함수를 추가한다고 가정할 때 해당 함수만 추가하여 기존 코드에 영향을 주지 않고 확장 가능하며, 독립적으로 작동하기 때문에결합도가 낮아진다는 장점이 있다.
그러나 함수 합성은 간단한 경우에만 쉽게 처리된다. 예를 들어, 입력값이 null
이거나 undefined
인 경우 결과는 NaN
이 나와버린다.
이런 문제를 해결하기 위해 우리는 일일이 널 체크
를 하는 것이 일반적이다.
function getStudentMajor(student) {
// 학생 객체에서 학교 정보를 가져옴
let school = student ? student.school : null;
if (school !== null) {
// 학교 정보가 있으면 전공 정보를 가져옴
let major = school.major ? school.major : null;
if (major !== null) {
// 전공 정보가 있으면 전공명을 반환
return major.name;
}
}
// 전공 정보를 찾을 수 없는 경우
return "전공 정보가 없습니다!";
}
// 사용 예시
const student = {
school: {
major: {
name: "컴퓨터 공학"
}
}
};
console.log(getStudentMajor(student)); // "컴퓨터 공학"
console.log(getStudentMajor()); // "전공 정보가 없습니다!"
❗️ 하지만 위와 같은 코드는 복잡하고 유지보수하기 어렵다. if문을 반복적으로 사용하여 널 값을 체크하는 과정에서 코드가 지저분해지고, 실수가 잦아진다.
이러한 문제를 해결하기 위해 함수형 프로그래밍에서는 모나드라는 개념을 도입하게 된다.
const getStudentMajor = (student) =>
Maybe.fromNullable(student)
.map(student => student.school)
.map(school => school.major)
.map(major => major.name)
.getOrElse("전공 정보가 없습니다!");
// 사용 예시
const studentWithMajor = {
school: {
major: {
name: "컴퓨터 공학"
}
}
};
const studentWithoutMajor = {
school: {}
};
console.log(getStudentMajor(studentWithMajor)); // "컴퓨터 공학"
console.log(getStudentMajor(studentWithoutMajor)); // "전공 정보가 없습니다!"
console.log(getStudentMajor()); // "전공 정보가 없습니다!"
여기서 Maybe
는 그 중 하나의 모나드로 Just
와 Nothing
을 두 하위형으로 갖고 있는 타입 클래스로 값이 있거나 없을 수 있는 상황을 안전하게 처리하는 데 유용하다. Maybe는 값을 감싸서 처리하며, 값이 null이거나 undefined인 경우 추가 작업을 하지 않고 안전하게 기본값을 반환하는 구조라고 볼 수 있다.
- Just(값이 있음): 값이 정상적으로 존재할 때 이를 감싸는 상태
- Nothing(값이 없음): 값이
null
이거나undefined
일 때 이를 나타내는 상태
요약 - Maybe는 값이 있을 때만 연산을 진행하고, 값이 없을 경우 이후 연산을 건너뛰고 기본값을 반환하는 구조다. 중첩된 if문을 사용하는 대신, 더 간결하고 안전한 방식으로 null 체크를 처리할 수 있는 것이다.
아까 코드 예시로 다시 설명해보겠다.
const getStudentMajor = (student) =>
Maybe.fromNullable(student) // student가 null/undefined일 수 있음
.map(student => student.school) // school에 접근 (student가 존재할 때만)
.map(school => school.major) // major에 접근 (school이 존재할 때만)
.map(major => major.name) // name에 접근 (major가 존재할 때만)
.getOrElse("전공 정보가 없습니다!"); // 값이 없으면 기본값 반환
모나드는 어떤 값을 담는 상자의 개념으로 접근한다. 이미지처럼 숫자 3과 같은 데이터를 담을 수 있다. 상자는 기능이 다르지만 공통 기능은 내부 값을 바꾸는 기능을 한다는 것이다. 이 상자는 순수 함수에 의해 기존 값으로부터 새롭게 만들어진 값을 담은 또 다른 상자를 만들 수 있다. (연쇄 작업이 가능)
사실 위 설명은 모나드가 아닌 그 상위 개념인 Functor
의 기능이다.
코드 예시로 살펴보겠다.
const BOX = [3];
const result = BOX
.map(x => x + 4) // [7]
.map(x => x * 10) // [70]
.map(x => x / 5); // [14]
console.log(result);
BOX 배열은 숫자 3이 들어있는 상자다. 그러므로
map
메소드로 순수 함수로 작성된 기능을 할 수 있는 것이다. 여기서 반환된 값으로map
메소드를 통해 연쇄적인 함수 기능을 하여 새로운 값들이 만들어지는 것이다.
즉, 자바스크립트의 배열처럼
map
또는filter
,reduce
와 같은 기능의 메소드를 가진 자료형은Functor
의 특성을 가지고 있다.
const BOX = [3];
const Fn = x => [x]
console.log(
BOX.map(Fn) // [[3]]
);
// 💩 No!
const badResult = BOX
.map(x => x + 4) // [7]
.map(x => [x * 4, x ** 4]) // [[28, 2401]]
.map(x => x / 7); // 💩 [NaN]
console.log(badResult);
// 🌠 Yes!
const goodResult = box
.map(x => x + 4) // [7]
.flatMap(x => [x * 4, x ** 4]) // [28, 2,401]
.map(x => x / 7); // [4, 343]
console.log(goodResult);
위 코드를 봤을 때 배열에 담긴 값을 다시 map
을 돌려 배열로 반환할 때 이중 배열이 생성되는 경우 다음 map
메서드를 진행할 수 없어 flatMap
을 적용해야 한다.
즉, 자바스크립트의 flatMap
자료형은 기본적으로 모나드의 기능까지 갖췄다고 볼 수 있다. map
의 기능을 가졌다면 Functor
, flatMap
의 기능을 가졌다면 Monad
이다.
자바스크립트의 Promise
도 모나드의 예시가 될 수 있다.
const delay1000 = (x) => new Promise((resolve) => {
setTimeout(() => {
resolve(x)
}, 1000)
})
delay1000(3) // 1초
.then(x => x + 4)
.then(x => delay1000(x * 7)) // 1초
.then(x => delay1000(x / 7)) // 1초
.then(console.log)
여기서 then
은 map
이나 flatMap
의 역할을 하므로 메소드를 구분할 필요는 없다. 연속적인 비동기 작업을 메서드 체이닝으로 이어줄 수 있다. (프로미스가 없던 시절 콜백 지옥을 떠올려보자. 그럼 매우 직관적이고 가독성이 좋다는 걸 인지할 수 있다.)
Promise
는 타입캡슐 의 개념이라고 한다면,Maybe
는 마치 슈뢰딩거의 고양이처럼 이 상자는 구조는 값을 담을 수도, 담지 않을 수도 있는 상자 (컨테이너라고 생각)라고 생각하는 게 좋다.
let a = 10;
a += 10;
a /= b; // b에 값이 없다면 해당 줄에서 연산이 끊긴다!
a *= 2;
let a = 상자;
상자 += 10;
상자 /= b;
상자 *= 2;
b로 나눌 때 b가 0이 아닌 값일 땐 정상적이나 0일 때(물론 자바스크립트는 Infinity를 반환함) 오류가 발생한다. 이때 아래 코드를 읽지 못하고 연산이 중단되어 버린다. 이때 a에 할당하는 숫자를 Maybe
라는 상자에 담음으로써, 중간에 문제가 발생해도 코드 실행이 이어질 수 있도록 하는 것이다.
class Maybe {
constructor(value) {
this.value = value;
}
// Unit (or Return)
static just(value) {
return new Maybe(value);
}
// Unit (or Return)
static nothing() {
return new Maybe(null);
}
isNothing() {
return this.value === null || this.value === undefined;
}
// Functor
map(fn) {
if (this.isNothing()) {
return Maybe.nothing();
}
return Maybe.just(fn(this.value));
}
// Bind (or FlatMap)
flatMap(fn) {
if (this.isNothing()) {
return Maybe.nothing();
}
return fn(this.value);
}
getOrElse(defaultValue) {
if (this.isNothing()) {
return defaultValue;
}
return this.value;
}
}
// 코드 출처: https://yalco.notion.site/Monad-f6054c8685f14a73a4a6853cd11f4431
Unit
을 통해 주어진 값을 모나드라는 상자로 감싼다. just
는 유효한 값을 value 필드에 해당 값을 할당하고(값이 있는 상자), 무효한 값일 경우 nothing
메서드를 사용하여 null을 할당한다(값이 없는 빈 상자).
그리고 map
은 아까 위에서 설명한 Functor
의 기능을 하는 함수로 먼저 값이 빈 경우 빈상자를 반환하며 유효한 값이 있다면 just
메서드를 실행하게 된다.
마지막 bind를 하는 flatMap
이다. return fn(this.value)
문에서 Maybe
상자로 감싸지 않는 걸 볼 수 있다.
isNothing
, getOrElse
는 모나드 고유의 기능이다. isNothing
은 상자가 비었는지 여부를 반환하고 getOrElse
는 값이 있을 경우 해당 값을, 없을 경우 매개변수로 주어진 값을 리턴하는 것이다.
const MaybeDivide = (a, b) => {
if (b === 0) {
return Maybe.nothing();
}
return Maybe.just(a / b);
};
// 값이 있을 때
const validResult = Maybe.just(3)
.map(a => a + 3)
.flatMap(x => safeDivide(a, 2))
.map(a => a * 2)
.map<(a => a - 1)
.getOrElse("에러가 발생함");
console.log(validResult); // 5
// 값이 0일 때
const invalidResult = Maybe.just(3)
.map(x => x + 3)
.flatMap(x => safeDivide(x, 0)) // 해당 시점에서 빈 상자가 되나 오류로 종료되진 않음
.map(x => x * 2) // 빈 상자 반환
.map(x => x - 1) // 빈 상자 반환
.getOrElse("에러가 발생함");
console.log(invalidResult); // 에러가 발생함
effect
, 값을 안전하게 감싸고 처리하는 방식을 모나드에서 착안한 boxed
라이브러리가 이 패턴을 쓴다.위 라이브러리는 모나드 개념을 도입해 값이 없거나 에러가 발생하는 경우를 처리하거나, 비동기 작업에서 값을 안전하게 처리하는 방법을 제공한다.
개선 전 코드: 아래와 같이 오류를 던지는 것은 문제가 될 수 있다. 함수의 시그니처 타입은 예외를 발생시킬 수 있음을 나타내지 않으므로 잠재적 오류에 대해 추론하기가 어렵다.
const divide = (a: number, b: number): number => { if (b === 0) { throw new Error("Cannot divide by zero") } return a / b } // 코드 출처: https://effect.website/
이 문제를 해결하기 위해
Effect
에서는 성공과 실패를 모두 나타내는 효과를 만들기 위한 전용 생성자를 도입합니다:Effect.succeed
,Effect.fail
. 이러한 생성자를 사용하면 유형 시스템을 활용하여 오류를 추적하는 동시에 성공 및 실패 사례를 명시적으로 처리할 수 있다.import { Effect } from "effect" const divide = (a: number, b: number): Effect.Effect<number, Error> => b === 0 ? Effect.fail(new Error("Cannot divide by zero")) : Effect.succeed(a / b) // 코드 출처: https://effect.website/
값이 있는 경우 Option.Some(값), 없는 경우 Option.None() 을 사용하여 있는 경우 연산을, 없는 경우 연산을 안전하게 중단하는 모나드 패턴의 코드라 볼 수 있다.
// 📍 map const some = Option.Some(1); // Option.Some<1> const none = Option.None(); // Option.None const doubledSome = some.map((x) => x * 2); // Option.Some<2> const doubledNone = none.map((x) => x * 2); // Option.None -> Nothing to transform! // 코드 출처: https://swan-io.github.io/boxed/core-concepts
출처 모음
Option
모나드를 사용하여 값이 있을 때만 안전하게 체인을 잇고, 없을 경우 getOr의 기본값을 반환한다. 우리가 처음 봤던 Maybe 예시 코드와 매우 흡사하다.// 📍 flatMap type UserInfo = { name: Option<string>; }; type User = { id: string; info: Option<UserInfo>; }; const name = user .flatMap((user) => user.info) // Returns the Option<UserInfo> .flatMap((info) => info.name) // Returns the Option<string> .getOr("Anonymous user"); // 코드 출처: https://swan-io.github.io/boxed/core-concepts