joeun.dev

moon indicating dark mode
sun indicating light mode

함수형 자바스크립트 - 기본 함수 구현하고 사용하기

October 17, 2017

기본 함수 구현하고 사용하기

함수형 프로그래밍에서 사용되는 기본 함수인 each, map, filter, reduce 함수와 함수를 실행하는 함수인 go 함수를 사용해보고 직접 구현해보자. (모든 예제는 웹 브라우저의 ‘검사’ 도구를 열어서 테스트할 수 있다.)

함수형 실전 코드 예제 소개

0. 예제 데이터

이하 예제에서 사용될 데이터이다. products는 객체 형태의 상품 정보를 담고 있는 배열이다. 장바구니에 담긴 데이터라고 가정하고 있다.

var products = [
{
is_selected: true, // <--- 장바구니에서 체크 박스 선택
name: "반팔티",
price: 10000, // <--- 기본 가격
sizes: [ // <---- 장바구니에 담은 동일 상품의 사이즈 별 수량과 가격
{ name: "L", quantity: 2, price: 0 },
{ name: "XL", quantity: 3, price: 0 },
{ name: "2XL", quantity: 2, price: 2000 }, // <-- 옵션의 추가 가격
]
},
{
is_selected: true,
name: "후드티",
price: 21000,
sizes: [
{ name: "L", quantity: 3, price: -1000 },
{ name: "2XL", quantity: 1, price: 2000 },
]
},
{
is_selected: false,
name: "맨투맨",
price: 16000,
sizes: [
{ name: "L", quantity: 4, price: 0 }
]
}
];

1. 모든 제품의 전체 수량 구하기

제품의 전체 수량을 구하는 코드는 아래와 같이 작성할 수 있다.

var sum_total_quantity = function(products) { // <-- 제품의 전체 수량을 구하는 함수
return reduce(products, function(tq, product) {
return reduce(product.sizes, function(tq2, size) {
return tq2 + size.quantity;
}, tq);
}, 0)
};
var total_quantity = sum_total_quantity(products); // <-- 예제 데이터를 넣으면 전체 수량을 구할 수 있다.
console.log(total_quantity); // 15

코드를 살펴보자. 우선 코드에 등장하는 reduce 함수는 돌림직한 데이터(Array, ArrayLike, Object)를 ‘줄여나가는(reduce)’ 함수다. underscore와 같은 라이브러리에선 fold라는 이름으로도 사용된다. 이와 같은 표현을 사용하면 reduce 함수는 데이터를 ‘접는(fold)’ 함수인 셈이다.

다시 위의 코드를 보면 가장 밖에 있는 함수가 products를 받을 준비를 하고 있다. 이 productsreduce에게 전달된다. reduce 함수는 세개의 인사를 받는데, 접을 데이터, 어떻게 접을지 정의하는 함수, 접을 때 사용할 초기값을 받는다. 여기서 products가 접을 데이터가 되고 두번째 함수가 어떻게 접을지를 정의하고 있다. 초기값으로는 0을 넘겼다. 그리고 넘겨진 값들로 인해 만들어진 결과를 리턴한다.

이때 어떻게 접을지 정의한 두번째 인자인 함수를 보면 다시 reduce를 사용하고 있는 것을 알 수 있다. 같은 함수가 반복되니 헷갈린다. 하지만 데이터를 보면 그 이유가 드러난다. 우리가 원하는 값은 제품의 수량을 의미하는 quantity다. 그런데 값은 sizes라는 배열 안에 있기에 다시 한번 reduce를 호출한 것이다. 다시 말해 두번째로 호출되는 reducesizes 배열 안의 값을 접어나가는 함수, 먼저 호출된 reduce는 두번째에 의해 접힌 결과를 한번 더 접는 함수인 것이다.

두번째로 호출되는 reduce를 다시 살펴보면 앞서 말한 것처럼 sizes 배열을 접을 데이터로 전달한다. 그리고 초기값에 tq를 전달하는데 이때 tq는 첫번째 reduce가 전달한 0에 해당하는 값이다. 이 값은 products 배열을 돌면서 값이 계속 누적된다. 값이 쌓여가고 접혀가는 것이다. (이해가 되지 않는다면 우선 넘어간다.) 그리고 어떻게 접을지 정의한 함수에서 진짜 수량을 더한다. tq2 + size.quantity 이를 통해 값을 더해나가면 우리가 원하는 총 수량을 구할 수 있다.

위의 과정을 통해 만들어진 함수가 실제로 동작하는 것은 호출이 일어났을 때다. var total_quantity = sum_total_quantity(products);처럼 코드를 실행하고 그 결과를 변수에 저장해서 다시 로그 함수에 넘길 수 있지만 선언된 sum_total_quantity 함수를 go 함수와 함께 사용하면 아래와 같은 코드가 된다. 별도의 변수 선언 없이 원하는 일을 할 수 있다.

go(products,
sum_total_quantity,
console.log); // 15

2. 선택된 제품의 전체 수량 구하기

