“모든 코드는 데이터의 변환이다” - 함수형 프로그래밍을 배우며 얻은 중요한 관점
최근 멀티패러다임 프로그래밍 관련 서적을 읽으며 TypeScript로 함수형 프로그래밍의 핵심 개념들을 직접 구현해보았습니다. 단순히 라이브러리를 사용하는 것이 아니라, 밑바닥부터 만들어보며 얻은 통찰을 기록해둡니다.
제네레이터와 지연 평가
제네레이터는 “값을 하나씩 반환하는 함수” 이상의 의미를 갖습니다. map
, filter
와 같은 연산을 직접 구현해보면서, 제네레이터가 지닌 메모리 효율성과 확장성에 주목하게 되었습니다.
function* map<A, B>(f: (value: A) => B, iterable: Iterable<A>): IterableIterator<B> {
for (const value of iterable) {
yield f(value);
}
}
메모리 효율성
const numbers = range(100_000_000);
// 기존 방식: 중간 결과들이 모두 메모리에 적재됨
const doubled1 = numbers.map(x => x * 2);
const filtered1 = doubled1.filter(x => x > 1000);
// 제네레이터 방식: 필요할 때만 값을 생성
const doubled2 = map(x => x * 2, numbers);
const filtered2 = filter(x => x > 1000, doubled2);
무한 시퀀스 처리
function* naturals() {
let i = 0;
while (true) {
yield i++;
}
}
const firstFiveEvens = take(5, filter(x => x % 2 === 0, naturals()));
// [0, 2, 4, 6, 8]
함수 오버로딩과 reduce
TypeScript의 함수 오버로딩을 활용하여 reduce
함수에 두 가지 시그니처를 제공했습니다.
export function reduce<A, Acc>(f: (acc: Acc, a: A) => Acc, acc: Acc, iterable: Iterable<A>): Acc;
export function reduce<A>(f: (a: A, b: A) => A, iterable: Iterable<A>): A;
export function reduce<A, Acc>(
f: ((acc: Acc, a: A) => Acc) | ((a: A, b: A) => A),
accOrIterable: Acc | Iterable<A>,
iterable?: Iterable<A>
): Acc | A {
if (iterable === undefined) {
// 초기값 없는 경우
} else {
// 초기값 있는 경우
}
}
오버로딩을 통해 사용자는 두 방식 중 자연스럽게 선택할 수 있고, 타입 안정성도 유지됩니다.
함수 합성과 체이닝의 조화
forEach(
console.log,
map(
x => x * 10,
filter(x => x % 2 === 0, [1, 2, 3, 4, 5])
)
);
이처럼 오른쪽에서 왼쪽으로 흐르는 함수 합성 방식은 강력하지만 가독성이 떨어질 수 있습니다. 이를 보완하기 위해 FxIterable
클래스를 도입했습니다.
fx([1, 2, 3, 4, 5])
.filter(x => x % 2 === 0)
.map(x => x * 10)
.forEach(console.log);
내부에서는 순수 함수를 사용하면서도, 메서드 체이닝의 가독성을 유지합니다.
chain()
메서드의 활용
chain<B>(f: (iterable: this) => Iterable<B>): FxIterable<B> {
return new FxIterable(f(this));
}
const result = fx([5, 2, 3, 1, 4, 5, 3])
.filter(x => x % 2 === 1)
.map(x => x * 10)
.chain(iter => new Set(iter))
.reduce((acc, x) => acc + x, 0); // 90
chain
은 다른 자료구조로 일시적으로 전환한 후 다시 체이닝 흐름에 합류하게 해줍니다. Functor나 Monad의 개념과 연결됩니다.
Generator vs Iterator
제네레이터 방식:
function* filter(f: (value: any) => boolean, iterable: Iterable<any>) {
for (const value of iterable) {
if (f(value)) yield value;
}
}
수동 이터레이터 방식:
function filterIterator(f: (value: any) => boolean, iterable: Iterable<any>) {
const iterator = iterable[Symbol.iterator]();
return {
next(): IteratorResult<any> {
const { done, value } = iterator.next();
if (done) return { done, value };
if (f(value)) return { done, value };
return this.next();
},
[Symbol.iterator]() { return this; }
};
}
제네레이터는 간결하지만, 수동 이터레이터는 세밀한 제어와 에러 핸들링에 유리합니다.
타입 안정성과 함수형 프로그래밍
function* map<A, B>(f: (value: A) => B, iterable: Iterable<A>): IterableIterator<B>
TypeScript의 제네릭은 함수형 프로그래밍에서 중요한 역할을 합니다. 중간 연산 과정에서 타입이 바뀔 때마다 컴파일 타임에 확인되므로 안정성이 높아집니다.
파이프 함수 구현
function pipe<T>(...fns: Array<(arg: any) => any>) {
return (input: T) => fns.reduce((acc, fn) => fn(acc), input);
}
const process = pipe(
arr => filter((x: number) => x % 2 === 0, arr),
iter => map((x: number) => x * 10, iter),
iter => [...iter]
);
const result = process([1, 2, 3, 4, 5]); // [20, 40]
데이터가 왼쪽에서 오른쪽으로 흐르는 구조는 실제 흐름을 따라가기 쉬워 유지보수에도 유리합니다.