Array.prototype.forEach vs Array.prototype.map

18740 단어 foreacharrayjsMapMap

수업시간에 고차함수 중 가장 많이 사용하고 중요한 4가지가 map, filter, reduce, forEach라고 말씀해주셨다. reduce는 평소에도 많이 사용해서 익숙하지만 그 이외 map, filter, forEach는 익숙하지않아 (아직 개념이 완벽하게 자리잡지않아서) 한 번 정리해보고 깊게 이해해보고자 한다.

반복문이 아닌 고차함수 forEach와 map을 사용하는 이유

for같은 반복문이나 if같은 조건문은 우리에게 익숙해져있지만, 사실 익숙하지 않다면 가독성이 상당히 떨어진다. 또한, 우리가 알고리즘 문제를 풀 때 머리 아픈 이유도 for문에 중첩되거나 반복문과 조건문을 많이 사용하면 헷갈리기 때문이다. (나도 헷갈리는데 다른 사람이 이해하기 어려운건 당연한거 아닐까...ㅠ) 그렇기때문에 최대한 혼란을 주는 반복문과 조건문을 사용하지 않고 구현하는 것이 좋다. 그렇다면 for문을 안쓰면 도대체 뭐를 쓰라는 말인가? 할 수 있지만 대안 방법은 항상 존재한다.

이 때 forEach와 map, reduce, filter같은 고차함수가 나온다. 고차함수란, 콜백함수를 전달받아 반복 호출하며 for문을 대체할 수 있다. 그러면 훨씬 가독성도 좋고, 혼란을 덜 유발한다.

Array.prototype.forEach

가장먼저 forEach를 자세히 알아보기위하여 MDN에 가서 활용법을 알아보았다.

arr.forEach(callback(currentvalue[, index[, array]])[, thisArg])

  1. callback : 각 요소에서 실행할 콜백함수이고, 3개의 매개변수를 받아온다.
  2. currentValue : 처리할 현재 요소 인덱스
  3. array : forEach()를 호출한 배열
  4. thisArg : callback을 실행할 때 this로 사용할 값

여기서 1번만 필수이고 그 이외는 옵셔널이다. forEach의 특징은 콜백함수가 값을 리턴해도 항상 undefined를 반환한다는 것이다. 그리고 각 배열 요소에 함수를 한 번씩 끝까지 실행한다. 즉, 중간에 반복문을 종료할 수 없고 끝까지 돌아야한다는 특징도 있어 break, continue같은 문은 사용할 수 없기 때문에 반복문을 끝까지 돌아야하는 경우가 forEach를 사용하기 적절한 예이다.

그럼 가장 먼저 for과 forEach의 차이점을 예시로 알아보자.

const num = [2, 4, 6, 7, 10];
const result = [];
const result2 = [];

//for 문
for (let i = 0; i < num.length; i++) {
  result.push(num[i] * 2);
}

//forEach 메서드
num.forEach(element => result2.push(element * 2));

분명 같은 결과값을 가지는데도 2줄에서 1줄로 줄었다. 물론 해당 예시는 코드가 단순하다보니 큰 차이점을 못느낄수도 있지만 만약 코드가 복잡해진다면 forEach 메서드가 훨씬 가독성이 좋다.

forEach()는 주어진 callback을 배열에 있는 각 요소에 대해 오름차순으로 한 번씩 실행합니다. 삭제했거나 초기화하지 않은 인덱스 속성에 대해서는 실행하지 않습니다. (예: 희소 배열)

여기서 '삭제했거나 초기화하지 않은 인덱스 속성에 대해서는 실행하지 않는다'라는 말이 잘 이해가 가지 않아서 예시를 찾아보고자 했다.

//초기화하지 않은 인덱스 속성에 대해서는 실행하지 않고(undefined를 반환하지않고) 무시하고 넘어간다.

const array = [1, 2, , 4, 5];
let count = 0;
array.map(element => count++); // count = 4

이 예시를 보면 2와 4사이에 공백이 있음에도 불구하고 초기화되지 않았기때문에 count는 5가 아닌 4번 증가했다. 즉, 희소배열 (배열내부의 데이터가 연속적으로 위치하지 않는 것)의 경우 존재하지 않는 요소는 순회하지 않고 넘어간다는 의미다 (그래서 결국 밀집 배열을 만드는 것 같다...)

하지만 이제 궁금증이 생겼다. count도 4로 잘 나오는데 도대체 undefined는 어디 나온다는 것일까? 다음 예시를 보자.

const array = [1,2,3,4,5].forEach(console.log);
console.log(array);

자, 그렇다면 위의 결과값은 어떻게될까?
정답은 undefined가 출력이 된다. 위에 길이가 5인만큼 돌면서 5번 해당 배열을 출력하지만, 결과값은 undefined이다. 즉, forEach 메서드는 항상 undefined를 반환해 내가 원하는 값을 array라는 객체에 담을 수 없다.

이제 3개의 매개변수 활용법을 알아보자.

const array = [1, 3, 5, 7, 9].forEach((item, index, arr) => {
  arr[index] = item * index;
  console.log(arr[index]);
});

이렇게 세 개의 매개변수를 받아와 원하는 값을 도출해낼 수 있다.

class Person {
  personList = [];

  add(arr) {
    arr.forEach(function (person) {
      this.personList.push((person += 3));
    }, this);
  }
}
const person = new Person();
person.add([1, 3, 5]);
console.log(person.personList);

