본문 바로가기
  • soldonii's devlog
Javascript 공부/Advanced Javascript(-)

자바스크립트의 함수형 프로그래밍 2 : 고차함수

by soldonii 2019. 10. 22.

*Udemy의 "Advanced Javascript Concepts"강의에서 학습한 내용을 정리한 포스팅입니다.

*자바스크립트를 배우는 단계라 오류가 있을 수 있습니다. 틀린 내용은 댓글로 말씀해주시면 수정하겠습니다. 감사합니다. :)


1. 고차 함수(Higher Order Function)와 클로져(Closure)

// HOF
const hof = () => () => 5;
hof(); // [Function]
hof()(); // 5

// Closure
const closure = function() {
  let count = 0;
  return function increment() {
    count++;
    return count;
  }
}
const incrementFn = closure();
incrementFn(); // 1
incrementFn(); // 2

- 고차 함수 : 한개 이상의 함수가 전달인자(argument)이거나, 또는 함수를 리턴시키는 함수를 의미한다.

한 편, 위 클로져 사례를 보면, 클로져를 활용해서 increment 함수가 외부의 count 변수의 값을 변경시키고 있다. 이는 함수형 프로그래밍의의 원칙에 위배되는 것 같은데, 그렇다면 클로져 활용이 불가능한 것일까?

 

const closure = function() {
  let count = 55;
  return function getCounter() {
		return count;
  }
}
const getCouner = closure();
getCounter(); // 55
getCounter(); // 55

이 사례에서 클로져를 활용해서 count에 접근하고 있지만, 값에 변형을 가하지는 않는다. 이 경우에는 클로져를 사용하면서도 함수형 프로그래밍의 패러다임을 잘 따르고 있는 것이다. 참고로 함수형 프로그래밍에서 클로져는 아주 중요한 개념이다.

그 이유는 함수 안에 변수를 담아두고 클로져를 이용해서 해당 변수에 접근할 경우, 글로벌 또는 다른 함수에서 함수 안에 보관된 변수에 접근이 불가능하기 때문에 중요한 데이터의 변형을 미리 예방하여 버그의 발생 가능성을 획기적으로 줄일 수 있기 때문이다. 

 

2. 커링(Currying) vs. Partial Application

// 1. currying을 이용하기
const curriedMultiply = (a) => (b) => (c) => a*b*c;
curriedMultiply(5)(3)(10); // 150

// 2. partial application
const partialMultiplyBy5 = multiply.bind(null, 5);
partialMultiplyBy5(4, 10); // 200

커링과 partial application은 유사하지만 다른 개념이다.

- 커링 : 여러 개의 전달인자가 필요한 함수를, 함수 실행 1회에 1개의 전달인자만 전달하여 함수를 여러 단계로 쪼개는 프로그래밍 기법이다. 함수를 조각냄으로써 재사용성을 높이기 위해서 사용한다.

- partial application : 첫번째 실행 시에 전달 인자를 1개만 전달하고, 두번째 실행 시에 나머지 전달 인자를 모두 전달하는 기법이다. 

 

만약 매개변수가 5개가 필요한 함수라고 할 경우, 커링을 이용하면 최대 5번으로 함수 실행을 분리시킬 수 있는 반면, partial application을 이용하면 최대 2번으로 함수 실행을 분리하게 된다.

 

3. 메모이제이션(Memoization)과 캐싱(Caching)

캐싱은 특정 value를 빠르게 접근이 가능한 곳에 보관하여 해당 값이 필요할 때 긴 process를 거치는 대신 보관된 데이터를 가져와서 실행 속도를 높이는 것을 말한다. 메모이제이션은 캐싱의 형태 중 하나이다.

function addTo80(n) {
  return n + 80; // 이 함수 실행에 10초가 걸린다고 가정해보자.
}
addTo80(5); // 85
addTo80(5); // 85
addTo80(5); // 85

addTo80 함수의 실행에 10초가 걸린다고 가정해보자. 아주 오랜 시간이 걸리는 복잡한 함수이다. 그런데 이 함수를 3번 실행할 경우 30초가 걸린다. 만약 1억번 실행해야 한다면? 무척 오랜 시간이 걸릴 것이다. 캐싱을 활용해서 이를 해결해보자.

 

let cache = {};
function memoizedAddTo80(n) {
  if (n in cache) {
    return cache[n];
  } else {
    cache[n] = addTo80(n);
    return cache[n];
  }
}

이 부분은 자료 구조의 개념이 약간 들어가는 내용이다. 여기서 사용되는 자료 구조의 내용이 궁금하다면 이 곳에서 확인할 수 있다. 위 코드를 보면, addTo80 함수를 실행하되, 최초 실행 시 실행의 결과값을 cache라는 객체(hash table)에 담고 있다. 그리고 만약 함수의 전달인자가 동일하다면, 결과 또한 동일하므로(idempotent) 함수 실행 대신 hash table에 저장된 값을 찾아서 그 값을 리턴시키고 있다.

