배열 데이터를 처리할 때 map, filter, reduce를 연쇄적으로 조합하면 코드는 깔끔해진다. 하지만 대부분의 개발자들은 이 방식이 배열을 몇 번이나 반복 순회하는지, 그로 인해 성능이 얼마나 떨어지는지 제대로 이해하지 못한 채 쓰고 있다. 같은 결과를 내면서도 한 번의 순회로 끝낼 수 있는데도 말이다. 이번에는 메서드 체이닝의 성능 문제가 정확히 뭔지, 왜 발생하는지, 어떻게 최적화할 수 있는지 실전 예제와 함께 완벽하게 정리해 보자.

 

1단계. 메서드 체이닝이 성능을 망치는 이유

JavaScript 배열 메서드들은 각각 새로운 배열을 반환한다. map()은 새 배열을 만들고, filter()도 새 배열을 만들고, reduce()도 최종 결과를 내놓는다. 세 메서드를 조합하면 중간에 2개의 임시 배열이 생기고, 원본 배열의 크기만큼 3번 순회한다는 뜻이다.

예를 들어 1000개 요소를 가진 배열에서 2배 증가한 후 10보다 큰 요소만 필터링하고 합계를 구한다면, 메모리에는 3개의 배열(원본 + map 결과 + filter 결과)이 동시에 존재한다. 이 문제는 배열 크기가 작을 때는 눈에 띄지 않지만, 수십만 개 이상의 대용량 데이터를 다룰 때 CPU와 메모리 모두 심각한 병목이 된다.

 

2단계. 실제 성능 비교 패턴 이해하기

체이닝 방식과 단일 순회 방식의 성능 차이를 직접 측정해 보자. 다음은 실무에서 흔히 나타나는 세 가지 패턴이다.

패턴 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회 줄였기 때문이다. 메모리 사용량도 임시 배열이 없어서 훨씬 효율적이다.

 

3단계. 실전 최적화 전략 3가지

전략 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 }

 

4단계. 흔한 실수 vs 올바른 방식

✗ 잘못된 방식: 조건이 여러 개인데 체이닝 남용

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);

// 불필요한 중간 변수 없음

 

5단계. 실무 예제: API 응답 데이터 정제

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)

 

6단계. 성능 측정과 프로파일링

직접 성능을 측정해서 최적화의 필요성을 확인해 보자.

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배 빠름!

 

7단계. 마무리와 선택 기준

메서드 체이닝은 코드 가독성이 좋지만, 성능이 중요한 상황에서는 도구일 뿐이다. 다음은 선택 기준이다.

상황 권장 방식 이유
데이터 천 개 이하 체이닝 성능 차이 무시할 수 있고 가독성 우선
데이터 만 개 이상, 단순 로직 reduce 통합 순회 횟수 최소화로 메모리 절약
복잡한 조건, 여러 누적값 forEach + 객체 로직 명확성이 가독성을 이김
함수형 프로그래밍 강조 체이닝 코드 스타일 일관성 우선

배열 메서드 최적화는 작은 습관이 모여 큰 성능 개선을 만드는 분야다. 이번 글의 전략 2번(대규모 배열 최적화)을 참고해 현재 프로젝트의 느린 데이터 처리 구간을 점검해 보면, 빠른 성능 향상을 체감할 수 있을 것이다.