자바스크립트 배열 고차 함수 - map filter reduce 와 체이닝 비용 (JS 중급 14단원)
이 단원에서 배우는 것
12·13단원에서 서버나 DB 에서 데이터를 가져오는 방법을 다뤘다. 가져온 다음에 하는 일이 이번 단원이다. 도서 목록을 걸러내고, 필요한 필드만 뽑고, 카테고리별로 합계를 내는 작업 말이다. 초급에서 for 문으로 하던 것을 map·filter·reduce 로 바꾸는 방법과, 바꾸지 않는 편이 나은 경우를 함께 본다. 9단원에서 다룬 클로저가 여기서 다시 나온다. 이 메서드들에 넘기는 콜백이 전부 클로저다.
map·filter·reduce·find·some·every·flatMap을 상황에 맞게 고른다.- 체이닝이 만드는 중간 배열의 비용을 수치로 파악하고, 언제
reduce한 번이나for문으로 바꿀지 판단한다. - 원본을 바꾸는 메서드와 새 배열을 만드는 메서드를 구분하고, ES2023 의 불변 버전을 쓴다.
왜 필요한가
재고가 있는 도서의 제목만 뽑는 코드를 for 문으로 쓰면 이렇다.
const titles = [];
for (let i = 0; i < books.length; i++) {
if (books[i].stock > 0) {
titles.push(books[i].title);
}
}
여기서 실제 의도는 마지막 두 줄뿐이다. 나머지는 배열을 순회하기 위한 도구 코드다. 그리고 이 도구 코드가 버그의 실제 출처다. i <= books.length 오타, titles 를 let 으로 선언해 놓고 중간에 재할당, 중첩 반복문에서 i 와 j 를 바꿔 쓰기 — 전부 도구 코드에서 난다.
const titles = books.filter((b) => b.stock > 0).map((b) => b.title);
인덱스가 사라지면 인덱스 버그도 사라진다. 그리고 코드가 무엇을 하는지 이름으로 드러난다. filter 라고 적혀 있으면 개수가 줄어들 뿐 항목이 바뀌지 않는다는 걸 안다. map 이면 개수는 그대로고 항목이 바뀐다는 걸 안다. 읽는 사람이 본문을 안 읽고도 절반은 아는 셈이다.
대신 대가가 있다. 세 번 체이닝하면 배열을 세 번 순회하고 중간 배열이 두 개 생긴다. 뒤에서 실측한다.
문법과 예제
이 단원 내내 다음 데이터를 쓴다.
const books = [
{ id: 'B001', title: '자바스크립트 완벽 가이드', category: 'JS', price: 42000, stock: 2, loans: 31 },
{ id: 'B002', title: '리팩터링', category: '설계', price: 38000, stock: 0, loans: 12 },
{ id: 'B003', title: '클린 코드', category: '설계', price: 33000, stock: 5, loans: 45 },
{ id: 'B004', title: '이펙티브 타입스크립트', category: 'JS', price: 29000, stock: 3, loans: 9 },
{ id: 'B005', title: 'HTTP 완벽 가이드', category: '네트워크', price: 40000, stock: 1, loans: 22 }
];
고르는 기준
| 하려는 일 | 메서드 | 반환 |
|---|---|---|
| 각 항목을 다른 값으로 바꾼다 | map | 같은 길이의 새 배열 |
| 조건에 맞는 항목만 남긴다 | filter | 더 짧아진 새 배열 |
| 하나의 값으로 접는다 | reduce | 임의의 값 |
| 조건에 맞는 첫 항목 | find / findIndex | 항목 / 인덱스 (없으면 undefined / -1) |
| 하나라도 / 전부 만족하는가 | some / every | 불리언 |
| 바꾼 뒤 한 단계 펼친다 | flatMap | 새 배열 |
| 부작용만 실행한다 | forEach | undefined |
console.log(books.filter((b) => b.stock > 0).length); // 4
console.log(books.find((b) => b.category === '설계')?.title); // 리팩터링
console.log(books.some((b) => b.stock === 0)); // true
console.log(books.every((b) => b.price >= 29000)); // true
console.log(books.reduce((sum, b) => sum + b.loans, 0)); // 119
find 뒤에 ?. 를 붙인 것에 주목한다. find 는 못 찾으면 undefined 를 돌려주므로 곧바로 프로퍼티를 읽으면 터진다. filter(...)[0] 대신 find 를 쓰는 이유는 찾는 즉시 멈추기 때문이다.
reduce 를 언제 쓰는가
reduce 는 "배열을 하나의 값으로 접는" 도구다. 숫자 합계는 가장 단순한 경우고, 실무에서 훨씬 자주 쓰는 것은 배열을 조회용 객체나 Map 으로 바꾸는 것이다.
// 1) 카테고리별 집계
const stats = books.reduce((acc, b) => {
const g = acc[b.category] ?? { count: 0, loans: 0, stock: 0 };
g.count += 1;
g.loans += b.loans;
g.stock += b.stock;
acc[b.category] = g;
return acc; // ← 반드시 반환한다
}, {});
console.log(stats);
// { JS: { count: 2, loans: 40, stock: 5 },
// '설계': { count: 2, loans: 57, stock: 5 },
// '네트워크': { count: 1, loans: 22, stock: 1 } }
// 2) id 로 바로 찾을 수 있는 Map
const bookById = new Map(books.map((b) => [b.id, b]));
console.log(bookById.get('B003').title); // 클린 코드
2번처럼 Map 생성자에 [키, 값] 배열을 넘기는 방식이 reduce 보다 짧고 읽기 쉽다. reduce 를 쓰기 전에 더 직접적인 방법이 있는지 먼저 본다. 목록을 id 로 찾는 코드가 반복되면 매번 find 하지 말고 이렇게 한 번 Map 을 만든다. find 를 n 번 부르면 O(n²)이고 Map 은 O(n)이다.
1번 같은 그룹핑은 최신 런타임에서 Object.groupBy(ES2024, Node 21+ / Chrome 117+)로 대체할 수 있다. 대상 환경을 확인하고 쓴다.
const grouped = Object.groupBy(books, (b) => b.category);
console.log(grouped['설계'].map((b) => b.id)); // [ 'B002', 'B003' ]
실무형 예제: 대출 통계 리포트
function buildReport(books, { minLoans = 0 } = {}) {
const target = books.filter((b) => b.loans >= minLoans);
const totalLoans = target.reduce((s, b) => s + b.loans, 0);
const rows = target
.map((b) => ({
id: b.id,
title: b.title,
loans: b.loans,
share: totalLoans === 0 ? 0 : Math.round((b.loans / totalLoans) * 1000) / 10
}))
.sort((a, b) => b.loans - a.loans);
return {
count: rows.length,
totalLoans,
top: rows[0] ?? null,
outOfStock: books.filter((b) => b.stock === 0).map((b) => b.id),
rows
};
}
const report = buildReport(books, { minLoans: 20 });
console.log(report.count, report.totalLoans); // 3 98
console.log(report.top); // { id: 'B003', title: '클린 코드', loans: 45, share: 45.9 }
console.log(report.outOfStock); // [ 'B002' ]
totalLoans 를 map 바깥에서 미리 계산한 점이 중요하다. share 를 구할 때마다 reduce 를 다시 부르면 O(n²)이 된다. 콜백 안에서 전체 배열을 다시 순회하는 코드는 항상 의심한다.
rows[0] ?? null 도 습관으로 삼는다. 빈 배열의 [0] 은 undefined 이고, 그대로 JSON.stringify 하면 그 키가 아예 사라진다.
체이닝의 비용 — 실측
객체 100만 건에 대해 같은 결과를 세 방식으로 구했다(Node 22, 애플 실리콘 기준). 절대값은 환경마다 다르지만 비율은 비슷하게 나온다.
const rows = Array.from({ length: 1_000_000 }, (_, i) => ({ id: i, stock: i % 5, loans: i % 97 }));
// (1) 체이닝 — 배열을 3번 순회, 중간 배열 2개 생성
rows.filter((r) => r.stock > 0).map((r) => r.loans).reduce((a, b) => a + b, 0); // 약 35ms
// (2) reduce 한 번 — 1번 순회, 중간 배열 없음
rows.reduce((a, r) => (r.stock > 0 ? a + r.loans : a), 0); // 약 6ms
// (3) for...of
let a = 0;
for (const r of rows) if (r.stock > 0) a += r.loans; // 약 13ms
여섯 배 차이다. 하지만 이 숫자를 오해하면 안 된다. 100만 건에서 29ms 차이라는 뜻은, 1,000건에서는 0.03ms 차이라는 뜻이다. 화면에 그리는 목록이 수백 건이라면 체이닝 쪽이 읽기 쉬우니 그대로 두는 게 맞다.
바꿀 기준을 정하면 이렇다.
- 수만 건 이상이고, 실제로 프로파일러에 잡히는 구간이다 →
reduce한 번이나for문으로 합친다. - 중간에
find나some을 콜백 안에서 부르고 있다 → 건수와 무관하게 먼저 고친다. 이건 상수 배가 아니라 차수가 다르다. - 그 외 → 읽기 쉬운 쪽을 택한다.
실무에서 자주 틀리는 것
1. sort 는 기본이 문자열 비교이고, 원본을 바꾼다
console.log([10, 9, 100, 1].sort()); // [ 1, 10, 100, 9 ] ← 사전 순
console.log([10, 9, 100, 1].sort((a, b) => a - b)); // [ 1, 9, 10, 100 ]
비교 함수를 안 주면 각 요소를 문자열로 바꿔 비교한다. 숫자 정렬에는 항상 비교 함수를 준다.
더 조용히 사고를 내는 쪽은 두 번째 성질이다.
function topBooks(list) {
return list.sort((a, b) => b.loans - a.loans).slice(0, 3); // ← 인자로 받은 배열을 바꿔 버린다
}
const ranked = topBooks(books);
console.log(books.map((b) => b.id)); // [ 'B003', 'B001', 'B005', 'B002', 'B004' ] ← 원본 순서가 깨졌다
sort·reverse·splice·push·fill 은 원본을 바꾼다. 함수 인자로 받은 배열을 이렇게 바꾸면 호출한 쪽에서 원인을 찾기 어렵다. ES2023 부터 불변 버전이 생겼다.
// ES2023 (Node 20+, Chrome 110+, Safari 16+)
const ranked = books.toSorted((a, b) => b.loans - a.loans).slice(0, 3);
console.log(books.map((b) => b.id)); // 원본 그대로
// 구형 환경 지원이 필요하면 복사 후 정렬
const ranked2 = [...books].sort((a, b) => b.loans - a.loans).slice(0, 3);
toSorted·toReversed·toSpliced·with 네 개가 한 벌이다. with(i, v) 는 한 요소만 바꾼 새 배열을 만든다. React 처럼 상태 불변을 요구하는 환경에서 특히 유용하다.
2. reduce 의 초기값을 생략한다
[].reduce((a, b) => a + b);
// TypeError: Reduce of empty array with no initial value
[].reduce((a, b) => a + b, 0); // 0
초기값을 생략하면 첫 요소가 초기값이 되고 두 번째부터 순회한다. 배열이 비면 시작할 값이 없어 터진다. 목록이 서버에서 오는 데이터라면 빈 배열은 언제든 온다. 초기값은 항상 명시한다.
초기값을 빠뜨렸을 때 더 나쁜 경우는 에러도 안 나는 쪽이다. 요소가 객체인데 초기값 없이 (acc, b) => acc + b.loans 를 쓰면, 첫 acc 가 숫자가 아니라 객체라 "[object Object]31" 같은 문자열이 나온다. 조용히 틀린 값이 흘러간다.
3. reduce 안에서 스프레드로 누적한다
// 자주 보이는 형태 — 겉보기엔 깔끔하다
const map = books.reduce((acc, b) => ({ ...acc, [b.id]: b.loans }), {});
반복마다 지금까지 쌓인 객체 전체를 복사한다. n 번째 반복에서 n-1 개를 복사하므로 전체가 O(n²)이다. 실측하면 이렇다.
| 건수 | 스프레드 누적 | 변형 누적 |
|---|---|---|
| 1,000 | 66ms | 0.5ms |
| 5,000 | 2,509ms | 0.3ms |
| 10,000 | 11,276ms | 0.7ms |
1만 건에서 11초다. 건수가 두 배가 될 때 시간이 네 배가 되는 전형적인 O(n²) 곡선이다. 목록 화면이 "데이터가 좀 늘었더니 갑자기 멈춘다"면 이 패턴을 먼저 찾는다.
// 누적 객체를 그대로 변형한다 — reduce 안에서 만든 객체이므로 부작용이 아니다
const map = books.reduce((acc, b) => {
acc[b.id] = b.loans;
return acc;
}, {});
// 더 낫다: 애초에 reduce 를 쓰지 않는다
const map2 = Object.fromEntries(books.map((b) => [b.id, b.loans]));
같은 이유로 배열 누적에서 [...acc, x] 도 피한다. acc.push(x); return acc; 를 쓴다. "불변성을 지켜야 한다"는 원칙은 함수 밖에서 온 데이터에 적용되는 것이지, 지금 만들고 있는 누적기에까지 적용할 이유가 없다.
4. 콜백에 async 를 붙인다
const details = books.map(async (b) => await loadDetail(b.id));
console.log(details[0]); // Promise { <pending> } ← 값이 아니다
const inStock = books.filter(async (b) => (await checkStock(b.id)) > 0);
console.log(inStock.length); // 5 ← 전부 통과한다
13단원에서 다룬 문제가 여기서도 나온다. map 은 Promise 배열을 돌려주므로 await Promise.all(...) 이 필요하다. filter 는 더 나쁘다. 콜백이 반환한 Promise 객체는 언제나 truthy 라 조건과 무관하게 전부 통과한다. 에러도 안 나고 결과만 틀린다.
// map: Promise.all 로 감싼다
const details = await Promise.all(books.map((b) => loadDetail(b.id)));
// filter: 먼저 판정 결과를 모은 뒤 동기적으로 거른다
const flags = await Promise.all(books.map((b) => checkStock(b.id)));
const inStock = books.filter((_, i) => flags[i] > 0);
5. 부작용을 map 으로 실행한다
books.map((b) => console.log(b.title)); // 쓸모없는 [undefined × 5] 배열이 만들어진다
books.forEach((b) => console.log(b.title));
반환값을 쓰지 않는다면 map 이 아니라 forEach 다. 배열을 하나 더 만드는 비용도 있지만, 진짜 문제는 읽는 사람에게 거짓말을 한다는 점이다. map 이 보이면 결과를 어딘가에 쓴다는 뜻으로 읽힌다. 반대로 forEach 안에서 값을 모으려고 바깥 배열에 push 하고 있다면, 그건 map 이나 filter 로 바꿔야 할 신호다.
스스로 확인하기
- 다음 코드의 출력과 그 이유를 쓰라.
const arr = [3, 1, 2]; const sorted = arr.sort(); console.log(sorted === arr, arr, [30, 4].sort()); books에서 카테고리별로 대출 수가 가장 많은 도서 한 권씩을 뽑아{ 카테고리: 제목 }형태의 객체를 만들어라. 원본books는 바뀌면 안 된다.- 아래 함수는 도서가 5,000건일 때 눈에 띄게 느리다. 두 가지 문제를 찾아 고쳐라.
function attachRank(books) { return books .sort((a, b) => b.loans - a.loans) .map((b) => ({ ...b, rank: books.findIndex((x) => x.id === b.id) + 1 })); }
정답
true [ 1, 2, 3 ] [ 30, 4 ].sort는 원본을 정렬하고 같은 배열을 반환하므로sorted === arr이true다.[30, 4]는 문자열'30'과'4'를 비교하는데'3'이'4'보다 작으므로30이 앞에 남는다.
정렬을 쓰지 않았으므로 원본이 바뀔 일이 없다.const topByCategory = books.reduce((acc, b) => { const best = acc[b.category]; if (!best || b.loans > best.loans) acc[b.category] = b; return acc; }, {}); const result = Object.fromEntries( Object.entries(topByCategory).map(([category, b]) => [category, b.title]) ); console.log(result); // { JS: '자바스크립트 완벽 가이드', '설계': '클린 코드', '네트워크': 'HTTP 완벽 가이드' }toSorted로 풀어도 되지만 정렬은 O(n log n)이고 이 방식은 O(n)이다.- 두 문제:
sort가 인자로 받은books를 그 자리에서 정렬해 호출자의 배열을 망가뜨린다. 게다가 그 결과로 아래findIndex가 이미 정렬된 배열을 뒤지게 되어 의도(원래 순서의 인덱스)와도 다른 값이 나온다.map콜백마다findIndex로 전체를 훑으므로 O(n²)이다. 5,000건이면 2,500만 번 비교한다.
function attachRank(books) { return books .toSorted((a, b) => b.loans - a.loans) .map((b, i) => ({ ...b, rank: i + 1 })); }toSorted를 쓸 수 없는 환경이면[...books].sort(...)로 바꾼다.