본문으로 건너뛰기

Monad Pattern

· 약 22분
Dongmin Yu

What is MONAD and How Can we Implement it

프로그래밍에서 모나드는 함수형 프로그래밍에서 사용되는 디자인 패턴 중 하나입니다. 모나드는 값을 캡슐화하고, 그 값을 변환하는 함수를 체이닝하는 방식으로 작동합니다. 이를 통해 코드의 가독성을 높이고, 부수 효과를 제어할 수 있습니다. Haskell 언어에서 모나드는 매우 중요한 개념으로 사용됩니다. 스칼라에서 모나드는 트레이트(trait)로 구현됩니다. 모나드는 flatMapunit이라는 두 가지 메소드를 가지고 있습니다. 다음은 스칼라에서 Option 모나드의 예제입니다.

val opt1: Option[Int] = Some(3)
val opt2: Option[Int] = Some(5)
val opt3: Option[Int] = None
val result = for {
  x <- opt1
  y <- opt2
} yield x + y
println(result) // Some(8)
val result2 = for {
  x <- opt1
  y <- opt3
} yield x + y
println(result2) // None

위의 예제에서 for 구문은 flatMapmap을 사용하여 변환됩니다. Option 모나드는 값이 있을 수도 있고 없을 수도 있는 상황에서 안전한 연산을 가능하게 합니다. flatMapunit은 모나드의 두 가지 기본 연산입니다. flatMap은 모나드 값을 받아서 다른 모나드 값을 반환하는 함수를 적용하는 연산입니다. 이를 통해 모나드 값을 변환하고 체이닝할 수 있습니다. unit은 일반 값을 모나드 값으로 변환하는 연산입니다. 다음은 스칼라에서 List 모나드의 flatMapunit 예제입니다.

val list1 = List(1, 2, 3)
val list2 = List(4, 5, 6)
val result = list1.flatMap(x => list2.map(y => x + y))
println(result) // List(5, 6, 7, 6, 7, 8, 7, 8, 9)
val unitResult = List.unit(3)
println(unitResult) // List(3)

위의 예제에서 flatMaplist1의 각 요소에 대해 함수를 적용하여 새로운 리스트를 생성하고 이를 하나의 리스트로 합칩니다. unit은 일반 값을 리스트로 변환합니다. 자바스크립트는 함수형 프로그래밍을 지원하는 언어이기 때문에 모나드 패턴을 사용할 수 있습니다. 다음은 자바스크립트에서 Maybe 모나드의 예제입니다.

class Maybe {
  constructor(value) {
    this.value = value;
  }
  static of(value) {
    return new Maybe(value);
  }
  isNothing() {
    return this.value === null || this.value === undefined;
  }
  map(fn) {
    return this.isNothing() ? Maybe.of(null) : Maybe.of(fn(this.value));
  }
  flatMap(fn) {
    return this.map(fn).join();
  }
  join() {
    return this.isNothing() ? Maybe.of(null) : this.value;
  }
}
const maybe1 = Maybe.of(3);
const maybe2 = Maybe.of(null);
const result = maybe1.flatMap((x) => Maybe.of(x + 2));
console.log(result); // Maybe { value: 5 }
const result2 = maybe2.flatMap((x) => Maybe.of(x + 2));
console.log(result2); // Maybe { value: null }

위의 예제에서 Maybe 모나드는 값이 있을 수도 있고 없을 수도 있는 상황에서 안전한 연산을 가능하게 합니다. flatMapMaybe 값을 받아서 다른 Maybe 값을 반환하는 함수를 적용하는 연산입니다. 다음은 자바스크립트에서 Promise 모나드의 예제입니다.

const promise1 = Promise.resolve(3);
const promise2 = Promise.reject(new Error("An error occurred"));
const result = promise1.then((x) => x + 2);
result.then(console.log); // 5
const result2 = promise2.then((x) => x + 2);
result2.catch(console.log); // Error: An error occurred

