함수형 프로그래밍 - 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);

 

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

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