개요: 자바스크립트로 함수형 프로그래밍을 할 때 유용한 라이브러리 ‘람다’를 간단히 소개한다. 함수형 프로그래밍 자체를 설명하는 글은 아니다.

자바스크립트에서 함수형 프로그래밍하기

자바스크립트는 함수형 패러다임을 위한 기반을 지원한다. ECMA스크립트 5판(2009년)에서 이미 Array.map(), Array.reduce() 같은 컬렉션 조작 함수가 추가되었고 6판(2015년)에서 익명 함수를 간결하게 표현하는 ‘화살표 함수’도 추가되었다. 언제 추가된 건지 확실히는 모르곘지만 일급 함수와 클로저는 그 전부터 지원한 것 같다.

이러한 자바스크립트의 기본 특성만으로도 함수형 패러다임을 제법 흉내낼 수 있다. 하지만 자바스크립트의 기본 문화가 함수형 패러다임이 아니어서 한계가 있다. 불변 데이터를 제대로 지원하지 않고, 컬렉션 조작 도구와 메타프로그래밍 도구도 부족하다. 자바스크립트로 함수형 표현을 작성하다보면 결국 이런 도구들을 직접 정의하게 된다. 자신만의 라이브러리를 만들어 보는 것도 좋지만 바쁜 현대인들은 나보다 뛰어난 프로그래머들이 잘 다듬어 놓은 도구를 잘 골라 쓰는 게 더 현명하다.

람다(Ramda)는 ECMA스크립트가 닦아 둔 기반 위에 제대로 된 함수형 프로그래밍을 위한 유틸리티 함수들을 추가해 준다. 이런 라이브러리로는 람다 말고도 언더스코어(Underscore.js), 로대시(Lodash) 같은 것들이 있고 람다는 꽤 나중에 나온 물건이다.

람다는 경쟁 라이브러리들보다 좀 더 정석에 가까운 함수형 표현을 사용할 수 있도록 설계되었다고 한다. 나는 로대시는 안 써봤고 언더스코어는 조금밖에 써보지 않아서 다른 라이브러리들이 람다보다 못한지 어떤지는 모르겠다. 다만 람다가 클로저(Clojure)의 컬렉션 함수들과 상당히 유사해서 금방 적응할 수 있었고 꽤 맘에 들었다.

함수형 프로그래밍에 익숙한 사람이라면 이들 중 하나를 익혀두면 자바스크립트 코딩이 한 결 수월해질 것이다. 함수형 프로그래밍을 하지 않더라도 컬렉션 조작 함수들을 유용하게 쓸 수 있다. 길고 쓸모없는 let i, len; for (i = 0, len = coll.length; i < len; ++i) { coll[i] = f(coll[i]); } 패턴을 coll = R.map(f, coll); 로 줄이는 것만 해도 어딘가.

참고: 자바스크립트가 정말 싫거나 좀 더 제대로 함수형 프로그래밍을 하고 싶다면 클로저스크립트(ClojureScript)를 써 보자. 리스프 문법으로 코딩하고 자바스크립트로 컴파일하는 방식이다. 하지만 자바스크립트를 타겟으로 빌드하는 언어보다는 자바스크립트 자체로 프로그래밍하는 것이 웹 클라이언트 개발의 정석이다. 다른 걸 하더라도 먼저 자바스크립트에 충분히 익숙해지는 것이 좋겠다.

공식 문서

람다의 공식 웹사이트는 http://ramdajs.com 다. 이곳에서 설치법, 기본 사용법, 전체 함수 목록과 상세 설명을 열람할 수 있다.

설치 & 로드

npm으로 자바스크립트 라이브러리를 관리하는 경우, 프로젝트에 람다를 추가하려면 다음과 같이 한다.

$ npm install ramda --save

이후의 예는 람다를 설치한 프로젝트에서, 노드(Node.js)의 REPL($ node로 실행) 위에서 실행해 보인 것이다. 노드를 사용할 여건이 되지 않는다면 웹 브라우저로 Try Ramda (웹 REPL)에 접속해 테스트해봐도 좋다.

