개발 기술/사소하지만 놓치기 쉬운 개발 지식

[JS] Array 빌트인 함수는 정말 성능이 나쁠까? (forEach, map 등)

by GicoMomg 2025. 1. 24.

0. 들어가며...

  • 코드 리뷰를 진행하던 중 반복문이 forEach로 작성된 예시를 보았고,
    과거에 읽었던 글에서 오버헤드로 인해 forEach의 성능이 일반적인 for 루프보다 느리다는 내용이 떠올랐다.
const fruits = ['apple', 'orange', 'banana'];

fruits.forEach((fruit) => ...));
  • 그래서 PR에 코멘트를 달기 전에 배열의 크기가 100, 500, 10,000일 때 forEach의 성능을 직접 측정해보았다.
  • 그 결과, 배열 크기가 100과 500일 때 forEach의 성능은 크게 나쁘지 않았다. 그러나 배열 크기가 커질수록 forEach의 성능 저하가 뚜렷하게 나타났다.
  • 이 결과를 보았을 때 한 가지 의문이 생겼다. 왜 forEach는 오버헤드가 발생하는거고 데이터가 많아질수록 성능이 저하되는걸까?
  • 이번 시간에는 Webkit의 wpewebkit-2.47.2 버전을 기준으로 Array 빌트인 함수의 구현 방식과 성능을 분석해보았다.



1. Array 빌트인 함수의 구현 방식과 성능 비교

1) Array 빌트인 함수의 구현 방식

  • wpewebkit-2.47.2(레포 링크)에서 forEach, map, filter와 같은 Array 빌트인 함수는 일반적인 for 루프를 기반으로 구현되었다.
  • 그러나 이 함수는 반복문 내에서 매번 콜백 함수를 호출하며, this 컨텍스트를 바인딩하는 형태였다.
// wpewebkit-2.47.2의 forEach 구현 코드

function forEach(callback /*, thisArg */)
{
    "use strict";

    var array = @toObject(this, "Array.prototype.forEach requires that |this| not be null or undefined");
    var length = @toLength(array.length);

    if (!@isCallable(callback))
        @throwTypeError("Array.prototype.forEach callback must be a function");

    var thisArg = @argument(1);

    for (var i = 0; i < length; i++) {
        if (i in array)
            callback.@call(thisArg, array[i], i, array); /* call함수로 this를 바인딩함 */
    }
}
  • 위 코드를 보면, forEach는 for 루프를 사용하지만, 반복문 내에서 매번 콜백 함수를 호출하면서 this를 별도로 바인딩하는 걸 알 수 있다.
  • mapfilter 함수도 for 루프 내에서 콜백 함수를 호출하며, 이때마다 thisArg를 this로 바인딩한다.
  • 결국 일반적인 for 루프와의 큰 차이점은 this 바인딩 이었는데, this 바인딩이 뭐길래 대용량 데이터를 처리할 때 성능에 영향을 주는 걸까?

 

2) this 바인딩이 성능에 미치는 영향

  • this 바인딩은 함수가 실행될 때 그 함수 안에서 this가 무엇을 가리키는지 설정하는 방법이다.
  • 여기서 this 바인딩이 필요한지 의문이 들 수 있다. this 바인딩 함수 중 하나인 call()의 예시를 간단히 살펴보자.
const person = {
    name: 'Alice',
    greet: function() {
        console.log('Hello, ' + this.name);
    }
};

const anotherPerson = {
    name: 'Bob'
};
// person 객체의 greet 함수를 호출할 때

person.greet(); // 출력: "Hello, Alice"
// greet 함수를 다른 객체에서 호출할 때

person.greet.call(anotherPerson); // 출력: "Hello, Bob"
  • 위 예시에서 person.greet()를 호출하면, this는 person 객체를 가리키게 되어 "Hello, Alice"가 출력된다.
  • call()를 사용하여 anotherPerson 객체를 this로 설정하면, 같은 greet 함수가 "Hello, Bob"을 출력된다.
  • 쉽게 말해 this 바인딩은 함수를 호출할 때마다 this가 가리키는 대상을 결정할 수 있다는 말이다!
  • 결국, 빌트인 Array 함수(forEach, map, filter 등)에서 this를 바인딩하는 이유는 콜백 함수가 특정 컨텍스트에서 실행되도록 한다.
  • 그러나 이 과정에서 다음과 같은 이유로 성능 오버헤드가 발생할 수 있다

