본문 바로가기

Backend/함수형 자바스크립트

함수형 자바스크립트의 시작

함수형 프로그래밍은 성공적인 프로그래밍을 위해 부수 효과를 최대한 멀리하고 조합성을 강조하는 프로그래밍 패러다임입니다.

함수형 프로그래밍이 부수 효과를 최대한 멀리하는 이유 중 하나는 오류를 줄이기 위해서고, 다른 하나는 조합성 혹은 모듈화 수준을 높이기 위해서 입니다. 높은 모듈화 수준은 생산성을 높이고, 오류 없는 함수들의 조합은 프로그램 전체의 안정성을 높여줍니다.

 

일전에 함수형 자바스크립트 라는 책을 읽으면서 리뷰를 남기기도 했었지만, (관련 링크) 아직은 약간 어색함도 있고 프로그래밍 자체도 해오던 그대로 하는 것 같다는 생각이 들어 함수형 프로그래밍에 대해서 다시 한 번 정리해보고자 합니다.

 

- 함수형 자바스크립트 예제들

 

함수형 자바스크립트를 책으로 접하면서 알게된 커링, 부분 적용 등의 개념은 이해했지만 얘네가 정말 실용적인가? 에 대한 의문은 스스로도 계속 들었고, 결국 실무에서 활용하는 일은 많이 없었습니다. 제 프로그래밍 스타일이 고착화된 걸 수도 있고, 이해했다고는 하지만 그 이해의 정도가 부족했었나? 하는 의문도 듭니다.

 

함수형 자바스크립트 스타일 중 하나인 값으로서의 함수, 부분 적용, 클로저, 스코프 등을 담고 있는 예시를 하나 보겠습니다.

 

function add(a) {
  return function(b) {
    return a + b;
  }
}

const add10 = add(10);
console.info(add10(3)); // 13
console.info(add10(11)); // 21

 

위에서 작성한 add 는 함수를 값으로 다루는 함수입니다. add(10) 을 미리 실행하여 add10 을 만들어두고, 이후 추가 인자를 전달해 결과를 얻어낼 수 있습니다. 실용성이 떨어진다는 생각이 들기도 하지만, 많이 사용되는 라이브러리들의 코드 일부분만 뜯어봐도 위처럼 함수로 함수를 리턴하는 기법은 정말 많이 사용하고 있습니다.

 

- 함수형 자바스크립트 실용성

 

자바스크립트에서 함수형 프로그래밍을 지원하는 유명한 라이브러리인 Underscore.js, Lodash.js 에 이미 포함되어 있는 each, map, filter, reduce 등의 함수들을 (굳이 이런 라이브러리들을 사용하지 않아도 Array.prototype 에서 해당 기능들을 사용할 수 있지만) 직접 유사하게 구현해보면서 그 실용성에 대해 다시금 짚어보도록 하겠습니다.

 

• filter

 

const users = [
  { id: 1, name: 'AA', age: 28 },
  { id: 2, name: 'BB', age: 29 },
  { id: 3, name: 'CC', age: 30 },
  { id: 4, name: 'DD', age: 31 },
  { id: 5, name: 'EE', age: 32 },
  { id: 6, name: 'FF', age: 33 },
];

// 기존 for loop 사용
const under30AgeUsers = [];
for (let i = 0 ; i < users.length ; i++) {
  if (users[i].age < 30) under30AgeUsers.push(users[i]);
}
console.info(under30AgeUsers.length); // 2

// filter 함수 생성
const filter = (list, predicate) => {
  const newList = [];
  for (let i = 0 ; i < list.length ; i++) {
    if (predicate(list[i])) newList.push(list[i]);
  }
  return newList;
};

const newUnder30AgeUsers = filter(users, user => user.age < 30);
console.info(newUnder30AgeUsers.length); // 2

 

기존의 for loop 를 사용하지 않고 filter 함수를 생성해서 변경한 코드에선, newList.push 가 실행될지에 대한 여부를 predicate 함수에게 위임하였습니다. filter 는 predicate 가 내부에서 뭔 일을 하는지 전혀 모르고, 마지막에 newList 를 반환합니다.

newList 를 새로 만들어서 반환하는, 즉 기존 값을 변경하지 않고 새로운 값을 만드는 것이 함수형 프로그래밍에선 아주 중요한 컨셉 중 하나입니다.

 

