logo
Nostrss
Published on

모나드(Monad)란 무엇인가 — Functor, Applicative, Monad를 JS로 이해하기

Authors
모나드(Monad)란 무엇인가

참고: 위키피디아 - 모나드 (함수형 프로그래밍)

오늘은 모나드라는 개면을 정리하면서 작성해봤다. ai와 핑퐁하면서 나름 아래와 같이 정리해봤다. 내용을 정리하면서 읽어보니, 강사가 지금까지 강의한 이유에 대해서 납득이 되기 시작했다. 왜 이터러블을 사용했었고, go, pipe 등등이 왜 필요했었는지 말이다. 그리고 지연평가, 비동기를 다룰려고 했는지 말이다.

솔직히 지금도 개념적으로 100% 잘 이해가 안간다. 자주 블로그 글을 읽어보면서 곱씹어봐야 할 것 같다.

왜 모나드가 필요한가

함수형 프로그래밍의 핵심은 합성이다. 작은 함수를 연결해서 큰 프로그램을 만든다. 문제는 현실의 값이 항상 "정상 값"이 아니라는 점이다.

  • 값이 없을 수 있다. (null, undefined)
  • 계산이 실패할 수 있다. (예외, 에러)
  • 결과가 나중에 도착할 수 있다. (비동기)

순수 함수 합성 f(g(x))는 이런 상황에서 쉽게 깨진다.

const g = (x) => x + 1
const f = (x) => x * x

console.log(f(g(2))) // 9
console.log(f(g(undefined))) // NaN

수학에서 함수 합성은 "입력이 정의역 안에 있다"는 가정 아래 안전하다. 하지만 프로그래밍에서는 입력이 불완전한 상태를 포함한다. 그래서 함수 합성만으로는 충분하지 않다.

여기서 모나드가 쓰는 관점이 값 + 맥락(context) 이다.

  • 값(value): 우리가 계산하고 싶은 실제 데이터
  • 맥락(context): 그 값이 놓인 상태(없음/실패/비동기)
  • 모나드(monad): 값을 변환해도 맥락은 보존하면서 다음 계산으로 연결하는 규약

핵심은 "문제를 숨기는 것"이 아니라 "문제를 맥락으로 드러낸 채 합성"하는 것이다.

직관적으로 보면

일반 합성은 "값만" 전달한다.

  • g(x)의 결과를 f에 넣는다.
  • 중간에 값 없음/실패/지연이 나오면 수동 분기 코드가 늘어난다.

모나드 합성은 "값과 상태"를 함께 전달한다.

  • chain이 값을 꺼내 다음 함수에 넣는다.
  • 다음 함수 결과의 맥락을 다시 평탄화해서 흐름을 유지한다.
  • 그래서 분기 처리 코드가 각 함수에 흩어지지 않고, 공통 인터페이스로 모인다.

간단히 말해, 모나드는 안전한 파이프라인 연결 규칙이다.

위키피디아 정의를 개발자 언어로 해석하기

위키피디아(함수형 프로그래밍의 모나드) 정의를 개발자 기준으로 바꿔보자.

  • 어떤 타입 생성자 M이 있고
  • 값을 맥락에 넣는 연산 of(=unit, return)가 있고
  • 맥락 안 계산을 이어붙이는 연산 chain(=bind, flatMap)이 있으며
  • 세 가지 모나드 법칙을 만족한다.

수학 기호와 JS 메서드의 대응은 이렇게 보면 된다.

  • η :: a -> M a --> of :: a -> M a
  • μ :: M (M a) -> M a --> flatten
  • bind :: M a -> (a -> M b) -> M b --> chain/flatMap/then

여기서 실무적으로 가장 중요한 것은 bind다.

  • map만 있으면 M (M b) 중첩이 생긴다.
  • bindmap + flatten처럼 동작해 중첩을 제거한다.

즉, 모나드의 본질은 "연결할수록 중첩되는 구조를 평탄화하며 합성"하는 데 있다.

Functor → Applicative → Monad

모나드는 갑자기 튀어나온 개념이 아니라, 합성 능력을 단계적으로 확장한 결과다.