선택된 제품들의 수량만을 구하는 코드는 아래와 같다.

var selected_products = filter(products, product => product.is_selected); // <-- 선택된 제품만 골라낸 데이터
var selected_products_total_quantity = sum_total_quantity(selected_products);
console.log(selected_product_total_quantity); // 11

비교적 간단한 코드다. _filter 함수를 이용해서 우리가 필요한 데이터를 골라내고 있다. 이 함수는 말 그대로 원하는 데이터만을 ‘거르는’ 함수다. 두번째 인자로 전달된 함수는 거를 기준을 제시한다. 리턴값이 참이면 그 값은 _filter 필요한 데이터라는 의미가 된다. 위의 코드에서는 선택된 데이터만을 골라내고 있다.

마찬가지로 go 함수를 사용하면 아래와 같이 표현할 수 있다.

go(products,
products => _filter(products, product => product.is_selected),
sum_total_quantity,
console.log); // 11

기본 함수 직접 구현하기

1. each

each 함수는 for와 같은 반복문을 대체하는 함수다. 오늘 만들 다른 함수들과 마찬가지로 돌림직한 데이터를 돌면서 어떤 동작을 한다. 다른 함수들이 부수효과를 지양하는 것에 반해 이 함수는 부수효과를 이용한다. 인자로 돌림직한 데이터돌면서 무엇을 할지 정의한 함수를 받는다.

사용의 예는 아래와 같다.

each([1,2,3,4,5], num => console.log(num)); // <-- 1부터 5까지 순서대로 로그가 남는다.

실제 함수는 아래와 같이 구현되어 있다.

function each(list, iter) {
if (Array.isArray(list)) { // <-- 배열을 돌리기 위한 부분
for (var i = 0, len = list.length; i < len; i++)
iter(list[i], i, list);
} else { // <-- 그 외의 객체를 돌리기 위한 부분
var keys = Object.keys(list);
for (var i = 0, len = keys.length; i < len; i++)
iter(list[keys[i]], keys[i], list);
}
}

코드를 살펴보면 for 구문으로 데이터를 순회하며 iter라고 정의한 보조 함수를 한번씩 실행시켜주고 있다. 배열과 객체를 구분해서 값을 찾고 보조 함수에 전달하는데 보조 함수에 전달하는 값은 순서대로 찾은 값, 그 값의 인덱스(키), 원본 배열(객체)이다. 이렇게 전달된 인자들을 이용해 each 함수를 보다 유연하게 사용할 수 있게 된다.

2. map

map 함수는 데이터를 돌면서 값을 매핑하고 새로운 배열을 리턴하는 함수다. 앞서 언급한 것처럼 부수효과를 지양한다. each와 마찬가지로 인자로 돌림직한 데이터돌면서 무엇을 할지 정의한 함수를 받는다. 차이점은 받은 함수가 새로운 배열의 값을 정의한다는 점이다.

사용의 예는 아래와 같다.

var result = map([1,2,3,4,5], num => num + 10);
console.log(result); // [11, 12, 13, 14, 15]

실제 함수는 아래와 같이 구현되어 있다.

function map(list, iter) {
var res = [];
if (Array.isArray(list)) {
for (var i = 0, len = list.length; i < len; i++)
res[i] = iter(list[i], i, list);
} else {
var keys = Object.keys(list);
for (var i = 0, len = keys.length; i < len; i++)
res[i] = iter(list[keys[i]], keys[i], list);
}
return res;
}

each 함수와 다른 점은 res라는 결과값을 내부에서 정의하고 이를 반환한다는 것이다. 결과적으로 map은 보조함수에 의해 정의된 값을 담은 새로운 배열을 리턴한다.

3. filter

위에서 이미 살펴본 filter 함수는 데이터를 거르는 함수다. 사실 each를 제외한 모든 함수는 리턴값이 중요하다. 그 값을 전달함으로 다른 함수와 소통한다.

사용의 예는 이미 위에서 살펴보았으니 구현체만 살펴보자.

function filter(list, predi) {
var res = [];
if (Array.isArray(list)) {
for (var i = 0, len = list.length; i < len; i++)
if (predi(list[i], i, list))
res.push(list[i]);
} else {
var keys = Object.keys(list);
for (var i = 0, len = keys.length; i < len; i++)
if (predi(list[keys[i]], keys[i], list))
res.push(list[keys[i]]);
}
return res;
}

map처럼 결과값 res를 갖지만 보조함수(predi)가 리턴한 값이 아닌 보조함수의 실행 결과가 참인 경우에만 값을 결과값에 담는다.

4. reduce

역시 이미 앞서 만나본 함수다. reduce는 데이터를 접는 함수다. 다른 함수들과 달리 세개의 인자를 값으로 받는다.

미리 봤던 예제보다 단순한 예제를 살펴보고 구현으로 넘어가자.

var result = reduce([1,2,3,4,5,6,7,8,9,10], function(memo, num) {
return memo + num;
}, 0);
console.log(result); // 55

