Skip to content

제13장: 모노이드, 모든 것을 결합하다

무한한 조합의 세계

이 장에서는 반군(Semigroup)을 통해 *모노이드(Monoid)*를 탐구합니다. 모노이드는 수학적 추상화 머리카락에 붙은 껌과 같습니다. 이 개념은 여러 분야를 아우르며, 문자 그대로 모든 것을 통합합니다. 모든 계산을 연결하는 근본적인 힘이자 코드베이스의 생명수이며, 프로그램이 작동하는 토대이며, 양자적 얽힘을 코드화한 존재입니다.

모노이드는 결합에 관한 개념입니다. 하지만 '결합'이란 무엇일까요? 이는 축적, 연결, 곱셈, 선택, 구성, 순서 지정, 심지어 평가까지 다양한 의미를 포괄합니다. 이 장에서는 여러 예시를 다루지만, 모노이드 산의 첫걸음만 얕볼 것입니다. 모노이드의 적용 사례는 무궁무진하며, 본 장의 목표는 직관을 제공하여 독자가 스스로 모노이드를 설계할 수 있게 하는 것입니다.

덧셈의 추상화

덧셈에는 논의할 가치가 있는 흥미로운 특성들이 존재합니다. 추상화 렌즈를 통해 살펴봅시다.

우선 이진 연산(binary operation)입니다. 즉, 동일한 집합 내에서 두 값을 입력받아 하나의 값을 반환하는 연산입니다.

js
// a binary operation
1 + 1 = 2

보이시나요? 정의역의 두 값, 공역의 한 값 모두 동일한 숫자 집합에 속합니다. 이를 '덧셈에 대해 닫혀있다'고 표현하며, 이 특성 덕분에 연산 결과가 항상 동일한 타입을 유지하므로 연산 체이닝이 가능합니다:

js
// we can run this on any amount of numbers
1 + 7 + 5 + 4 + ...

게다가 (의도된 말장난...) 결합 법칙(associativity)이 존재하며, 이는 우리가 원하는 대로 연산을 그룹화할 수 있게 합니다. 흥미롭게도 결합적인 이진 연산은 작업을 분할하고 배분할 수 있으므로 병렬 계산의 기반이 됩니다.

js
// associativity
(1 + 2) + 3 = 6
1 + (2 + 3) = 6

이를 교환 법칙(commutativity)과 혼동하지 마십시오. 교환 법칙은 덧셈에 적용되지만, 현재 우리의 추상화 목적에는 너무 구체적인 특성입니다.

추상 슈퍼클래스에 어떤 속성을 포함시켜야 할까요? 덧셈의 고유 특성과 일반화 가능한 특성을 어떻게 구분할 수 있을까요? 이런 사고 방식이 바로 추상대수학 인터페이스 구상의 기반이 되었습니다.

역사적으로 덧셈 추상화 과정에서 군(Group) 개념이 도출되었습니다. 은 음수 개념을 포함한 완전한 구조를 지닙니다. 하지만 현재 우리는 결합법칙을 만족하는 이진 연산자에 주목할 것이므로 더 범용적인 인터페이스인 *반군(Semigroup)*을 선택하겠습니다. 반군은 결합적 이진 연산자 역할을 하는 concat 메서드를 가진 타입입니다.

덧셈을 위해 Sum이라는 반군을 구현해봅시다:

js
const Sum = x => ({
  x,
  concat: other => Sum(x + other.x)
})

다른 Sumconcat을 수행하며 항상 Sum을 반환합니다.

프로토타입 방식 대신 객체 팩토리를 사용한 이유는 Sum포인티드 타입이 아니며 new 키워드를 피하기 위함입니다. 실제 동작 예시:

js
Sum(1).concat(Sum(3)) // Sum(4)
Sum(4).concat(Sum(37)) // Sum(41)

이처럼 구현이 아닌 인터페이스에 맞춰 프로그래밍할 수 있습니다. 이 인터페이스는 군 이론에서 유래했으며 수세기 동안 검증된 이론적 기반을 가지고 있습니다. 무료 문서화의 혜택!

앞서 언급했듯 Sum포인티드 타입도 *함자(Functor)*도 아닙니다. 연습문제: 법칙을 확인해 그 이유를 찾아보십시오. 정답을 알려드리자면, 숫자만을 보유할 수 있으므로 타입 변환이 불가능한 map은 여기서 무의미합니다.