(1) 컨텍스트 전환 비용

  • this를 바인딩하기 위해 Function.prototype.call이나 Function.prototype.apply와 같은 메서드를 사용할 경우,
    추가적인 연산을 필요하다. 이러한 컨텍스트 전환은 성능 저하를 유발할 수 있다.

(2) 엔진 최적화 방해

  • 현대 JavaScript 엔진(예: V8, JavaScriptCore)은 JIT(Just-In-Time) 컴파일러를 사용하여 코드를 최적화한다.
  • 그러나 콜백 함수의 this 바인딩은 함수의 인라인화나 기타 최적화 기법을 방해할 수 있다.
  • 이는 전체 코드의 최적화 가능성을 줄이고, 실행 속도를 저하시킬 수 있다.

(3) 메모리 사용 증가

  • 반복마다 새로운 함수 호출과 this 바인딩을 처리함으로써 메모리 사용량이 증가할 수 있다.
  • 특히 대용량 데이터 처리 시 GC(Garbage Collection) 부담을 가중시켜 추가적인 성능 저하를 초래할 수 있다.



2) 성능 비교

Array 빌트인 함수의 성능을 평가하기 위해, 세 가지 방식으로 구현한 함수들의 실행 시간을 비교해보았다(성능 비교한 코드 예시)

(1) 비교 대상

  • 표준 Array 빌트인 함수 (forEach, map, filter)
  • 커스텀 구현 함수 (this 바인딩을 사용한 함수)
  • 순수 for 루프 기반 구현 함수

(2) 테스트 환경

  • Webkit 버전: wpewebkit-2.47.2
  • 테스트 배열 크기: 100, 500, 100,000
  • 측정 항목: 평균 실행 시간 (밀리초 단위)

(3) 성능 측정에 사용한 코드

  • 성능 측정에 사용한 커스텀 filter(customFilter)와 일반 for 반복문으로 필터 기능을 구현한 코드이다.
function customFilter(array, callback, thisArg) {
  if (array == null) {
    throw new TypeError(
      "Cannot read properties of null or undefined (array is null/undefined)"
    );
  }
  if (typeof callback !== "function") {
    throw new TypeError("callback must be a function");
  }

  const length = array.length >>> 0;

  const result = [];
  let nextIndex = 0;

  for (let i = 0; i < length; i++) {
    if (!(i in array)) {
      continue;
    }
    const current = array[i];

    if (callback.call(thisArg, current, i, array)) {
      result[nextIndex] = current;
      nextIndex++;
    }
  }
  return result;
}
function filterUsingForLoop(arr, callback) {
  const result = [];

  for (let i = 0; i < arr.length; i++) {
    if (callback(arr[i], i, arr)) {
      result.push(arr[i]);
    }
  }
  return result;
}

 

  • 커스텀 map(customMap)와 일반 for 반복문으로 map 기능을 구현한 코드이다.
function customMap(array, callback, thisArg) {
  if (array == null) {
    throw new TypeError("Cannot read properties of null or undefined (array is null/undefined)");
  }
  if (typeof callback !== "function") {
    throw new TypeError("callback must be a function");
  }

  const length = array.length >>> 0;
  const result = new Array(length);

  for (let i = 0; i < length; i++) {
    if (!(i in array)) {
      continue;
    }
    const mappedValue = callback.call(thisArg, array[i], i, array);
    result[i] = mappedValue;
  }

  return result;
}
function mapUsingForLoop(arr, callback) {
  const result = [];

  for (let i = 0; i < arr.length; i++) {
    result.push(callback(arr[i], i, arr));
  }
  return result;
}

 

  • 커스텀 forEach(customForEach)와 일반 for 반복문 코드이다.
function customForEach(array, callback, thisArg) {
  if (array == null) {
    throw new TypeError("Cannot read properties of null or undefined (array is null/undefined)");
  }

  if (typeof callback !== 'function') {
    throw new TypeError("callback must be a function");
  }

  const length = array.length >>> 0;

  for (let i = 0; i < length; i++) {
    if (i in array) {
      callback.call(thisArg, array[i], i, array);
    }
  }
}
function forEachUsingForLoop(arr, callback) {
  for (let i = 0; i < arr.length; i++) {
    callback(arr[i], i, arr);
  }
}

 

(4) 비교 결과: 배열의 크기가 100일 때

  • Filter의 경우
메서드 평균 실행 시간 비고
표준 Array.filter 0.003 ms 가장 빠름, 최적화된 엔진 내부 구현
커스텀 filter 0.010 ms call로 인한 오버헤드로 표준보다 느림
for 루프 기반 filter 0.006 ms 커스텀보다 빠르지만, 표준보다는 느림

 

  • Map의 경우