위의 예제에서 Promise는 비동기 연산의 결과를 캡슐화하는 모나드입니다. then 메소드는 Promise 값을 받아서 다른 Promise 값을 반환하는 함수를 적용하는 연산입니다. 이를 통해 비동기 연산을 체이닝하고 에러 처리를 할 수 있습니다.

모나드 패턴의 장점

  1. 코드의 가독성 향상: 모나드를 사용하면 코드의 가독성이 높아집니다. 모나드는 값을 캡슐화하고, 그 값을 변환하는 함수를 체이닝하는 방식으로 작동하기 때문에 코드가 간결해집니다.
  2. 부수 효과 제어: 모나드를 사용하면 부수 효과를 제어할 수 있습니다. 예를 들어 Maybe 모나드는 값이 있을 수도 있고 없을 수도 있는 상황에서 안전한 연산을 가능하게 합니다. Promise 모나드는 비동기 연산의 결과를 캡슐화하여 에러 처리를 할 수 있게 합니다.
  3. 추상화: 모나드는 추상화 수준을 높여줍니다. 예를 들어 List 모나드는 리스트에 대한 연산을 추상화하여 코드의 재사용성을 높여줍니다.
  4. 함수형 프로그래밍 지원: 모나드는 함수형 프로그래밍 패러다임을 지원합니다. 함수형 프로그래밍은 순수 함수와 불변성을 강조하여 코드의 예측 가능성과 안정성을 높여줍니다.

타입스크립트에서 모나드를 구현할 때 주로 사용되는 키워드와 패턴

  1. 인터페이스와 제네릭: 타입스크립트에서 모나드는 인터페이스와 제네릭을 사용하여 구현됩니다. 인터페이스를 사용하면 모나드가 가져야 할 메소드를 정의할 수 있습니다. 제네릭을 사용하면 모나드가 다양한 타입의 값을 캡슐화할 수 있습니다.

    interface Monad<T> {
      flatMap<U>(fn: (value: T) => Monad<U>): Monad<U>;
      unit(value: T): Monad<T>;
    }
    
  2. 클래스와 메소드: 타입스크립트에서 모나드는 클래스로 구현됩니다. 클래스를 사용하면 모나드의 인스턴스를 생성하고 메소드를 정의할 수 있습니다.

    class Maybe<T> implements Monad<T> {
      constructor(private value: T | null) {}
      static of<T>(value: T | null) {
        return new Maybe(value);
      }
      flatMap<U>(fn: (value: T) => Maybe<U>): Maybe<U> {
        return this.value === null ? Maybe.of(null) : fn(this.value);
      }
      unit(value: T): Maybe<T> {
        return Maybe.of(value);
      }
    }
    
  3. 함수 체이닝: 타입스크립트에서 모나드는 함수 체이닝 패턴을 사용하여 작동합니다. flatMap 메소드를 사용하면 모나드 값을 변환하고 체이닝할 수 있습니다.

    const maybe1 = Maybe.of(3);
    const maybe2 = Maybe.of(null);
    const result = maybe1.flatMap((x) => Maybe.of(x + 2));
    console.log(result); // Maybe { value: 5 }
    const result2 = maybe2.flatMap((x) => Maybe.of(x + 2));
    console.log(result2); // Maybe { value: null }
    

다음은 타입스크립트에서 Either 모나드의 예제입니다.

type Either<L, R> = Left<L, R> | Right<L, R>;
class Left<L, R> {
  constructor(readonly value: L) {}
  static of<L, R>(value: L) {
    return new Left<L, R>(value);
  }
  flatMap<U>(fn: (value: R) => Either<L, U>): Either<L, U> {
    return this as Either<L, U>;
  }
  unit(value: R): Either<L, R> {
    return Right.of(value);
  }
}
class Right<L, R> {
  constructor(readonly value: R) {}
  static of<L, R>(value: R) {
    return new Right<L, R>(value);
  }
  flatMap<U>(fn: (value: R) => Either<L, U>): Either<L, U> {
    return fn(this.value);
  }
  unit(value: R): Either<L, R> {
    return Right.of(value);
  }
}
const either1: Either<string, number> = Right.of(3);
const either2: Either<string, number> = Left.of("An error occurred");
const result = either1.flatMap((x) => Right.of(x + 2));
console.log(result); // Right { value: 5 }
const result2 = either2.flatMap((x) => Right.of(x + 2));
console.log(result2); // Left { value: 'An error occurred' }

