함수형 프로그래밍과 Iteration Protocol
Iteration Protocol
이터레이션 프로토콜은 자바스크립트에서 데이터를 순회하기 위한 표준화된 규약으로, 컬렉션(배열, 문자열 등)을 순회 가능한 형태로 정의한다. 이 프로토콜은 두 가지로 나뉘며, 하나는 이터러블 프로토콜(Iterable Protocol), 다른 하나는 이터레이터 프로토콜(Iterator Protocol)이다. 어떤 객체가 Symbol.iterator 메서드를 가지고 있고 이를 호출한 결과로 이터레이터(iterator)가 반환된다면, 그 객체는 이터러블하다고 말할 수 있다. 이터러블한 객체는 for...of 문, 전개 연산자(...), 비구조화 할당 등에서 사용할 수 있으며, 자바스크립트의 다양한 문법이 이 프로토콜 위에 구성되어 있다.
반면, 이터레이터는 next() 메서드를 가진 객체로, 이 메서드는 { value, done } 형태의 객체를 반환하면서 순회 가능한 값을 하나씩 제공한다. 이터레이터는 보통 순회 상태를 내부에 유지하고 있으며, 대부분 Symbol.iterator 메서드를 직접 구현해 자신을 반환하도록 한다. 이로 인해 이터레이터는 자기 자신을 이터러블로 만들며, 아래의 코드처럼 iterator[Symbol.iterator]() === iterator가 항상 성립하는 구조를 가진다. 따라서 모든 이터레이터는 동시에 이터러블이기도 하지만, 모든 이터러블이 이터레이터는 아니다.
함수형 프로그래밍에서는 이터러블/이터레이터 프로토콜이 지연 평가(lazy evaluation)를 구현하는 핵심 도구로 활용된다. map, filter, reduce 같은 고차 함수들이 이터러블과 결합되면 연산을 필요한 시점까지 미루며 처리할 수 있어, 불필요한 연산을 줄이고 성능을 향상시킬 수 있다. 특히 자바스크립트의 제너레이터 함수는 이터러블과 이터레이터를 동시에 구현할 수 있어, 무한한 시퀀스나 조건부 순회 같은 복잡한 로직을 선언적이고 효율적으로 표현할 수 있는 강력한 도구로 사용된다. 이터레이션 프로토콜을 이해하는 것은 자바스크립트 언어 자체를 더 깊이 이해하고, 함수형 스타일로 데이터를 다루는 데 있어 큰 기반이 된다.
const iterable = {
[Symbol.iterator]() {
let i = 3;
return {
next() {
return i == 0 ? {done: true} : {value: i--, done: false};
},
[Symbol.iterator]() {
return this;
}
}
}
};
자바스크립트의 모든 값이 이터러블/이터레이터 프로토콜을 따르는 것은 아니기 때문에, 복잡하거나 사용자 정의 순회 로직이 필요한 경우 직접 이터러블을 구현해야 한다. 이때 유용하게 사용할 수 있는 도구가 바로 제너레이터(generator)이다. 제너레이터는 function* 키워드로 정의되며, 호출 시 즉시 실행되지 않고 이터레이터를 반환한다. 이 이터레이터는 next() 메서드를 통해 yield 지점까지 실행되며, 중단과 재개가 가능한 흐름을 제공한다. 제너레이터는 Symbol.iterator를 구현하고 있어 이터러블로도 동작하며, 이를 통해 지연 평가나 복잡한 순회 로직을 간결하고 직관적으로 작성할 수 있다. 이러한 특성 덕분에 제너레이터는 함수형 프로그래밍이나 사용자 정의 반복 제어가 필요한 상황에서 강력한 도구로 활용된다.
function* infinity(i = 0) {
while (true) yield i++;
}
function* limit(l, iter) {
for (const a of iter) {
yield a;
if (a == l) return;
}
}
function* odds(l) {
for (const a of limit(l, infinity(1))) {
if (a % 2) yield a;
}
}
map, filter, reduce
함수형 프로그래밍에서 자주 사용되는 map, filter, reduce 함수는 이터러블 데이터를 선언적이고 간결하게 처리할 수 있는 핵심 도구이다. map은 이터러블의 각 요소에 함수를 적용하여 새로운 값을 생성하고, filter는 주어진 조건을 만족하는 값만 걸러내며, reduce는 모든 요소를 하나의 값으로 축약한다. 이 세 함수는 내부적으로 for...of를 사용해 이터러블을 순회하며 동작하고, 결과를 새로운 배열로 반환하거나 누적된 하나의 값으로 반환한다.
아래의 예시처럼 직접 구현한 map, filter, reduce 함수는 이터러블/이터레이터 프로토콜을 기반으로 동작하며, 제너레이터와 결합하면 지연 평가를 활용한 효율적인 데이터 처리도 가능해진다. 이러한 함수들은 복잡한 반복 로직을 추상화하여 코드의 가독성과 재사용성을 크게 향상시켜준다.
const map = (f, iter) => {
let res = [];
for (const a of iter) {
res.push(f(a));
}
return res;
};
const filter = (f, iter) => {
let res = [];
for (const a of iter) {
if (f(a)) res.push(a);
}
return res;
};
const reduce = (f, acc, iter) => {
if (!iter) {
iter = acc[Symbol.iterator]();
acc = iter.next().value;
}
for (const a of iter) {
acc = f(acc, a);
}
return acc;
};
reduce 함수는 초기값이 생략된 경우, 내부적으로 이터레이터를 생성한 뒤 첫 번째 값을 초기값으로 사용하고, 나머지 요소들에 대해 누적 연산을 수행한다. 이 방식은 reduce(f, [1, 2, 3])처럼 호출했을 때 1을 초기값으로 삼고 2, 3부터 순회하도록 동작한다. 따라서 초기값을 명시하지 않아도 자연스럽게 처리할 수 있지만, 이터러블이 비어 있는 경우 예외가 발생할 수 있으므로 주의가 필요하다.
유틸리티 함수 go, pipe, curry
함수형 프로그래밍에서는 여러 연산을 자연스럽게 연결하기 위해 go, pipe, curry 같은 유틸리티 함수를 자주 활용한다. go는 인자를 순차적으로 전달하며 함수들을 실행하는 일종의 함수형 파이프라인 실행기로, 데이터를 흐름처럼 처리할 수 있게 해준다.
const go = (...args) => reduce((a, f) => f(a), args);
go(
products,
(products) => filter((p) => p.price < 20000, products),
(products) => map((p) => p.price, products),
(prices) => reduce(add, prices),
log
);
pipe는 첫 함수를 여러 인자를 받아 실행하고, 그 결과를 다음 함수들에 순차적으로 전달하는 함수 합성 도구로, 재사용 가능한 연산 체인을 정의하는 데 유용하다.
const pipe = (f, ...fs) => (...as) => go(f(...as), ...fs);
const calcProductPrice = pipe(
(a, b) => a + b,
(products) => filter((p) => p.price < 20000, products),
(products) => map((p) => p.price, products),
(prices) => reduce(add, prices),
log
);
curry는 다중 인자를 받는 함수를 부분 적용할 수 있도록 변환해주는 함수로, 인자를 나눠서 제공하거나 중간에 함수 조합을 더 유연하게 만들어준다. 이 세 가지 도구는 서로 결합되어 복잡한 데이터 흐름을 간결하고 선언적으로 표현할 수 있게 해주며, 특히 이터러블을 다루는 map, filter, reduce 같은 함수와 함께 사용할 때 높은 가독성과 재사용성을 제공한다.
const curry = f => (a, ...args) => args.length ? f(a, ...args) : (...args) => f(a, ...args)
// 다른 함수들도 curry로 감싸줬다고 치겠다
const map = curry((f, iter) => {
let res = [];
for (const a of iter) {
res.push(f(a));
}
return res;
});
go(
products,
filter((p) => p.price < 20000),
map((p) => p.price),
reduce(add),
log
);
이 세 가지 도구는 서로 결합되어 복잡한 로직을 간결하고 선언적으로 표현할 수 있을 뿐 아니라, 여러 함수를 조합하여 새로운 함수를 만들어내는 함수형 조합(composition)의 기반을 형성한다. 특히 map, filter, reduce와 같은 이터러블 처리 함수들과 함께 사용할 때, 코드의 가독성과 재사용성을 극대화할 수 있다.
예를 들어, 아래의 base_total_price는 함수형 파이프라인으로서 이터러블을 받아 여러 단계를 거쳐 최종 결과를 반환한다. 이를 go(products, base_total_price(p => p.price < 20000))처럼 사용하면, 상품 배열에서 조건에 맞는 항목들을 필터링하고 가격만 추출한 뒤 모두 더하는 과정을 순차적으로 실행하여, 최종적으로는 조건을 만족하는 상품 가격들의 합계라는 단일 숫자 값을 반환한다. 즉, 이러한 함수 조합을 통해 중간에 여러 배열을 생성하는 대신 필요한 연산만 수행하여 원하는 최종 결과를 간결하게 얻을 수 있도록 설계할 수 있게 되는 것이다.
const base_total_price = (predi) =>
pipe(
filter(predi),
map((p) => p.price),
reduce(add)
);
const addAllPriceOver20000 = go(
products,
base_total_price((p) => p.price < 20000)
);
지연 평가
지연 평가(lazy evaluation)는 값의 계산을 실제로 필요할 때까지 미루는 평가 전략이다. 즉, 표현식이나 데이터의 생성이 즉시 이루어지지 않고, 그 결과가 사용되는 시점에 계산이 수행된다. 자바스크립트의 이터러블/이터레이터 프로토콜은 이러한 지연 평가를 자연스럽게 구현할 수 있는 기반을 제공한다. 예를 들어, 제너레이터 함수는 값을 한 번에 모두 생성하지 않고 yield를 통해 필요한 시점마다 하나씩 값을 반환함으로써 메모리 사용을 최소화하고, 무한한 데이터 스트림도 안전하게 다룰 수 있게 해준다.
function* numbers() {
let i = 0;
while (true) {
yield i++;
}
}
const iter = numbers();
console.log(iter.next().value); // 0 — 이 시점에야 값이 계산됨
console.log(iter.next().value); // 1
console.log(iter.next().value); // 2
지연 평가는 특히 큰 데이터셋이나 무한한 시퀀스를 처리할 때 성능과 자원 관리 측면에서 큰 장점을 가진다. 전통적인 배열 메서드인 map, filter는 즉시 실행되어 중간 배열을 생성하는 반면, 지연 평가를 활용하면 중간 결과를 실제로 사용할 때까지 연산을 미루어 불필요한 계산과 메모리 할당을 줄일 수 있다. 이러한 이유로 함수형 프로그래밍에서 지연 평가는 효율적이고 확장 가능한 데이터 처리 방식으로 널리 사용된다.
const curry = f => (a, ...rest) =>
rest.length
? f(a, ...rest)
: (...rest2) => f(a, ...rest2);
// 제너레이터 함수에 curry 적용
const map = curry(function* (iter, f) {
for (const a of iter) {
yield f(a);
}
});
const filter = curry(function* (iter, predicate) {
for (const a of iter) {
if (predicate(a)) yield a;
}
});
// 한꺼번에 인자 전달
const result1 = [...map([1,2,3], x => x * 2)];
// 부분 적용 (커링)
const double = map(x => x * 2);
const result2 = [...double([1,2,3])];
생성자 함수와 소비자 함수
이터러블 프로그래밍에서는 함수들이 결과를 생성하는 방식에 따라 크게 두 가지 유형으로 나눌 수 있다. 첫 번째는 map, filter처럼 지연 평가를 기반으로 새로운 이터러블을 반환하는 함수들이다. 이 함수들은 내부적으로 yield를 사용해 각 요소를 필요한 순간에 하나씩 생성하며, 전체 연산을 즉시 수행하지 않는다. 이런 함수들은 단독으로 실행해도 아무 일도 일어나지 않으며, 최종 소비자 함수에 의해 순회되거나 펼쳐질 때 비로소 연산이 수행된다. 이들은 데이터 흐름을 조정하는 역할을 하며, 파이프라인 중간에서 연산의 조각을 구성하는 데 주로 사용된다.
반면, reduce, take, find와 같은 함수들은 이터러블을 실제로 순회하여 즉시 결과를 만들어내는 소비자 함수들이다. 이 함수들은 이터러블 전체 또는 일부를 소비하면서 최종적인 값을 반환하며, 이 과정에서 지연된 연산들이 실제로 실행된다. 예를 들어 reduce는 누적 계산을 통해 하나의 값을 만들고, take는 원하는 개수만큼 값을 수집해 배열로 반환한다. 이러한 함수들은 지연된 연산 파이프라인의 끝에서 실제 결과를 만들어내는 종결자 역할을 하며, 이터러블 기반 함수형 프로그래밍의 흐름을 마무리짓는 중요한 구성 요소이다.
모나드
모나드(Monad)는 함수형 프로그래밍에서 매우 중요한 추상화 개념으로, 값을 특정한 문맥(Context)에 감싸고, 그 안에서 연산을 안전하게 연결할 수 있도록 설계된 구조이다. 이를 통해 null이나 undefined, 실패, 비동기 등 다양한 예외 상황을 일관된 방식으로 다룰 수 있으며, 복잡한 조건 분기 없이도 순차적인 연산 흐름을 구성할 수 있다. 다시 말해, 모나드는 값뿐 아니라 그 값이 "어떤 상태에 놓여 있는지"를 함께 다루는 일종의 컨테이너이자 연산 체인이다.
모나드는 기본적으로 두 가지 연산, 즉 of(또는 unit)와 flatMap(또는 bind)을 기반으로 동작한다. of는 일반 값을 모나드로 감싸 특정 문맥을 부여하는 역할을 하며, 예를 들어 Maybe.of(3)은 값 3을 null-safe한 문맥 안으로 넣는다. flatMap은 이 감싸진 값에 연산을 적용하고, 그 결과가 또 다른 모나드일 경우 이를 중첩 없이 평탄하게 이어붙여 연산 흐름을 유지할 수 있게 해준다. 이 덕분에 복잡한 상태에서도 연산을 자연스럽고 예측 가능하게 연결할 수 있다.
예를 들어 Maybe(3).flatMap(x => Maybe(x + 2))는 내부 값 3에 연산을 적용한 뒤 Maybe(5)로 이어진다. 만약 map만 사용한다면 결과는 Maybe(Maybe(5))처럼 중첩될 수 있지만, flatMap은 이런 중첩을 방지해 부드러운 연산 체인을 가능하게 해준다. 이렇게 보면 of는 값을 문맥에 감싸는 역할이고, flatMap은 감싼 값을 꺼내어 연산을 적용하고 다시 문맥을 유지하는 방식으로 체인을 구성하는 핵심 도구이다. 이 두 연산은 모나드라 부르기 위한 최소 조건이며, 다양한 컨텍스트를 가진 연산을 일관되게 조합할 수 있는 기반이 된다.
const Maybe = (value) => ({
value,
isNothing: value == null,
map: function (fn) {
return this.isNothing ? Maybe(null) : Maybe(fn(this.value));
},
flatMap: function (fn) {
return this.isNothing ? Maybe(null) : fn(this.value);
}
});
Maybe.of = (v) => Maybe(v);
// 사용 예시
const safeDivide = (a, b) => b === 0 ? Maybe(null) : Maybe(a / b);
const result = Maybe.of(10)
.map(x => x + 2)
.flatMap(x => safeDivide(x, 4));
console.log(result); // { value: 3 }
모나드는 종종 메서드 체이닝처럼 보일 수 있지만, 체이닝 자체가 모나드를 의미하는 것은 아니다. 단순한 체이닝은 문법적인 연결일 뿐, 값의 문맥을 감싸거나 통제하는 구조는 없다. 반면 모나드는 연산이 일어나는 문맥 자체를 추상화하고, 이를 통제 가능한 방식으로 구성하는 규칙 기반의 구조이다. 이 차이는 모나드가 단순한 코드 스타일이 아니라, 복잡한 연산 흐름을 안전하게 구성하고 조합하기 위한 고차원 추상화라는 점에서 중요하다. 결과적으로 모나드는 함수형 프로그래밍에서 안정성, 예측 가능성, 그리고 조합 가능성을 극대화하는 핵심 도구로 작동한다.
Kleisli Composition
일반적인 경우, f: B → C, g: A → B인 두 함수를 f(g(x))처럼 연결해 하나의 흐름으로 만들 수 있다. 예를 들어 다음과 같은 함수가 있다고 해보자.
const double = (x: number): number => x * 2;
const plusOne = (x: number): number => x + 1;
const composed = (x: number) => double(plusOne(x));
console.log(composed(3)); // 8
이처럼 값 → 값으로 진행되는 함수들은 순수하게 합성할 수 있다. 하지만 함수가 단순히 값을 반환하는 게 아니라 Promise, Maybe, Result처럼 어떤 컨텍스트에 싸인 값, 즉 모나드를 반환한다면 상황은 달라진다. 이때는 단순한 합성으로는 원하는 결과를 얻기 어렵다.
const plusOneAsync = (x: number): Promise<number> =>
Promise.resolve(x + 1);
const doubleAsync = (x: number): Promise<number> =>
Promise.resolve(x * 2);
// 이렇게 하면?
const result = doubleAsync(plusOneAsync(3));
// 타입: Promise<Promise<number>> 😵
위 코드는 Promise<number>를 또 Promise로 감싸 이중 프라미스가 된다. 우리가 원하는 건 Promise<number> 하나인데, 일반 합성으로는 이걸 깔끔하게 처리할 수 없다. 이런 상황에서 필요한 것이 바로 클레이슬리 컴포지션이다. 클레이슬리 컴포지션은 A → M<B>, B → M<C> 형태의 함수들을 flatMap(chain) 기반으로 안전하게 합성하는 방식이다. JavaScript에서는 then을 이용해 구현할 수 있다.
const kleisliCompose = <A, B, C>(
f: (a: A) => Promise<B>,
g: (b: B) => Promise<C>
): ((a: A) => Promise<C>) => {
return (a: A) => f(a).then(g);
};
const composed = kleisliCompose(plusOneAsync, doubleAsync);
composed(3).then(console.log); // 8
이제 composed(3)은 내부적으로 plusOneAsync(3) → Promise<4> → doubleAsync(4) → Promise<8>로 이어진다. 함수는 비동기적인 컨텍스트를 가지고 있지만, 클레이슬리 컴포지션 덕분에 이를 부드럽게 연결할 수 있는 것이다.
클레이슬리 컴포지션은 Promise뿐 아니라, Maybe, Either, IO, Reader처럼 다양한 모나드 컨텍스트 안에 있는 값들을 다룰 때도 유용하게 사용된다. "값을 감싸는 함수들"을 합성하려면, 값을 꺼내서 다시 넣어주는 과정이 필요하다는 점을 기억한다면, 클레이슬리 컴포지션은 매우 직관적인 개념으로 다가올 것이다.
클레이슬리 컴포지션은 단지 기술적인 트릭이 아니라, 컨텍스트를 가진 값들을 다룰 때 필요한 핵심 사고 방식이다. 예를 들어 사용자 입력을 받아 검증하고, 그 결과를 기반으로 비즈니스 로직을 처리하는 함수들을 생각해보자. 각각의 단계가 실패할 수도 있기 때문에, 이를 Maybe나 Result 같은 구조로 감싸 처리하는 경우가 많다. 이때 각 함수는 A → Maybe<B>, B → Maybe<C>와 같은 형태를 가지며, 일반적인 함수 합성으로는 연결할 수 없다. 하지만 클레이슬리 컴포지션을 사용하면 실패 가능성을 고려한 흐름을 자연스럽게 구성할 수 있다.
// Maybe 타입의 간단한 구현
type Maybe<T> = T | null;
const parseNumber = (str: string): Maybe<number> =>
isNaN(Number(str)) ? null : Number(str);
const reciprocal = (n: number): Maybe<number> =>
n === 0 ? null : 1 / n;
const kleisliComposeMaybe = <A, B, C>(
f: (a: A) => Maybe<B>,
g: (b: B) => Maybe<C>
): ((a: A) => Maybe<C>) => {
return (a: A) => {
const b = f(a);
return b === null ? null : g(b);
};
};
const safeReciprocal = kleisliComposeMaybe(parseNumber, reciprocal);
console.log(safeReciprocal("5")); // 0.2
console.log(safeReciprocal("0")); // null
console.log(safeReciprocal("abc")); // null
이 코드에서는 사용자의 입력을 숫자로 파싱하고, 그 숫자의 역수를 계산한다. 파싱이 실패하거나, 0을 입력했을 경우 모두 null이 반환되어 흐름이 안전하게 끊어진다. 클레이슬리 컴포지션 덕분에 예외 처리를 중간중간 끼워 넣지 않아도 되고, 각 단계는 자기 책임만 다하면 된다.
이런 식의 합성은 명령형 코드에서는 흔히 if, try-catch, guard 절 등으로 표현되던 로직을 더욱 선언적으로 표현할 수 있게 해준다. 또 각 함수를 독립적으로 테스트하거나, 재사용하기도 쉬워진다. 이처럼 클레이슬리 컴포지션은 함수형 사고에서 매우 강력한 무기이며, 실제 코드에서 복잡한 로직을 단순한 흐름으로 표현할 수 있게 돕는다.
블로그의 정보
Ayden's journal
Beard Weard Ayden