이정도 레벨의 코드에선, 단순 for loop 가 더 보기 편할 수 있습니다. 하지만 이런 스타일은 굉장히 많은 중복 코드를 만들어낼 수 있습니다. 그에 비해 filter 라는 함수를 하나 만듦으로써 age 가 30 미만인 유저를 구하는 부분의 코드는 짧아졌고, 재사용성이 높은 함수를 얻어냈다는 수확을 거뒀습니다.

 

함수형 프로그래밍 관점에서, filter 함수엔 if 나 for 등이 있지만 항상 동일하게 동작합니다. 동일한 입력값에 대해선 항상 동일한 반환값을 보장하고, 이 말은 즉 단 한가지 로직을 가졌다는 뜻입니다. 이 로직은 외부나 내부의 어떤 상태 변화에도 의존하지 않죠.

함수형 프로그래밍에서는 '항상 동일하게 동작하는 함수' 를 먼저 만들고 보조 함수를 조합하는 식으로 로직을 완성합니다.

 

똑같은 얘기지만, 내부에서 관리하고 있는 상태를 따로 두지 않고 넘어온 인자에만 의존하며 동일한 인자에 대해 항상 동일한 값을 반환하도록 합니다. 조합되는 보조 함수 역시 인자이며, 보조 함수에서도 상태를 변경하지 않으면 보조 함수를 받은 함수 역시 항상 동일한 결과를 반환하는 함수입니다.

 

더보기

* 참고 : 뜬금없이 객체지향에 대해 잠깐 언급하자면, 저는 지금까지 객체지향 프로그래밍과 함수형 프로그래밍은 서로 반대되는 개념이라고 생각해왔습니다. 하지만 객체지향에서도 부수 효과를 줄이기 위해 새 객체를 만드는 식의 방법을 취하기도 하고, (아주 어울리는 방법은 아니지만) 함수형 프로그래밍은 부수 효과를 최소화 하는 것이 목표에 가깝기 때문에 결국 이 둘은 함께 동작해야 하는 것 같습니다.

현대의 프로그래밍에서 다루는 대부분의 값은 객체이기에 함수형에서도 결국 객체를 다뤄야 하고, 여기서 둘의 차이는 기능의 확장을 객체의 확장 vs 함수의 확장으로 풀어가느냐에 있다고 봅니다.

 

• map

 

const users = [
  { id: 1, name: 'AA', age: 28 },
  { id: 2, name: 'BB', age: 29 },
  { id: 3, name: 'CC', age: 30 },
  { id: 4, name: 'DD', age: 31 },
  { id: 5, name: 'EE', age: 32 },
  { id: 6, name: 'FF', age: 33 },
];

// 기존 for loop 사용
const ages = [];
for (let i = 0 ; i < users.length ; i++) {
  ages.push(users[i].age);
}
console.info(ages); // [28, 29, 30, 31, 32, 33]

// map 함수 생성
const map = (list, iteratee) => {
  const newList = [];
  for (let i = 0 ; i < list.length ; i++) {
    newList.push(iteratee(list[i]));
  }
  return newList;
};

const newAges = map(users, user => user.age);
console.info(newAges); // [28, 29, 30, 31, 32, 33]

 

filter 함수를 만들었을 때와 유사합니다. map 이라는 함수를 만들면서 newList 에 어떤값을 넣을지는 iteratee 에 위임했습니다.

마찬가지로 새로운 배열을 만들어내는 코드는 짧아졌으며, map 이라는 재사용성 높은 함수를 얻어냈습니다.

이렇게 만든 filter 와 map 함수는, 서로 조합도 가능합니다.

 

const under30Ages = map(
  filter(users, user => user.age < 30),
  user => user.age
);
console.info(ages); // [28, 29]

const over30Ages = filter(
  map(users, user => user.age),
  age => age >= 30
);
console.info(ages); // [30, 31, 32, 33]

 

map 의 iteratee 에 user => user.age 를 넣어서 최종 반환값은 저걸 어디에쓰나.. 싶은 형태가 되기도 했지만 이런 형태도 가능하며 여기서 작은 함수를 하나 더 만들면 변수 할당 자체를 하지 않게끔 처리할 수도 있습니다. (개인적으론 그건 투머치인 것 같아서 전 여기까지..)

 