위의 예제에서 Either 모나드는 값을 캡슐화하고 에러 처리를 가능하게 합니다. flatMap 메소드는 Either 값을 받아서 다른 Either 값을 반환하는 함수를 적용하는 연산입니다. 이를 통해 연산을 체이닝하고 에러 처리를 할 수 있습니다. infer 키워드는 타입스크립트에서 조건부 타입을 정의할 때 사용되는 키워드입니다. infer 키워드를 사용하면 타입을 추론할 수 있습니다. infer 키워드는 모나드와 직접적인 관련이 없지만, 모나드를 구현할 때 유용하게 사용될 수 있습니다. 다음은 infer 키워드를 사용하여 Unwrap 타입을 정의하는 예제입니다.

type Unwrap<T> = T extends Monad<infer U> ? U : never;
interface Monad<T> {
  flatMap<U>(fn: (value: T) => Monad<U>): Monad<U>;
  unit(value: T): Monad<T>;
}
class Maybe<T> implements Monad<T> {
  constructor(private value: T | null) {}
  static of<T>(value: T | null) {
    return new Maybe(value);
  }
  flatMap<U>(fn: (value: T) => Maybe<U>): Maybe<U> {
    return this.value === null ? Maybe.of(null) : fn(this.value);
  }
  unit(value: T): Maybe<T> {
    return Maybe.of(value);
  }
}
const maybe1 = Maybe.of(3);
type Result = Unwrap<typeof maybe1>; // number

위의 예제에서 Unwrap 타입은 Monad 타입의 값을 추론하여 그 내부의 값을 반환하는 타입입니다. 이를 통해 모나드의 내부 값을 추론할 수 있습니다. 다음은 타입스크립트에서 IO 모나드의 예제입니다.

class IO<T> {
  constructor(private effect: () => T) {}
  static of<T>(value: T) {
    return new IO(() => value);
  }
  static from<T>(effect: () => T) {
    return new IO(effect);
  }
  flatMap<U>(fn: (value: T) => IO<U>): IO<U> {
    return new IO(() => fn(this.effect()).run());
  }
  unit(value: T): IO<T> {
    return IO.of(value);
  }
  run() {
    return this.effect();
  }
}
const io1 = IO.from(() => {
  console.log("Hello");
  return "Hello";
});
const io2 = io1.flatMap((x) => IO.from(() => x + " World"));
io2.run(); // Hello World

위의 예제에서 IO 모나드는 부수 효과를 캡슐화하는 모나드입니다. flatMap 메소드는 IO 값을 받아서 다른 IO 값을 반환하는 함수를 적용하는 연산입니다. 이를 통해 부수 효과를 체이닝하고 제어할 수 있습니다. 다음은 타입스크립트에서 IO 모나드를 사용하여 부수 효과를 제어하는 예제입니다.

class IO<T> {
  constructor(private effect: () => T) {}
  static of<T>(value: T) {
    return new IO(() => value);
  }
  static from<T>(effect: () => T) {
    return new IO(effect);
  }
  flatMap<U>(fn: (value: T) => IO<U>): IO<U> {
    return new IO(() => fn(this.effect()).run());
  }
  unit(value: T): IO<T> {
    return IO.of(value);
  }
  run() {
    return this.effect();
  }
}
const io1 = IO.from(() => {
  console.log("First effect");
  return "First";
});
const io2 = io1.flatMap((x) =>
  IO.from(() => {
    console.log("Second effect");
    return x + " Second";
  }),
);
const io3 = io2.flatMap((x) =>
  IO.from(() => {
    console.log("Third effect");
    return x + " Third";
  }),
);
io3.run(); // First effect Second effect Third effect