메서드 평균 실행 시간 비고
표준 Array.map 0.002 ms 가장 빠름, 최적화된 엔진 내부 구현
커스텀 map 0.007 ms call로 인한 오버헤드로 표준보다 느림
for 루프 기반 map 0.007 ms 커스텀과 비슷한 성능

 

  • ForEach의 경우
메서드 평균 실행 시간 비고
표준 Array.forEach 0.001 ms 가장 빠름, 최적화된 엔진 내부 구현
커스텀 forEach 0.007 ms call로 인한 오버헤드로 표준보다 느림
for 루프 기반 forEach 0.004 ms 커스텀보다 빠르지만, 표준보다는 느림

 

배열 크기가 100일 때 비교한 결과, 표준 메서드 > for 루프 기반 구현 > 커스텀 구현 순서로 성능이 좋았다.

 

(5) 비교 결과: 배열의 크기가 500일 때

  • Filter의 경우
메서드 평균 실행 시간 비고
표준 Array.filter 0.011 ms 가장 빠름, 최적화된 엔진 내부 구현
커스텀 filter 0.026 ms call로 인한 오버헤드로 표준보다 느림
for 루프 기반 filter 0.019 ms 커스텀보다 빠르지만, 표준보다는 느림

 

  • Map의 경우
메서드 평균 실행 시간 비고
표준 Array.map 0.012 ms 가장 빠름, 최적화된 엔진 내부 구현
커스텀 map 0.019 ms call로 인한 오버헤드로 표준보다 느림
for 루프 기반 map 0.019 ms 커스텀과 유사한 성능

 

  • ForEach의 경우
메서드 평균 실행 시간 비고
표준 Array.forEach 0.004 ms 가장 빠름, 최적화된 엔진 내부 구현
커스텀 forEach 0.015 ms call로 인한 오버헤드로 표준보다 느림
for 루프 기반 forEach 0.011 ms 커스텀보다 빠르지만, 표준보다는 느림

 

배열 크기가 500일 때 비교한 결과, 배열 크기가 100일 때와 결과가 같았다. (표준 메서드 > for 루프 기반 구현 > 커스텀 구현)

 

(6) 비교 결과: 배열의 크기가 100,000일 때

  • Filter의 경우
메서드 평균 실행 시간 비고
표준 Array.filter 8.446 ms 상대적으로 빠르지만, for 루프보다 느림
커스텀 filter 7.670 ms call로 인한 오버헤드로 성능 저하
for 루프 기반 filter 3.261 ms 가장 빠름, 단순 구현으로 인해 효율적

 

  • Map의 경우
메서드 평균 실행 시간 비고
표준 Array.map 7.308 ms 표준 메서드로 최적화된 성능
커스텀 map 6.755 ms 표준보다 약간 빠르지만, 내부 최적화 부족
for 루프 기반 map 7.345 ms 표준과 유사한 성능

 

  • ForEach의 경우
메서드 평균 실행 시간 비고
표준 Array.forEach 5.280 ms 안정적이고 빠름
커스텀 forEach 4.734 ms 표준보다 약간 빠름, 최적화 효과 제한적
for 루프 기반 forEach 0.300 ms 압도적으로 빠름, 단순 반복문으로 최적화

 

배열 크기가 100,000일 때 비교한 결과, for 루프 기반 > 커스텀 구현 > 표준 메서드 순으로 성능이 좋았다.



2. 마치며...

이번 성능 측정을 통해 다음과 같이 결론을 내릴 수 있었다.

  • 작은 규모의 배열(크기 100, 500)에서는 표준 메서드가 for 루프 기반 구현보다 약간 더 빠른 성능을 보였으나,
  • 큰 규모의 배열(크기 100,000)에서는 for 루프 기반 구현이 훨씬 우수한 성능을 보였다.
  • this 바인딩은 함수 호출 시 컨텍스트 설정, 엔진 최적화 방해, 메모리 사용 증가 등 여러 요인으로 인해 성능 오버헤드를 발생한다.
  • 반복 횟수가 많은 상황에서 call, apply, bind 메서드를 사용하여 this를 바인딩할 시, 성능 저하의 주요 원인이 된다.

결국, 성능이 특히 중요한 애플리케이션에서는 for 루프 기반 구현을 우선적으로 고려할수 있다.
하지만 대용량 데이터를 다루지 않는다면, 코드의 가독성과 유지보수성 관점에서 표준 Array 빌트인 함수이 장점이 될 수 있다고 본다.

 

 

 

 

 

반응형

댓글