이렇게 만든 filter 와 map 함수들은 들어온 데이터가 어떤것이든 루프를 돌리거나, 분기를 만들기도 하고 push 혹은 predicate 등의 로직을 수행합니다. list 내부에 뭐가 들어 있는지에 대해선 관심 없습니다. 인자로 들어온 배열의 내부 상태를 변경하지도 않습니다.

 

이처럼 함수형 프로그래밍에선 보조 함수를 통해 완전히 위임하는 방식을 취합니다. 이를 통해 더 높은 다형성과, 안정성을 보장합니다.

물론 다형성을 만족시키기 위해선, 보조 함수로 대응이 필요할 수도 있습니다. 아래 예시 코드로 보조 함수 대응을 보도록 하겠습니다.

 

const users = [
  { id: 1, name: 'AA', age: 28 },
  { id: 2, name: 'BB', age: 29 },
  { id: 3, name: 'CC', age: 30 },
  { id: 4, name: 'DD', age: 31 },
  { id: 5, name: 'EE', age: 32 },
  { id: 6, name: 'FF', age: 33 },
];

function User(id, name, age) {
  this.getId = () => id;
  this.getName = () => name;
  this.getAge = () => age;
}

const users2 = [
  new User(1, 'AA', 28),
  new User(2, 'BB', 29),
  new User(3, 'CC', 30),
  new User(4, 'DD', 31),
  new User(5, 'EE', 32),
  new User(6, 'FF', 33),
];

/*
  map, filter 는 생략
*/
const names = map(
  filter(users, user => user.age >= 30),
  user => user.name
);
// users2 로 변경
const names2 = map(
  filter(users2, user => user.getAge() >= 30),
  user => user.getName()
];

console.info(names); // ['CC', 'DD', 'EE', 'FF']
console.info(names2); // ['CC', 'DD', 'EE', 'FF']

 

이처럼 같은 함수를 사용하면서, 데이터에 맞는 보조 함수로 대응해 어떠한 배열이 들어와도 처리가 가능합니다.

 

- 고차 함수

 

위에서 구현했던 filter, map 과 같은 함수들은 모두 고차 함수입니다.

고차 함수란, 함수를 인자로 받거나 함수를 리턴하는 함수를 말하며 둘 다 하는 경우도 포함합니다.

 

  • 함수를 인자로 받아 대신 실행하는 함수
  • 함수를 리턴하는 함수
  • 함수를 인자로 받아서 또 다른 함수를 리턴하는 함수

일반적으로 고차 함수는 함수를 인자로 받아 필요한 때에 실행하거나 클로저를 만들어 반환합니다.

 

앞서 filter, map 함수들은 이미 라이브러리들에 포함되어 있는 함수라고 말씀드렸습니다. 그럼 여기서 지금까지 구현한 함수를 Underscore.js 의 filter 와 map 에 좀 더 가깝게 변경해보겠습니다.

 

_.map = (list, iteratee) => {
  const newList = [];
  for (let i = 0 ; i < list.length ; i++) {
    newList.push(iteratee(list[i], i, list));
  }
  return newList;
};

_.filter = (list, predicate) => {
  const newList = [];
  for (let i = 0 ; i < list.length ; i++) {
    if (predicate(list[i], i, list)) newList.push(list[i]);
  }
  return newList;
};

 

기존에는 iteratee 와 predicate 에 predicate(list[i]) 처럼 하나의 인자만 넘겼습니다. 하지만 위 코드에선 predicate(list[i], i, list) 형태로 두개의 인자를 더 넘깁니다. 이로써 좀 더 할 수 있는 일이 많아졌습니다.

 

const list = _.filter([1, 2, 3, 4], (val, idx) => idx > 1);
console.info(list); // [3, 4]

 

- 함수 합성

 

함수를 쪼갤수록 합성은 더 쉬워집니다. 함수 합성에 대한 내용은, 이후 더 많이 다룰 예정이기에 여기서 그 내용은 생략하겠습니다.

짚고 넘어갈 만한 점은, 값 대신 함수로, for loop 와 if 대신 고차 함수와 보조 함수, 연산자 대신 함수 혹은 함수 합성 등을 사용하면서 코드가 간결해지고 명확해졌는데, 그럴수록 점점 인자나 변수 선언도 적어진다는 점입니다.

 