이것이 유용한 이유는 무엇일까요? 모든 인터페이스와 마찬가지로 인스턴스를 교체하여 다른 결과를 얻을 수 있기 때문입니다:

js
const Product = x => ({ x, concat: other => Product(x * other.x) })

const Min = x => ({ x, concat: other => Min(x < other.x ? x : other.x) })

const Max = x => ({ x, concat: other => Max(x > other.x ? x : other.x) })

이 개념은 숫자에 국한되지 않습니다. 다른 타입 예시를 살펴봅시다:

js
const Any = x => ({ x, concat: other => Any(x || other.x) })
const All = x => ({ x, concat: other => All(x && other.x) })

Any(false).concat(Any(true)) // Any(true)
Any(false).concat(Any(false)) // Any(false)

All(false).concat(All(true)) // All(false)
All(true).concat(All(true)) // All(true)

[1,2].concat([3,4]) // [1,2,3,4]

"miracle grow".concat("n") // miracle grown"

Map({day: 'night'}).concat(Map({white: 'nikes'})) // Map({day: 'night', white: 'nikes'})

이러한 예시들을 충분히 관찰하다 보면 마술 사진처럼 패턴이 명확히 드러납니다. 데이터 구조 병합, 로직 결합, 문자열 조립 등 거의 모든 작업을 이 조합 기반 인터페이스로 해결할 수 있습니다.

Map을 여러 차례 사용했습니다. 익숙하지 않다면 양해 바랍니다. Map은 단순히 Object를 래핑하여 추가 메서드를 부여한 것으로, 기존 구조를 변경하지 않습니다.

제가 선호하는 모든 함자(Functor)는 반군(Semigroup)입니다.

지금까지 살펴본 함자 인터페이스 구현체들은 모두 반군이기도 합니다. Identity(과거 Container로 알려진)를 예시로 들겠습니다:

js
Identity.prototype.concat = function(other) {
  return new Identity(this.__value.concat(other.__value))
}

Identity.of(Sum(4)).concat(Identity.of(Sum(1))) // Identity(Sum(5))
Identity.of(4).concat(Identity.of(1)) // TypeError: this.__value.concat is not a function

값을 놓치기 쉬운 그릇처럼, 담고 있는 값이 반군이라면 스스로도 반군이 됩니다. 즉 __value가 반군일 때만 이 타입도 반군입니다.

다른 타입들도 유사한 특성을 보입니다:

js
// combine with error handling
Right(Sum(2)).concat(Right(Sum(3))) // Right(Sum(5))
Right(Sum(2)).concat(Left('some error')) // Left('some error')


// combine async
Task.of([1,2]).concat(Task.of([3,4])) // Task([1,2,3,4])

이러한 반군들을 계층적으로 결합할 때 특히 유용합니다:

js
// formValues :: Selector -> IO (Map String String)
// validate :: Map String String -> Either Error (Map String String)

formValues('#signup').map(validate).concat(formValues('#terms').map(validate)) // IO(Right(Map({username: 'andre3000', accepted: true})))
formValues('#signup').map(validate).concat(formValues('#terms').map(validate)) // IO(Left('one must accept our totalitarian agreement'))

serverA.get('/friends').concat(serverB.get('/friends')) // Task([friend1, friend2])

// loadSetting :: String -> Task Error (Maybe (Map String Boolean))
loadSetting('email').concat(loadSetting('general')) // Task(Maybe(Map({backgroundColor: true, autoSave: false})))

첫 번째 예시에서는 IO 내부의 EitherMap을 결합해 폼 값을 검증·병합했습니다. 다음으로 TaskArray를 사용해 비동기 방식으로 서버 결과를 통합했습니다. 마지막으로 Task, Maybe, Map을 결합해 여러 설정을 불러오고 파싱하며 병합했습니다.

chain이나 ap을 사용할 수 있지만, 반군은 더 간결한 접근 방식을 제공합니다.

이는 함자를 넘어 모든 반군으로 구성된 구조체 자체가 반군이 됩니다: 구성 요소를 결합할 수 있다면 전체 구조도 결합 가능합니다.

js
const Analytics = (clicks, path, idleTime) => ({
  clicks,
  path,
  idleTime,
  concat: other =>
    Analytics(clicks.concat(other.clicks), path.concat(other.path), idleTime.concat(other.idleTime))
})