1) Functor: map 가능한 맥락

map :: M a -> (a -> b) -> M b

Functor의 메시지는 단순하다.

  • 값은 바꾸되
  • 맥락은 유지한다.
const xs = [1, 2, 3]
console.log(xs.map((x) => x + 10)) // [11, 12, 13]

Functor만으로 부족한 이유

map 함수가 M b를 반환하면 결과가 M (M b)로 중첩된다.

const arr = [1, 2]
const toPair = (x) => [x, x * 10]

console.log(arr.map(toPair))
// [[1, 10], [2, 20]]

중첩이 나쁜 건 아니지만, "계속 합성"하려면 보통 평탄화가 필요하다.

2) Applicative: 맥락 안 함수를 적용

of :: a -> M a
ap :: M (a -> b) -> M a -> M b

Applicative는 "함수도 맥락 안에 들어갈 수 있음"을 다룬다. 독립적인 두 맥락 값을 결합하기 좋다.

const fns = [(x) => x + 1, (x) => x * 2]
const vals = [10, 20]

const ap = (fs, xs) => fs.flatMap((fn) => xs.map(fn))
console.log(ap(fns, vals)) // [11, 21, 20, 40]

Applicative만으로 부족한 이유

Applicative는 주로 "독립 계산 결합"에 강하다. 하지만 "이전 결과에 따라 다음 계산이 달라지는" 경우는 불편해진다.

예를 들어 "0이면 실패, 아니면 역수" 같은 분기 계산은 결과 의존적이다. 이때는 chain이 더 직접적이다.

3) Monad: 의존적 계산 합성

chain :: M a -> (a -> M b) -> M b

Monad는 "이전 단계 결과"를 다음 단계가 사용할 수 있게 한다. 그리고 맥락 중첩을 자동으로 평탄화한다.

const safeReciprocal = (x) => (x === 0 ? null : 1 / x)
const safeSqrt = (x) => (x < 0 ? null : Math.sqrt(x))

이런 함수를 일반 합성으로 연결하면 null 처리 분기가 반복된다. 모나드 체인에서는 그 분기 규칙이 인터페이스로 모인다.

모나드 법칙(Monad Laws)

연산 이름만 같다고 모나드가 아니다. 아래 법칙이 성립해야 "합성 의미"가 안정된다.

Left Identity (좌항등원)

of(x).chain(f)  ≡  f(x)

쉬운 말로: "값을 박스에 넣고 바로 꺼내 함수에 넣는 것"은 그냥 함수 직접 호출과 같아야 한다.

Right Identity (우항등원)

m.chain(of)  ≡  m

쉬운 말로: "꺼냈다가 다시 같은 박스에 넣는 작업"은 원래 값과 같아야 한다.

Associativity (결합법칙)

m.chain(f).chain(g)  ≡  m.chain((x) => f(x).chain(g))

쉬운 말로: 체인을 왼쪽부터 묶든 오른쪽부터 묶든 결과가 같아야 한다.

법칙이 실무에서 중요한 이유

법칙이 있으면 리팩터링할 수 있다.

  • 체인 순서를 논리적으로 같은 형태로 바꿔도 동작이 유지된다.
  • 구현체가 달라도(Promise, Maybe, Either) 추론 방식이 같다.
  • 테스트가 "결과값"뿐 아니라 "합성 가능성"을 검증하게 된다.

법칙이 깨진 추상화는 처음엔 동작해도, 코드가 커질수록 예측 불가능해진다.

Kleisli Composition 관점

모나드 세계의 함수는 보통 a -> M b 형태다. 이 함수들을 위한 합성이 Kleisli composition이다.

(>=>) :: (a -> M b) -> (b -> M c) -> (a -> M c)

일반 합성 g(f(x))가 아닌 이유는 f(x) 결과가 M b이기 때문이다. g는 그냥 b를 받는 함수가 아니라 b -> M c 함수여야 하며, 연결 과정에서 chain이 필요하다.

const composeK = (f, g, chain) => (x) => chain(f(x), g)