코드에 인자와 변수가 등장하지 않고 함수의 내부가 보이지 않는다는 것은 새로운 상황도 생기지 않는다는 것이고, 이는 즉 개발자가 예측할 수 없는 상황이 없다는 뜻입니다. 상태를 공유하지 않는 작은 단위의 함수들은 테스트하기도 쉽고 테스트 케이스 또한 작성하기 쉽습니다.

그 과정에서 arguments 객체가 자주 등장하고 나중엔 무인자 코드 라는 것도 나오긴 하지만, 우선 위 부분을 다시한번 각인시키면 좋을 것 같습니다.

 

- 일급 함수

 

자바스크립트에서 함수는 일급 객체이자 일급 함수입니다. 자바스크립트에서 객체는 일급 객체이고, '일급' 이란 값으로 다룰 수 있다 라는 의미로 아래 조건들을 만족해야 합니다.

 

  • 변수에 담을 수 있다
  • 함수나 메서드의 인자로 넘길 수 있다
  • 함수나 메서드에서 리턴할 수 있다

자바스크립트에서 모든 값은 일급이고, 모든 객체는 일급 객체이며 함수도 객체이자 일급 객체 입니다.

여기서 일급 함수 란, 아래 조건들을 추가로 만족해야 합니다.

 

  • 아무 때나 (런타임 포함) 선언이 가능하다
  • 익명으로 선언할 수 있다
  • 익명으로 선언한 함수도 함수나 메서드의 인자로 넘길 수 있다

자바스크립트의 함수는 위 조건을 모두 만족하는 일급 함수입니다. 아래 예시)

 

function f1() {}
const a = typeof f1 === 'function' ? f1 : function() {};

function f2() {
  return function() {}
}

(function(x, y) { return x + y; })(13, 9);

 

- 클로저

 

함수형 자바스크립트에서 클로저 또한 상당히 중요한 개념입니다.

제가 작성한 포스팅 중에 클로저에 대한 글이 있으니 그 링크로 내용을 대체하겠습니다. 꼭 읽어보면 좋을 것 같습니다.

 

- 부분 적용 + 커링

 

이 내용 또한 위에 링크로 남긴 클로저에 대한 포스팅에 담겨있습니다.

추가하자면, 위에서 언급했던 라이브러리엔 이미 잘 구현된 함수들이 있습니다. (Lodash 의 _.curry / Underscore 의 _.partial)

이후 포스팅에서 이 부분 또한 더 깊게 파고들어 볼 예정이므로 여기선 Underscore.js 의 _.partial 에 대한 예시만 보고 넘어가겠습니다.

 

function abc(a, b, c) {
  console.info(a, b, c);
}

const ac = _.partial(abc, _, 'b'); // a 자리를 비워두고 c 는 생략
ac('a', 'c'); // a b c

const b = _.partial(abc, 'a', _, 'c'); // b 자리를 비워둠
b('b'); // a b c

function equal(a, b) {
  return a === b;
}

const isUndefined = _.partial(equal, undefined); // equal 함수의 a 자리에 undefined 를 적용해 둠
isUndefined(undefined); // true
isUndefined(1); // false

 

_.partial 은 적용해 둘 인자와 비워둘 인자를 구분하는 구분자로 undefined 대신 _ 를 사용합니다.

일부 네이밍 컨벤션 혹은 lint 에선 _ 를 사용하지 못하는 룰도 있긴 합니다만, 여기선 나름 적합하며 표현력도 좋은 것 같습니다.

 

내용 사이사이에 함수형 자바스크립트의 실용성에 대한 부분도 있긴 하지만, 아직 이게 정말 좋은건가..? 라는 생각이 들 수 있습니다. 우선 새겨둬야 할 것은 이정도의 내용들이 함수형 자바스크립트의 포인트들이며, 이후의 포스팅에선 좀 더 디테일한 구현들을 통해 함수형으로 더 가까이 가보도록 하겠습니다.

'Backend > 함수형 자바스크립트' 카테고리의 다른 글

Underscore.js - _.reject & _.find  (0) 2021.08.29
Underscore.js - _.filter  (0) 2021.08.21
Underscore.js - _.map & _.each  (0) 2021.08.14
Underscore.js - ArrayLike  (0) 2021.08.07
Underscore.js  (0) 2021.08.01