세개의 인자를 전달 받았다. 첫번째 인자는 1부터 10까지를 담고 있는 배열이다. 이를 어떻게 처리할지 알고 있는 함수와 초기 값을 나머지 인자로 받았다. 예제에서 보조함수는 받은 값을 더하는 함수다. 결과적으로 reduce는 1부터 10까지의 총합을 반환한다.

이를 구현한 코드는 아래와 같다.

function reduce(list, iter, memo) {
var i = 0;
if (Array.isArray(list)) {
var res = (memo !== undefined ? memo : list[i++]); // <-- 남다른 결과값 선언부
for (var len = list.length; i < len; i++)
res = iter(res, list[i], i, list);
} else {
var keys = Object.keys(list), res = (memo !== undefined ? memo : list[keys[i++]]);
for (var len = keys.length; i < len; i++)
res = iter(res, list[keys[i]], keys[i], list);
}
return res;
}

결과값 res를 선언하는 부분이 조금 남다르다. 앞선 map, filter가 배열을 리턴했던 것과 달리 reduce는 결과값의 데이터형이 호출 당시에 결정된다. 코드에서 memo에 해당하는 변수가 바로 초기값이다. 결과값은 초기값의 데이터형에 의해 정해지기 때문에 res를 선언하는 과정에서 memo가 데이터를 가지고 있는지를 검사한다. undefined인 경우 호출 당시 초기값이 전달되지 않은 것으로 판단하고 list의 첫번째 값을 초기값으로 사용한다.

이후에 다른 함수들과 마찬가지로 반복문을 수행한다. 이때 보조함수 iter가 리턴하는 값을 결과값에 덮어씌운다. list의 마지막 값을 가지고 보조함수가 수행한 결과가 최종 결과값이 된다.

5. go

go 함수는 파이프라인 코딩이 가능하도록 돕는 함수다. 클로저(Clojure)에서의 ->> 연산자나 엘릭서(Elixir)에서의 |> 연산자와 같은 역할을 한다. 첫번째로 받은 인자(데이터)를 두번째로 받은 인자(함수)에 넘긴다. 두번째 함수가 리턴하는 값을 다시 세번째 인자(함수)로 넘긴다. 예시를 한번 더 살펴보자.

go([1,2,3,4,5,6,7,8,9,10],
arr => filter(arr, num => num % 2), // <-- 홀수 값만을 갖는 배열을 리턴한다.
arr => reduce(arr, (total, num) => total + num), // [1, 3, 5, 7, 9]을 더하여 리턴한다.
console.log); // 25

코드는 아래와 같이 구현되어 있다.

var slice = Array.prototype.slice;
function go(seed) {
var fns = slice.call(arguments, 1);
return reduce(fns, (se, fn) => fn(se), seed);
}

짧은 코드지만 재미난 구석이 많은 코드다. 살펴보자. 우선 slice를 사용해서 arguments 객체를 배열로 만들어준다. 첫번째 인자인 seed를 제외한 나머지들을 모두 fns라는 변수에 선언하는데 첫번째 인자를 제외한 모든 인자가 함수일 것이기 때문이다. 이제 이 함수들을 순서대로 실행시켜나가면 된다. 그런데 뜬금없이 reduce가 등장한다. 잠깐 생각해보자.

go는 첫번째 인자로 들어온 데이터를 여러 함수들에 통과시키며 리턴값을 만들어가는 함수다. 데이터를 변형해서 무엇으로 만들어가는 함수는 이미 하나 있었다. 데이터를 접는 함수라고 소개했던 reduce가 그런 역할을 한다. 초기값의 데이터형을 기준으로 돌림직한 데이터를 돌면서 데이터를 접어나간다. 우리는 이미 돌림직한 데이터인 fns(배열)와 초기값인 seed를 가지고 있다. 이제 필요한건 오직 어떻게 접어나갈지 정의하는 함수뿐이다. 그렇다. 이게 바로 reduce가 사용된 이유다. 이렇게 이미 정의된 함수 덕에 보다 쉽게 새로운 함수를 만들 수 있다. 이제 어떻게 접어나갈지 살펴보자.

우리는 fns가 갖고 있는 함수들의 실행 결과가 필요하다. 이 작업을 단지 (se, fn) => fn(se)라고 정의함으로 해결할 수 있다. 이미 정의된 어떤 함수 덕분이다. 여기서 se는 처음에는 seed와 같은 값이었다가 이후에는 fn의 리턴값이 될 것이다. fnreduce의 내부에서 반복문으로 배열(fns)을 돌며 계속해서 다음 값을 넘겨서 받게 되는 함수다.

문제 풀어보기

끝으로 앞서 소개된 ‘함수형 실전 코드 예제’와 유사한 예제를 풀어보자. 데이터는 products라는 변수에 선언되어 있다. (‘검사’ 창에서 풀어볼 수 있다.)

  1. 모든 제품의 총 가격
  2. 선택된 제품의 총 가격

전체 스터디 일정

예제 코드