💬 들어가기 앞서
유인동 님의 함수형 자바스크립트 강의를 기반으로 학습한 내용을 정리합니다. 관련하여 더 탐구한 내용은 기술서적과 ECMAScript Specification을 참고하여 검증, 정리하고 있습니다. 본문 내 잘못된 부분이나 궁금하신 부분은 언제든 코멘트 남겨주세요.
감사합니다.
📚 코드를 값으로 다루어 표현력 높이기
console.log(
reduce(
add,
map(
(p) => p.price,
filter((p) => p.price < 20000, products)
)
)
);
이전 글의 마지막 섹션에서, 만들어둔 고차함수들을 연속적으로 활용하여 원하는 값(Products 객체 중 20000원이 넘는 제품의 가격 총합)을 평가해보았다.
이처럼 연속적인 함수들을 통해 값이 평가되는 과정을 조금 더 우아하게 표현하기 위해 사용되는 메서드들을 살펴보고자 한다.
1️⃣ go
- 정해진 갯수가 없는 인자를 입력받아, 첫 번째 인자부터 연속적으로 값을 평가한다.
- 위의 예시와 같이 reduce 함수를 활용하지만, 코드를 작성하는 입장에서 병렬적으로 처리되는 것처럼 보이기 위해 감쌌다고 볼 수 있다.
const go = (...args) => reduce((a, f) => f(a), args);
go(
0,
(a) => a + 1,
(a) => a + 10,
(a) => a + 100
)
초기값을 첫 번째 파라미터로 할당, 이후 연속적으로 처리할 함수들을 담아준다.
2️⃣ pipe
- 여러 개의 함수를 나열, 반환하는 고차함수이다. 내부적으로 go 메서드를 활용한다.
- 초기 파라미터 값을 분리한다는 점에서 go 메서드와 차이점이 있다. 고차함수를 이용해 함수 배열을 한 번 감싸고, 이후 초기 파라미터를 전달하는 방식이다.
const pipe =
(...fs) =>
(a) =>
go(a, ...fs);
Rest 파라미터로 여러 개의 함수를 입력받아 나열한 후, 초기 파라미터 a를 함께 전달해 go 메서드를 실행한다.
const f = pipe(
(a) => a + 1,
(a) => a + 10,
(a) => a + 100
); // (a) => go(a, ...fs)
console.log(f(0)); // 111
변수 f에 여러 개의 함수가 나열되어 있는 pipe 함수를 할당해주었다. 이제 변수 f는 인자 a를 입력받아 이전에 입력받아둔 함수들과 함께 go 메서드를 실행하는 함수가 되었다. 연속적으로 처리되는 과정이 잘 떠오르지 않아, 중간 중간 콘솔을 출력해보며 진행하였다.
초기값 또한 여러 개를 입력받고, 이들의 총합을 초기값으로 평가하도록 응용할수도 있다.
const pipe =
(f, ...fs) =>
(...as) =>
go(f(...as), ...fs);
3️⃣ curry
- 인자를 여러 개 입력받는 함수를 분리하여, 하나씩 연속적으로 입력받는 함수로 변환해주는 역할을 한다. (이를 Curring이라고 부른다.)
- f(a, b, c)처럼 단일 호출로 처리되는 함수를 f(a)(b)(c)와 같이 각각의 프로세스로 분리하여 처리한다.
- (함수형 프로그래밍에서 중요한) 원하는 시점의 평가를 위한 구현체이다.
const curry =
(f) =>
(a, ..._) =>
_.length ? f(a, ..._) : (..._) => f(a, ..._);
curry 메서드 또한 고차함수이다. 함수를 입력받고 실행되었을 때, 인자가 두 개 이상이라면 받아둔 함수를 즉시 실행하고 두 개보다 작은 경우 인자를 전달받을때까지 기다리다 실행되도록 구성되어 있다. 이를 지연성이라고 하며, 다음 글부터 자세히 다뤄 볼 예정이다. 내부 함수의 파라미터(a)를 유연하게 입력받아 재사용성을 고려한 추상화를 위해 사용된다.
const curringSum = curry((a, b) => a + b);
console.log(
curringSum, // (a, ..._ ) => _.length ? f(a, ..._) : (..._) => f(a, ..._);
curringSum(1), // (..._) => f(a, ..._); 나머지 인자를 더 전달했을 때, 받아두었던 함수에게 인자들을 전달
curringSum(1)(2) // 최종 평가된 값을 얻을 수 있음
);
합계을 구하는 함수에 Curring을 적용하고, 인자를 전달하는 갯수에 따라 어떤 값을 갖고 있는지 순차적으로 출력해 보았다. 인자를 늘릴 때마다 고차함수의 Depth가 벗겨지고, 필요한 인자를 전부 전달할 경우 최종적으로 값이 평가된다.
4️⃣ 적용하기
📚 go
console.log(
go(
products,
(products) => filter((p) => p.price < 20000, products),
(products) => map((p) => p.price, products),
(prices) => reduce(add, prices)
)
);
앞에서 봤던 여러 메서드들의 연쇄적인 실행과정을 go 메서드를 이용해 코드를 개선해보았다. 개선 전에는 연속적인 함수들을 역순으로 읽어가며 평가되는 값들을 예측하였는데, 이 순서와 같이 함수들을 나열해주면 된다. 중첩함수의 Depth를 벗겨내 병렬적으로 배치함으로써 좀 더 읽기 쉬운 코드가 되지 않았나 생각된다.
📚 curry
만들어뒀던 고차함수들에도 Curring을 적용해 코드를 한 번 더 개선해보자. curry함수를 wrapping해주기만 하면 된다.
const map = curry((f, iter) => {
let res = [];
for (const a of iter) {
res.push(f(a));
}
return res;
});
const reduce = curry((f, acc, iter) => {
if (!iter) {
iter = acc[Symbol.iterator]();
acc = iter.next().value;
}
for (const a of iter) {
acc = f(acc, a);
}
return acc;
});
const filter = curry((f, iter) => {
let res = [];
for (const a of iter) {
if (f(a)) res.push(a);
}
return res;
});
console.log(
go(
products,
(products) => filter((p) => p.price < 20000)(products),
(products) => map((p) => p.price)(products),
(prices) => reduce(add)(prices)
)
);
go 메서드가 내부적으로 함수 배열을 전달하기 때문에, 중간중간 명시한 평가 값의 전달을 추가적으로 생략해줄 수 있다. 최종적으로 go, curry 메서드를 이용해 개선된 코드는 다음과 같다.
// Before
console.log(
reduce(
add,
map(
(p) => p.price,
filter((p) => p.price < 20000, products)
)
)
);
// After
console.log(
go(
products,
filter((p) => p.price < 20000),
map((p) => p.price),
reduce(add)
)
);
확실히 읽기 편해진 것 같다..!
5️⃣ 중복 함수 추상화하기
go(
products,
filter((p) => p.price < 20000),
map((p) => p.price),
reduce(add)
);
go(
products,
filter((p) => p.price >= 20000),
map((p) => p.price),
reduce(add)
);
각각 products 객체 중 가격이 20000원 초과, 이하인 제품을 필터링하기위해 개선된 로직을 적용한 코드이다. 함수 나열 간 중복된 부분들이 보인다. 이 때 pipe를 사용해 중복되는 함수들을 묶어주면 된다.
const total_price = pipe(
map((p) => p.price),
reduce(add)
);
go(
products,
filter((p) => p.price < 20000),
total_price
);
객체의 프로퍼티에 좀 더 유연한 메서드로 추상화해볼 수도 있다.
const sum = (f, iter) => go(iter, map(f), reduce(add));
const total_quantity = (products) => sum((p) => p.quantity, products);
해당 함수 또한 Curring을 적용시켜 실행 간 전달되는 배열들을 생략하여 표현해줄 수 있다.
# Before
const total_price = pipe(
map((p) => p.price),
reduce(add)
);
# After
const total_price = sum((p) => p.price);
6️⃣ 생각하기
평소 리액트를 사용하며 중점적으로 고민하는 부분은 재사용성과 가독성, 추상화가 아닐지 싶다. 함수형 프로그래밍을 공부하면서 이런 부분들이 굉장히 결이 비슷하다는 느낌을 받았다. 작업과 협업의 효율을 위해 꼭 필요한 요소들이라는 점에서, 개발이라는 분야를 관통하는 것일지도 모르겠다.
실무에서는 어떨까? 유용하게 쓰일 수 있을까? 아직 배울 내용이 많이 남았지만, 당장 이번 예제만 보더라도 자바스크립트에서 기본적으로 제공하는 map, reduce, filter 등의 메서드들을 직접 선언하여 사용하고 있다. 이터러블한 요소들에 대해 범용적인 순회가 가능하도록 한다는 취지는 정말 좋은 것 같은데, 사전에 설정해줘야 할 것들도 많고.. 무엇보다 동료 개발자들과의 동기화 관점에서 합을 맞추기 어렵지 않을까라는 생각이 들었다. 그래도 코드에 대한 직관성과 표현력을 높히고, 유연한 자료 구조의 입력을 고민하는 과정이 되게 매력있다.