Web: JS Array 메서드 알아보기 (map, filter, reduce, forEach)

Heechan
HcleeDev
Published in
12 min readSep 15, 2022
Photo by Christina Rumpf on Unsplash

프로그래밍을 한지 얼마 안되었을 때는, Array의 데이터를 조작하는 것이 쉽지 않은 경우가 많았다. 자주 쓰이는 경우는 따로 메서드를 만들어 사용하곤 했다.

하지만 알고보니 여러 언어에서 편하게 배열을 조작하고 만들 수 있는 방법이 많이 있었다. JS에서도 그런 메서드들이 많아서 지금도 매우 애용하고 있다.

최근에 빡센 글들만 썼어서… 이번주는 가볍게 JS의 Array 메서드에 대해 알아보도록 하자.

함수형 메서드?

오늘 모든 메서드를 소개하는 것은 아니고, 많이 쓰는 몇 가지, forEach , map , filter , reduce 정도를 살펴보려고 한다.

그런데 이런 메서드들을 주로 함수형 메서드라고 부르는 경우가 많아보인다.

Functional Programming, 함수형 프로그래밍이 추구하는 요소에 부합하는 면이 있기 때문에 그렇게 부르는 것일텐데, 아래에서 각 메서드에 대해 소개할 때 좀 더 자세히 알아보자.

일단 내가 생각하는 함수형 프로그래밍의 가장 포인트는 단순하게 함수를 사용한다가 아니라, 가변 데이터를 피함으로써 Side Effect를 최소화하는 것에 있다.

함수형 프로그래밍에서는 수많은 기능들을 여러 순수 함수로 나눈다. 이 순수 함수는 어떤 ‘상태’에 의해서 변하는 것이 아니라, 같은 매개변수를 넣었을 때 같은 결과 값을 낸다는 것을 보장할 수 있는 함수다.

그리고 함수 자체도 1급 객체이기 때문에 함수를 다른 함수의 Parameter로 넘길 수도 있다. 참고로 JavaScript에선 모든 것이 객체라는 말이 있듯, 함수도 객체로 만들어져있다. JS는 함수형 프로그래밍에 어울리는 느낌이 좀 있다.

forEach 의 경우에는 사실 함수형 프로그래밍이랑은 그리 어울리지 않는 면이 있다. 함수를 매개변수로 받고 있다는 점을 제외하면 딱히 결과값이 있는 것도 아니고…

다만 다른 메서드들은 데이터가 변하지 않게 하면서 우리가 원하는 계산을 하는데 큰 도움을 주고 있다고 볼 수 있다. 이 다음에서 알아보자.

우리가 원하는 배열을 만드는, map, filter

배열에 담겨있는 전반적인 데이터에 대해 변형을 하고 싶을 때, 조건에 맞는 데이터만 남기고 싶을 때, 원래라면 for 문을 이용해 하나하나씩 보면서 새로운 배열에 push해줘야 할 것이다.

하지만 mapfilter 를 사용하면 이를 조금 더 쉽게 할 수 있다.

만약 이렇게 생긴 배열이 있다고 하자.

const users = [
{ name: 'Heechan', age: 24 },
{ name: 'Lee', age: 25 },
]

이를 기반으로 화면을 만드려고 하는데, 경우에 따라서 나이는 필요없고 이름만 필요할 수도 있다. 그러면 이름만 담겨있는 배열로 만들어야 한다.

const userNames = users.map(user => user.name);

map 을 이용하면 우리가 원하는 배열로 변형할 수 있다.

여기서 특징은 매개변수로 함수가 들어갔다는 점이다.

map 은 첫 번째 매개변수로 callback을 받아서, 배열의 각 요소를 해당 callback에 넣어서 나온 결과로 새로운 배열을 만들어서 준다.

어떠한 행동을 하는 함수를 매개변수를 넘긴다는 점에서 함수형 프로그래밍의 느낌이 물씬 풍긴다.

그리고 중요한 점은 기존의 배열은 건드리지 않고 새로운 배열로 만들어진다는 점이다.

usersuserNames 는 아예 다른 배열이다. JS에서 배열은 Reference 타입이기 때문에, 잘못 건들면 기존 배열이 손상될 위험이 있다. 하지만 map 메서드를 쓰면 아예 새로운 배열을 만들어서 돌려주는 것이기 때문에 보다 안전하다.

여기서 함수형 프로그래밍의 이상인 불변성을 충족해주는 특징을 느낄 수 있다.

불변한다는 특징이 있으면 코드의 결과를 예상할 수 있다는 장점이 있고, 따라서 데이터가 변경되어서 의도치 않은 동작을 피할 수 있다.

const users = [ ... ];
const userNames = [];
for (let i = 0; i < users.length; i++) {
userNames.push(users[i].name);
}
==============const users = [ ... ];
const userNames = users.map(user => user.name);

