함수형 프로그래밍 - map, filter, reduce

프로그래밍에서 요소들을 순회하면서 특정한 작업을 하는 경우는 매우 많습니다. 

함수형 프로그래밍에서는 이러한 작업들을 좀 더 보기 쉽도록 해주는 함수들을 사용하여 요소들을 순회합니다.

 

map, filter, reduce 함수란?

 

대표적인 예시가 `map`, `filter`, `reduce` 입니다.

`map` 함수는 요소를 순회하면서 요소마다 작업을 하는 경우에 사용됩니다.

`filter` 함수는 요소를 순회하면서 특정 조건을 만족하는 요소들만 필터링할 때 사용됩니다.

`reduce` 함수는 요소를 순회하면서 내부적으로 특정 함수를 재귀적으로 호출하려는 경우에 사용됩니다.

이제 각각의 함수들을 만들어보도록 합시다.

 

1) map 함수

먼저 다음과 같이 순회할 수 있는 배열이 있습니다.

type Product = {
  name: string;
  price: number;
};

const products: Product[] = [
  { name: "반팔티", price: 15000 },
  { name: "긴팔티", price: 20000 },
  { name: "핸드폰케이스", price: 15000 },
  { name: "후드티", price: 30000 },
  { name: "바지", price: 25000 },
];

 

일반적인 JS의 반복문을 사용하여 요소를 순회하면 다음과 같은 코드가 될 것입니다.

// map 이전의 코드
const names: string[] = [];
for (const p of products) {
  names.push(p.name);
}
console.log(names);

const prices: number[] = [];
for (const p of products) {
  prices.push(p.price);
}
console.log(prices);

 

그렇다면 요소를 순회하면서 특정 작업을 계속 수행할 수 있는 map 함수를 만들어봅시다.

이때, 저희가 고려해야할 것이 2가지가 있습니다.

  1. 함수형 프로그래밍은 함수가 인자와 반환값으로만 소통하는 것을 권장합니다.
  2. 외부 변수(전역 변수 등)에 의존적인 함수가 되어서는 안됩니다.

이 2가지를 모두 고려한 `map`함수를 만들어 봅시다.

 

const map = (f: Function, iter: Iterable<any>) => {
  const res: any[] = [];
  
  for (const item of iter) {
    res.push(f(item));
  }
  
  return res;
};

 

우리가 만든 `map`함수를 이용하면 결과가 잘 나옴을 확인할 수 있습니다.

console.log(map((p: Product) => p.name, products));
console.log(map((p: Product) => p.price, products));

 

배열에 있는 `map` 메서드를 사용하지 않고, 왜 `map` 함수를 따로 만들었을까요?

왜냐하면 이터러블 프로토콜을 따른 모든 데이터에서 요소를 순회할 수 있도록 하기 위함입니다. 

 

`document.querySelectorAll()` 함수가 반환해주는 NodeList는 Array를 상속받지 않음으로 다음처럼 `map()` 메서드를 사용하면 에러가 발생합니다.

const allElems = document.querySelectorAll("*"); // NodeList
allElems.map((el) => el.nodeName); // ❌: Error

 

하지만 위에서 만들었던 `map` 함수를 사용하면 에러가 발생하지 않습니다. 왜냐하면 `NodeList`는 이터러블 프로토콜을 따르기 때문이죠.

map((el: Element) => el.nodeName, allElems);

const iter = document.querySelectorAll("*")[Symbol.iterator]();

 

추가적으로 제네레이터 함수가 반환해주는 이터러블에도 `map` 함수를 사용할 수 있습니다.

 

function* gen() {
  yield 2;
  yield 3;
  yield 4;
}

console.log(mapV3((a: number) => a * a, gen()));

 

2) filter 함수

`filter` 함수는 특정 조건을 만족시키는 요소들만 필터링할 때 사용하는 함수입니다.

 

일단 for문을 사용하여 필터링을 진행해보도록 합시다.

// filter
const under20000: Product[] = [];
for (const p of products) {
  if (p.price < 20000) under20000.push(p);
}
console.log(under20000);

