폴드
누적값과 원소를 받는 함수를 반복 적용해 컬렉션이나 재귀 구조를 값 하나로 줄이는 함수형 패턴.
· 마지막 업데이트: 2026-10-25 · 13 min read
개요
주문 목록에서 합계, 건수, 가장 큰 주문을 각각 루프로 구한다고 하자. 세 루프는 모두 var acc = 초기값; foreach (var x in items) acc = 갱신(acc, x); 모양이고, 달라지는 것은 초기값과 갱신식뿐이다. 그런데도 루프를 쓸 때마다 같은 골격을 다시 적고, 목록이 비었을 때 무엇을 돌려줄지도 루프마다 따로 정한다.
폴드(fold)는 반복의 골격을 함수 하나로 묶어, 초기값(seed)과 결합 함수(step)만 인자로 받는다. 초기값은 아직 원소를 보지 않았을 때의 결과이자 첫 단계의 입력이고, 결합 함수는 지금까지의 결과와 다음 원소로 새 결과를 만든다. 단계 사이를 건너가는 이 중간 결과가 누산기(accumulator)이며 C#에서는 LINQ의 Aggregate가 이 일을 맡는다.
리스트 외에 트리 같은 재귀 구조도 접을 수 있다. 노드 종류마다 처리 함수를 받아 자식을 먼저 접고, 그 결과로 부모의 값을 만든다.
흐름
같은 원소와 같은 초기값이라도 왼쪽 폴드는 ((0 - 1) - 2) - 3 = -6, 오른쪽 폴드는 1 - (2 - (3 - 0)) = 2를 낸다. 뺄셈처럼 묶는 방식에 따라 값이 달라지는 연산에서는 어느 쪽으로 접는지가 결과를 바꾼다.
역사
Hutton은 1999년 튜토리얼에서 fold(foldr)를 리스트를 처리하는 흔한 재귀 패턴을 담은 표준 연산자로 설명했다. 그는 fold의 기원을 재귀 이론(Kleene, 1952)에 두고, 프로그래밍 언어의 중심 개념으로 쓰인 예를 APL의 reduction 연산자(Iverson, 1962)와 FP의 insertion 연산자(Backus, 1978)로 들었다.1
같은 글에서 fold f v는 리스트 끝의 빈 리스트를 v로, 각 cons 생성자를 f로 바꾸는 함수다. Hutton은 유한 리스트에서 g [] = v, g (x : xs) = f x (g xs)를 만족하는 함수는 fold f v 하나뿐이라는 보편 성질(universal property)을 증명과 정의의 도구로 강조했다. 왼쪽부터 처리하는 foldl은 fold로 정의할 수 있지만, foldl이 리스트 꼬리에 대해 엄격하기 때문에 반대 방향의 정의는 불가능하다고도 적었다.2
Meijer, Fokkinga, Paterson은 1991년 논문에서 리스트를 분해하는 이런 함수를 catamorphism(그리스어 전치사 '아래로'에서 온 말)이라 부르고 바나나 괄호 (| |)로 표기했다. 이 논문은 같은 재귀 연산자를 리스트에서 대수적 데이터 타입 일반으로 넓힌다.3
왜 사용하는가?
반복의 골격은 폴드에 맡기고 계산만 따로 적는다. 결합 함수는 누산기와 원소 하나를 받으니 이름을 붙여 시험하기 쉽고, 요약할 값이 여러 개라면 누산기를 레코드로 만들어 한 번의 순회에서 함께 구할 수 있다.
LINQ의 Sum, Count, Min, Max, Average는 자주 쓰는 집계를 초기값과 결합 함수까지 정해 둔 연산으로 볼 수 있다. Microsoft 문서도 이들을 흔한 집계를 간단히 하려고 둔 표준 쿼리 연산자로 소개한다.4 Aggregate를 직접 쓰는 때는 이런 전용 연산자로 표현되지 않는 누산기가 필요할 때다.
이터레이터 패턴이 제공하는 순회를 Aggregate는 호출한 자리에서 끝까지 소비해 값 하나를 돌려준다. Select와 Where가 지연 실행되는 것과 달리 집계를 바로 시작하므로, 끝나지 않는 시퀀스라면 집계도 끝나지 않는다.5
뺄셈처럼 묶는 방식에 따라 결과가 달라지면 어느 방향으로 접을지는 직접 정해야 한다. 묶는 방식과 관계없이 같은 결과가 나오는 결합 법칙과, 시작값으로 쓸 항등원까지 갖춘 구조는 모노이드에서 다룬다.
초기값이 있으면 빈 시퀀스의 결과도 그 값으로 정해진다. 반면 초기값 없는 Aggregate는 첫 원소를 초기 누산기로 쓰므로 비어 있는 시퀀스에서 InvalidOperationException을 던진다.6
가독성과 비용도 따져야 한다. Hutton도 fold로 쓴 프로그램이 명시적 재귀보다 읽기 어려울 수 있다고 적었다.1 결합 함수가 길어지면 평범한 foreach가 더 읽기 쉽다. 누산기를 단계마다 통째로 복사하면 전체 비용이 원소 수의 제곱에 비례해 늘어난다. (acc, s) => acc + s로 문자열을 이으면 단계마다 지금까지의 문자열 전체가 새 문자열로 복사되므로, 한 글자 문자열 1,000개를 이을 때 만들어지는 중간 문자열 길이의 합은 1 + 2 + ... + 1,000 = 500,500자다. 이럴 때는 StringBuilder를 누산기로 쓰거나 string.Concat, string.Join을 쓴다. 원소를 덧붙인 새 배열을 매번 만드는 누산기도 같은 문제가 있다.
예제
같은 [1, 2, 3]과 초기값을 두고 왼쪽과 오른쪽에서 접어 보자. 결합 함수가 식을 문자열로 만들도록 하면 괄호가 묶이는 방향이 보이는데, LINQ에는 오른쪽 폴드가 없어 FoldRight를 직접 정의했다. 함수의 인자 순서도 Aggregate는 (acc, x), FoldRight는 (x, acc)로 다르다.
using System;
using System.Collections.Generic;
using System.Linq;
int[] numbers = [1, 2, 3];
// Aggregate는 왼쪽 폴드다. 앞 원소부터 누산기를 갱신한다.
string leftText = numbers.Aggregate("0", (acc, x) => $"({acc} - {x})");
int left = numbers.Aggregate(0, (acc, x) => acc - x);
// LINQ에는 오른쪽 폴드가 없어서 뒤에서부터 접는 함수를 직접 둔다.
string rightText = FoldRight(numbers, "0", (x, acc) => $"({x} - {acc})");
int right = FoldRight(numbers, 0, (x, acc) => x - acc);
Console.WriteLine($"left: {leftText} = {left}");
Console.WriteLine($"right: {rightText} = {right}");
// 이어 붙이기는 묶는 순서가 달라도 결과가 같다.
string[] letters = ["a", "b", "c"];
string leftConcat = letters.Aggregate("", (acc, s) => acc + s);
string rightConcat = FoldRight(letters, "", (s, acc) => s + acc);
Console.WriteLine($"concat: {leftConcat} / {rightConcat}");
// 오른쪽 폴드: 마지막 원소와 초기값을 먼저 결합하고 앞쪽으로 진행한다.
static TAcc FoldRight<T, TAcc>(IReadOnlyList<T> items, TAcc seed, Func<T, TAcc, TAcc> step)
{
TAcc acc = seed;
for (int i = items.Count - 1; i >= 0; i--) acc = step(items[i], acc);
return acc;
}left: (((0 - 1) - 2) - 3) = -6
right: (1 - (2 - (3 - 0))) = 2
concat: abc / abc뺄셈은 접는 방향에 따라 -6과 2로 갈리고, 이어 붙이기는 양쪽 모두 abc다.
범위 넓힌 예제
앞부분은 주문 목록을 Aggregate 한 번으로 요약한다. 누산기 Summary는 건수, 수량, 합계, 가장 큰 주문을 담는 불변 레코드이고 Summary.Empty가 초기값이다. 빈 목록에서는 초기값이 있는 집계, 초기값 없는 Aggregate, Max, Sum이 어떻게 다른지 확인한다.
뒷부분에서는 Num, Add, Mul 레코드로 만든 식 트리를 접는다. Fold가 노드마다 처리 함수를 받아 자식을 접은 결과로 부모의 값을 만들기 때문에, 함수만 바꾸면 같은 순회에서 값 계산과 문자열 출력, 깊이 측정을 할 수 있다. 데이터 타입의 생성자를 처리 함수로 바꿔 끼우는 이 접기가 catamorphism이다.
using System;
using System.Collections.Generic;
using System.Linq;
Order[] orders =
[
new("A-1", 2, 15_000m),
new("A-2", 1, 42_000m),
new("A-3", 3, 8_000m),
];
Order[] none = [];
// 주문 요약: Aggregate 한 번으로 건수, 수량, 합계, 가장 큰 주문을 함께 구한다.
Console.WriteLine(orders.Aggregate(Summary.Empty, Summary.Include));
Console.WriteLine(none.Aggregate(Summary.Empty, Summary.Include));
// 빈 입력: 초기값이 없는 Aggregate와 Max는 예외를 던지고, Sum은 0을 돌려준다.
Report("Aggregate(no seed)", () => none.Select(o => o.Amount).Aggregate((a, b) => a + b));
Report("Max", () => none.Max(o => o.Amount));
Report("Sum", () => none.Sum(o => o.Amount));
// 식 트리 폴드: 생성자마다 처리 함수를 하나씩 받는다.
Expr expr = new Mul(new Add(new Num(2), new Num(3)), new Num(4));
int value = Fold(expr, n => n, (l, r) => l + r, (l, r) => l * r);
string text = Fold(expr, n => n.ToString(), (l, r) => $"({l} + {r})", (l, r) => $"{l} * {r}");
int depth = Fold(expr, _ => 1, (l, r) => 1 + Math.Max(l, r), (l, r) => 1 + Math.Max(l, r));
Console.WriteLine($"{text} = {value}, depth {depth}");
static void Report(string label, Func<decimal> run)
{
try
{
Console.WriteLine($"{label}: {run()}");
}
catch (InvalidOperationException ex)
{
Console.WriteLine($"{label}: {ex.GetType().Name}: {ex.Message}");
}
}
// C#에는 닫힌 합 타입이 없어서 마지막 갈래가 필요하다.
static T Fold<T>(Expr e, Func<int, T> num, Func<T, T, T> add, Func<T, T, T> mul) => e switch
{
Num n => num(n.Value),
Add a => add(Fold(a.Left, num, add, mul), Fold(a.Right, num, add, mul)),
Mul m => mul(Fold(m.Left, num, add, mul), Fold(m.Right, num, add, mul)),
_ => throw new ArgumentOutOfRangeException(nameof(e), e, "unknown node"),
};
sealed record Order(string Id, int Quantity, decimal UnitPrice)
{
public decimal Amount => Quantity * UnitPrice;
}
// 누산기: 지금까지 본 주문의 요약을 담는 불변 값이다.
sealed record Summary(int Count, int Quantity, decimal Total, Order? Largest)
{
public static readonly Summary Empty = new(0, 0, 0m, null);
public static Summary Include(Summary acc, Order o) => new(
acc.Count + 1,
acc.Quantity + o.Quantity,
acc.Total + o.Amount,
acc.Largest is null || o.Amount > acc.Largest.Amount ? o : acc.Largest);
public override string ToString() =>
$"orders={Count} qty={Quantity} total={Total} largest={Largest?.Id ?? "(none)"}";
}
abstract record Expr;
sealed record Num(int Value) : Expr;
sealed record Add(Expr Left, Expr Right) : Expr;
sealed record Mul(Expr Left, Expr Right) : Expr;orders=3 qty=6 total=96000 largest=A-2
orders=0 qty=0 total=0 largest=(none)
Aggregate(no seed): InvalidOperationException: Sequence contains no elements
Max: InvalidOperationException: Sequence contains no elements
Sum: 0
(2 + 3) * 4 = 20, depth 3Summary는 원소마다 새 레코드를 만들고, 금액이 같으면 먼저 나온 주문을 유지한다. 식 트리의 Fold는 재귀 호출이라서 트리 깊이만큼 호출 스택을 쓴다. 같은 Fold로 Add가 한쪽으로만 이어진 트리를 접으면 깊이 1,000은 처리되지만, 깊이 1,000,000에서는 .NET 10 기본 스택에서 스택 오버플로가 나고 프로세스가 끝났다. StackOverflowException은 try/catch로 잡을 수 없으므로7 깊이가 입력에 따라 커지는 트리는 명시적 스택을 쓰는 반복으로 바꾸거나 깊이를 먼저 제한한다. 또 C#에는 닫힌 합 타입이 없어서 switch에 _ 갈래를 두었고, 그 때문에 노드 타입을 새로 추가해도 컴파일러가 빠진 처리를 알려 주지 않는다.
TypeScript 예제
JavaScript 배열에서는 reduce가 왼쪽 폴드, reduceRight가 오른쪽 폴드이며, 초기값 없이 빈 배열을 reduce하면 C#의 Aggregate와 마찬가지로 실패하되 예외는 TypeError다. 식 트리는 kind로 모양을 구별하는 판별 유니온(discriminated union)으로 만들었으므로 switch에서 세 경우를 모두 다루면 컴파일러가 누락이 없다고 판단하고, C#처럼 마지막 갈래를 요구하지 않는다.
const numbers = [1, 2, 3];
// reduce는 왼쪽 폴드, reduceRight는 오른쪽 폴드다.
const leftText = numbers.reduce((acc, x) => `(${acc} - ${x})`, "0");
const rightText = numbers.reduceRight((acc, x) => `(${x} - ${acc})`, "0");
console.log(`left: ${leftText} = ${numbers.reduce((acc, x) => acc - x, 0)}`);
console.log(`right: ${rightText} = ${numbers.reduceRight((acc, x) => x - acc, 0)}`);
// 빈 배열: 초기값이 없으면 TypeError, 있으면 초기값이 그대로 결과가 된다.
const empty: number[] = [];
try {
console.log(empty.reduce((a, b) => a + b));
} catch (e) {
if (!(e instanceof TypeError)) throw e;
console.log(`no seed: ${e.name}: ${e.message}`);
}
console.log(`seed 0: ${empty.reduce((a, b) => a + b, 0)}`);
type Expr =
| { kind: "num"; value: number }
| { kind: "add"; left: Expr; right: Expr }
| { kind: "mul"; left: Expr; right: Expr };
// 식 트리 폴드: 생성자마다 처리 함수를 하나씩 받는다.
function fold<T>(e: Expr, num: (v: number) => T, add: (l: T, r: T) => T, mul: (l: T, r: T) => T): T {
switch (e.kind) {
case "num": return num(e.value);
case "add": return add(fold(e.left, num, add, mul), fold(e.right, num, add, mul));
case "mul": return mul(fold(e.left, num, add, mul), fold(e.right, num, add, mul));
}
}
const n = (value: number): Expr => ({ kind: "num", value });
const expr: Expr = { kind: "mul", left: { kind: "add", left: n(2), right: n(3) }, right: n(4) };
const value = fold(expr, (v) => v, (l, r) => l + r, (l, r) => l * r);
const text = fold(expr, (v) => String(v), (l, r) => `(${l} + ${r})`, (l, r) => `${l} * ${r}`);
console.log(`${text} = ${value}`);left: (((0 - 1) - 2) - 3) = -6
right: (1 - (2 - (3 - 0))) = 2
no seed: TypeError: Reduce of empty array with no initial value
seed 0: 0
(2 + 3) * 4 = 20reduceRight도 결합 함수의 첫 인자가 누산기라서, 오른쪽 폴드의 뺄셈은 (acc, x) => x - acc로 피연산자 순서를 직접 맞춘다. 식 트리의 fold는 C# 예제와 마찬가지로 재귀라서 트리 깊이만큼 호출 스택을 쓴다.
같이 보기
예제 실행 환경: C#은 독립된 .NET 10 콘솔 프로그램, TypeScript는 ES2022 이상으로 컴파일한 뒤 Node.js에서 실행한다.
각주
- Graham Hutton. "A tutorial on the universality and expressiveness of fold". Journal of Functional Programming 9(4): 355-372 (1999). 저자 PDF. 2절 The fold operator. ↩
- Graham Hutton. "A tutorial on the universality and expressiveness of fold". Journal of Functional Programming 9(4): 355-372 (1999). 저자 PDF. 3절 The universal property of fold, 5.1절 The foldl operator. ↩
- Erik Meijer, Maarten Fokkinga, Ross Paterson. "Functional Programming with Bananas, Lenses, Envelopes and Barbed Wire". Functional Programming Languages and Computer Architecture, Lecture Notes in Computer Science, pp. 124-144 (1991). DOI, 저자 PDF. 2절 The data type of lists, 4절 Recursion Schemes. ↩
- Microsoft Learn. Enumerable.Aggregate Method. Remarks. ↩
- Microsoft Learn. Introduction to LINQ Queries. Classification of standard query operators by manner of execution. ↩
- Microsoft Learn. Enumerable.Aggregate Method. Aggregate<TSource>(IEnumerable<TSource>, Func<TSource,TSource,TSource>), Exceptions. ↩
- Microsoft Learn. StackOverflowException Class. Remarks. ↩