확실히 map 을 사용하는 것이 코드도 깔끔해진다.

filtermap 과 특징은 거의 비슷하다. 다만 여기는 filter 에 넘겨주는 callback이 데이터를 변형하는 함수가 아닌, 우리가 원하는 조건을 판단해줄 함수를 넣어야 한다.

예를 들어 위의 users 에서 25살 미만의 사용자만 포함하는 배열을 만들고 싶다면 아래처럼 할 수 있다.

const userUnder25 = users.filter(user => user.age < 25);

filter 에 들어가는 메서드의 핵심은 boolean을 반환하는 함수라는 점이다. filter 는 해당 요소에 대한 결과 값이 true 가 나오는 것만 골라서 배열로 만들어준다.

이 경우도 마찬가지로 기존 배열을 건드리지 않고 새로운 배열이 만들어진다.

filter 는 생각보다 유용하게 쓰일 때가 많다.

if (users.filter(user => user.age < 25).length < 2) { ... }

이런 느낌으로 사용할 때도 많다. 그렇게 깔끔한 코드는 아니지만… 각 배열 내부 값을 우리 입맛에 따라 적절히 뽑아내는데 강점이 있기에 저렇게 사용하게 되는 경우도 많은 것 같다.

하나의 값으로 압축해주는 reduce

reduce 는 배열을 쭉 돌면서 하나의 값으로 합쳐주는 기능을 한다. 사실 이렇게만 설명을 들으면 이해하기 어렵다.

예시 코드를 하나 가지고 왔다.

const array1 = [1, 2, 3];const initialValue = 0;
const sumWithInitial = array1.reduce(
(previousValue, currentValue) => previousValue + currentValue,
initialValue
);

이 경우 reduce 에는 배열의 요소들을 단순하게 더해주는 콜백 메서드가 들어갔다. 이 reduce 는 아래와 같이 실행된다고 볼 수 있다.

  • 첫 번째 요소 1에 대해서 콜백 메서드를 실행한다. initialValue 가 0으로 주어졌으므로 previousValue 는 0으로, currentValue 는 1이고, 결과 값은 1이 된다.
  • 두 번째 요소 2에 대해서 메콜백 서드를 실행한다. 직전의 결과 값이 1이었으므로, previousValue 는 1, currentValue 는 2다. 결과 값은 3이 된다.
  • 마지막 요소 3에 대해서 메서드를 실행한다. 직전의 결과 값이 3이었으므로, previouseValue 는 3, currentValue 는 3이다. 결과 값은 6이 된다.

따라서, 한 요소씩 돌아가면서 그 전의 결과값을 이용해서 하나의 값으로 합치고 있다고 확인할 수 있다.

주로 이런 느낌으로 활용한다. 위의 users 의 이름을 'Heechan, Lee' 이런 식으로 문자열 하나로 합치고 싶을 수도 있다.

const initialValue = '';const nameString = users.reduce((acc, curr, idx) => {
if (idx === 0) return acc + curr.name
else return acc + ', ' + curr.name
}, initialValue);
console.log(nameString); // 'Heechan, Lee' 출력

reduce 에 들어가는 메서드는 4개의 인자를 받을 수 있다. 지금까지 합쳐진 값 (위에선 acc ), 현재 요소 (위에선 curr ), 현재 요소의 인덱스 (위에선 idx ), 그리고 원본 배열(위에는 안나왔지만 마지막 인자로 넣으면 됨)으로 4가지다.

여기서는 초기 값을 빈 문자열로 두고, 첫 번째 이름 앞에는 , 를 붙이지 않도록 처리했다. 우리가 의도한대로 결과 값이 잘 나온다.

reduce 를 처리할 때 가급적이면 initialValue, 초기값을 제공해주는 것이 좋다. 초기값을 정해두지 않으면 배열이 비어있거나 했을 때 에러가 발생할 위험이 있다.

반복문과 함정, forEach

배열의 각 요소를 돌면서 어떤 행동을 취하기 위한, 반복문처럼 동작하기 위한 메서드도 있다. 바로 forEach 다.

const array1 = ['a', 'b', 'c'];array1.forEach(element => console.log(element));// expected output: "a"
// expected output: "b"
// expected output: "c"

forEach 도 마찬가지로 인자에 콜백 메서드를 넣어준다. forEach 는 각 요소를 순서대로 돌며 배열의 요소를 콜백 메서드의 인자로 넣어서 실행시킨다.

forEach 가 매개변수로 함수를 받는다는 점과, 기존 배열을 변형시키지는 않는다. 다만 어떤 값을 반환하는 것은 아니라는 차이가 있다. 각 요소에 맞춰 어떤 행동을 시키는 것이 목표다.

