💬 들어가기 앞서
유인동 님의 함수형 자바스크립트 강의를 기반으로 학습한 내용을 정리합니다. 관련하여 더 탐구한 내용이나, 지엽적인 부분은 모던 자바스크립트 딥다이브와 You Don't Know JS Yet, ECMAScript Specification을 참고하여 검증, 정리하고 있습니다. 본문 내 잘못된 부분이나 궁금하신 부분은 언제든 코멘트 남겨주세요. 감사합니다.
📚 제너레이터와 고차함수
1️⃣ 제너레이터
제너레이터란 이터레이터이자 이터러블을 생성하는 함수를 말한다. 일반 함수명 앞에 *를 붙혀 선언할 수 있다.
function* gen() {
yield 1;
yield 2;
yield 3;
}
let iter = gen();
console.log(iter.next());
console.log(iter.next());
console.log(iter.next());
명령어 yield를 통해 할당된 값을 순회하며, 함수이기 때문에 값을 반환할 수는 있지만 for..of 문 등의 순회를 통한 확인은 불가하다.
제너레이터는 어떠한 값이든 쉽게, 직관적으로 순회 가능한 상태(이터러블)로 만든다는 점에서 의미가 있다. 또한 Well-formed하기 때문에, 자기 자신을 이터레이터로 반환하며 변경된 상태를 유지한다는 특징이 있다.
아래 예시 코드를 살펴보자.
function* infinity(i = 0) {
while (true) yield i++;
}
function* limit(l, iter) {
for (const a of iter) {
yield a;
if (a == l) return;
}
}
function* odds(l) {
for (const a of limit(l, infinity(1))) {
if (a % 2) yield a;
}
}
for (const a of odds(40)) console.log(a); // 1, 3, 5, 7, ... 37, 39
limit과 infinity 함수를 이용해 수의 범위를 형성하고, 이 중 홀수 값들을 반환하는 함수 odds을 제너레이터로 구현해보았다. 평소의 코드 작성 방식으로 해당 로직을 구현하는 것과 어떤 차이점이 있을까?
return 대신 yield를 사용하고, 모든 값이 이터레이터를 통해 순회하고 있다는 점 정도로 추릴 수 있을 것 같다. 그러나 이런 방법이 왜 함수형 프로그래밍이라 불리우는지, 왜 문장형으로 작성되었다고 하는지, 어떤 영향을 미치는지 아직 그리 와닿진 않는다.. 🥲
2️⃣ 고차함수, 유연하게 생각하기
map()
리액트를 경험해봤다면 친숙한 메서드일 것이다. JSX 내부에서 객체를 담고 있는 배열을 순회할 때 주로 사용한다.
const products = [
{ name: '반팔티', price: 15000 },
{ name: '긴팔티', price: 20000 },
{ name: '핸드폰케이스', price: 11000 },
{ name: '후드티', price: 13000 },
{ name: '바지', price: 25000 },
];
const map = (product) => {
let res = [];
for (const p of product) {
res.push(p.name);
}
return res;
};
console.log(map(products)) // '반팔티', '긴팔티', ...
map 함수의 내부를 구현해보았다. 옷의 정보가 담긴 객체로 이루어진 products 배열을 순회하며, 객체의 name 키에 해당하는 값을 출력하고 있다. 함수형 프로그래밍 관점에서의 map 함수는 어떨까?
const map = (f, iter) => { // 순회를 위해 어떤 값이든 인자로 받을 수 있다는 의미의 iter
let res = [];
for (const a of iter) {
res.push(f(a)); // p 내부의 어떤 프로퍼티를 순회할 것인지의 결정을 함수 f에 위임한다.
}
return res;
};
console.log(p => p.name, products) // same result
두 가지 변경점을 확인할 수 있다. 파라미터 f가 추가되었고, products가 iter로 변경되었다. 예제에선 변수명만 바뀌었지만, 어떤 값이든 순회할 수 있게 하기 위한 이터레이터를 파라미터로 받는다는 의미를 갖는다. OOP의 다형성을 떠올릴 수 있었다.
자바스크립트에서 제공하는 기존의 map 함수는 배열의 프로토타입 메서드이다. 따라서 map을 통해 순회할 수 있는 요소는 굉장히 제한적이다. 이전 시간에 확인한 document.querySelectorAll('*')을 통해 불러온 값들도 물론 순회할 수 없다. (NodeList)
또한 수집하고자 하는 값을 보조함수 f를 통해 지정하여 함수를 값으로 다루고, 원하는 시점에 내부에서 인자를 적용하는 고차함수의 형태를 띄고 있다는 것도 확인할 수 있었다.
filter()와 reduce()
filter와 reduce 함수도 각각 구현해보았다. 적용 포인트는 map()에서 했던 방식과 동일하며, 이전에 학습한 제너레이터 또한 순회할 수 있는 것을 확인할 수 있었다.
// 일반적인 Filter 메서드의 동작 방식
let under20000 = [];
for (const p of products) {
if (p.price <= 20000) under20000.push(p);
}
// 함수형 관점에서 바라본 Filter 메서드
const filter = (f, iter) => {
let res = [];
for (const a of iter) {
if (f(a)) res.push(a);
}
return res;
};
// 호출 예시
filter((p) => p.price < 20000, products);
filter(
(n) => n % 2,
(function* () {
yield 1;
yield 2;
yield 3;
yield 4;
yield 5;
})()
);
// 일반적인 Reduce 메서드의 동작 방식
const add = (a, b) => a + b;
console.log(reduce(add, 0, [1, 2, 3, 4, 5])); // 15
console.log(add(add(add(add(add(0, 1), 2), 3), 4), 5)); // 15
// 함수형 관점에서 바라본 Reduce 메서드
const reduce = (f, acc, iter) => {
if (!iter) {
// 초기값의 전달이 생략되었을 때 두 번째 인자를 바탕으로 추론하는 로직
iter = acc[Symbol.iterator]();
acc = iter.next().value;
}
for (const a of iter) {
acc = f(acc, a);
}
return acc;
};
reduce 함수의 파라미터 예외처리가 눈에 띄었다. 의미적으로는 두 번째 인자인 acc를 생략했지만, 함수의 인자는 순서를 중요시하기에 acc가 iter의 역할을 대신하게 되었다. 로직을 정상적으로 처리하기 위해서는 acc로 전달받은 이터레이터를 iter 변수에 할당하고, 자신은 이터레이터의 첫 번째 값이 되어야 한다.
3️⃣ 함수형으로 사고하기
console.log(
reduce(
add,
map(
(p) => p.price,
filter((p) => p.price < 20000, products)
)
)
);
코딩테스트를 준비하다보면, 연쇄적인 메서드로 구성된 한 줄짜리 풀이를 종종 발견할 때가 있다. 함수형 프로그래밍을 지향한 작성 방식이라는 것을 막연하게는 알고 있었지만, 이번 계기로 함수형 프로그래밍을 제대로 공부해보며 조금씩이나마 이해해가고 있는 중이다.
제일 안쪽에 있는 박스부터 왼쪽 방향으로 읽어나가보자. products 중 가격이 20,000원 아래인 것들을 filter로 뽑아내고, 이들 중 가격 속성만 추출해 나열한다. 나열한 값들을 전부 더한 후, 로그로 출력한다. 쉬운 예제이긴 하지만, 분명 단계 별로 어떻게 처리되는지 예측 가능하다는 것을 체감할 수 있다.
반대로 코드를 작성할 땐 어떨까? 단계별로 (메서드 별로) 평가될 결과값을 예측하며 작성해나간다. 중간중간 mockdata를 넣어 돌려보며 유효성을 검증하고, 다음으로 평가될 코드를 추론(기대)하며 로직을 작성한다. 최종적으로 기대하는 결과값이 평가될 때까지 해당 과정을 반복한다.
4️⃣ Deeper
- 제너레이터는 실제 코드레벨에서 어떻게 사용될까? 순회하지 못했던 값들에 대해 순회가 가능해지는 사례가 있을까? 이는 어떤 의의를 갖는가?
- 리액트의 JSX 내부에서 map을 통해 순회가 이루어지는 이유