Underscore.js 는 꽤나 작은 함수형 자바스크립트 라이브러리 입니다.
제가 처음 이 라이브러리를 접했을 땐, 빈약한 자바스크립트 기본 객체들을 다루기 위한 유틸성 라이브러리로만 생각했습니다.
배열에만 사용할 수 있는 전용 메서드들을 다양한 타입에 적용할 수 있는 정도의 용도로 말이죠.
하지만 이 라이브러리는 자바스크립트를 함수적으로 다루는 패러다임을 제시하고 있습니다.
Underscore.js 의 함수들은 간결하고 단순하며 아주 작지만, 함수들 사이에 아주 잘 어우러집니다.
함수의 결과가 또 다른 함수의 인자와 어울리고, 함수로 만든 함수가 다른 고차 함수의 보조 함수로 사용되는 등 연계가 잘 이뤄지도록 되어 있습니다.
- Underscore.js 맛보기
함수명만으로 어떤 기능을 하는지 쉽게 알 수 있는 Underscore.js 의 몇 가지 함수들을 보도록 하겠습니다.
const _ = require('underscore');
const x1 = _.each([1, 2, 3], function(val, idx, list) { console.info(val, idx, list); });
console.info(x1);
/*
1 0 [1, 2, 3]
2 1 [1, 2, 3]
3 2 [1, 2, 3]
[1, 2, 3]
*/
const x2 = [1, 2, 3].forEach(function(val, idx, list) { console.info(val, idx, list); });
console.info(x2);
/*
1 0 [1, 2, 3]
2 1 [1, 2, 3]
3 2 [1, 2, 3]
undefined
*/
_.each({ a: 1, b: 2 }, function(val, key, obj) { console.info(val, key, obj); });
/*
1 a { a: 1, b: 2 }
2 b { a: 1, b: 2 }
*/
_.each 는 Array.prototype.forEach 와 비슷하게 동작하지만, 다른 점이 위 예시에도 보이듯 꽤 있습니다.
먼저 리턴값이 다릅니다. Array.prototype.forEach 는 undefined 를 리턴하지만, _.each 는 받았던 첫번째 인자를 그대로 리턴합니다.
두번째로 _.each 에는 사용 가능한 값의 종류가 더 많습니다. forEach 는 Array 에만 사용 가능한 배열 전용 메서드라 literal object 에는 사용할 수 없지만, _.each 는 이러한 타입에도 사용할 수 있습니다.
const list = [1, 2, 3, 4, 5, 6];
const x1 = _.reject(list, function(num) { return num % 2 === 0; });
console.info(x2); // [1, 3, 5]
console.info(list); // [1, 2, 3, 4, 5, 6]
const x2 = list.filter(num => num % 2 !== 0);
console.info(x2); // [1, 3, 5]
_.reject 는 list 를 받아 predicate 의 실행 결과가 true 인 값들을 제외한 새로운 list 를 리턴합니다.
Array 메서드를 사용해 동일한 결과를 만들고 싶다면 Array.prototype.filter 를 사용할 수 있습니다.
console.info(_.contains([1, 2, 3], 3)); // true
console.info([1, 2, 3].includes(3)); // true
_.contains 는 첫 번째 인자인 배열에 두 번째 인자의 값이 포함되어 있는지를 판단해 boolean (ture / false) 을 리턴합니다.
Array.prototype.includes 메서드를 사용해 동일한 결과를 만들 수 있습니다.
console.info(_.isArray([1, 2, 3])); // true
console.info(Array.isArray([1, 2, 3])); // true
_.isArray 는 인자로 들어온 객체가 배열인지를 검사합니다. Array 에 동일한 이름의 static 메서드가 존재합니다.
const users = [
{ id: 1, name: 'A', age: 20 },
{ id: 2, name: 'B', age: 21 },
{ id: 3, name: 'C', age: 22 },
{ id: 4, name: 'D', age: 23 },
{ id: 5, name: 'E', age: 24 },
{ id: 6, name: 'F', age: 25 },
{ id: 7, name: 'G', age: 26 },
];
console.info(_.pluck(users, 'name')); // ['A', 'B', 'C', 'D', 'E', 'F', 'G']
console.info(_.first([5, 4, 3, 2, 1])); // 5
console.info(_.first([5, 4, 3, 2, 1], 1)); // [5]
console.info(_.first([5, 4, 3, 2, 1], 2)); // [5, 4]
console.info(_.last([5, 4, 3, 2, 1])); // 1
console.info(_.last([5, 4, 3, 2, 1], 1)); // [1]
console.info(_.last([5, 4, 3, 2, 1], 2)); // [2, 1]
console.info(_.rest([5, 4, 3, 2, 1])); // [4, 3, 2, 1]
console.info(_.rest([5, 4, 3, 2, 1], 2)); // [3, 2, 1]
console.info(_.initial([5, 4, 3, 2, 1])); // [5, 4, 3, 2]
console.info(_.initial([5, 4, 3, 2, 1], 2)); // [5, 4, 3]
console.info(_.lastIndexOf([1, 2, 3, 1, 2, 3], 2)); // 4
console.info(_.lastIndexOf([1, 2, 3, 1, 2, 3], 3)); // 5
console.info(_.lastIndexOf([1, 2, 3, 1, 3], 2)); // 1
console.info(_.flatten([[1, 2, 3], [4, 5], 6])); // [1, 2, 3, 4, 5, 6]
_.pluck 은 두 번째 인자로 넘긴 key 에 해당하는 value 를 모아서 리턴합니다. Array.prototype.map 을 사용해 동일한 결과의 코드를 만들 수 있습니다. 하지만 이 다음 함수들은 직접 구현하기엔 조금 귀찮습니다.
_.first(list) 는 list[0] 과 같고 _.last(list) 는 list[list.length - 1] 와 같습니다. 두 번째 인자를 추가하면, 인자로 넘긴 수 만큼의 갯수를 가진 배열로 리턴하죠. Array.prototype.slice 를 사용하면 동일하게 만들 수 있을 겁니다.
_.rest 는 앞쪽의 값을 제외한 새로운 리스트를 반환하고, _.initial 은 _rest 와 반대방향으로 동작합니다.
_.lastIndexOf 는 뒤에서부터 동일한 값을 찾아 index 를 리턴하고, _.flatten 은 depth 가 있는 배열을 펴 주는 함수입니다.
처음 이런 함수들을 볼 때, Array 의 메서드들을 사용하면 될 것인데 왜 굳이 만들어놨을까? 하는 생각이 제일 먼저 들었습니다.
하지만 편의성은 둘째치고 이런 사소한 것도 함수로 만들면 조합성이 생기며, 실행 시점을 다룰 수 있는 이점이 생깁니다.
위의 함수들보다 좀 더 편의성을 느낄만한 함수들을 추가로 살펴보겠습니다.
const x1 = _.extend({ id: 1, name: 'A', age: 20 }, { age: 21, height: 200 });
console.info(x1); // { id: 1, name: 'A', age: 21, height: 200 }
const x2 = _.pick({ id: 1, name: 'A', age: 20 }, 'name', 'age');
console.info(x2); // { name: 'A', age: 20 }
const x3 = _.omit({ id: 1, name: 'A', age: 20 }, 'name', 'age');
console.info(x3); // { id: 1 }
_.extend 는 왼쪽에 있는 객체에 오른쪽의 객체를 덮어 씌웁니다. Object.assign 과 비슷하죠.
_.pick 은 두 번째 인자에 넘겨진 key 를 기준으로 key/value 를 남깁니다.
_.omit 은 _.pick 과 반대로 동작(제외)합니다.
const eq = function(a) { return a === 5; };
console.info(eq(5)); // true
const neq = _.negate(eq);
console.info(neq(5)); // false
console.info(_.noop()); // undefined
console.info(_.noop(10)); // undefined
console.info(_.noop({})); // undefined
이런 함수들은 좀 특이합니다. _.negate 에 함수를 전달하면 원래 함수의 결과를 반대로 바꾸는 함수를 리턴합니다.
_.noop 은 뭘 인자로 받든 undefined 만 리턴합니다.
사실 이런 함수들은 아직도 뭐에 쓰임새가 있어서 만들어놨는지 잘 모르겠습니다.
추후에 이 함수의 활용 예시를 발견한다면, 가져와보도록 하겠습니다.
// functional
const x1 = _.filter(_.map([1, 2, 3], n => n * 2), n => n <= 4);
console.info(x1); // [2, 4]
// chaining
const x2 = _.chain([1, 2, 3])
.map(n => n * 2)
.filter(n => n <= 4)
.value();
console.info(x2); // [2, 4]
위 코드는 Underscore.js 를 체인 방식으로 사용하는 예시입니다.
제가 회사에서 여러 레거시 코드들을 보면서, 이러한 형태로 체인 방식을 써놓은 코드를 굉장히 많이 봤습니다.
물론 회사에서 주로 사용하는 라이브러리는 Lodash.js 이지만, Underscore.js 도 비슷하게 _.chain 을 이용하면 값을 바꿔 나갈 객체가 생성되고 Underscore 의 함수들을 체인 방식으로 계속 실행할 수 있으며 메서드가 실행될 때마다 내부의 값을 바꿔 놓습니다.
하지만 회사에서도 누군가가 이미 _.chain 을 사용해 작성한 코드들을 볼 때 부터 들었던 의문은, 겨우 이정도의 코드 레벨에 굳이 _.chain 을 써야하는가? 였습니다. 그런 의문에서 시작해 함수형 프로그래밍을 다시 파봐야겠다 라고 생각이 들어서 지금 현재도 보고있지만 아직까지는 '유용한 것 같아 보이긴하는데 실제 활용도는 글쎄..?' 입니다. 이 의문은 앞으로의 포스팅에서 계속해 풀어보도록 하겠습니다.
Underscore 가 체인 방식을 지원하긴 하지만 기본적으로 Underscore 의 함수들은 메서드가 아닌 '함수' 입니다.
객체를 만들고 메서드를 실행하는 식으로 사용하지 않는다는 의미죠.
함수는 이미 모두 선언되어 있고, initialize 등이 필요 없이 실행하고 싶을 때 실행하면 됩니다.
또한 객체의 메서드가 아니므로 하나의 함수가 여러 개의 type 을 지원할 수 있습니다. (다형성)
- Underscore vs Lodash
• Underscore : http://underscorejs.org/
• Lodash : https://lodash.com/
Lodash 는 Underscore 의 superset 의 형태로 함수, 문서, 유닛 테스트, 성능이 더 뛰어나다고 알려져 있습니다.
실제로 Underscore 는 나온지 좀 된 라이브러리고, Lodash 는 훨씬 많은 버전 업데이트가 있었으며 인기(다운로드) 또한 많은 차이를 보이고 있습니다. 그럼 왜 이 포스팅에선 Underscore 를 보고 있을까요?
superset 형태 라는 말처럼 Lodash 는 Underscore 의 API 를 그대로 유지하면서 추가 함수들이 구현되어있고, 지연 평가 알고리즘을 통해 100배 이상의 성능 향상을 얻을 수 있다고 합니다. 여기에 틀린 말은 없지만, 정말 이정도의 차이가 있다면 Underscore 는 아예 들여다 볼 가치조차 없을 겁니다. Lodash 에서 말하는 성능 개선 케이스는 크게 3가지가 있는데 take 를 통한 지연 평가, map->map->map 과 같은 상황에서의 지연 평가, 그리고 지연 실행 입니다. 모두 체인 방식에서만 동작하는 이 3가지에 대해 확인해보도록 하겠습니다.
• take 를 통한 지연 평가
Lodash 는 지연 평가를 통해 filter 와 take 등이 하나의 체인에서 사용될 때 take 의 값을 이용해 성능을 최적화합니다.
filter 의 경우 take 에게 넘긴 숫자만큼 값이 모아졌으면 루프를 빠져나가 더 이상 조회하지 않습니다.
const _ = require('underscore');
const lodash = require('lodash');
const list = _.range(50); // [1, 2, 3, ... 49]
// Underscore
console.time('Underscore');
let i1 = 0;
const result1 = _.chain(list)
.filter(function(num) {
i1++;
return num % 2 === 0;
})
.take(5)
.value();
console.info(result1, i1); // [0, 2, 4, 6, 8] 50
console.timeEnd('Underscore');
// Lodash
console.time('Lodash');
let i2 = 0;
const result2 = lodash.chain(list)
.filter(function(num) {
i2++;
return num % 2 === 0;
})
.take(5)
.value();
console.info(result2, i2); // [0, 2, 4, 6, 8] 9
console.timeEnd('Lodash');
환경에 따라 조금 편차는 있겠지만, console.time 을 활용해 실행 시간을 같이 체크해봤습니다.
i1 과 i2 에서 보여지는 차이처럼 loop 를 도는 횟수에 차이가 있고 (take 에 의해) 제 환경에서의 실행 시간은 Underscore 가 평균 7.5ms, Lodash 가 0.74ms 로 기록되었습니다.
예전 Lodash 에선 배열의 길이가 200 이상일 때부터 지연 평가가 동작하도록 구현되어 있었습니다.
지연 평가를 하려면 선행 로직이 필요하고, take 의 값으로 loop 를 중간에서 나가야 하기 때문에 또 추가 로직이 들어가기에 배열의 길이가 200개 보다 적은 케이스에선 무조건 지연 평가를 하는 것이 오히려 성능상 불리함을 가져오기 때문이었습니다.
하지만 지금의 버전 (Underscore : 1.13.1 / Lodash : 4.17.21) 에선 더 최적화가 되어 배열의 길이에 대한 조건이 없어도 Lodash 가 훨씬 강점을 갖고 있습니다. 구조상 Lodash 의 체인 객체가 조금 더 복잡하고 크다는 단점이 있을 순 있겠습니다.
여기선 filter 를 통해 찾아지는 값들의 갯수가 take 의 수보다 크고 비교적 list 의 앞쪽에 있을수록 Lodash 의 성능은 더 좋을 것입니다.
그 반대의 상황에선 이득은 적어집니다. 만약 찾아지는 값이 한개도 없는 상황이라면 오히려 지연 평가보다는 즉시 평가되는 경우가 더 나을 수 있습니다.
물론 위 코드는 Underscore 와 Lodash 의 차이를 보이기 위한 예시라 실무에서 이런 형태로 활용될 여지는 거의 없을 수 있습니다.
우선 위처럼 take 를 쓰기 위해선 체이닝 형태로 작성해야 한다는 점도 있고, filter 와 같은 함수는 많이 쓰지만 선착순 5개만을 필터링 해야 하는 케이스는 보지 못한 것 같습니다. 물론 이런 특정 상황을 코딩해야한다면, 성능이 좋은 Lodash 를 쓰는 것이 좋아 보입니다.
• map->map->map 과 같은 상황에서의 지연 평가
이 케이스는 map 과 같은 함수를 연속으로 사용할 때 Lodash 가 지연 평가를 사용해 성능을 개선하는 부분입니다.
const _ = require('underscore');
const lodash = require('lodash');
const mul = num => num * 10;
const sub = num => num - 10;
const square = num => num * num;
// Underscore
console.time('Underscore');
const list = [1, 2, 3, 4, 5];
const result1 =
_.chain(list)
.map(mul)
.map(sub)
.map(square)
.value();
console.info(result1);
console.timeEnd('Underscore');
// Lodash
console.time('Lodash');
const result2 =
lodash.chain(list)
.map(mul)
.map(sub)
.map(square)
.value();
console.info(result2);
console.timeEnd('Lodash');
이번에도 실행 시간을 체크해봤고, Lodash 가 10배정도 빠른 결과를 보였습니다.
위 예시에서 Underscore 와 Lodash 의 코드는 같지만, 내부적으로 다르게 동작합니다.
Underscore 의 경우 loop 를 15번 돌고 Array 객체가 새로 3번 생성되며 push 는 총 15번 일어납니다.
Lodash 의 경우 loop 는 5번, 객체는 1번 생성, push 는 5번 일어납니다.
최적화에 의한 이 차이를 절차지향적으로 표현하자면 아래와 같습니다.
// Underscore
const result1 = [];
for (let i = 0 ; i < list.length; i++) {
result1.push(mul(list[i]));
}
const result2 = [];
for (let i = 0 ; i < result1.length; i++) {
result2.push(sub(result1[i]));
}
const result3 = [];
for (let i = 0 ; i < result2.length; i++) {
result3.push(square(result2[i]));
}
// Lodash
const result4 = [];
for (let i = 0 ; i < list.length; i++) {
result4.push(square(sub(mul(list[i]))));
}
Lodash 는 map 을 통해 함수들을 받아 두었다가, 마지막에 commit 혹은 value 로 실행될 때 받아둔 함수들을 한 번의 for 문에서 연속 실행되도록 합니다. Lodash 에 확실한 이점이 보이지만, 사실 이 예시 코드도 실무에서 사용되는 케이스는 극히 적습니다.
map 을 3번 돌릴 이유가 없기 때문에, 하나의 map 에서 square, sub, mul 을 조합한 형태로 만드는게 더 낫겠죠. (함수형 관점에서도)
그래서 이미 Underscore 에서는 compose 함수를 사용해 아래처럼 작성합니다.
_.map(list, _.compose(square, sub, mul));
두 가지 지연 평가 케이스에선 Lodash 가 성능상의 확실한 이점이 있지만 체인 방식을 사용해야 한다는 점과 분명 최적화가 잘 되어있지만 실무에선 활용될 부분인가? 하는 점은 생각이 남습니다. 확실한 케이스라면 Lodash 를 사용하는게 좋을 것입니다.
• 지연 실행
Underscore 의 체인 객체는 메서드를 실행하는 즉시 내부의 값을 변경합니다.
그에 반해 Lodash 의 체인 객체는 최종적으로 .value() 등을 실행할 떄까지 체인에 쌓인 함수들이 실행되지 않습니다.
const _ = require('underscore');
const lodash = require('lodash');
const users = [...];
// Underscore
const q1 = _.chain(users)
.filter(...)
.reject(...);
// Lodash
const q2 = lodash.chain(users)
.filter(...)
.reject(...);
위와 같은 코드가 있다고 가정할 때, Underscore 의 경우 코드가 아래로 가기 전 filter 와 reject 가 이미 실행되어 있습니다.
Lodash 의 경우 value() 가 없으니 filter 와 reject 는 실행되어 있지 않고 체인 내부에 예약만 되어 있는 상태입니다.
이러한 기법들은 하는 일 자체의 성능 개선보다는 최대한 실행을 미뤄 초기 로딩 속도를 개선하거나(Lodash) 반대로 미리 일정 부분까지 최대한 실행을 해서 나중에 실행될 때 빠르게 실행되도록 하기 위해(Underscore) 사용됩니다.
지연 이라는 말이 자주 등장하는 것처럼 Lodash 의 이러한 기법들은 아무래도 브라우저 환경에서는 꽤나 유용할 법 합니다.
실제로 회사에서도 프론트팀이 Lodash 를 폭넓게 쓰고 있는 듯 하네요. 백엔드 영역에선 '지연 실행' 에 별 이점이 있어 보이진 않습니다.
• 함수형 관점에서의 each
Underscore 와 Lodash 의 each 는 동일하게 동작하는 듯 하지만, 약간 다릅니다.
Underscore 와 달리 Lodash 의 each 는 중간에 루프를 멈출 수 있습니다.
const _ = require('underscore');
const lodash = require('lodash');
let i1 = 0;
_.each([1, 2, 3, 4, 5], function(num) {
i1++;
if (num < 3) console.info(num);
});
// 1
// 2
console.info(i1); // 5
let i2 = 0;
lodash.each([1, 2, 3, 4, 5], function(num) {
i2++;
console.info(num);
return num < 2;
});
// 1
// 2
console.info(i2); // 2
Underscore each 의 iteratee 함수는 무조건 돌지만, Lodash each 의 iteratee 는 false 를 리턴할 경우 루프를 멈춥니다.
언뜻 보면 기능이 하나 더 있는 모양새라 더 쓸만해 보이긴 합니다만, each 라는 함수가 해야되는 일을 생각해보면 이 기능을 대체 왜 넣은건가 싶기도 합니다.
우리는 each 라는 (Array.prototype.forEach 와 같이) 함수를 배열의 모든 요소를 전부 도는 것으로 기대하고 사용합니다.
이런 관점에서 본다면, Lodash 의 each 는 본래 해야 하는 일에 한가지 일을 더 하고 있으며 유용하다 라는 생각보단 주의해야 할 포인트가 더 늘었다고 볼 수 있습니다.
이렇게 봤을 땐 Lodash 의 each 는 함수형 스럽지도 않고, 오히려 Underscore 의 each 가 break 를 못하는게 아닌 안한다 라고 받아들여지네요. 만약 loop 에서 break 가 필요하다면 _.find, _.some, _.every 등을 사용하는게 훨씬 좋은 선택일 것입니다.
• 그 외 (2021.08 기준)
Underscore | Lodash | |
버전 | 1.13.1 | 4.17.21 |
Unpakced size | 901kB | 1.41MB |
Last updated | 5 months ago | 7 months ago |
https://www.npmtrends.com/underscore-vs-lodash
확실히 Lodash 가 버전도 많이 나왔고, 다운로드 수에서도 보이듯이 인기가 압도적입니다.
아마도 이 부분은 최신 브라우저나 기술과의 협력면에서 Lodash 가 더 좋기 때문입니다.
용량은 Lodash 가 더 크고, 함수 개수도 약 200개 정도 더 많습니다. 하지만 실용성 낮은 함수들 또한 꽤 포함되어 있죠.
예전 버전에선 Lodash 에만 있던 path 기능이 아주 유용했지만, Underscore 에서도 이 기능은 추가되었습니다.
결과적으로 현재까지는 이 두 라이브러리에 큰 차이는 없어 보입니다. 일부 케이스에선 Lodash 가 성능상의 이점을 분명히 갖고 있지만 함수형과는 거리가 멀어 보이는 부분도 있기 때문에, 앞으로 더 많은 업데이트를 통해 Lodash 가 함수형 자바스크립트에 더 큰 영향을 주는 라이브러리가 되었으면 합니다.
핵심 컨셉 및 API 를 Underscore 가 먼저 제시했기 때문에 (아직까지 Lodash 는 그만의 특별함이 있다기보단 Underscore 의 기능 확장편 정도로 보임..) 앞으로의 포스팅에선 Underscore.js 에 대해 좀 더 알아보면서 함수형 자바스크립트에 깊숙히 들어가보도록 하겠습니다.
'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 |
함수형 자바스크립트의 시작 (0) | 2021.07.24 |