람다를 사용하려면 다음과 같이 ramda 모듈을 로드한 후 R에 바인딩 해준다. (노드 기준. 브라우저에서는 script 태그로 로드하면 된다.)

> const R = require('ramda');

기본 개념과 도구

매핑과 리덕션

R.map(f, coll)

매핑은 지정한 함수를 컬렉션의 모든 원소에 각각 적용하여 새로운 컬렉션을 구하는 것이다.

자바스크립트의 기본 문법 기반인 C 스타일로 컬렉션에 매핑을 적용하려면 다음과 같이 for를 사용한 패턴이 필요하다. (for in을 사용할 수도 있지만 이 구문은 객체의 모든 프로퍼티를 순회하도록 정의된 탓에 hasOwnProperty를 검사해야 해서 패턴이 더욱 지저분해진다.)

> let square = x => x * x;
> let coll = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
> let i, len, new_coll = [];
> for (i = 0, len = coll.length; i < len; ++i) {
... new_coll[i] = square(coll[i]);
... }
> new_coll;
[ 1, 4, 9, 16, 25, 36, 49, 64, 81, 100 ]

사실 자바스크립트는 매핑 함수 Array.map()을 기본으로 제공하므로 매핑을 위해 for 구문을 쓸 필요가 없다. 이 함수를 사용하면 매핑을 훨씬 간결하게 표현할 수 있다.

> coll.map(square);
[ 1, 4, 9, 16, 25, 36, 49, 64, 81, 100 ]

문제는 자바스크립트가 객체지향과 함수형 패러다임을 버무려 놨다는 것이다. Array.map() 함수는 매개변수의 순서가 컬렉션-함수 순으로 되어 있다. 함수형 프로그래밍 언어의 매핑 함수는 보통 (map f coll) 처럼 함수-컬렉션 순서로 인자를 매개변수에 넘기도록 정의되어 있다. 자바스크립트의 방식은 이 관습과 순서가 반대인 것이다.

참고: 메서드 소유자 객체는 메서드 관점에서는 그저 첫번째 인자일 뿐이다. 위의 예에서 collArray.map()에 전달되는 인자다. 반대로 말하면, Array.map() 메서드는 겉보기에는 함수 하나(square)만을 인자로 전달받는 체 하지만 실질적으로는 컬렉션(coll)과 함수(square)를 순서대로 전달받는 것이다. 컬렉션이 전달되지 않는다면 이 메서드는 제대로 실행될 수 없다.

람다의 매핑 함수인 R.map()은 다음과 같이 함수-컬렉션 순으로 인자를 매개변수를 전달하도록 되어 있다.

> R.map(square, coll);
[ 1, 4, 9, 16, 25, 36, 49, 64, 81, 100 ]

R.apply(f, coll)

매핑하려는 함수가 컬렉션이 아니라 가변 인자 배열(args)을 취할 때는 R.map() 대신 R.apply()를 사용하면 된다.

> R.map(Math.max, coll);    // 부적절. Math.max([1, 2, 3, 4, 5, ...])
[ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 ]
> R.apply(Math.max, coll);  // 적절. Math.max(1, 2, 3, 4, 5, ...)
10

R.reduce(f, init, coll)

리덕션은 컬렉션의 모든 원소를 하나의 값으로 축약하는 것이다. (예를 들면, 전체 컬렉션의 합, 또는 전체 컬렉션의 평균을 계산하는 것.) 리덕션에 사용되는 함수는 누적값과 컬렉션의 원소 하나를 매개변수에 전달받아 새로운 누적값을 생산한다. 이 함수에 컬렉션의 모든 원소를 순서대로 적용하면 하나의 값으로 누적되는 것이다.

자바스크립트는 리덕션 함수도 Array.reduce()으로 기본 제공한다. Array.map()과 마찬가지로 Array 프로토타입의 메서드로 정의되어 있다. 이 함수에는 두 번째 매개변수에 초기값을 지정할 수 있다.