Analytics(Sum(2), ['/home', '/about'], Right(Max(2000))).concat(Analytics(Sum(1), ['/contact'], Right(Max(1000))))
// Analytics(Sum(3), ['/home', '/about', '/contact'], Right(Max(2000)))

모든 요소가 자기 결합 방식을 알고 있습니다. 실제로 Map 타입만으로도 무료 구현이 가능합니다:

js
Map({clicks: Sum(2), path: ['/home', '/about'], idleTime: Right(Max(2000))}).concat(Map({clicks: Sum(1), path: ['/contact'], idleTime: Right(Max(1000))}))
// Map({clicks: Sum(3), path: ['/home', '/about', '/contact'], idleTime: Right(Max(2000))})

원하는 만큼 계층화하고 결합할 수 있습니다. 코드베이스에 따라 숲에 나무를 추가하거나 산불에 불씨를 더하는 것과 같습니다.

일반적으로 타입 내부 값을 결합하지만 컨테이너 자체 결합이 필요한 경우도 있습니다. Stream 타입을 예로 들겠습니다:

js
const submitStream = Stream.fromEvent('click', $('#submit'))
const enterStream = filter(x => x.key === 'Enter', Stream.fromEvent('keydown', $('#myForm')))

submitStream.concat(enterStream).map(submitForm) // Stream()

두 이벤트 스트림을 새로운 단일 스트림으로 결합할 수 있습니다. 반군을 보유하도록 강제하는 방식도 가능합니다. 실제로 각 타입마다 다양한 인스턴스가 존재합니다. Task의 경우 더 빠른/늦은 결과를 선택하거나 모든 오류를 무시하며 첫 번째 Right를 선택하는 방식 등이 있습니다. Alternative 인터페이스는 이러한 선택 중심의 대체 인스턴스를 구현합니다.

공짜 모노이드

덧셈을 추상화했지만 바빌로니아인처럼 영(Zero) 개념이 누락되었습니다.

영(Zero)은 항등원(Identity) 역할을 하며, 0에 어떤 수를 더해도 원본이 유지됩니다. 추상화 관점에서 0은 중립적이거나 비어 있는 요소로 생각해야 합니다. 이진 연산의 좌우 측면에서 동일하게 작동하는 것이 중요합니다:

js
// identity
1 + 0 = 1
0 + 1 = 1

이 개념을 empty라 명명하고 새 인터페이스 *모노이드(Monoid)*를 생성하겠습니다. 모노이드는 반군(semigroup)에 특별한 항등원을 추가한 구조입니다. 타입 자체에 empty 함수를 구현합니다:

js
Array.empty = () => []
String.empty = () => ""
Sum.empty = () => Sum(0)
Product.empty = () => Product(1)
Min.empty = () => Min(Infinity)
Max.empty = () => Max(-Infinity)
All.empty = () => All(true)
Any.empty = () => Any(false)

empty 항등값의 유용성은 영(Zero)의 중요성을 묻는 것과 같습니다.

아무것도 없을 때 우리는 영을 믿습니다. 버그 허용 한도는? 영. 안전하지 않은 코드에 대한 내성은? 영. 최종 가격표이자 절체절명의 순간에 모든 것을 파괴하거나 구원하는 존재입니다.

코드 측면에서 합리적 기본값에 대응합니다:

js
const settings = (prefix="", overrides=[], total=0) => ...

const settings = (prefix=String.empty(), overrides=Array.empty(), total=Sum.empty()) => ...

또는 다른 값이 없을 때 유용한 값을 반환합니다:

js
sum([]) // 0

이들은 누산기(accumulator)에 완벽한 초기값이기도 합니다...

축약의 기술

concatemptyreduce의 첫 두 인자에 완벽히 부합합니다. 반군 배열을 empty 값 없이 축약할 수 있지만, 이는 위험을 초래합니다:

js
// concat :: Semigroup s => s -> s -> s
const concat = x => y => x.concat(y)

[Sum(1), Sum(2)].reduce(concat) // Sum(3)

[].reduce(concat) // TypeError: Reduce of empty array with no initial value

JavaScript는 빈 배열을 처리할 때 NaN이나 false, -1 같은 모호한 값을 반환하기보다는 런타임 오류를 발생시키는 방식을 선택합니다. Maybe로 실패 가능성을 표시할 수 있지만 더 나은 방법이 있습니다.

커리된 reduce 함수를 활용해 empty 값을 필수 인자로 받는 안전한 fold 함수를 만들겠습니다:

js
// fold :: Monoid m => m -> [m] -> m
const fold = reduce(concat)

초기값 m은 중립적 시작점(empty)이며, m 배열을 단일 값으로 압축합니다.

js
fold(Sum.empty(), [Sum(1), Sum(2)]) // Sum(3)
fold(Sum.empty(), []) // Sum(0)

fold(Any.empty(), [Any(false), Any(true)]) // Any(true)
fold(Any.empty(), []) // Any(false)


fold(Either.of(Max.empty()), [Right(Max(3)), Right(Max(21)), Right(Max(11))]) // Right(Max(21))
fold(Either.of(Max.empty()), [Right(Max(3)), Left('error retrieving value'), Right(Max(11))]) // Left('error retrieving value')

fold(IO.of([]), ['.link', 'a'].map($)) // IO([<a>, <button class="link"/>, <a>])

마지막 두 예시는 타입 자체에 empty를 정의할 수 없어 수동으로 제공했습니다. 타입 언어는 자동 추론이 가능하지만 여기서는 직접 전달해야 합니다.

완전한 모노이드 아님

초기값을 제공할 수 없는 반군이 존재합니다. First를 예로 들겠습니다:

js
const First = x => ({ x, concat: other => First(x) })

Map({id: First(123), isPaid: Any(true), points: Sum(13)}).concat(Map({id: First(2242), isPaid: Any(false), points: Sum(1)}))
// Map({id: First(123), isPaid: Any(true), points: Sum(14)})

여러 계정 병합 시 First ID를 유지합니다. 하지만 여기에 empty 값을 정의할 방법은 없습니다. 유용성은 여전합니다.

대통일 이론

군 이론 vs 범주 이론?

이진 연산은 추상대수학에서 보편적입니다. 실제로 이것이 *범주(Category)*의 주요 연산입니다. 하지만 항등원(identity) 없이는 범주 이론에서 모델링할 수 없습니다. 이 때문에 우리는 군 이론의 반군으로 시작하여 empty 확보 후 범주 이론의 모노이드로 전환합니다.

모노이드는 사상(morphism)이 concat, identity가 empty, 결합이 보장된 단일 대상 범주를 형성합니다.

모노이드로서의 합성

정의역과 공역이 동일한 a -> a 타입 함수를 *엔도모피즘(Endomorphism)*이라 합니다. 이를 포착한 Endo 모노이드 구현:

js
const Endo = run => ({
  run,
  concat: other =>
    Endo(compose(run, other.run))
})

Endo.empty = () => Endo(identity)


// in action

// thingDownFlipAndReverse :: Endo [String] -> [String]
const thingDownFlipAndReverse = fold(Endo(() => []), [Endo(reverse), Endo(sort), Endo(append('thing down')])

thingDownFlipAndReverse.run(['let me work it', 'is it worth it?'])
// ['thing down', 'let me work it', 'is it worth it?']

모든 타입이 동일하므로 composeconcat을 수행할 수 있으며 타입 일관성이 유지됩니다.

모노이드로서의 모나드

join 연산이 두 (중첩된) 모나드를 결합해 하나로 만드는 결합적 방식을 취함을 눈치채셨을 겁니다. 이는 자연 변환(natural transformation)이기도 합니다. 엔도펑터(Endofunctor) 범주에서 join은 모노이드(모나드)를 형성합니다. 코드로의 정확한 구현은 복잡하지만 핵심 아이디어는 이와 같습니다.

모노이드로서의 어플리커티브

어플리커티브 함자 또한 *완화 모노이드 함자(lax monoidal functor)*라는 모노이드 형태를 가집니다. 모노이드 인터페이스로 구현하여 ap을 복원할 수 있습니다:

js
// concat :: f a -> f b -> f [a, b]
// empty :: () -> f ()

// ap :: Functor f => f (a -> b) -> f a -> f b
const ap = compose(map(([f, x]) => f(x)), concat)

요약

모든 것이 연결될 수 있음을 깨달은 순간, 모노이드는 앱 아키텍처부터 데이터 단위까지 강력한 모델링 도구로 작용합니다. 누적/결합 작업이 필요할 때마다 모노이드를 고려해보십시오. 기본을 숙지한 후 그 정의를 더 넓은 영역으로 확장해보세요(모노이드의 적용 가능성은 놀라울 정도입니다).

연습문제