콜백 메서드는 인자를 3개까지 받을 수 있다.

  • 배열의 요소 값
  • 현재 요소의 인덱스
  • 원래 배열

실제로 쓰다보면 인덱스까지는 쓸 일이 자주 생기는 것 같다.

for 문과 차이점이 있다. 이거때문에 forEach 를 비동기 구문과 함께 사용할 때 걸리는 함정이 있다.

for 문은 단순히 내부에 있는 명령을 순서대로 N번 실행하는 느낌이 강하다.

const delay = async (time) => new Promise(resolve => setTimeout(resolve, time * 1000));const main = async () => {
for (let i = 1; i < 4; i++) {
await delay(i);
console.log(i);
}
};
main();

이런 코드라면 for 문이기 때문에 실행 후 1초가 지난 후 콘솔에 1이 출력되고, 1이 출력된 시점으로부터 2초 후에 2가, 2가 출력된 시점으로부터 3초 뒤에 3이 출력된다.

순서대로 연결되어 동작하기 때문에, 이전의 await에 의해 동작이 멈춰있는 것도 기다렸다고 볼 수 있다.

하지만 forEach 는 다르다. forEachasync 콜백 메서드를 넣어봤자, forEach 가 콜백 메서드 앞에 await 를 붙여서 실행하지 않는다.

const delay = async (ms = 1000) =>
new Promise(resolve => setTimeout(resolve, ms))

[1, 2, 3].forEach(async (num, index) => {
await delay(index * 1000)
console.log(num)
})
// 1
// 1 second later
// 2
// 1 second later
// 3

이 코드는 위에서 본 for 문 코드와 비슷하지만, forEach 를 사용했기 때문에 실제 구동은 다르게 한다. 1 다음에 2가 나올 때까지 2초가 아닌 1초만 기다리고, 2 다음에 3이 나올 때까지 3초가 아닌 1초만 기다린다. 마치 동시에 시작한 것처럼 동작한다.

이걸 보고 forEach 는 각 요소에 대한 콜백 메서드를 비동기적으로, 병렬적으로 움직인다고 설명하는 경우도 있는 것 같은데, 사실은 그렇지 않다.

분명히 forEach 는 순서대로 진행된다. 순서대로 진행되지만 각 콜백 메서드가 async 메서드여도 잠시 멈춰주지 않는다는 특징이 있다.

예를 들어 위의 경우에는 어떻게 굴러갈까. 제대로 된 문법은 아니고 그냥 각 async 메서드 단위로 묶어서 표현한 것이다.

async { // A
await delay(1 * 1000) // 1
console.log(1) // 2
}
async { // B
await delay(2 * 1000) // 3
console.log(2) // 4
}
async { // C
await delay(3 * 1000) // 5
console.log(3) // 6
}

실질적으로 1 -> 3 -> 5 -> 2 -> 4 -> 6으로 동작하게 될 것이다.

1이 발동하면 일단 해당 메서드 내에서는 1초 동안 Suspend 될 것이다. 각 콜백 메서드 내부에서는 await 가 동작을 하기 때문에, 1초를 기다리긴 할 것이다.

하지만 밖에서 봤을 때는 이 콜백 메서드가 async 함수더라도 딱히 내부에서 멈춰있다고 같이 멈춰주지 않는다. 따라서 그냥 forEach 는 A를 실행시켜놓고 다음 메서드인 B를 실행시키러간다.

B에서도 3이 호출되면 해당 메서드 내에서는 2초를 기다리겠지만, forEach 입장에서는 그냥 돌려놓기만 하고 다음 C로 넘어가서 C도 똑같이 실행시킨다.

이렇게 되면 실제로는 A, B, C를 순서대로 실행시키고 있지만, 그 사이의 시간은 매우 짧기 때문에 그냥 실행시킨 결과를 보면 1초 단위로 찍혀서, 혹시 이게 병렬적으로 한 번에 돌아가는 것은 아닐까 생각할 수 있다고 본다.

아무튼 forEach 에서는 async/await 문을 사용한다고 해서 그 다음 메서드의 동작까지 기다리게 만들 수 없다. 따라서 의도하지 않은 움직임, 다른 순서의 움직임이 나올 수 있으니 그런 경우에는 가급적이면 일반적인 for 문을 사용하거나 다른 방식을 생각해보는 것이 좋다.

결론

JS라는 언어가 짜증도 나지만 또 매력도 있다. 편한 것도 있지만 위 forEach 처럼 함정도 있고 하기 때문에…

아예 딴소리긴 한데 JS는 객체지향보다도 적절한 수준의 함수형 프로그래밍을 섞어서 사용하는 것이 훨씬 효과적이라고 생각된다.

참고한 것

https://atomizedobjects.com/blog/javascript/is-javascript-foreach-async/

--

--

Heechan
HcleeDev

Junior iOS Developer / Front Web Developer, major in Computer Science