Hash Table에서 searching 작업의 Big O는 O(1), Constant Time이다. 따라서 이 경우 최초 1회 실행 시에만 10초의 시간이 소요될 뿐이지, 나머지 실행에서는 아주 아주 빠르게 값을 리턴시킬 수 있다. 

 

하지만 이 코드 또한 약간의 개선이 필요하다. cache라는 변수가 글로벌에 선언되어 있는데, 이를 어떻게 방지할 수 있을까?

function memoizedAddTo80() {
  let cache = {};
  return function(n) {
    if (n in cache) {
      return cache[n];
    } else {
      cache[n] = n+80;
      return cache[n];
    }
  }
}

const memoized = memoizedAddTo80();
memoized(5); // 85

본 글의 맨 처음에 언급된 고차함수와 클로져 개념을 사용해서 개선시킬 수 있다. memoizedAddTo80 함수 내부에 cache 객체를 보관함과 동시에 다른 함수를 리턴시키고 있다.(고차함수) 그리고 리턴되는 함수에서 cache에 접근하고 있는데, 리턴되는 함수는 memoizedAddTo80 함수의 child scope이기 때문에 클로져가 형성되어 cache 변수에 접근할 수 있다. 이처럼 클로져와 고차함수를 이용해서 글로벌 변수 환경의 오염을 최소화시키면서도 캐싱을 이용할 수 있게 되었다.

 

4. Compose and Pipe

# Compose

compose는 컨베이어 벨트라고 생각하면 된다. 일련의 함수들(=컨베이어 벨트의 기계들)이 나열되어 있고, 데이터(=조립할 대상)이 컨베이어 벨트에 input되는 것이다. 그러면 1번 함수가 input 받아 데이터에 변형을 가한 뒤, 2번 함수에게 전달하고, 2번 함수는 1번 함수의 결과물 데이터를 input 받아 함수 실행 후 output을 3번 함수에게 전달한다. 이렇게 일련의 과정을 거친 후에 최종적으로 원하는 결과물을 리턴시키는 것을 compose라고 한다.

 

정리하자면, Composition은 원하는 결과를 얻기 위해 데이터와 일련의 함수들을 배치시키고 구성하는 시스템 디자인 원칙이다.

참고로 compose는 자바스크립트에 내재된 개념은 아니며, 보통 Ramda 같은 라이브러리를 이용해서 사용한다고 한다.

const compose = (f, g) => (data) => f(g(data));
const multiplyBy3 = (num) => num*3;
const makePositive = (num) => Math.abs(num);

const multiplyBy3AndAbsolute = compose(multiplyBy3, makePositive);
multiplyBy3AndAbsolute(-50); // 150

여기서는 compose를 라이브러리를 이용하지 않고 자체적으로 구현했다. 여기서 최초 input 데이터는 -50이고, 이를 조작할 함수(=컨베이어 벨트의 기계)는 multiplyBy3, makePositive 함수이다. compose 안에선 이들 함수를 컴포넌트라고 부른다. 이 컴포넌트들은 최대한 pure해야 한다.

 

# Pipe

pipe는 본질적으로는 compose와 동일한 개념인데, 단지 방향이 반대일 뿐이다.

const compose = (f, g) => (data) => f(g(data));
const pipe = (f, g) => (data) => g(f(data)); // compose와 순서가 다르다. 왼쪽부터 오른족으로.
const multiplyBy3 = (num) => num*3;
const makePositive = (num) => Math.abs(num);

const multiplyBy3AndAbsolute = compose(multiplyBy3, makePositive);
multiplyBy3AndAbsolute(-50); // 150

compose는 컴포넌트 실행의 순서가 오른쪽 -> 왼쪽이라면, pipe는 왼쪽 -> 오른쪽이다. 본인 개인의 선호(?)에 따라 달리 사용하면 된다고 한다.

fn1(fn2(fn3(50)));
compose(fn1, fn2, fn3)(50); // fn3 실행 후, fn2 실행 후, fn1 실행(오른쪽 -> 왼쪽)
pipe(fn3, fn2, fn1)(50); // fn3 실행 후, fn2 실행 후, fn1 실행(왼쪽 -> 오른쪽)

 

# Arity

Arity는 함수가 전달받는 전달인자(argument)의 개수를 의미한다. 일반적으로 arity가 적을수록 함수형 프로그래밍에서 더욱 선호되는 함수이다. currying, compose 등 함수형 프로그래밍의 프로그래밍 기법들을 사용하기 용이할 뿐 아니라, pure하고 버그를 발생시킬 가능성이 더 적기 때문이다.

 

5. 함수형 프로그래밍 적용 - 아마존 쇼핑카트

// Amazon shopping
const user = {
  name: 'Kim',
  active: true,
  cart: [],
  purchases: []
}

//Implement a cart feature:
// 1. Add items to cart.
// 2. Add 3% tax to item in cart
// 3. Buy item: cart --> purchases
// 4. Empty cart

여지껏 배운 함수형 프로그래밍 기법을 적용하여 4가지 작업을 완료해보자.

 

const user = {
  name: 'Kim',
  active: true,
  cart: [],
  purchases: []
}