실무에서 보면 Kleisli composition은 낯선 수학이 아니라 "then/flatMap 파이프라인의 일반형"이다.

JavaScript에서 모나드의 의미

JavaScript는 HKT(Higher-Kinded Types)를 언어 차원에서 직접 제공하지 않는다. 그래서 JS의 모나드는 "타입클래스 선언"보다는 "동일 인터페이스와 법칙"으로 이해하는 것이 현실적이다.

  • of가 있고
  • map이 있고
  • chain 역할(flatMap/then)이 있으며
  • 법칙을 만족하면 모나드적 추상화로 다룰 수 있다.

아래는 동일 템플릿으로 보는 4가지 사례다.

Array: 비결정성/다중 값의 모나드

  • 맥락: "값이 여러 개일 수 있음"
  • of: Array.of
  • map: Array.prototype.map
  • chain: Array.prototype.flatMap
const xs = [1, 2]
const step = (x) => [x, x * 10]

console.log(xs.flatMap(step)) // [1, 10, 2, 20]

잘 맞는 문제

  • 조합 탐색
  • 브랜치 생성
  • 다중 결과 파이프라인

주의할 점

  • 브랜치 수가 빠르게 커질 수 있다(조합 폭발).

Promise: 비동기 시간 축의 모나드

  • 맥락: "값이 미래에 도착함"
  • of: Promise.resolve
  • map 역할: then 내부 동기 변환
  • chain: then (콜백이 Promise 반환 시 자동 평탄화)
const f = (x) => Promise.resolve(x + 1)
const g = (x) => Promise.resolve(x * 2)

Promise.resolve(10).then(f).then(g).then(console.log) // 22

잘 맞는 문제

  • 네트워크 I/O
  • 순차 비동기 파이프라인
  • 실패 전파(catch)

주의할 점

  • Promise는 생성 시점에 실행이 시작되는 eager 성격이 있어, 지연 실행 추상화와는 다르다.

Maybe: 값 없음(null/undefined) 처리 모나드

  • 맥락: "값이 없을 수 있음"
  • of: Maybe.of
  • map: 값이 있으면 함수 적용
  • chain: 값이 있으면 다음 Maybe 함수로 연결
class Maybe {
  static of(x) {
    return new Maybe(x)
  }

  constructor(value) {
    this.value = value
  }

  isNothing() {
    return this.value === null || this.value === undefined
  }

  map(f) {
    return this.isNothing() ? this : Maybe.of(f(this.value))
  }

  chain(f) {
    return this.isNothing() ? this : f(this.value)
  }
}

const parseNumber = (s) => {
  const n = Number(s)
  return Number.isNaN(n) ? Maybe.of(null) : Maybe.of(n)
}

const reciprocal = (n) => (n === 0 ? Maybe.of(null) : Maybe.of(1 / n))

console.log(parseNumber('10').chain(reciprocal))
console.log(parseNumber('foo').chain(reciprocal))

잘 맞는 문제

  • null 안전 파이프라인
  • 중간 단계 누락 허용 로직

주의할 점

  • 실패 이유를 보존하지 않는다(왜 실패했는지 정보 없음).

Either: 실패 이유를 보존하는 모나드

  • 맥락: "성공/실패 + 실패 정보"
  • of: Right
  • map: Right에서만 함수 적용
  • chain: Right에서만 다음 단계 연결, Left는 그대로 전파
class Left {
  constructor(value) {
    this.value = value
  }

  map(_) {
    return this
  }

  chain(_) {
    return this
  }
}

class Right {
  constructor(value) {
    this.value = value
  }

  map(f) {
    return new Right(f(this.value))
  }

  chain(f) {
    return f(this.value)
  }
}

const parseIntEither = (s) => {
  const n = Number.parseInt(s, 10)
  return Number.isNaN(n) ? new Left('숫자가 아닙니다') : new Right(n)
}

const invert = (n) => (n === 0 ? new Left('0으로 나눌 수 없습니다') : new Right(1 / n))

