이번 유닛에서는 JavaScript의 가장 큰 장점 중 하나인 비동기 흐름을 학습한다.
비동기 흐름은 callback, promise, async/await 중 하나의 문법을 이용하여 구현할 수 있다.
오늘은 라이브러리를 직접 만들며, 기존에 학습한 callback을 복습하기로 한다.
💡 라이브러리 ?
예전에는 배열 메서드가 브라우저에서 자체적으로 지원되지 않던 시절이 있었다.
이때 선배 개발자들은 보다 나은 방법으로 배열이나 객체를 다루기 위한 도구 모음집을 만들었는데,
이것을 후에 라이브러리(Library)라고 부르기 시작했다.
이번 과제에서는 배열, 객체를 다루는 Underbar라는 라이브러리를 직접 구현하면서 자바스크립트 내장 메서드가 어떻게 콜백(Callback) 함수를 활용하는지 원리부터 자세히 학습할 수 있다.
Underbar의 모티브가 되는 라이브러리는, underscore.js, lodash 등이 있다.
이 라이브러리는 여전히 JavaScript 생태계에서 인기 있는 라이브러리이다.
다음과 같은 test 기준을 통과하도록, 코드를 작성하는 것이다.
// 예시를 들자면 이런 식이다.
_.identity = function (val) {
// TODO: 여기에 코드를 작성합니다.
return val;
};
좀 괜찮았던 문제만 다뤄보도록 하겠다.
다음 문제를 미리 주어진 어디서 많이 본 것 같은 slice 메서드같은 프로토타입을 이용하는 것이다.
// _.slice는 배열의 start 인덱스부터 end 인덱스 이전까지의 요소를 shallow copy하여 새로운 배열을 리턴합니다.
_.slice = function (arr, start, end) {
// 변수를 선언할 경우, 아래와 같이 콤마(,)를 이용해 선언할 수 있습니다.
// 이때, 콤마로 연결된 변수들은 모두 동일한 선언 키워드(let, const)가 적용됩니다.
// 이런 코딩 스타일도 가능하다는 것을 보여드리기 위한 예시일 뿐, 사용을 권장하는 것은 아닙니다.
// 오픈 소스에 기여하든, 회사 내에서 개발을 하든 본인이 속한 조직의 코딩 스타일, 코딩 컨벤션을 따르면 됩니다.
// 그리고 아래와 같은 코딩 스타일을 봐도 당황하지 않고 해석할 수 있으면 됩니다.
let _start = start || 0, // `start`가 undefined인 경우, slice는 0부터 동작합니다.
_end = end;
// 입력받은 인덱스가 음수일 경우, 마지막 인덱스부터 매칭한다. (예. -1 => arr.length - 1, -2 => arr.length - 2)
// 입력받은 인덱스는 0 이상이어야 한다.
if (start < 0) _start = Math.max(0, arr.length + start);
if (end < 0) _end = Math.max(0, arr.length + end);
// `end`가 생략될 경우(undefined), slice는 마지막 인덱스까지 동작합니다.
// `end`가 배열의 범위를 벗어날 경우, slice는 마지막 인덱스까지 동작합니다.
if (_end === undefined || _end > arr.length) _end = arr.length;
let result = [];
// `start`가 배열의 범위를 벗어날 경우, 빈 배열을 리턴합니다.
for (let i = _start; i < _end; i++) {
result.push(arr[i]);
}
return result;
};
주석처럼 .last는 배열의 마지막 n개의 element를 담은 새로운 배열을 리턴한다.
위에 주어진 .slice를 이용하면 쉽게 풀 수 있을 것 같다.
// _.last는 배열의 마지막 n개의 element를 담은 새로운 배열을 리턴합니다.
// n이 undefined이거나 음수인 경우, 배열의 마지막 요소만을 담은 배열을 리턴합니다.
// n이 배열의 길이를 벗어날 경우, 전체 배열을 shallow copy한 새로운 배열을 리턴합니다.
// _.take와 _.drop 중 일부 또는 전부를 활용할 수 있습니다.
_.last = function (arr, n) {
// TODO: 여기에 코드를 작성합니다.
};
_.last = function (arr, n) {
// TODO: 여기에 코드를 작성합니다.
if (n === 0) {
return [];
} else return n ? _.slice(arr, -n) : _.slice(arr, -1); // 두 번째 입력이 생략된 경우, 마지막 요소만을 담은 배열을 리턴해야 합니다.
};
// Spread 문법 사용
_.last = function (arr, n) {
if (n === 0) {
return [];
} else {
return n ? [...arr.slice(-n)] : [...arr.slice(-1)];
}
};
일단 두 번째 입력이 0인 경우, 빈 배열을 리턴해야 하기에 if문으로 먼저 설정해주었다.
그다음 우리가 실행할 메인을 참인 경우와 거짓인 경우인, 두 가지 경우로 나눠야 하기에 삼항연산자를 사용했다.
참일 경우는 차례대로 마지막 n개의 요소를 갖는 배열을 리턴해야 하기에, .slice(arr, -n)
거짓인 경우는 두 번째 입력 값이 들어오지 않은 것이라 할 수 있다.
마지막 요소만을 담은 배열을 리턴해야 함으로 .slice(arr, -1)
이다.
단순히 '그냥 .last 안에서 돌아가는 구나'가 아니라
'.last가 _.slice를 호출하기때문에 불러와서 사용할 수 있다' 라는 접근 사고방식이 필요하다.
다음은 조금 길더라도, 앞으로 모든 것들이 참고될 자료이기에 작성하겠다.
// _.each는 collection의 각 데이터에 반복적인 작업을 수행합니다.
// 1. collection(배열 혹은 객체)과 함수 iteratee(반복되는 작업)를 인자로 전달받아 (iteratee는 함수의 인자로 전달되는 함수이므로 callback 함수)
// 2. collection의 데이터(element 또는 property)를 순회하면서
// 3. iteratee에 각 데이터를 인자로 전달하여 실행합니다.
// iteratee에는 테스트 케이스에 따라서 다양한 함수가 할당됩니다.
// Array.prototype.forEach 메소드를 사용할 때, 다양한 형태의 callback 함수를 사용할 수 있었던 걸 기억하시나요?
// 우리가 만드는 _.each 함수도 그렇게 잘 작동하게 하기 위한 방법을 고민해 봅시다.
/*
* SpecRunner를 열고 each의 네 번째 테스트 케이스를 눌러 보시기 바랍니다.
* 이 테스트 케이스의 collection은 letters이고,
* iteratee는 익명함수 function(letter) { iterations.push(letter); }); 입니다.
*
* const letters = ['a', 'b', 'c'];
* const iterations = [];
* _.each(letters, function(letter) {
* iterations.push(letter);
* });
* expect(iterations).to.eql(['a', 'b', 'c']);
*
* iteratee는 차례대로 데이터(element 또는 value), 접근자(index 또는 key), collection을 다룰 수 있어야 합니다.
* 배열 arr을 입력받을 경우, iteratee(ele, idx, arr)
* 객체 obj를 입력받을 경우, iteratee(val, key, obj)
* 이처럼 collection의 모든 정보가 iteratee의 인자로 잘 전달되어야 모든 경우를 다룰 수 있습니다.
* 실제로 전달되는 callback 함수는 collection의 모든 정보가 필요하지 않을 수도 있습니다.
*/
// _.each는 명시적으로 어떤 값을 리턴하지 않습니다.
// forEach ==> 똑같은 역할을 하는 함수
// each를 잘 구현하면 얘를 그대로 재사용해서 리턴한 결과값을 모은 새로운 배열을 반환하도록 만들면
// 배열 메소드 forEach과 다르게 입력값으로 객체도 받습니다.
_.each = function (collection, iteratee) {
// TODO: 여기에 코드를 작성합니다.
};
먼저 _.each를 구현해야 한다.
수도 코드와 함께 작성하면서 뼈를 잡았다.
_.each = function (collection, iteratee) {
// iteratee 함수가 입력값으로 들어온다.
// 얘는 배열의 각 요소를 인자로 넣고 실행할 함수
// 먼저 배열인지 아닌지 검사해서 배열이라면 -> Array.isArray()
// 배열을 순회해서 iteratee를 실행하는 코드를 작성 -> for 문
// 나머지 객체라면 for in 문을 사용해서
// 각 프로퍼티에 값을 iteratee에 넣고 실행하는 코드를 작성
if (Array.isArray(collection)) {
for (let i = 0; i < collection.length; i++) {
// for문을 이용해서 배열 전체를 순회한다.
iteratee(collection[i], i, collection);
// 그리고 i를 인덱스로 사용하여 배열의 각 요소들을 참조할 수 있다.
// 이 참조한 요소들을 iteratee의 전달인자로 넣어 호출한다.
}
} else {
for (let key in collection) {
iteratee(collection[key], key, collection);
}
}
};
처음엔 iteratee(collection[i]) 이런 식으로 하고, 어.. 왜 안돼..? 이랬었는데
iteratee 함수는 데이터, 접근자, collection 세 개의 매개변수를 인자로 받는다.
따라서 위와 같은 형태를 띄게 된다.
다음 문제의 수도 코드는 다음과 같다.
// _.filter는 test 함수를 통과하는 모든 요소를 담은 새로운 배열을 리턴합니다.
// test(element)의 결과(return 값)가 truthy일 경우, 통과입니다.
// test 함수는 각 요소에 반복 적용됩니다.
_.filter = function (arr, test) {
// TODO: 여기에 코드를 작성합니다.
// each를 사용한다.
// iteratee가 test가 되겠죠?
// test(arr[i]) ===> true냐 false냐
// 반환될 배열에 포함할지 안포함할지
// test(배열의 요소) ===> true ===> result.push(배열의 요소)
};
바로 _.each() 이런 형태로 다뤄지게 될 것이고, test 를 거치게 된다.
추가로 새로운 배열을 리턴하니까 빈 배열을 임의로 담아둘 공간도 필요할 것이다.
_.filter = function (arr, test) {
// each를 사용한다.
let result = [];
// iteratee가 test가 되겠죠?
// test(arr[i]) ===> true냐 false냐
// 반환될 배열에 포함할지 안포함할지
// test(배열의 요소) ===> true ===> result.push(배열의 요소)
_.each(arr, (el) => {
if (test(el)) {
result.push(el);
}
});
return result;
};
test를 거쳐야하기 때문에, _.each를 호출해서 두 번째 인자(함수)로 test 함수를 넘겨준다.
그 후 참이면, 임의로 생성한 공간 result에 arr 안을 순환한 요소인 el을 push 해준다.
다음은 중복을 제외한 새로운 배열을 리턴한다.
위와 비슷하게 _.each를 호출하지만 for문을 어떻게 다루느냐가 관건이다.
// _.uniq는 주어진 배열의 요소가 중복되지 않도록 새로운 배열을 리턴합니다.
// 중복 여부의 판단은 엄격한 동치 연산(strict equality, ===)을 사용해야 합니다.
// 입력으로 전달되는 배열의 요소는 모두 primitive value라고 가정합니다.
_.uniq = function (arr) {
// TODO: 여기에 코드를 작성합니다.
};
처음엔 for in 문으로 접근하려했으나, 생각해보니 그럼 요소들의 순서 개념이 없어진다.
따라서 기본적인 for 문을 사용해야 한다.
_.uniq = function (arr) {
// _.uniq([1, 2, 2, 3, 4, 5, 5]) ===> [1, 2, 3, 4, 5]
let result = [];
// 결과를 담을 새로운 배열을 하나 만든다.
// 결과 배열에 요소들을 하나씩 추가한다.
// 검증 단계를 하나 거친다.
// result 배열에 해당 요소가 이미 존재한다면 포함하지 않는다.
// indexOf
// [1, 2, 2, 3, 4, 5, 5]
// [1, 2,]
// _.indexOf(result, 2) !== -1
// ex) -1이 아니면 걸러낸다.
_.each(arr, (el) => {
for (let i = 0; i < result.length; i++) {
if (result[i] === el) {
return;
}
}
result.push(el);
});
return result;
};
다음은 _.map 메서드다.
// _.map은 iteratee(반복되는 작업)를 배열의 각 요소에 적용(apply)한 결과를 담은 새로운 배열을 리턴합니다.
// 함수의 이름에서 드러나듯이 _.map은 배열의 각 요소를 다른 것(iteratee의 결과)으로 매핑(mapping)합니다.
_.map = function (arr, iteratee) {
// TODO: 여기에 코드를 작성합니다.
// _.map 함수는 매우 자주 사용됩니다.
// _.each 함수와 비슷하게 동작하지만, 각 요소에 iteratee를 적용한 결과를 리턴합니다.
// each와 매우 비슷하지만 iteratee(배열요소) ===> 이걸 실행한 결과를 모은 새로운 배열을 반환한다.
};
_.map = function (arr, iteratee) {
// _.map 함수는 매우 자주 사용됩니다.
// _.each 함수와 비슷하게 동작하지만, 각 요소에 iteratee를 적용한 결과를 리턴합니다.
// each와 매우 비슷하지만 iteratee(배열요소) ===> 이걸 실행한 결과를 모은 새로운 배열을 반환한다.
let result = [];
_.each(arr, (el) => {
return result.push(iteratee(el));
});
return result;
};
iteratee 함수 결과를 거친 요소를 빈 배열에 push 해주면 끝이다 !
_.reduce 메서드다.
// _.reduce는
// 1. 배열을 순회하며 각 요소에 iteratee 함수를 적용하고,
// 2. 그 결과값을 계속해서 누적(accumulate)합니다.
// 3. 최종적으로 누적된 결과값을 리턴합니다.
// 예를 들어, 배열 [1, 2, 3, 4]를 전부 더해서 10이라는 하나의 값을 리턴합니다.
// 각 요소가 처리될 때마다 누적되는 값은 차례대로 1, 1+2, 1+2+3, 1+2+3+4 입니다.
// 이처럼 _.reduce는 배열이라는 다수의 정보가 하나의 값으로 축소(응축, 환원, reduction)되기 때문에 reduce라는 이름이 붙게 된 것입니다.
// _.reduce는 위에서 구현한 많은 함수처럼, 입력으로 배열과 각 요소에 반복할 작업(iteratee)을 전달받습니다.
// iteratee에 대해서 복습하면 아래와 같습니다. (일반적으로 객체를 reduce 하지는 않으므로, 배열 부분만 복습합니다.)
// iteratee는 차례대로 데이터(element 또는 value), 접근자(index 또는 key), collection을 다룰 수 있어야 합니다.
// 배열 arr을 입력받을 경우, iteratee(ele, idx, arr)
// _.reduce는 반복해서 값을 누적하므로 이 누적되는 값을 관리해야 합니다.
// 따라서 _.reduce의 iteratee는 인자가 하나 더 추가되어 최종 형태는 아래와 같습니다.
// iteratee(acc, ele, idx, arr)
// 누적되는 값은 보통 tally, accumulator(앞글자만 따서 acc로 표기하기도 함)로 표현하거나
// 목적을 더 분명하게 하기 위해 sum(합), prod(곱), total 등으로 표현하기도 합니다.
// 이때, acc는 '이전 요소까지'의 반복 작업의 결과로 누적된 값입니다.
// ele는 잘 아시다시피 반복 작업을 수행할(아직 수행하지 않은) 현재의 요소입니다.
// 여기까지 내용을 정리하면 다음과 같습니다.
// _.reduce(arr, iteratee)
// iteratee(acc, ele, idx, arr)
// 그런데 사실 누적값에 대해서 빠뜨린 게 하나 있습니다.
// 바로 '누적값은 어디서부터 시작하는가'라는 의문에 대한 대답을 하지 않았습니다.
// 이를 해결하는 방법은 초기 값을 직접 설정하거나 자동으로 설정하는 것입니다.
// _.reduce는 세 번째 인자로 초기 값을 전달받을 수 있습니다.
// 이 세 번째 인자로 초기 값이 전달되는 경우, 그 값을 누적값의 기초(acc)로 하여 배열의 '첫 번째' 요소부터 반복 작업이 수행됩니다.
// 반면 초기 값이 전달되지 않은 경우, 배열의 첫 번째 요소를 누적값의 출발로 하여 배열의 '두 번째' 요소부터 반복 작업이 수행됩니다.
// 따라서 최종적인 형태는 아래와 같습니다.
// _.reduce(arr, iteratee, initVal)
// iteratee(acc, ele, idx, arr)
// 아래 예제를 참고하시기 바랍니다.
// const numbers = [1,2,3];
// const sum = _.reduce(numbers, function(total, number){
// return total + number;
// }); // 초기 값이 주어지지 않았으므로, 초기 값은 배열의 첫 요소인 1입니다. 두 번째 요소부터 반복 작업이 시작됩니다.
// // 1 + 2 = 3; (첫 작업의 결과가 누적되어 다음 작업으로 전달됩니다.)
// // 3 + 3 = 6; (마지막 작업이므로 최종적으로 6이 리턴됩니다.)
//
// const identity = _.reduce([3, 5], function(total, number){
// return total + number * number;
// }, 2); // 초기 값이 2로 주어졌습니다. 첫 번째 요소부터 반복 작업이 시작됩니다.
// // 2 + 3 * 3 = 11; (첫 작업의 결과가 누적되어 다음 작업으로 전달됩니다.)
// // 11 + 5 * 5 = 36; (마지막 작업이므로 최종적으로 36이 리턴됩니다.)
_.reduce = function (arr, iteratee, initVal) {
// TODO: 여기에 코드를 작성합니다.
};
reduce의 구조는 초기 값에 계속 함수 리턴 값을 적용하는 방식이다.
따라서 임의의 공간에 초기 값을 담아주고, 함수를 계속 거치도록 구조를 짜야한다.
_.reduce = function (arr, iteratee, initVal) {
let acc = initVal;
_.each(arr, function (ele, idx, arr) {
if (acc === undefined) {
acc = arr[0];
} else {
acc = iteratee(acc, ele, idx, arr);
}
});
return acc;
};
// Spread 문법 적용
_.reduce = function (arr, iteratee, initVal) {
let acc = initVal;
for (const ele of arr) {
if (acc === undefined) {
acc = arr[0];
} else {
acc = iteratee(acc, ...ele); // 배열 요소를 개별적인 인수로 전달
}
}
return acc;
};
나는 매번 보던 예시인 acc로 초기 값인 initVal을 담아주었다.
그리고 초기 값이 설정이 되지 않는다면, 바로 acc가 arr 배열의 0번째 인덱스 값으로 설정되게 했다.
그래야 나머지 함수를 거치는 진행이 가능해지기 때문이다.
초기 값이 설정되었다면 곧바로 acc, ele, idx, arr 4개의 인자가 iteratee에 전달되어 실행되게 했다.
💡 정리
초기값 initVal을 acc에 할당하고, 배열 요소들을 순회하면서 iteratee 함수를 호출하여 acc 값을 갱신한다.
최종적으로 계산된 acc 값을 반환한다.
위 코드에서 사용된 _.each 함수는 배열을 순회하며 각 요소에 대해 주어진 함수를 실행하는 함수다.
이를 통해 배열의 요소들을 순차적으로 처리할 수 있다.