위의 예제에서 IO 모나드는 부수 효과를 캡슐화하고 체이닝합니다. flatMap 메소드를 사용하면 부수 효과를 순차적으로 실행할 수 있습니다. 이를 통해 부수 효과의 실행 순서를 제어할 수 있습니다. flatMap 메소드는 모나드 값을 받아서 다른 모나드 값을 반환하는 함수를 적용하는 연산입니다. 이를 통해 모나드 값을 변환하고 체이닝할 수 있습니다. map 메소드는 이터러블 객체의 각 요소에 대해 함수를 적용하여 새로운 이터러블 객체를 생성하는 연산입니다. forEach 메소드는 이터러블 객체의 각 요소에 대해 함수를 적용하지만 새로운 이터러블 객체를 생성하지 않습니다.

flatMap 메소드와 map, forEach 메소드의 차이점

  1. 적용 대상: flatMap 메소드는 모나드 값에 적용되는 반면, map, forEach 메소드는 이터러블 객체에 적용됩니다.
  2. 반환 값: flatMap 메소드는 모나드 값을 반환하는 반면, map 메소드는 이터러블 객체를 반환하고 forEach 메소드는 반환값이 없습니다.
  3. 체이닝: flatMap 메소드는 모나드 값을 체이닝하는 데 사용됩니다. map, forEach 메소드는 이터러블 객체의 각 요소에 대해 함수를 적용하는 데 사용됩니다.

모나드는 타입스크립트에서 기본적으로 정의된 타입이 아닙니다. 모나드는 함수형 프로그래밍에서 사용되는 디자인 패턴으로, 개발자가 직접 구현해야 합니다. 모나드는 flatMapunit이라는 두 가지 메소드를 가지고 있어야 하며, 이러한 메소드를 사용하여 값을 변환하고 체이닝하는 방식으로 작동합니다. 따라서 모나드는 사용하기로 약속된 규범이라고 볼 수 있습니다. 모나드라는 용어가 코드에 사용되면 다른 개발자들은 해당 타입이 flatMapunit 메소드를 가지고 있을 것이라고 예상할 수 있습니다. 이는 모나드가 함수형 프로그래밍에서 널리 사용되는 디자인 패턴이기 때문입니다.

모나드의 인터페이스나 추상 클래스를 제공하는 타입 라이브러리는 존재하지 않습니다. 하지만 타입스크립트에서는 인터페이스를 사용하여 모나드가 가져야 할 메소드를 정의할 수 있습니다. 또한, 모나드를 구현한 모듈을 작성하여 임포트해서 사용할 수도 있습니다.

interface Monad<T> {
  flatMap<U>(fn: (value: T) => Monad<U>): Monad<U>;
  unit(value: T): Monad<T>;
}
class Maybe<T> implements Monad<T> {
  constructor(private value: T | null) {}
  static of<T>(value: T | null) {
    return new Maybe(value);
  }
  flatMap<U>(fn: (value: T) => Maybe<U>): Maybe<U> {
    return this.value === null ? Maybe.of(null) : fn(this.value);
  }
  unit(value: T): Maybe<T> {
    return Maybe.of(value);
  }
}

위의 예제에서 Monad 인터페이스는 모나드가 가져야 할 메소드를 정의합니다. Maybe 클래스는 Monad 인터페이스를 구현하여 flatMapunit 메소드를 정의합니다.

타입스크립트의 미리 정의된 유틸리티 타입 중에는 모나드의 구현체가 없습니다. 모나드는 함수형 프로그래밍에서 사용되는 디자인 패턴으로, 개발자가 직접 구현해야 합니다. 타입스크립트에서는 인터페이스와 제네릭을 사용하여 모나드를 구현할 수 있습니다.

자바스크립트의 객체는 불변성을 가지고 있지 않습니다. 이는 객체의 속성이 변경될 수 있다는 것을 의미합니다. 이러한 특성은 모나드의 구현에 문제를 일으킬 수 있습니다. 모나드는 함수형 프로그래밍에서 사용되는 디자인 패턴으로, 순수 함수와 불변성을 강조합니다. 따라서 모나드를 사용할 때는 객체의 불변성을 유지하는 것이 중요합니다. 자바스크립트에서는 Object.freeze 메소드를 사용하여 객체의 불변성을 유지할 수 있습니다.