purchaseItem(
  emptyCart,
  buyItem,
  applyTaxToItems,
  addItemToCart
)(user, {name: 'laptop', price: 200});

function purchaseItem(user, item) {}

function addItemToCart() {}
function applyTaxToItems() {}
function buyItem() {}
function emptyCart() {}

기본적으로 compose를 이용할 것이고, compose의 실행 방향은 오른쪽 -> 왼쪽이기 때문에 가장 먼저 실행되어야 하는 addItemToCart를 가장 오른쪽에, 맨 마지막 작업인 emptyCart 함수를 가장 왼쪽에 위치시켰다.

 

// Amazon shopping
const user = {
  name: 'Kim',
  active: true,
  cart: [],
  purchases: []
}

const compose = (f,g) => (...args) => f(g(...args));

purchaseItem(
  emptyCart,
  buyItem,
  applyTaxToItems,
  addItemToCart
)(user, {name: 'laptop', price: 200});

function purchaseItem(...fns) {
  return fns.reduce(compose);
}

function addItemToCart(user, item) {
  const updateCart = user.cart.concat([item]);
  return Object.assign({}, user, { cart: updateCart });
}

function applyTaxToItems(user) {
  const {cart} = user;
  const taxRate = 1.3;
  const updatedCart = cart.map(item => {
    return {
      name: item.name,
      price: item.price * taxRate
    }
  });
  return Object.assign({}, user, { cart: updatedCart });
}

function buyItem(user) {
  return Object.assign({}, user, { purchases: user.cart });
}

function emptyCart(user) {
  return Object.assign({}, user, { cart: [] });
}


//Implement a cart feature:
// 1. Add items to cart.
// 2. Add 3% tax to item in cart
// 3. Buy item: cart --> purchases
// 4. Empty cart

위 코드가 최종적으로 compose 구현 및 각 함수의 동작을 구현한 코드이다. compose 구현 부분은 아직 이해가 잘 안되는 부분도 있는데, 그 밑에 각 함수들에 주목해보자. 우선 최초 addItemToCart 함수는 함수 외부의 데이터를 변형시키지 않고 그 값을 복사한 후 이용하고 있다. 그리고 새로운 객체를 생성해서 리턴시키고 있다.

그러면 다음에 실행되는 applyTaxToItems 함수는 앞선 함수의 리턴 결과물을 input 받아 나름의 동작을 한 후 마찬가지로 리턴 결과물을 다음 함수에게 전달하고 있다. 딱 컨베이어 벨트처럼 동작한다. 

 

compose를 활용한 함수형 프로그래밍의 훌륭한 점은, 각 함수가 자신만의 세계를 가지고 있고 다른 어떤 외부의 것에 영향을 주지도, 받지도 않기 때문에 버그의 가능성이 줄어들고 코드 재사용성이 아주 높아진다는 점이다. 뿐만 아니라, 만약 위 사례에서 추가적인 동작, 예를 들면 상품 환불의 동작이 필요할 경우 모든 것을 변형할 필요 없이, 하나의 함수만 추가해서 compose 안에서 위치시키면 된다는 점이다.

 

compose의 또 다른 훌륭한 점은 이 모든 일련의 과정들이 log로 기록될 수 있다는 점이다. 예를 들어 고객이 cart에 담지 않은 물건이 배송되었다는 complain을 넣을 경우, 실제로 그러했는지 아래 코드처럼 확인해볼 수 있다.

// Amazon shopping
const user = {
  name: 'Kim',
  active: true,
  cart: [],
  purchases: []
}

let amazonHistory = []; // History log 배열을 만들고, 
const compose = (f,g) => (...args) => f(g(...args));

purchaseItem(
  emptyCart,
  buyItem,
  applyTaxToItems,
  addItemToCart
)(user, {name: 'laptop', price: 200});

function purchaseItem(...fns) {
  return fns.reduce(compose);
}

function addItemToCart(user, item) {
  amazonHistory.push(user); // 모든 함수 실행마다 user state를 담아준다.
  const updateCart = user.cart.concat([item]);
  return Object.assign({}, user, { cart: updateCart });
}

function applyTaxToItems(user) {
  amazonHistory.push(user); // 모든 함수 실행마다 user state를 담아준다.
  const {cart} = user;
  const taxRate = 1.3;
  const updatedCart = cart.map(item => {
    return {
      name: item.name,
      price: item.price * taxRate
    }
  });
  return Object.assign({}, user, { cart: updatedCart });
}

function buyItem(user) {
  amazonHistory.push(user); // 모든 함수 실행마다 user state를 담아준다.
  return Object.assign({}, user, { purchases: user.cart });
}

function emptyCart(user) {
  return Object.assign({}, user, { cart: [] });
  amazonHistory.push(user); // 모든 함수 실행마다 user state를 담아준다.
}

amazonHistory라는 배열을 만들고, 각 함수가 실행될 때마다 user의 state를 해당 배열에 담아줬다. 따라서 amazonHistory 배열에는 고객의 행동이 모두 log로 남아있기 때문에 기록을 찾아보며 문제점을 발견할 수 있는 것이다.

댓글