> let plus = (a, b) => a + b;
> coll.reduce(plus);
55
> coll.reduce(plus, 1000);
1055

위의 예에서 보듯이 이 함수의 매개변수 순서는 컬렉션-함수-초기값 순이다. 이 또한 일반적인 함수형 패러다임 관습 함수-초기값-컬렉션 순서와 어긋난다.

람다의 리덕션 함수 R.reduce()는 매개변수가 함수-초기값-컬렉션 순이다.

> R.reduce(plus, 1000, coll);
1055

매개변수의 순서는 단순한 취향 문제만은 아니다. 함수를 조합하거나 커리할 때 매개변수의 순서가 중요한 요소가 된다.

R.sum(coll)

컬렉션의 합계를 구할 때는 직접 리덕션을 수행할 필요 없이 R.sum() 함수를 사용하면 된다.

> R.sum(coll);
55

커리와 조합

R.curry(f)

커리1란 여러 매개변수를 가진 일반적인 함수가 있을 때, 그 함수의 일부 매개변수를 어떤 값으로 특정함으로써 더 구체적인 일을 하는 함수를 생산하는 것이다.

R.curry()를 사용하면 함수가 커리 가능한 함수로 변한다. 커리 가능한 함수에 정의된 매개변수보다 적은 수의 인자를 전달하여 호출하면, 인자를 전달받은 매개변수가 그 인자로 고정되고 나머지 매개변수만을 갖는 새로운 함수가 반환된다. 다음은 정육면체의 부피를 구하는 함수를 커리한 예다.

> let volume_of_cube = R.curry((depth, w, h) => depth * w * h);
> volume_of_cube(10, 20, 30);
6000
> volume_of_cube(10);  // 첫번째 매개변수를 10으로 고정한 함수
[Function]
> volume_of_cube(10)(20, 30);  // 그 함수에 20, 30을 전달
6000
> volume_of_cube(10)(20)(30);  // 연속 커리
6000
> let area_of_square = volume_oF_cube(1);  // 커리된 함수 바인딩
> area_of_square(3, 4);
12

보다시피 커리에서는 맨 앞의 매개변수부터 차례대로 고정되기 때문에 매개변수의 순서가 중요하다. 커리할 매개변수의 순서를 수정하고 싶다면 R.__ 상수를 사용하여 빈자리를 나타낼 수 있다.

> let bin_to_dec = R.curry((d3, d2, d1) => d3 * 4 + d2 * 2 + d1);
> bin_to_dec(1, 1, 1);
7
> let odd_bin = bin_to_dec(R.__, R.__, 0);
> odd_bin(1, 1);
6

람다가 제공하는 함수들은 기본으로 커리가 적용되어 있는 듯하다.(확인 필요) 예를 들어 객체의 유형을(엄밀하게 말하면 생성자의 인스턴스 여부를) 검사하는 R.is() 함수를 다음과 같이 커리할 수 있다.

> R.is(String, 'hello!');
true
> let is_string = R.is(String);
> is_string('hello!');
true

R.pipe(f1, f2, …, fn)

R.pipe() 함수로 함수 여러 개를 결합해서 새로운 함수를 정의할 수 있다. 결합된 함수는 왼쪽 것부터 오른쪽 순으로 실행된다.

> let sqrtstr = R.pipe(Math.sqrt, Math.round, String);
> sqrtstr(10);
'3'

R.compose(f1, f2, …, fn)

R.compose() 함수로 결합된 함수는 오른쪽 것부터 왼쪽 순으로 실행된다. R.pipe() 함수와 방향이 반대다.

> let sqrtstr2 = R.compose(String, Math.round, Math.sqrt);
> sqrtstr2(10);
'3'

컬렉션 함수와 유틸리티 함수

원소 참조

R.nth(n, coll)

R.nth() 함수는 배열의 n번째 원소를 참조한다. 원소가 없으면 undefined를 반환한다.

> R.nth(5, coll);
6
> R.nth(10, coll);
undefined

R.prop(prop, obj)

