배열 데이터를 처리할 때 map, filter, reduce를 연쇄적으로 조합하면 코드는 깔끔해진다. 하지만 대부분의 개발자들은 이 방식이 배열을 몇 번이나 반복 순회하는지, 그로 인해 성능이 얼마나 떨어지는지 제대로 이해하지 못한 채 쓰고 있다. 같은 결과를 내면서도 한 번의 순회로 끝낼 수 있는데도 말이다. 이번에는 메서드 체이닝의 성능 문제가 정확히 뭔지, 왜 발생하는지, 어떻게 최적화할 수 있는지 실전 예제와 함께 완벽하게 정리해 보자.
JavaScript 배열 메서드들은 각각 새로운 배열을 반환한다. map()은 새 배열을 만들고, filter()도 새 배열을 만들고, reduce()도 최종 결과를 내놓는다. 세 메서드를 조합하면 중간에 2개의 임시 배열이 생기고, 원본 배열의 크기만큼 3번 순회한다는 뜻이다.
예를 들어 1000개 요소를 가진 배열에서 2배 증가한 후 10보다 큰 요소만 필터링하고 합계를 구한다면, 메모리에는 3개의 배열(원본 + map 결과 + filter 결과)이 동시에 존재한다. 이 문제는 배열 크기가 작을 때는 눈에 띄지 않지만, 수십만 개 이상의 대용량 데이터를 다룰 때 CPU와 메모리 모두 심각한 병목이 된다.
체이닝 방식과 단일 순회 방식의 성능 차이를 직접 측정해 보자. 다음은 실무에서 흔히 나타나는 세 가지 패턴이다.
패턴 1: 전통적인 메서드 체이닝 (다중 순회)
const numbers = Array.from({ length: 100000 }, (_, i) => i);
console.time('chaining');
const result1 = numbers
.map(n => n * 2) // 순회 1회: 100,000개 처리
.filter(n => n > 10) // 순회 2회: 100,000개 처리
.reduce((sum, n) => sum + n, 0); // 순회 3회
console.timeEnd('chaining');
console.log('결과:', result1);
패턴 2: reduce로 한 번에 처리 (단일 순회)
console.time('reduce-once');
const result2 = numbers.reduce((acc, n) => {
const doubled = n * 2;
if (doubled > 10) {
return acc + doubled; // 조건을 만족하는 경우만 누적
}
return acc; // 조건을 만족하지 않으면 그냥 넘어감
}, 0);
console.timeEnd('reduce-once');
console.log('결과:', result2);
두 코드의 결과는 완전히 같지만, 두 번째 방식이 처리 속도는 2~3배 빠르다. 순회를 1회 줄였기 때문이다. 메모리 사용량도 임시 배열이 없어서 훨씬 효율적이다.
전략 1: 데이터 양이 적으면(천 개 이하) 체이닝 유지
메서드 체이닝의 강점은 가독성과 함수형 프로그래밍 스타일이다. 데이터 양이 적으면 성능 차이가 체감되지 않으므로 코드 명확성을 우선하는 게 맞다.
✓ 소규모 배열 처리 (권장)
const smallNumbers = [1, 2, 3, 4, 5];
const result = smallNumbers
.map(n => n * 2)
.filter(n => n > 4)
.reduce((sum, n) => sum + n, 0);
// 결과: 18 (6 + 8 + 10)
전략 2: 데이터가 크면 reduce 하나로 통합
수만 개 이상의 데이터를 다룬다면 체이닝을 풀어서 reduce 하나로 작성해야 한다. 순회를 1회로 줄이는 것이 목표다.
✓ 대규모 배열 최적화 (권장)
const largeNumbers = Array.from({ length: 100000 }, (_, i) => i);
const result = largeNumbers.reduce((acc, n) => {
// map 로직
const transformed = n * 2;
// filter 로직
if (transformed > 10) {
// reduce 로직
return acc + transformed;
}
return acc;
}, 0);
전략 3: forEach로 명시적 루프 (복잡한 로직일 때)
여러 변수를 누적하거나 복잡한 조건이 많으면 reduce보다 forEach가 더 읽기 쉬울 수 있다.
✓ 복잡한 로직 처리
const transactions = [
{ id: 1, amount: 100, type: 'purchase' },
{ id: 2, amount: 50, type: 'refund' },
{ id: 3, amount: 200, type: 'purchase' },
];
const stats = {
totalPurchase: 0,
totalRefund: 0,
count: 0
};
transactions.forEach(tx => {
if (tx.amount > 30) {
if (tx.type === 'purchase') {
stats.totalPurchase += tx.amount;
} else {
stats.totalRefund += tx.amount;
}
stats.count++;
}
});
console.log(stats);
// { totalPurchase: 300, totalRefund: 0, count: 2 }
✗ 잘못된 방식: 조건이 여러 개인데 체이닝 남용
const data = Array.from({ length: 50000 }, (_, i) => ({
id: i,
value: Math.random() * 100,
active: i % 2 === 0
}));
// 4번 순회 발생
const result = data
.filter(d => d.active) // 순회 1
.map(d => ({ ...d, value: d.value * 1.1 })) // 순회 2
.filter(d => d.value > 50) // 순회 3
.reduce((sum, d) => sum + d.value, 0); // 순회 4
// 메모리: 임시 배열 3개 생성
✓ 올바른 방식: 한 번의 순회로 통합
const result = data.reduce((sum, d) => {
// 첫 번째 필터링
if (!d.active) return sum;
// 맵핑 (값 계산)
const newValue = d.value * 1.1;
// 두 번째 필터링
if (newValue <= 50) return sum;
// 축약
return sum + newValue;
}, 0);
// 메모리: 임시 배열 0개, 순회 1회만 발생
✗ 잘못된 방식: 중간 결과를 변수에 여러 번 저장
const step1 = numbers.map(n => n * 2);
const step2 = step1.filter(n => n > 10);
const step3 = step2.reduce((sum, n) => sum + n, 0);
// 각 step 변수가 메모리에 계속 남음
✓ 올바른 방식: 최종 결과만 변수에 저장
const result = numbers.reduce((sum, n) => {
const doubled = n * 2;
return doubled > 10 ? sum + doubled : sum;
}, 0);
// 불필요한 중간 변수 없음
API에서 받은 사용자 목록을 정제하는 실제 상황을 보자.
// ✗ 느린 방식 (메서드 체이닝)
const users = [
{ id: 1, name: 'Alice', age: 25, premium: true, balance: 1000 },
{ id: 2, name: 'Bob', age: 17, premium: false, balance: 500 },
{ id: 3, name: 'Charlie', age: 30, premium: true, balance: 2000 },
{ id: 4, name: 'David', age: 16, premium: false, balance: 100 },
];
const report = users
.filter(u => u.age >= 18) // 미성년자 제외
.map(u => ({ ...u, discount: u.premium ? 0.2 : 0.05 })) // 할인율 추가
.filter(u => u.balance >= 500) // 최소 잔액 확인
.reduce((acc, u) => acc + (u.balance * u.discount), 0); // 총 할인액
console.log('총 할인액:', report);
// ✓ 빠른 방식 (단일 순회)
const report = users.reduce((totalDiscount, u) => {
// 조건 1: 성인인가
if (u.age < 18) return totalDiscount;
// 조건 2: 최소 잔액이 있는가
if (u.balance < 500) return totalDiscount;
// 할인액 계산
const discount = u.premium ? 0.2 : 0.05;
return totalDiscount + (u.balance * discount);
}, 0);
console.log('총 할인액:', report);
// 결과: 620 (Alice 200 + Charlie 400)
직접 성능을 측정해서 최적화의 필요성을 확인해 보자.
function benchmarkChaining() {
const numbers = Array.from({ length: 1000000 }, (_, i) => i);
console.time('체이닝 방식');
const result1 = numbers
.map(n => n * 2)
.filter(n => n % 3 === 0)
.reduce((sum, n) => sum + n, 0);
console.timeEnd('체이닝 방식');
console.time('단일 reduce 방식');
const result2 = numbers.reduce((sum, n) => {
const doubled = n * 2;
return doubled % 3 === 0 ? sum + doubled : sum;
}, 0);
console.timeEnd('단일 reduce 방식');
console.log('결과 동일:', result1 === result2);
}
benchmarkChaining();
// 체이닝 방식: ~150ms
// 단일 reduce 방식: ~40ms
// 약 3.7배 빠름!
메서드 체이닝은 코드 가독성이 좋지만, 성능이 중요한 상황에서는 도구일 뿐이다. 다음은 선택 기준이다.
| 상황 | 권장 방식 | 이유 |
|---|---|---|
| 데이터 천 개 이하 | 체이닝 | 성능 차이 무시할 수 있고 가독성 우선 |
| 데이터 만 개 이상, 단순 로직 | reduce 통합 | 순회 횟수 최소화로 메모리 절약 |
| 복잡한 조건, 여러 누적값 | forEach + 객체 | 로직 명확성이 가독성을 이김 |
| 함수형 프로그래밍 강조 | 체이닝 | 코드 스타일 일관성 우선 |
배열 메서드 최적화는 작은 습관이 모여 큰 성능 개선을 만드는 분야다. 이번 글의 전략 2번(대규모 배열 최적화)을 참고해 현재 프로젝트의 느린 데이터 처리 구간을 점검해 보면, 빠른 성능 향상을 체감할 수 있을 것이다.