모나드(Monad), 니가 대체 뭔데?

kim yeseul·2024년 8월 28일
2
post-thumbnail

💡 모나드 패턴이란 함수형 프로그래밍을 기반으로 둔 프로그래밍 패턴이다. 먼저 우리가 모나드 패턴을 알기 전 알아야 할 지식이 있다. 그건 바로 '함수형 프로그래밍'이다.

그래서 함수형 프로그래밍이 뭘까..?

함수형 프로그래밍이란?

1. 함수형 프로그래밍의 정의

프로그래밍 패러다임 중 하나로, 순수 함수와 불변성을 강조하며, 프로그램의 상태 변경을 최소화하는 것을 목표로 합니다. 이를 통해 코드의 간결성, 모듈성 및 예측 가능성을 높일 수 있습니다.
출처

함수형 프로그래밍에선 순수 함수(Pure Function)들이 사용됩니다. 순수 함수란 인자에만 의존하고 부수 효과가 없는 함수로 인자 외에 외부 어떤 값에도 영향을 받거나 주지 않습니다. 즉, 언제 실행되든, 몇 번이고 실행되든 같은 입력값에 대해 같은 결과값을 반환하는 함수를 말합니다.

“함수형 프로그래밍은 순수 함수와 선언형 프로그래밍의 토대 위에 함수 조합(function composition)과 모나드 조합(monadic composition)으로 코드를 설계하고 구현하는 기법입니다.”
출처: <Do it 타입스크립트 프로그래밍>, 전예홍

함수형 프로그래밍 정의를 찾다보면 순수 함수, 불변성, 선언형 라는 단어를 자주 찾아볼 수 있다.

2. 함수형 프로그래밍이 왜 좋을까?

💡 함수형 프로그래밍은 순수 함수들을 조합하여 선언적이고 예측 가능한 안전한 코드를 작성할 수 있다.

💫 순수 함수

: 같은 입력값에 대해 항상 같은 결과값을 반환하므로, 코드의 예측 가능성이 높아지고 테스트가 용이해진다.

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

3. 실제 코드 적용

장점에 대해 들었을 때는 잘 와닿지 않는다. 위와 같은 이점들로 작성해본 간단한 코드 예제를 보자.

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

위 코드는 각 함수를 쪼개 기능을 나누었기 때문에 일단 재사용성에 좋다. 코드의 가독성 또한 좋고,, 예를 들어, addNumaddNumber(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는 그 중 하나의 모나드로 JustNothing 을 두 하위형으로 갖고 있는 타입 클래스로 값이 있거나 없을 수 있는 상황을 안전하게 처리하는 데 유용하다. 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("전공 정보가 없습니다!"); // 값이 없으면 기본값 반환

모나드(Monad) 패턴

👽 이제 진짜 모나드

모나드는 어떤 값을 담는 상자의 개념으로 접근한다. 이미지처럼 숫자 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이다.

꼭 flatMap을 써야만이 모나드는 아니다!

자바스크립트의 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)

여기서 thenmap이나 flatMap의 역할을 하므로 메소드를 구분할 필요는 없다. 연속적인 비동기 작업을 메서드 체이닝으로 이어줄 수 있다. (프로미스가 없던 시절 콜백 지옥을 떠올려보자. 그럼 매우 직관적이고 가독성이 좋다는 걸 인지할 수 있다.)

🤷‍♂️ Maybe를 코드로 알아보자

🐈‍⬛ 슈뢰딩거의 고양이 (?)

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, map, flatMap 메소드가 필수 요소다.

Unit을 통해 주어진 값을 모나드라는 상자로 감싼다. just는 유효한 값을 value 필드에 해당 값을 할당하고(값이 있는 상자), 무효한 값일 경우 nothing 메서드를 사용하여 null을 할당한다(값이 없는 빈 상자).

그리고 map은 아까 위에서 설명한 Functor의 기능을 하는 함수로 먼저 값이 빈 경우 빈상자를 반환하며 유효한 값이 있다면 just 메서드를 실행하게 된다.

마지막 bind를 하는 flatMap이다. return fn(this.value) 문에서 Maybe 상자로 감싸지 않는 걸 볼 수 있다.

isNothing, getOrElse

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 라이브러리가 이 패턴을 쓴다.

위 라이브러리는 모나드 개념을 도입해 값이 없거나 에러가 발생하는 경우를 처리하거나, 비동기 작업에서 값을 안전하게 처리하는 방법을 제공한다.

1. effect

개선 전 코드: 아래와 같이 오류를 던지는 것은 문제가 될 수 있다. 함수의 시그니처 타입은 예외를 발생시킬 수 있음을 나타내지 않으므로 잠재적 오류에 대해 추론하기가 어렵다.

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/

2. boxed

값이 있는 경우 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
출처 모음
profile
출발선 앞의 준비된 마음가짐, 떨림, 설렘을 가진 주니어 개발자

0개의 댓글