R.prop() 함수는 객체의 프로퍼티를 참조한다. 프로퍼티가 없으면 undefined를 반환한다.

> R.prop('name', {name: 'bakyeono'});
'bakyeono'
> R.prop('email', {name: 'bakyeono'});
undefined

컬렉션 생성

R.range(from, to)

R.range() 함수를 사용하면 수열을 쉽게 생성할 수 있다.

> R.range(20, 30);
[ 20, 21, 22, 23, 24, 25, 26, 27, 28, 29 ]

시작값을 0으로 고정하고 싶다면 커리하면 된다.

> let range_to = R.range(0);
> range_to(10);
[ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ]

R.repeat(item, n)

R.repeat() 함수는 지정한 원소를 n개 담은 컬렉션을 생성한다.

> R.repeat(0, 5);
[ 0, 0, 0, 0, 0 ]

컬렉션 필터, 정렬

R.filter(pred, coll)

R.filter() 함수는 컬렉션에서 조건 함수를 만족하는 원소만 남긴 컬렉션을 반환한다.

> R.filter(x => x % 2, coll);
[ 1, 3, 5, 7, 9 ]

R.sort(f, coll)

R.sort() 함수는 두 인자의 차를 구하는 함수를 컬렉션에 적용해 정렬한다.

> R.sort((a, b) => a - b, [99, 23, 18.5, 36.99, 6, -50]);
[ -50, 6, 18.5, 23, 36.99, 99 ]

R.reverse(coll)

R.reverse() 함수는 컬렉션 또는 문자열의 순서를 반대로 한다.

> R.reverse(coll);
[ 10, 9, 8, 7, 6, 5, 4, 3, 2, 1 ]
> R.reverse('안녕하세요');
'요세하녕안'

컬렉션 연결, 분할, 원소 추가, 원소 제거

R.concat(coll1, coll2)

R.concat() 함수는 두 컬렉션 또는 문자열을 연결한다.

> R.concat(coll, [11, 12, 13]);
[ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13 ]
> R.concat('Hello, ', 'World');
'Hello, World'

R.append(elm, coll)

R.append() 함수는 컬렉션 뒤에 원소를 추가한 컬렉션을 반환한다.

> R.append(99, coll);
[ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 99 ]

R.prepend(elm, coll)

R.prepend() 함수는 컬렉션 안에 원소를 추가한 컬렉션을 반환한다.

> R.prepend(99, coll);
[ 99, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 ]

R.head(coll)

R.head() 함수는 컬렉션에서 첫번째 원소를 취하는 함수다. 리스프의 car 또는 first에 해당하는 함수다.

> R.head(coll);

R.take(n, coll)

R.take() 함수는 컬렉션에서 처음부터 n개의 원소를 취한 컬렉션을 구한다. 뒤에서부터 취하고 싶다면 R.takeLast() 를 사용한다.

> R.take(3, coll);
[ 1, 2, 3 ]
> R.takeLast(3, coll);
[ 8, 9, 10]

R.drop(n, coll)

R.drop() 함수는 컬렉션에서 처음부터 n개의 원소를 제거한 컬렉션을 구한다. 뒤에서부터 제거하고 싶다면 R.dropLast()다.

> R.drop(3, coll);
[ 4, 5, 6, 7, 8, 9, 10 ]
> R.dropLast(3, coll);
[ 1, 2, 3, 4, 5, 6, 7 ]

R.slice(from, to, coll)

범위를 지정해 잘라낼 때는 R.slice()다. 상수 Infinity를 쓰면 끝까지 잘라낸다.

> R.slice(3, 7, coll);
[ 4, 5, 6, 7 ]
> R.slice(-Infinity, Infinity, coll);
[ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 ]

R.assoc(k, v, obj)

R.assoc() 함수는 객체의 키/값(프로퍼티/값) 쌍을 갱신한 객체를 반환한다.

> let person = {name: 'bakyeono'};
> R.assoc('email', 'bakyeono@gmail.com', person);
{ name: 'bakyeono', email: 'bakyeono@gmail.com' }
> R.assoc('name', 'clojure', person);
{ name: 'clojure' }

