현록

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)
// true

forEach()도 마찬가지로 콜백에서 객체 속성을 바꾸거나 배열에 값을 추가하면 외부 상태가 변경된다.

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...ofawait를 함께 쓴다.

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...ofawait를 사용한다.
반환하려는 결과의 형태와 작업 순서를 먼저 정하면 메서드 선택은 자연스럽게 따라온다.

참고 자료

관련 포스트
TypeScript type과 interface 차이 thumbnail
TypeScript type과 interface 차이
TypeScript의 type과 interface 차이를 객체 모델링, 유니온, 선언 병합, extends와 교차 타입의 충돌 처리, 실무 선택 기준으로 나누어 정리합니다.
JavaScript ==와 === 차이 thumbnail
JavaScript ==와 === 차이
JavaScript ==와 ===의 차이를 암시적 타입 변환, null과 undefined, boolean과 문자열, 객체 참조, NaN, 비교 연산자 선택 기준으로 정리합니다.
marked.js 사용법과 기본 설정 thumbnail
marked.js 사용법과 기본 설정
marked.js 사용법을 초보자 기준으로 정리합니다. marked.parse와 parseInline, gfm과 breaks 옵션, 브라우저 출력, HTML sanitize 주의점까지 함께 봅니다.
JavaScript var let const 차이 thumbnail
JavaScript var let const 차이
JavaScript var, let, const 차이를 함수와 블록 스코프, 재할당과 재선언, 호이스팅과 TDZ, 반복문 closure, 객체 변경 기준으로 정리합니다.
URL path parameter와 query string 차이 thumbnail
URL path parameter와 query string 차이
URL path parameter와 query string의 차이를 초보자 기준으로 정리합니다. 리소스 식별자, 조회 조건, URLSearchParams, 인코딩, API 설계 기준을 함께 봅니다.
쿠키와 localStorage 차이 thumbnail
쿠키와 localStorage 차이
쿠키와 localStorage의 차이를 초보자 기준으로 정리합니다. 서버 자동 전송, JavaScript 접근, 만료 시간, HttpOnly, Secure, SameSite, 로그인 상태 저장 기준을 함께 봅니다.
script async와 defer 차이 thumbnail
script async와 defer 차이
HTML script 태그의 기본 동작, async와 defer의 다운로드 방식, 실행 순서, DOMContentLoaded와의 관계를 초보자 기준으로 정리합니다.
CORS 에러와 해결 기준 thumbnail
CORS 에러와 해결 기준
브라우저의 Same-Origin Policy, CORS 응답 헤더, preflight, 서버에서 해결해야 하는 이유를 초보자 기준으로 정리합니다.
fetch와 axios 차이 thumbnail
fetch와 axios 차이
fetch와 axios의 차이를 초보자 기준으로 정리합니다. 설치 여부, JSON 처리, HTTP 에러 처리, timeout, interceptor, 언제 어떤 방식을 쓰면 좋은지 함께 봅니다.
Vue 3 입문기 thumbnail
Vue 3 입문기
Vue의 메인 버전이 3가 된지 꽤 됐다. 작성 당시에는 Nuxt 3가 RC 단계였기 때문에 실서비스에 바로 적용하기에는 조심스러웠다. 그래서 궁금하고 심심하던 참에 간단한 Todo App을 만들어봤다.
marked.js renderer custom 하기 thumbnail
marked.js renderer custom 하기
marked.js renderer custom 방법을 정리합니다. Renderer의 역할, image와 link 렌더러 커스텀, alt와 rel 처리, sanitize-html 같은 HTML 정화 주의점까지 함께 봅니다.
가독성 있게 상수 넣기 thumbnail
가독성 있게 상수 넣기
오늘 회사 동료의 PR 리뷰 과정에서 좋은 기능을 공유해주셔서 TIL로 남겨봅니다. 아래와 같이 상수에 언더바(_)로 콤마처럼 구분을 시켜줄 수 있습니다. 앞으로 깔끔하고 좋은 코드를 작성하기 위해 자주 사용해야겠습니다 :)
TS에서 generic optional 하게 설정하기 thumbnail
TS에서 generic optional 하게 설정하기
오늘 next에서 `getStaticProps`와 `getLayout` 패턴을 함께 사용할 때, typescript generic을 넘겨주는 작업을 하고 있었는데, 기본값이 없다보니 기존 코드에 에러가 발생했었다. 이를 해결하기 위해 찾아보니 단순히 아래 예시처럼 `= {}`을 추가해주면 해결된다고 한다.
Backend에서 API Response가 snake_case인 경우엔? thumbnail
Backend에서 API Response가 snake_case인 경우엔?
안녕하세요. 프론트엔드 개발자의 경우, 가끔 백엔드의 API Response 값이 snake_case일 경우 어떻게 관리할지에 대해 고민에 빠지게 됩니다. 저도 오늘 같은 상황을 겪게 되었는데,  이번엔 네이밍 컨벤션을 맞춰주기로 했습니다. 컨벤션을 맞추는 데에는 여러가지 방법이 있겠지만, 고민 끝에 저는 axios의 interceptors를 통해 해결을 해보았습니다.
Promise 다루기 (feat. 병렬실행, 순차실행) thumbnail
Promise 다루기 (feat. 병렬실행, 순차실행)
오늘은 Promise를 통해 구문을 동기 처리 할 때, 여러 Promise들을 다루는 법을 소개해보겠습니다. Javscript를 작성하다 보면, 가끔 여러 Promise들을 다룰 때가 있습니다. 필자도 Nodejs 서버에서 동시에 여러 쿼리를 실행할 때 자주 마주쳤었는데요. 오늘은 어떻게 하면 Promise들을 유연하게 다룰 수 있는지 알아보겠습니다. 시작하기 앞서, 네 가지 Promise를 선언하고, 그들을 하나의 Array에 묶어보겠습니다.