const obj = { x: 3 };
Object.freeze(obj);
obj.x = 4; // TypeError: Cannot assign to read only property 'x' of object '#<Object>'

위의 예제에서 Object.freeze 메소드를 사용하여 객체의 속성을 변경할 수 없도록 합니다. 이를 통해 객체의 불변성을 유지할 수 있습니다. 다음은 자바스크립트에서 Object.freeze 메소드를 사용하는 예제입니다.

const obj = { x: 3, y: { z: 5 } };
Object.freeze(obj);
obj.x = 4; // TypeError: Cannot assign to read only property 'x' of object '#<Object>'
obj.y.z = 6; // No error
console.log(obj); // { x: 3, y: { z: 6 } }

위의 예제에서 Object.freeze 메소드를 사용하여 객체의 속성을 변경할 수 없도록 합니다. 하지만 Object.freeze 메소드는 얕은 동결(shallow freeze)만 수행하기 때문에 중첩된 객체의 속성은 변경할 수 있습니다.

const obj = { x: 3, y: { z: 5 } };
Object.freeze(obj);
Object.freeze(obj.y);
obj.x = 4; // TypeError: Cannot assign to read only property 'x' of object '#<Object>'
obj.y.z = 6; // TypeError: Cannot assign to read only property 'z' of object '#<Object>'
console.log(obj); // { x: 3, y: { z: 5 } }

위의 예제에서 Object.freeze 메소드를 사용하여 중첩된 객체의 속성도 변경할 수 없도록 합니다. 이를 통해 객체의 불변성을 유지할 수 있습니다. Object.freeze 메소드를 사용하여 enum과 유사한 기능을 하는 객체를 생성할 수 있습니다. Object.freeze 메소드를 사용하면 객체의 속성을 변경할 수 없도록 할 수 있기 때문입니다.

const Color = Object.freeze({
  RED: "red",
  GREEN: "green",
  BLUE: "blue",
});
console.log(Color.RED); // red
Color.RED = "yellow"; // TypeError: Cannot assign to read only property 'RED' of object '#<Object>'

위의 예제에서 Object.freeze 메소드를 사용하여 Color 객체의 속성을 변경할 수 없도록 합니다. 이를 통해 enum과 유사한 기능을 하는 객체를 생성할 수 있습니다. immer.jslodash.js는 모나드의 구현과 직접적인 관련이 없습니다. 하지만 이러한 라이브러리를 사용하면 모나드를 사용하는 코드의 가독성과 사용성을 높일 수 있습니다.

import produce from "immer";
const baseState = [
  { task: "Learn typescript", done: true },
  { task: "Try immer", done: false },
];
const nextState = produce(baseState, (draftState) => {
  draftState.push({ task: "Tweet about it" });
  draftState[1].done = true;
});
console.log(baseState); // [{ task: 'Learn typescript', done: true }, { task: 'Try immer', done: false }]
console.log(nextState); // [{ task: 'Learn typescript', done: true }, { task: 'Try immer', done: true }, { task: 'Tweet about it' }]

immer.js는 불변성을 유지하는 데이터 구조를 제공하는 라이브러리입니다. immer.js를 사용하면 모나드를 사용하는 코드에서 불변성을 쉽게 유지할 수 있습니다. 위의 예제에서 produce 함수를 사용하여 baseState 객체의 불변성을 유지하면서 새로운 상태를 생성합니다.

import _ from "lodash";
const users = [
  { user: "barney", age: 36, active: true },
  { user: "fred", age: 40, active: false },
  { user: "pebbles", age: 1, active: true },
];
const result = _.filter(users, (o) => !o.active);
console.log(result); // [{ user: 'fred', age: 40, active: false }]

lodash.js는 유틸리티 함수를 제공하는 라이브러리입니다. lodash.js를 사용하면 모나드를 사용하는 코드에서 데이터 변환을 쉽게 할 수 있습니다. 위의 예제에서 filter 함수를 사용하여 users 배열에서 active 속성이 false인 요소만 추출합니다.