const over20000: Product[] = [];
for (const p of products) {
  if (p.price >= 20000) over20000.push(p);
}
console.log(over20000);

 

`map` 함수와 마찬가지로 외부에 의존성이 없는 함수로 만들어보도록 합시다.

const filter = (f: Function, iter: Iterable<any>) => {
  const res: Product[] = [];
  
  for (const a of iter) {
    if (f(a)) res.push(a);
  }
  
  return res;
};

console.log(filter((p: Product) => p.price < 20000, products));
console.log(filter((p: Product) => p.price >= 20000, products));

 

완성이 된 것을 볼 수 있죠. 

3) reduce 함수

특정 함수를 중첩해서 실행하여 하나의 결과로 만드는 함수이며, 내부적으로 재귀적으로 실행되는 함수입니다.

 

먼저 for 문을 이용하여 배열의 모든 수를 더하는 함수를 만들어봅시다.

// reduce
const nums = [1, 2, 3, 4, 5];

let sum = 0;
for (const n of nums) {
  sum += n;
}
console.log(sum);

 

매우 간단한 코드입니다. 이를 `reduce` 함수를 통해서 작성해봅시다.

const reduce = (f: Function, acc: any, iter: Iterable<any>) => {
  for (const item of iter) {
    acc = f(acc, item);
  }
  return acc;
};

const add = (a: number, b: number) => a + b;
console.log(reduce(add, 0, [1, 2, 3, 4, 5])); // 15

 

이렇게 코드를 작성한다면 실제 내부동작은 다음과 같이 함수 내부에서 재귀적으로 실행될 것입니다.

add(add(add(add(add(0, 1), 2), 3), 4), 5);

 

그런데 실제 `reduce` 함수는 `acc`라는 매개변수를 옵셔널하게 실행할 수 있도록 해줍니다.

즉 `reduce(add, [1, 2, 3, 4, 5])` 이렇게도 실행할 수 있으며, 이는 `reduce(add, 1, [2, 3, 4, 5])` 이렇게 실행됩니다.

 

그렇다면 `acc`를 옵셔널하게 사용할 수 있도록 리팩토링해봅시다.

const reduce = (f: Function, acc: any, iter?: any) => {
  if (!iter) {
    iter = acc[Symbol.iterator]();
    acc = iter.next().value;
  }
  for (const item of iter) {
    acc = f(acc, item);
  }
  return acc;
};
const add = (a: number, b: number) => a + b;
console.log(reduce(add, [1, 2, 3, 4, 5])); // 15

 

그럼 다음과 같이 3번째 매개변수의 존재 여부에 따라서, 함수의 내부 실행이 조금 변경이 되었습니다.

만약 3번째 매개변수가 존재하지 않는다면 2번째 매개변수에 이터러블이 들어갔다고 가정하고, 이터러블의 `Symbol.iterator` 메서드를 활용해 이터레이터를 꺼낸 후, 첫 번째 요소를 `acc`로 사용하도록 해줍니다.

 

그 외에는 내부적으로 함수를 중첩해서 사용하는 것은 동일합니다.

map, filter, reduce를 종합적으로 사용해보도록 합시다.

이제, 만들어두었던 모든 함수를 다 사용해서 코드를 작성해보도록 합시다.

type Product = {
  name: string;
  price: number;
};

const products: Product[] = [
  { name: "반팔티", price: 15000 },
  { name: "긴팔티", price: 20000 },
  { name: "핸드폰케이스", price: 15000 },
  { name: "후드티", price: 30000 },
  { name: "바지", price: 25000 },
];

const add = (a: number, b: number) => a + b;
const total = reduce(
  add,
  0,
  map(
    (p: Product) => p.price,
    filter((p: Product) => p.price < 20000, products)
  )
);

console.log(total);

 

중첩함수 형태로 실행이 되며 오른쪽에서 왼쪽으로 읽어나가면 됩니다.

이렇게 중첩함수들을 모두 사용하게 된다면, 가독성이 조금 떨어진다는 단점은 존재합니다. 이를 보완하기 위한 여러 기법들을 다음에 배워보도록 하겠습니다.