JavaScript map과 forEach 차이
배열의 모든 요소를 한 번씩 처리할 때 map()과 forEach()를 자주 사용한다.
두 메서드 모두 콜백에 현재 요소, 인덱스, 원본 배열을 전달한다.
코드 모양도 비슷해서 단순히 취향에 따라 바꿔 써도 된다고 생각하기 쉽다.
하지만 두 메서드의 목적은 다르다.map()은 각 요소를 다른 값으로 변환해 새 배열을 만들고, forEach()는 각 요소에 필요한 작업을 실행한다.
결과 배열이 필요한지가 가장 먼저 볼 기준이다.
map의 반환값과 새 배열
map()은 콜백이 반환한 값을 모아 새로운 배열을 반환한다.
const prices = [1000, 2000, 3000]
const discountedPrices = prices.map((price) => price * 0.9)
console.log(discountedPrices)
// [900, 1800, 2700]
console.log(prices)
// [1000, 2000, 3000]입력 배열의 숫자 하나가 결과 배열의 숫자 하나로 대응된다.
이처럼 기존 데이터를 화면에 필요한 형태로 바꾸거나 객체 목록에서 특정 속성만 꺼낼 때 map()이 잘 맞는다.
const users = [
{ id: 1, name: 'Kim' },
{ id: 2, name: 'Lee' },
]
const names = users.map((user) => user.name)
console.log(names)
// ['Kim', 'Lee']map() 콜백의 return은 새 배열에 들어갈 값을 결정한다.
중괄호를 쓴 화살표 함수에서 return을 빠뜨리면 해당 위치에는 undefined가 들어간다.
const numbers = [1, 2, 3]
const doubled = numbers.map((number) => {
number * 2
})
console.log(doubled)
// [undefined, undefined, undefined]이 경우에는 계산 결과를 명시적으로 반환해야 한다.
const doubled = numbers.map((number) => {
return number * 2
})한 줄 표현식이라면 중괄호를 생략해 암시적으로 반환할 수도 있다.
const doubled = numbers.map((number) => number * 2)forEach의 반환값과 부수 효과
forEach()는 각 요소마다 콜백을 실행한 뒤 항상 undefined를 반환한다.
콜백에서 어떤 값을 return해도 forEach()의 결과로 모이지 않는다.
const numbers = [1, 2, 3]
const result = numbers.forEach((number) => {
return number * 2
})
console.log(result)
// undefined그래서 forEach()는 반환 배열이 아니라 작업 실행 자체가 목적일 때 사용한다.
로그 출력, 알림 전송, 외부 상태 갱신처럼 배열 밖에 효과를 만드는 코드가 대표적이다.
const messages = ['시작', '처리 중', '완료']
messages.forEach((message) => {
console.log(message)
})콜백 안의 return은 현재 콜백 호출을 끝낼 뿐 전체 반복을 중단하지 않는다.
예외를 던지는 방법을 제외하면 forEach()에는 break에 해당하는 기능이 없다.
조건을 만족했을 때 반복을 끝내야 한다면 for...of, find(), some() 같은 도구가 의도를 더 잘 드러낸다.
원본 배열 변경의 실제 기준
map()과 forEach() 자체는 push(), splice(), sort()처럼 원본 배열을 직접 변경하는 메서드가 아니다.
그렇다고 콜백 안의 코드까지 원본을 보호해 주는 것은 아니다.
map()이 새 배열을 반환한다는 말은 깊은 복사본을 만든다는 뜻도 아니다.
객체를 그대로 반환하면 두 배열은 같은 객체를 가리킨다.
const users = [{ name: 'Kim', active: false }]
const sameUsers = users.map((user) => {
user.active = true
return user
})
console.log(users[0].active)
// true
console.log(users[0] === sameUsers[0])
// true원본 객체를 유지하면서 값을 바꾼 새 객체가 필요하다면 복사한 뒤 변경한다.
const users = [{ name: 'Kim', active: false }]
const activeUsers = users.map((user) => ({
...user,
active: true,
}))
console.log(users[0].active)
// false
console.log(activeUsers[0].active)
// trueforEach()도 마찬가지로 콜백에서 객체 속성을 바꾸거나 배열에 값을 추가하면 외부 상태가 변경된다.
const names = []
const users = [{ name: 'Kim' }, { name: 'Lee' }]
users.forEach((user) => {
names.push(user.name)
})이 코드는 동작하지만 새 배열을 만드는 목적이라면 map()이 더 직접적이다.
메서드 이름만 보고 불변성을 판단하지 말고 콜백이 어떤 값을 읽고 바꾸는지까지 확인해야 한다.
비동기 콜백의 차이
forEach()는 콜백이 반환한 Promise를 기다리지 않는다.
따라서 async 콜백을 넣었다고 해서 모든 비동기 작업이 끝난 뒤 다음 줄로 넘어가는 것은 아니다.
async function saveAll(users) {
users.forEach(async (user) => {
await saveUser(user)
})
console.log('저장 완료')
}위 로그는 saveUser() 작업이 끝나기 전에 출력될 수 있다.
함수 바깥에서 await users.forEach(...)라고 써도 forEach()가 반환하는 값은 undefined이므로 해결되지 않는다.
여러 비동기 작업을 동시에 시작하고 모두 끝날 때까지 기다리려면 map()으로 Promise 배열을 만든 뒤 Promise.all()에 전달한다.
async function saveAll(users) {
const savedUsers = await Promise.all(
users.map((user) => saveUser(user)),
)
console.log('저장 완료')
return savedUsers
}여기서 map()은 저장된 사용자 배열을 즉시 만드는 것이 아니다.
각 콜백이 반환한 Promise를 모은 배열을 만들고, Promise.all()이 그 Promise들이 이행될 때까지 기다린다.
작업을 반드시 하나씩 순서대로 실행해야 한다면 for...of와 await를 함께 쓴다.
async function saveInOrder(users) {
for (const user of users) {
await saveUser(user)
}
}동시 실행과 순차 실행은 속도뿐 아니라 서버 부하와 작업 순서에도 영향을 준다.
단순히 비동기 코드라는 이유로 항상 Promise.all()을 선택하지는 말아야 한다.
선택 기준
각 입력 요소를 가공한 결과 배열이 필요하면 map()을 사용한다.
API 응답을 UI 모델로 바꾸기, 객체에서 ID만 추출하기, 숫자 단위를 변환하기 같은 작업이 여기에 해당한다.
반환 배열 없이 각 요소에 같은 작업을 실행하면 forEach()를 사용한다.
로그 남기기나 이미 존재하는 외부 객체 갱신처럼 부수 효과가 목적일 때 의도가 분명해진다.
반복 중간에 멈춰야 한다면 forEach() 대신 for...of, some(), find() 등을 검토한다.
비동기 작업을 동시에 실행하고 기다려야 한다면 Promise.all(array.map(...)) 패턴을 사용한다.
비동기 작업을 순서대로 기다려야 한다면 for...of를 사용한다.
누적값 하나를 만들 때는 map()이나 외부 변수를 바꾸는 forEach()보다 reduce()가 목적에 맞을 수 있다.
조건에 맞는 요소만 남길 때는 map()에서 값을 억지로 없애지 말고 filter()를 사용한다.
배열 메서드는 반복 가능 여부보다 최종 결과의 형태를 기준으로 고르는 편이 읽기 쉽다.
자주 발생하는 오용
첫 번째 오용은 반환값을 쓰지 않는 map()이다.
users.map((user) => {
console.log(user.name)
})이 코드는 사용하지 않을 새 배열을 만들고 각 칸을 undefined로 채운다.
결과 배열이 필요 없다면 forEach()나 for...of가 더 알맞다.
두 번째 오용은 forEach()의 결과를 이어서 사용하는 것이다.
users
.forEach((user) => user.name)
.filter((name) => name.length > 2)
// 오류: forEach()의 결과는 undefined다.가공 결과를 체이닝하려면 map()으로 배열을 반환해야 한다.
const longNames = users
.map((user) => user.name)
.filter((name) => name.length > 2)세 번째 오용은 여러 인자를 받는 함수를 콜백으로 그대로 넘기는 것이다.map()은 요소뿐 아니라 인덱스와 배열도 콜백에 전달한다.
console.log(['10', '10', '10'].map(parseInt))
// [10, NaN, 2]parseInt()는 두 번째 인자를 진법으로 해석하므로 배열 인덱스가 예상하지 않은 진법으로 전달된다.
필요한 인자만 명시하는 화살표 함수를 쓰면 의도가 분명하다.
const numbers = ['10', '10', '10'].map((value) => {
return parseInt(value, 10)
})
console.log(numbers)
// [10, 10, 10]희소 배열의 동작
인덱스 일부가 비어 있는 희소 배열에서는 두 메서드 모두 빈 칸에 콜백을 호출하지 않는다.map()은 결과 배열에서도 같은 위치를 빈 칸으로 유지한다.
const sparse = [1, , 3]
const doubled = sparse.map((number, index) => {
console.log(index)
return number * 2
})
// 0
// 2
console.log(doubled)
// [2, empty, 6]일반적인 데이터 배열에서는 일부러 빈 슬롯을 만들 일이 많지 않다.
그래도 undefined 값이 들어 있는 칸과 아예 존재하지 않는 빈 슬롯은 다르게 순회된다는 점은 알아둘 만하다.
희소 배열의 동작은 map()과 forEach()를 고르는 주된 기준이 아니라 디버깅할 때 확인할 예외 조건에 가깝다.
정리
map()은 콜백 반환값으로 새 배열을 만든다.forEach()는 콜백을 실행하고 undefined를 반환한다.
새 배열이 필요하면 map(), 작업 실행 자체가 목적이면 forEach()를 선택한다.
두 메서드 모두 콜백이 객체나 외부 상태를 바꾸는 것까지 막아주지는 않는다.
비동기 콜백에서는 차이가 더 중요하다.
동시 작업을 모두 기다리려면 Promise.all(array.map(...)), 순차 처리가 필요하면 for...of와 await를 사용한다.
반환하려는 결과의 형태와 작업 순서를 먼저 정하면 메서드 선택은 자연스럽게 따라온다.
참고 자료