그럼 forEach 메서드 내부에서 this는 바인딩이 어떻게 될까? 우선 forEach로 전달된 내부 함수는 함수 선언식(값이니까)이기때문에 일반함수로 호출이 된것이므로 this는 전역을 가리킨다. 하지만 애초에 this는 객체의 프로퍼티나 메서드를 참조하기 위한 자기 참조 변수이기때문에 객체를 생성하지 않는 일반 함수에서는 this는 정말 무의미하다.

그렇기때문에 strict mode일때는 this가 undefined를 가리키게되는데 (그래서 콜백함수와 고차함수가 가리키는 this가 달라 문제가될때 화살표함수나 bind를 많이 사용한다)

Array.prototype.map

map은 forEach와 달리 undefined가 아닌 원래 배열의 길이와 정확히 일치하는 새로운 배열을 반환한다. 즉, 원본 배열의 길이가 4였다면 반환할 배열의 길이또한 4가 된다. 이는 map을 사용하는 이유가 forEach와 다르기 때문이다. map은 다른 값과 매핑해서 새로운 배열을 반환하는것이 목적이라면, forEach문은 진짜 for의 대체용도이다 (for에서도 return 값이 존재하지않는 것 처럼 forEach도 undefined를 반환)

arr.map(callback(currentValue[, index[, array]])[, thisArg])
1. callback : 새로운 배열 요소를 생성하는 함수.
2. currentValue : 처리할 현재 요소의 인덱스
3. array : map()을 호출할 배열
4. thisArg : callback을 실행할 때 this로 사용되는 값

forEach와 마찬가지로 callback함수만 필수이고 그 이외는 다 선택사항이다. 받아오는 인자수도 똑같고, 받아온 콜백 함수를 반복 호출한다는 점도 똑같다. 하지만 map의 경우 만약 배열 중간이 비어있다면 forEach처럼 무시하고 넘어가는것이 아니라 반환할 배열에서또한 동일하게 빈 값으로 유지한다.

그렇다면 어디서 map을 많이 사용하는지 한 번 살펴보자.

class Prefixer {
  constructor(prefix) {
    this.prefix = prefix;
  }

  add(arr) {
    return arr.map(function (item) {
      return this.prefix + item;
    }, this);
  }
}
const prefixer = new Prefixer('-webkit-');
console.log(prefixer.add(['transition', 'user-select']));

이것도 class 라서 strict mode가 적용되어있다. 또한, map에 호출된 내부 함수는 일반 함수로써 호출된것이기때문에 외부에서 this를 전달하지않았다면 undefined를 가리키게 된다.

특정 프로퍼티 값을 추출할 때에도 map을 사용할 수 있다. 갑자기 특정 값을 추출하면 filter 아니야? 라고 할 수 있지만, 만약 원본 배열과 반환될 배열의 길이가 같다면 map을 사용하는 것이 적절하다.

const friends = [
  { name: 'Ji', age: '20' },
  { name: 'Lee', age: '23' },
];
const getValues = (friends, key) => friends.map(friend => friend[key]);
console.log(getValues(friends, 'name')); //[ 'Ji', 'Lee' ]

위의 예시는 정확하게 내가 원본 배열의 길이와 반환 되는 배열의 길이가 같기때문에 map을 사용했다.

즉, 프로퍼티 정렬, 프로퍼티 값 반전등 기존의 길이와 같을때는 map을 사용한다.

map과 forEach의 차이점과 공통점

공통점

  • 둘 다 고차 함수이고, 인수로 전달받은 모든 콜백 함수를 반복 호출한다.

차이점

  • 희소 배열일 경우, forEach는 빈 배열을 순회하지않고 그냥 넘어가지만 (count를 세면 빈 배열의 경우만큼 덜 순회한다) map의 경우 빈 배열을 그대로 남겨 결과에 포함시킨다.
  • map의 모든 인수를 순회한 후 나온 반환 결과는 기존 가지고 있던 배열의 길이와 같지만 forEach는 항상 undefined를 반환한다는 큰 차이점이 있다.
  • forEach는 단순히 반복문을 대체하기 위한 함수이고, map은 요소값을 다른 값으로 매핑한 새로운 배열을 생성하기 위한 함수이다.
  • map 메서드의 길이와 반환한 배열의 길이는 반드시 일치한다. 즉, 반환한 배열의 갯수를 이것보다 적게 하고싶다면 (조건에 맞게 반환값이 가변한다면) map이 아닌 filter이나 reduce를 사용해야한다.
  • forEach는 비순수함수이고 map은 순수함수이다. 그래서 값을 바꿀때 forEach를 사용하지만, 되도록 forEach를 사용하지않는것이 좋다.

즉, 둘의 목적이 다르니 목적에 맞게 사용해야한다.

결론

모든 함수들은 다 비슷한 동작을 해도 만들어지게 된 원인은 다르다. 하지만 그것을 무시하고 동작만 비슷하다고해서 막 사용하면 안되고 우리가 원하는 목적에 맞게 (+부수효과도 잘 확인해서) 적절히 사용해야한다. 그러므로 함수를 많이 사용해보는 연습이 필요한 것 같다. 나 또한 이런 연습이 부족하니 더 열심히 다양한 예시에 노출되어야겠다.

참고자료

좋은 웹페이지 즐겨찾기