console.log(parseIntEither('8').chain(invert))
console.log(parseIntEither('0').chain(invert))
console.log(parseIntEither('foo').chain(invert))

잘 맞는 문제

  • 검증 파이프라인
  • 도메인 에러 메시지 누적/전파

주의할 점

  • 팀 내에서 Left/Right 의미를 일관되게 약속해야 한다.

함수형 프로그래밍에서의 의미

함수형 프로그래밍에서 모나드는 "부수효과를 없애는 기술"이 아니다. 더 정확히는 다음이다.

  • 효과(실패, 없음, 비동기, 환경 의존)를 맥락으로 승격하고
  • 그 맥락을 유지한 채 계산을 합성하며
  • 법칙으로 합성의 안정성을 보장한다.

즉 모나드는 "효과를 숨기는 마법"이 아니라 "효과를 통제 가능한 형태로 노출하는 인터페이스"다.

JS 실무에서의 의미

JS에서 모나드는 아래 질문에 대한 공통 해법을 준다.

  • null 분기를 함수마다 반복할 것인가?
  • 비동기 흐름을 콜백 중첩으로 처리할 것인가?
  • 실패를 throw/catch만으로 표현할 것인가?

모나드 관점으로 바꾸면 일관된 전략이 나온다.

  • 컨텍스트를 값으로 다루고
  • map/chain으로 연결하고
  • 법칙이 맞는 추상화만 공통 연산으로 사용한다.

그래서 모나드는 "새 문법"이 아니라, 복잡도를 줄이는 설계 원칙이다.

가장 많이 막히는 지점 4가지

1) map과 chain 차이

  • map: a -> b를 적용해서 M b
  • chain: a -> M b를 적용해서 바로 M b

함수가 이미 모나드 값을 돌려주면 chain이 맞다.

2) 맥락을 값처럼 못 느끼는 문제

"값만 진짜고 맥락은 부가물"로 보면 모나드가 어렵다. 실제로는 맥락이 핵심 데이터다.

  • Promise에서는 "아직 안 옴"이 핵심 상태
  • Either에서는 "왜 실패했는지"가 핵심 상태

3) 법칙이 왜 필요한지 체감이 안 됨

법칙은 수학 과제가 아니라 리팩터링 안전장치다.

  • 괄호 재배치
  • 중간 함수 추출
  • 체인 분리/합치기

이런 변경이 동치로 유지되게 만든다.

4) Promise만 모나드라고 생각함

Promise는 모나드 사례 중 하나다. Array, Maybe, Either도 같은 합성 패턴을 공유한다.

자주 생기는 오해

  • 모나드는 Promise 그 자체가 아니다.
  • 모나드는 비동기 전용 개념이 아니다.
  • 모나드는 클래스 이름이 아니라 연산 + 법칙의 조합이다.
  • 모나드는 "어려운 수학 장식"이 아니라 실무 분기 복잡도 제어 방식이다.

5분 요약

  • 모나드는 "값 + 맥락"을 합성하는 규약이다.
  • map은 값만 바꾸고, chain은 맥락 중첩까지 정리한다.
  • 법칙 3개(좌항등/우항등/결합)가 있어야 합성이 안정적이다.
  • JS에서는 Array, Promise, Maybe, Either로 같은 패턴을 반복해서 볼 수 있다.

실무 체크리스트:

  1. 함수가 a -> b인가 a -> M b인가 먼저 구분한다.
  2. a -> M bmap 대신 chain을 쓴다.
  3. null/에러/비동기 규칙을 각 함수에서 따로 처리하지 말고, 맥락 인터페이스에 모은다.

정리

모나드는 추상적 용어로 시작하지만, 실제로는 매우 실용적이다.

  • 합성이 깨지는 지점을 맥락으로 모델링하고
  • ofchain으로 의존적 계산을 연결하며
  • 법칙으로 리팩터링 안정성을 확보한다.

함수형 프로그래밍에서 모나드는 "효과 있는 계산의 합성 규약"이고, JavaScript에서 모나드는 "null/에러/비동기를 같은 사고방식으로 다루게 하는 공통 인터페이스"다.