최근 멀티패러다임 프로그래밍 관련 내용을 보면서 TypeScript로 함수형 프로그래밍의 기본 도구들을 직접 구현해봤다. 라이브러리로만 쓰던 map, filter, reduce를 직접 만들어보니, 함수형 프로그래밍이 단순히 문법 취향의 문제가 아니라 데이터를 어떻게 흘려보낼지에 대한 설계 방식이라는 생각이 들었다.

제네레이터와 지연 평가

가장 먼저 눈에 들어온 것은 제네레이터였다. 제네레이터는 값을 한 번에 모두 만들어두지 않고, 필요한 순간에 하나씩 꺼내준다. map을 제네레이터로 구현하면 이 특징이 바로 드러난다.

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)

이 차이는 무한 시퀀스를 다룰 때 더 분명해진다. 아래처럼 끝이 없는 자연수 목록을 만들어도, take가 필요한 만큼만 소비하면 프로그램은 정상적으로 끝난다.

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와 타입

reduce를 직접 구현할 때는 TypeScript의 함수 오버로딩이 유용했다. 초기값이 있는 경우와 없는 경우를 하나의 함수로 다루되, 호출하는 쪽에서는 자연스럽게 타입이 잡히도록 만들 수 있다.

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을 넣으면 중간에 다른 자료구조로 바꿨다가 다시 이터러블 흐름으로 돌아올 수 있다.

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

이런 구조의 장점은 데이터 흐름이 한 방향으로 보인다는 점이다. 어디서 필터링되고, 어디서 변환되고, 어디서 최종 값으로 접히는지가 눈에 들어온다.

Generator와 Iterator

제네레이터는 같은 일을 훨씬 짧게 표현하게 해준다.

function* filter(f: (value: any) => boolean, iterable: Iterable<any>) {
  for (const value of iterable) {
    if (f(value)) yield value
  }
}

반대로 이터레이터를 직접 다루면 코드가 길어지지만, next()가 언제 호출되고 어떤 값을 반환하는지 더 세밀하게 제어할 수 있다.

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
    },
  }
}

처음에는 제네레이터가 단지 간단한 문법처럼 보였는데, 직접 구현해보니 이터러블 프로토콜 위에서 값을 지연시키고 조합하는 도구에 가깝다는 생각이 들었다.

pipe로 흐름 만들기

마지막으로 pipe를 구현하면 데이터가 왼쪽에서 오른쪽으로 흐르는 형태를 만들 수 있다.

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]

이번 구현을 통해 가장 크게 느낀 점은 함수형 프로그래밍이 “짧게 쓰는 방법”만은 아니라는 것이다. 핵심은 데이터를 한 번에 처리하지 않고 필요한 만큼 흘려보내는 것, 그리고 작은 변환 단위를 조합해서 전체 흐름을 만드는 것이다.