R.dissoc(k, obj)

R.dissoc() 함수는 객체에서 키를 제거한 객체를 반환한다.

> R.dissoc('name', person);
{}

집합 연산

R.union(coll1, coll2)

R.union()함수는 두 컬렉션의 합집합을 구한다.

> R.union(R.range(0, 8), R.range(4, 12));
[ 10, 11, 12, 13, 14, 15, 16, 17, 18, 19 ]

R.intersection(coll1, coll2)

R.intersection()함수는 두 컬렉션의 교집합을 구한다.

> R.intersection(R.range(0, 8), R.range(4, 12));
[ 4, 5, 6, 7 ]

R.difference(coll1, coll2)

R.difference()함수는 두 컬렉션의 차집합을 구한다.

> R.difference(R.range(0, 8), R.range(4, 12));
[ 0, 1, 2, 3 ]

논리 함수

R.is(generator, obj)

R.is() 함수는 앞에서도 언급했다. 객체가 생성자의 인스턴스인지 검사하는 함수다. 커리하여 특정 타입을 검사하는 함수를 만들기 좋다.

> R.is(String, 'text');
true
> let is_number = R.is(Number);
is_number(Math.PI);
true

R.complement(f)

R.complement() 함수는 인자로 넘긴 논리 함수의 반대의 결과를 내는 함수를 반환한다. 여집합 함수가 아니니 헷갈리지 말자.

> let is_not_number = R.complement(is_number);
> is_not_number(Math.PI);
false

R.all(pred, coll)

R.all() 함수는 컬렉션의 모든 원소가 조건 함수를 만족하는지 검사한다. 모두 참일 때만 true, 아니면 false다.

> R.any(R.is(String), coll);
false
> R.all(R.is(Number), coll);
true
> R.all(x => x < 10, coll);
false

R.any(pred, coll)

R.any() 함수는 컬렉션의 원소 중 하나라도 조건 함수를 만족하는지 검사한다. 모두 거짓일 때만 false, 아니면 true다.

> R.any(R.is(String), coll);
false
> R.any(R.is(Number), coll);
true
> R.any(x => x < 10, coll):
true

R.T(), R.F(), R.always(obj)

R.T() 함수는 항진함수다. 매개변수는 없으며 언제나 true를 반환한다. 그 반대는 R.F() 함수다.

> R.T();
true
> R.F();
false

R.always() 함수는 지정된 인자를 항상 반환하는 함수를 만든다.

> let emptiness = R.always(null);
> emptiness();
null

R.cond([cond1, f1], [cond2, f2], …)

R.cond() 함수는 다중 if … else … 문을 묶어 표현하는 함수다. 적절히 사용하면 지저분한 다중 if … else … 문을 깔끔하게 정리할 수 있다.

> let between = R.curry((from, to, x) => (from <= x) && (x < to));
> let month_to_season = R.cond([
... [between(3, 6),  R.always('봄')],      // 3-6:  봄
... [between(6, 9),  R.always('여름')],    // 6-9:  여름
... [between(9, 12), R.always('가을')],    // 9-12: 가을
... [R.T,            R.always('겨울')]]);  // else: 겨울
> R.map(month_to_season, R.range(1, 13));
[ '겨울', '겨울', '봄', '봄', '봄', '여름', '여름', '여름', '가을', '가을', '가을', '겨울' ]

간단한 소개글을 쓸 생각이었는데 함수 몇개 소개하다보니 벌써 라이브러리 레퍼런스마냥 길어지고 있다. 이 정도로 정리해야겠다. 지금까지 소개한 함수는 전체 람다 라이브러리의 일부분일 뿐이다. 필요한 함수는 공식 문서를 통해 더 찾아보도록 하자.

참고

이 글을 쓸 때 참고한 자료

추천 자료

미주

  1. 수학자 해스켈 커리(Haskell Brooks Curry)의 이름에서 딴 용어. 카레가 아니다.