모노이드
개요
주문을 여러 배치로 나눠 받아 배치마다 주문 수와 금액 합계, 가장 큰 금액을 구한 뒤 부분 결과를 합쳐 전체 통계를 만든다고 하자. 어떤 배치를 먼저 처리하고 어떻게 묶어 합칠지는 작업 환경에 따라 달라진다.
묶는 방식에 따라 합계가 달라진다면 같은 데이터로도 다른 통계가 나온다. 배치 평균끼리 다시 평균을 내면 Avg(Avg(10, 20), 60)은 37.5인데 Avg(10, Avg(20, 60))은 25이고, 빈 배치의 최댓값을 0으로 두면 환불처럼 음수 금액뿐인 배치와 합쳤을 때 최댓값마저 0으로 바뀐다.
모노이드(Monoid)는 값의 집합과 두 값을 합치는 연산, 항등원(identity)으로 이루어진다. 무엇과 합쳐도 상대를 바꾸지 않는 항등원이 빈 배치와 집계의 시작값을 맡고, 합치는 연산은 Combine(Combine(a, b), c)와 Combine(a, Combine(b, c))가 같다는 결합법칙(associativity)을 지킨다. 이 두 규칙을 갖추면 부분 결과를 어떻게 묶어도 같은 전체 결과를 얻는다. 아래에서는 주문 통계 레코드 OrderStats가 값, Combine이 연산, Empty가 항등원이다.
정수 덧셈과 0, 정수 곱셈과 1, 문자열 이어 붙이기와 빈 문자열 "", 리스트 이어 붙이기와 빈 리스트가 흔한 모노이드다. 최댓값 고르기는 항등원을 따로 정해야 한다. int라면 int.MinValue가 항등원이 되고, 그런 값이 없으면 "아직 값 없음"을 나타내는 옵션 타입의 빈 값(C#에서는 decimal?의 null)을 항등원으로 둔다. 키별 값을 담은 맵도 같은 키의 값을 합치는 규칙이 결합법칙을 지키면, 빈 맵을 항등원으로 하는 모노이드가 된다.
흐름
배치마다 만든 부분 결과 p0부터 p3까지를 왼쪽부터 합쳐도, 오른쪽부터 합쳐도, 둘씩 묶어 Combine해도 전체 결과는 같다. 빈 배치 p2는 Empty와 같으니 어느 자리에 들어가든 결과를 바꾸지 않는다.
역사
모노이드는 추상대수학의 용어로, 결합법칙을 지키는 이항 연산과 항등원을 갖춘 집합을 가리킨다. 대수학 문헌에서는 항등원이 있는 반군(unital semigroup)이라고도 부른다. nLab의 참고문헌 항목은 "monoid"라는 말이 Arthur Cayley의 곡면 논문(1863, 1869)에서 지금과 관계없는 뜻으로 먼저 쓰였고, 지금의 뜻은 Hollings(2009, p. 529)에 따르면 Bourbaki의 Éléments de Mathématique(1943)에서 비롯했다고 정리한다.1
Haskell 표준 라이브러리 base는 Monoid 타입 클래스를 class Semigroup a => Monoid a로 선언한다. 문서는 항등원 mempty와 연산 <>에 대해 오른쪽 항등(x <> mempty = x), 왼쪽 항등(mempty <> x = x), 결합법칙(x <> (y <> z) = (x <> y) <> z)을 법칙으로 적고, mconcat은 foldr (<>) mempty와 같아야 한다고 정한다. 이 마지막 법칙이 모노이드와 폴드가 만나는 지점이다. 숫자에는 덧셈과 곱셈이라는 모노이드가 둘 있으므로 base는 Sum(덧셈과 0)과 Product(곱셈과 1)라는 감싸는 타입으로 둘을 구분한다.2
상위 클래스 Semigroup은 base 4.9.0.0부터 있으며 항등원 없이 결합법칙만 요구한다. 최솟값을 고르는 Min은 비교(Ord)만 있으면 Semigroup이 된다. 항등원이 없는 연산은 반군에 그치므로, 빈 입력까지 다루려면 옵션 값처럼 항등원 역할을 할 값을 따로 더해야 한다.3
왜 사용하는가?
결합법칙이 있으면 합치는 쪽은 데이터를 어디서 잘랐는지 신경 쓰지 않는다. 배치를 왼쪽부터 차례로 합치든, 둘씩 짝지어 합치든, 스레드마다 나눠 합친 뒤 다시 합치든 결과가 같으므로 순차 집계를 병렬 집계로 바꿔도 합치는 규칙은 그대로다. 항등원이 있으면 빈 입력에도 결과가 정해지므로 "값이 하나도 없을 때"를 따로 처리하는 분기가 줄어든다.
C# 타입 시스템은 결합법칙이나 항등원 조건까지 검사하지 않으니, 모노이드라는 이름만 붙여서는 법칙을 보장할 수 없다. 아래의 double 덧셈처럼 묶는 위치에 따라 결과가 달라질 수 있고, decimal도 정밀도가 유한하므로 실제로 쓰는 수의 범위와 중간 계산을 함께 살펴야 한다.
결합법칙은 교환법칙(commutativity)과 다른 성질이다. 문자열 이어 붙이기는 묶는 위치를 바꿔도 같지만 "a" + "b"와 "b" + "a"는 다르다. 교환법칙이 없는 모노이드를 병렬로 합칠 때는 부분 결과의 순서를 지켜야 한다. 항등원도 정말 중립이어야 한다. 최댓값의 시작값을 0으로 두면 음수만 있는 입력에서 틀린 값이 나온다.
예제
정수 목록과 decimal 목록을 제네릭 메서드 하나로 합친다. C#에는 Monoid 인터페이스가 없지만, .NET 7의 제네릭 수학(generic math)이 덧셈 연산자를 IAdditionOperators<TSelf,TOther,TResult>로, 덧셈 항등원을 IAdditiveIdentity<TSelf,TResult>.AdditiveIdentity로 제공한다. 문서는 후자를 (x + T.AdditiveIdentity) == x라는 개념으로 설명한다.4 실제로 합치는 반복은 LINQ의 Aggregate(seed, func)가 맡는다. 시작값 seed로 항등원을 넘기므로 빈 목록도 0을 돌려준다.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Numerics;
int[] counts = [3, 5, 2, 7];
decimal[] amounts = [1.10m, 2.20m, 3.30m];
Console.WriteLine(CombineAll(counts));
Console.WriteLine(CombineAll(amounts));
Console.WriteLine(CombineAll(Array.Empty<int>()));
// 정수 덧셈은 묶는 위치를 바꿔도 결과가 같다.
Console.WriteLine((1 + 2) + 3 == 1 + (2 + 3));
// 뺄셈은 묶는 위치에 따라 결과가 달라진다.
Console.WriteLine($"{(10 - 4) - 3} vs {10 - (4 - 3)}");
// double 덧셈은 매 단계 반올림하므로 묶는 위치에 따라 결과가 달라질 수 있다.
double left = (0.1 + 0.2) + 0.3;
double right = 0.1 + (0.2 + 0.3);
Console.WriteLine($"{left} vs {right}");
// 문자열 이어 붙이기는 결합법칙을 지키지만 순서를 바꾸면 결과가 달라진다.
Console.WriteLine(("a" + "b") + "c" == "a" + ("b" + "c"));
Console.WriteLine("a" + "b" == "b" + "a");
// 항등원 AdditiveIdentity(정수와 decimal에서는 0)에서 시작해 + 로 하나씩 합친다.
static T CombineAll<T>(IEnumerable<T> values)
where T : IAdditionOperators<T, T, T>, IAdditiveIdentity<T, T>
=> values.Aggregate(T.AdditiveIdentity, (acc, x) => acc + x);
17
6.60
0
True
3 vs 9
0.6000000000000001 vs 0.6
True
False정수 덧셈과 문자열 이어 붙이기는 묶는 위치를 바꿔도 같은데 뺄셈과 double 덧셈은 값이 달라진다. 마지막 두 줄에서는 결합법칙을 지키더라도 순서까지 바꿀 수 있는 것은 아니라는 점을 볼 수 있다.
범위 넓힌 예제
주문 통계를 레코드 하나로 묶는다. OrderStats는 주문 수, 합계, 최댓값을 함께 들고, Combine은 개수와 합계를 더하고 최댓값은 큰 쪽을 고른다. 주문이 없으면 decimal?의 null을 "없음"으로 두고, 튜플 패턴 매칭 (a.Max, b.Max) switch로 한쪽이 null인 경우를 처리한다. 각 필드의 합치기가 모노이드 법칙을 지키면 레코드 전체도 같은 법칙을 따르므로, 여기서는 작은 금액으로 이 조합을 살펴본다. 같은 부분 결과를 세 가지 방식으로 묶고, PLINQ의 Aggregate로도 합친다. 이 오버로드는 데이터를 파티션으로 나눠 파티션마다 누적한 뒤, 파티션 결과를 combineAccumulatorsFunc로 합친다.5
using System;
using System.Collections.Generic;
using System.Linq;
// 주문 금액 배치 네 개. 세 번째는 빈 배치이고, 네 번째는 환불(음수)만 있다.
decimal[][] batches =
[
[12.50m, 8.00m, 30.25m],
[5.75m, 41.00m],
[],
[-3.00m, -7.50m],
];
OrderStats[] p = batches.Select(OrderStats.FromBatch).ToArray();
foreach (var s in p) Console.WriteLine($"batch {s}");
// 같은 배치 결과를 세 가지 방식으로 묶는다.
var leftFirst = p.Aggregate(OrderStats.Empty, OrderStats.Combine);
var rightFirst = OrderStats.Combine(p[0], OrderStats.Combine(p[1], OrderStats.Combine(p[2], p[3])));
var pairwise = OrderStats.Combine(OrderStats.Combine(p[0], p[1]), OrderStats.Combine(p[2], p[3]));
// PLINQ는 주문을 파티션으로 나눠 부분 결과를 만들고, 그 부분 결과를 Combine으로 합친다.
var parallel = batches.SelectMany(b => b).AsParallel().Aggregate(
() => OrderStats.Empty,
(acc, amount) => OrderStats.Combine(acc, OrderStats.Of(amount)),
OrderStats.Combine,
s => s);
Console.WriteLine($"left {leftFirst}");
Console.WriteLine($"right {rightFirst}");
Console.WriteLine($"pairwise {pairwise}");
Console.WriteLine($"parallel {parallel}");
Console.WriteLine(leftFirst == rightFirst && rightFirst == pairwise && pairwise == parallel);
// 중립이 아닌 시작값: Max를 0으로 두면 환불만 있는 배치의 최댓값이 0으로 바뀐다.
var notNeutral = new OrderStats(0, 0m, 0m);
Console.WriteLine($"empty {OrderStats.Combine(OrderStats.Empty, p[3])}");
Console.WriteLine($"wrong {OrderStats.Combine(notNeutral, p[3])}");
// 평균끼리 평균을 내는 합치기는 결합법칙을 지키지 않는다.
static decimal Avg(decimal a, decimal b) => (a + b) / 2;
Console.WriteLine($"{Avg(Avg(10m, 20m), 60m)} vs {Avg(10m, Avg(20m, 60m))}");
// 개수와 합계를 모아 두면 평균은 마지막에 한 번만 계산하면 된다.
var three = new[] { 10m, 20m, 60m }.Aggregate(OrderStats.Empty, (acc, x) => OrderStats.Combine(acc, OrderStats.Of(x)));
Console.WriteLine(three.Sum / three.Count);
record OrderStats(int Count, decimal Sum, decimal? Max)
{
// 항등원: 어떤 값과 합쳐도 그 값을 바꾸지 않는다. 주문이 없으면 최댓값도 없다(null).
public static readonly OrderStats Empty = new(0, 0m, null);
public static OrderStats Of(decimal amount) => new(1, amount, amount);
public static OrderStats FromBatch(decimal[] batch) =>
batch.Aggregate(Empty, (acc, x) => Combine(acc, Of(x)));
// 결합법칙을 지키는 합치기: 개수와 합계는 더하고, 최댓값은 큰 쪽을 고른다.
public static OrderStats Combine(OrderStats a, OrderStats b) =>
new(a.Count + b.Count, a.Sum + b.Sum, (a.Max, b.Max) switch
{
(null, _) => b.Max,
(_, null) => a.Max,
_ => Math.Max(a.Max.Value, b.Max.Value),
});
public override string ToString() => $"count={Count}, sum={Sum}, max={Max?.ToString() ?? "none"}";
}
batch count=3, sum=50.75, max=30.25
batch count=2, sum=46.75, max=41.00
batch count=0, sum=0, max=none
batch count=2, sum=-10.50, max=-3.00
left count=7, sum=87.00, max=41.00
right count=7, sum=87.00, max=41.00
pairwise count=7, sum=87.00, max=41.00
parallel count=7, sum=87.00, max=41.00
True
empty count=2, sum=-10.50, max=-3.00
wrong count=2, sum=-10.50, max=0
37.5 vs 25
30네 가지 방식 모두 count=7, sum=87.00, max=41.00이 나온다. 이 입력에서는 모든 중간 합계가 정확히 표현되므로 Combine으로 묶음과 순서를 바꿔도 결과가 같지만, decimal을 썼다는 사실만으로 모든 금액에서 결합법칙이 보장되지는 않는다. 10^28, -10^28, 0.01을 차례로 더하면 왼쪽 둘부터 묶을 때는 0.01, 오른쪽 둘부터 묶을 때는 반올림 때문에 0이 되며 범위 초과도 일어나지 않는다. PLINQ Aggregate 문서의 Remarks는 파티션 결과를 합치는 순서를 적지 않으므로, 병렬 집계에는 실제 입력에서 결합법칙과 교환법칙이 성립하는 연산을 넘겨야 한다. 평균은 평균끼리 합치는 대신 개수와 합계를 모아 마지막에 한 번 계산하되, 개수와 합계도 중간 계산이 정확히 표현되는 범위에서 다룬다. 예제에는 decimal 범위 초과 검사나 서로 다른 통화를 구분하는 처리가 없다.
TypeScript 예제
TypeScript에서는 항등원 empty와 합치는 함수 combine을 직접 두고, 배열의 reduce와 reduceRight로 접는다. number는 double이니 금액은 정수 센트로 저장해 묶는 방식에 따라 덧셈 결과가 달라지는 것을 피한다.
type OrderStats = { readonly count: number; readonly sum: number; readonly max: number | null };
// 항등원: 주문이 없으면 개수와 합계는 0이고 최댓값은 없다.
const empty: OrderStats = { count: 0, sum: 0, max: null };
function of(cents: number): OrderStats {
return { count: 1, sum: cents, max: cents };
}
// 결합법칙을 지키는 합치기: 개수와 합계는 더하고, 최댓값은 큰 쪽을 고른다.
function combine(a: OrderStats, b: OrderStats): OrderStats {
const max = a.max === null ? b.max : b.max === null ? a.max : Math.max(a.max, b.max);
return { count: a.count + b.count, sum: a.sum + b.sum, max };
}
function show(s: OrderStats): string {
return `count=${s.count}, sum=${s.sum}, max=${s.max ?? "none"}`;
}
// 금액은 정수 센트로 둔다. 소수 금액을 number(double)로 더하면 결합법칙이 깨질 수 있다.
const batches: number[][] = [[1250, 800, 3025], [575, 4100], [], [-300, -750]];
const p = batches.map((batch) => batch.map(of).reduce(combine, empty));
const leftFirst = p.reduce(combine, empty);
const rightFirst = p.reduceRight((acc, s) => combine(s, acc), empty);
const pairwise = combine(combine(p[0], p[1]), combine(p[2], p[3]));
console.log("left " + show(leftFirst));
console.log("right " + show(rightFirst));
console.log("pairwise " + show(pairwise));
console.log(show(leftFirst) === show(rightFirst) && show(rightFirst) === show(pairwise));
// 빈 목록도 항등원이 있으면 결과가 정해진다. 시작값 없는 reduce는 TypeError를 던진다.
const none: OrderStats[] = [];
console.log("empty " + show(none.reduce(combine, empty)));
try {
none.reduce(combine);
} catch (e) {
console.log(e instanceof TypeError ? "TypeError: " + e.message : "unexpected");
}
console.log((0.1 + 0.2) + 0.3, 0.1 + (0.2 + 0.3));left count=7, sum=8700, max=4100
right count=7, sum=8700, max=4100
pairwise count=7, sum=8700, max=4100
true
empty count=0, sum=0, max=none
TypeError: Reduce of empty array with no initial value
0.6000000000000001 0.6항등원을 시작값으로 넘기면 빈 목록의 결과도 정해지지만, 시작값 없이 reduce를 부르면 빈 배열에서 TypeError가 난다. 정수 센트 덧셈도 Number.MAX_SAFE_INTEGER 안에서만 정확하며 이 예제에는 그 경계를 검사하는 코드는 없다.
같이 보기
예제 실행 환경: C#은 독립된 .NET 10 콘솔 프로그램, TypeScript는 ES2022 이상으로 컴파일한 뒤 Node.js에서 실행한다.
각주
- nLab. monoid. Idea와 References 항목. 같은 항목이 인용하는 문헌: Hollings, Christopher. The Early Development of the Algebraic Theory of Semigroups. Archive for History of Exact Sciences 63 (2009) 497-536, doi:10.1007/s00407-009-0044-3. ↩
- Haskell base 4.22.0.0. Data.Monoid. class Monoid의 법칙, Sum과 Product 설명. ↩
- Haskell base 4.22.0.0. Data.Semigroup. class Semigroup(Since: base-4.9.0.0)와 Min 설명. ↩
- Microsoft Learn. Generic math. Numeric interfaces 표의 IAdditiveIdentity 설명. IAdditiveIdentity.AdditiveIdentity API 문서. ↩
- Microsoft Learn. ParallelEnumerable.Aggregate. seedFactory와 combineAccumulatorsFunc를 받는 오버로드의 Remarks. ↩