[서론]
전제
JS 코드는 그냥 실행되지 않는다. 브라우저와 Node.js 환경에서 JS는 V8 엔진 위에서 동작한다. V8은 JS 코드를 단순히 해석하는 데 그치지 않고, 실행 중에 코드를 분석하고 최적화한다. 같은 코드라도 V8이 어떻게 판단하느냐에 따라 실행 속도가 달라진다.
이 글은 그 최적화 방식을 따라가며 forEach, map, filter, reduce의 내부구조를 이해하는 것을 목표로 한다. 이 네 가지 함수는 배열 전체를 순회하며 콜백을 실행한다는 공통 구조를 가지면서도, 내부에서 하는 일이 각자 다르다. Elements Kind가 무엇인지, 이터레이터 메서드가 왜 이런 형태로 설계됐는지, 그리고 각 함수가 내부에서 실제로 어떻게 다르게 동작하는지를 다룬다.
여기서 다루는 내용은 V8 엔진 기준이다.
SpiderMonkey(Firefox), JavaScriptCore(Safari) 같은 다른 엔진은 내부 구현이 다를 수 있다. 다만 배열을 효율적으로 처리하기 위한 핵심 원리는 엔진을 막론하고 유사하다.
[본론]
내부구조를 다루기 앞서, 아래 문제를 보자.
100,000개 요소로 완전히 채워진 배열에서, 동일한 더하기 연산 x+1을 map()과 forEach()로 실행할 때 어느 쪽이 더 빠를까?
const arr = Array(100000).fill(0);
arr.map(x => x+1);
arr.forEach(x => x+1);
forEach 방식이 18.907ms, Map 방식이 41.168ms로 forEach가 약 2배 정도 더 빠르다. 내부적으로 어떤 차이가 있길래 이런 문제가 발생할까?
1. [Elements Kind]
V8은 배열을 연산할 때 단순히 인덱스를 조회하는 것이 아니라, 배열의 요소들이 어떤 종류들인지 파악하는 Element Kind 과정을 거친다. 이 작업을 잘 이해하면 특정 상황에서 배열의 처리 속도가 달라지는 이유를 알 수 있다.
[배열은 6가지 조합으로 분류된다.]
V8은 배열을 두 가지 축으로 분류한다.
- 첫번째 축은 배열에 구멍(Hole)이 있는지 여부이다. 구멍이 없으면 Packed, 있으면 Holey이다.
- 두번째 축은 요소의 타입이다. 작은 정수면 SMI(Small Integer), 부동소수점이면 Double, 그 외 문자열은 Elements로 분류된다.
첫 번째 축(2개), 두 번째 축(3개)의 조합으로 배열은 6가지로 분류된다.

[1, 2, 3] // PACKED_SMI_ELEMENTS
[1.1, 2.2, 3.3] // PACKED_DOUBLE_ELEMENTS
[1, 'a', {}] // PACKED_ELEMENTS
[1, , 3] // HOLEY_SMI_ELEMENTS
[1.1, , 3.3] // HOLEY_DOUBLE_ELEMENTS
[1, , 'a'] // HOLEY_ELEMENTS
V8이 이렇게 분류한데는 이유가 있을 것이다. 그 이유를 설명하겠다.
[첫 번째 축, Packed와 Holey]
첫 번째 축에 소개된 Hole에 대한 개념을 다루겠다. 아래 코드를 보자.
두 배열을 모두 arr [1]으로 읽으면 undefined가 나온다. 겉보기엔 모두 undefined로 동일해 보인다.
const holey = [1, , 3]; // hole
const withUndefined = [1, undefined, 3]; // 명시적 undefined
in 연산자로 "1번 인덱스에 값이 존재하는가"를 물어보면 대답이 달라진다. 대답은 "holey에서는 값 자체가 존재하지 않는다"이다.
1 in holey // false — 인덱스 1에 값 자체가 없음
1 in withUndefined // true — 인덱스 1에 undefined라는 값이 있음
왜 같은 인덱스인데 []와 in의 결과가 다른 걸까.
in은 <이 인덱스에 값이 존재하는가>라는 물음이다.
hole은 그 자리에 값을 할당한 적이 없으니 false, 명시적으로 undefined를 넣은 건 값이 존재하니 true다.
arr[1]은 <값을 달라>는 요청이다.
V8은 인덱스 1을 확인하고, 없으면 프로토타입 체인을 타고 올라가고, 거기도 없으면 undefined를 반환한다. hole이든 명시적 undefined든 최종적으로 undefined가 나오니 구분이 안 된다.
여기서 hole의 정체가 드러난다. hole은 "undefined가 저장된 셀"이 아니라 "값 자체가 할당되지 않은 셀"이다.
이 차이가 V8에게 중요한 이유는, hole이 있는 배열을 순회할 때 JS 스펙상 각 인덱스마다 HasProperty 체크를 수행해야 하기 때문이다. 값이 없으면 프로토타입 체인을 타고 올라가 찾아봐야 한다는 보장을 지켜야 한다. V8은 이 가능성을 배제할 수 없다.
(프로토타입에 대한 설명은 다음에 자세히 다뤄보겠다.)
반면 Packed 배열은 모든 인덱스에 값이 보장되므로 HasProperty 체크 자체가 필요 없다.
그래서 V8은 Packed와 Holey를 구분한다. hole이 없는 배열에서 불필요한 탐색 비용을 없애기 위해서다.
[두 번째 축, 요소의 타입]
앞서 설명했듯 V8은 요소의 타입을 세 가지로 구분하고 있다.
1. SMI(Small Integer)는 작은 정수이다. V8은 이 범위의 정수를 포인터 안에 직접 집어넣는다. 별도의 힙 할당이 없고, 읽는 순간 정수값이 나올 수 있다.
2. Double은 부동 소수점이다. 배열 내부에서 64bit Float로 저장된다. 힙 할당은 없으나 SMI보다 두 배의 메모리 공간을 차지한다.
3. Element는 그 외의 모든 것이다. 문자열, 객체가 여기에 속한다. SMI과 Double과는 다르게, 각 요소를 가리키는 포인터를 저장한다. (실제 값은 힙 어딘가에 존재) 따라서 읽을 때마다 포인터를 따라가는 간접 참조가 요구된다.
이 차이가 실제 성능에 영향을 미치는지 의문이 든다면 아래 사례를 확인해 보자.
먼저, 같은 배열을 ForEach로 순회했을 때 비용이다.
[SMI] forEach → 0.758ms
[Double] forEach → 1.183ms
[Elements] forEach → 0.687ms
별 차이가 없을 수 있다. map으로 바꿨을 때는?
[SMI] map → 2.131ms
[Double] map → 5.302ms
[Elements] map → 1.948ms
Double이 갑자기 튀어 오른다. forEach와 대비했을 때 4.5배 차이이다. 이유는 Map은 결과 배열을 새로 만들기 때문이다.
100,000개의 배열을 초기화할 때 SMI보다 두 배의 메모리를 쓰게 된다.
V8이 타입을 세 가지로 구분하는 이유가 바로 여기에 있다. 각 타입에 맞는 메모리 레이아웃을 선택해서 읽기 비용을 줄이는 것이다.
2. [Elements Kind의 특징]
이를 통해 V8은 Kind가 구체적일수록 더 공격적으로 최적화하게 된다. 예로 PACKED_SMI_ELEMENTS은 모든 요소가 정수임이 보장되므로 타입 체크를 건너뛰고 연속된 메모리를 직접 읽는다. HOLEY_ELEMENTS는 매 인덱스마다 구멍이 있는지를 확인하고, 구멍이면 HasProperty 체크를 실행한다.
그럼 이 Elements Kind는 어느 시점에 결정이 될까? Kind는 배열이 생성되는 순간 결정된다. 이후 요소가 추가되거나 변경될 때 재평가한다.
const a = [1, 2, 3]; // 생성 시점에 PACKED_SMI_ELEMENTS
const b = [1, 2, 'hello']; // 생성 시점에 PACKED_ELEMENTS
const c = []; // 생성 시점에 PACKED_SMI_ELEMENTS (비어있어도)
중요한 점은 이 전환은 단방향이다. pop()으로 요소를 제거해서 배열이 다시 정수만 남아도 Kind는 되돌아오지 않는다.
const a = [1, 2, 3]; // PACKED_SMI_ELEMENTS
a.push(1.5); // PACKED_DOUBLE_ELEMENTS
a.push('x'); // PACKED_ELEMENTS
a.pop(); a.pop(); // 다시 [1, 2, 3]이 됐지만 — 여전히 PACKED_ELEMENTS
V8은 Kind를 되돌리기 위해 배열 전체를 다시 스캔하느니, Kind를 유지하는 편이 낫다고 판단한다.
그럼 아래 arr의 Elements Kind는 어떻게 평가될까? 0으로 꽉 찬 정수 배열이니 PACKED_SMI_ELEMENTS이지 않을까?
const arr = Array(100000).fill(0);
정답은 HOLEY_SMI_ELEMENTS이다. Array(n)은 크기만 지정된 빈 배열을 만드는 순간 Holey로 출발하기 때문에, 이후 fill(0)으로 값을 전부 채워도 떠나간 Kind는 되돌아오지 않는다. 매정한 놈이다.
Elements Kind를 정리하자면
1. V8은 배열의 hole 여부, 요소의 타입으로 배열을 6가지 종류로 분류한다.
2. 분류된 6가지 종류로 연산의 차이를 주어, 최적화를 진행한다.
3. Kind는 배열이 생성되는 순간 결정되고, 요소가 추가/변경될 때 재평가된다. 단, 이 전환은 단방향이라 한 번 강등되면 되돌아오지 않는다.
하지만 이것만으로 도입부의 질문이 완전히 설명된 것은 아니다. 이 파트에서는 배열이 어떻게 분류되는 것인지 설명한 것이다.
말했듯이 Array(100000).fill(0)은 HOLEY_SMI 배열이다. 이 배열을 순회하는 어느 함수든 HasProperty 비용을 치르게 된다.
이제 이 분류 위에서 각 함수가 어떻게 다르게 동작하는지 알아볼 필요가 있다.
3. [이터레이터]
본론으로 들어가기 전에 한 가지 질문을 먼저 던져야 한다. forEach, map, filter, reduce는 왜 이런 형태로 설계됐을까?
JS가 이 네 함수를 도입하기 전, 배열 순회는 전부 for 루프였다.
const data = [1, 2, 3, 4, 5];
// 짝수만 골라서 2배로 — for 루프
const result = [];
for (let i = 0; i < data.length; i++) {
if (data[i] % 2 === 0) {
result.push(data[i] * 2);
}
}
이 코드에는 두 가지 관심사가 섞여 있다. i++, i < data.length 같은 순회 로직과, % 2 === 0, * 2 같은 비즈니스 로직이다.
코드가 길어질수록 이 둘이 뒤엉켜 읽기 어려워진다. 그리고 매번 개발자가 직접 순회를 제어해야 하므로 off-by-one 에러 같은 실수도 생긴다.
ES5에서 forEach, map, filter, reduce가 도입된 건 이 문제를 해결하기 위해서다. 핵심 아이디어는 순회 제어권을 개발자에서 V8런타임으로 위임하는 것이다.
아래 예시를 보자. 개발자는 "무엇을 할지"만 콜백으로 넘긴다. "어떻게 순회할지"는 V8런타임이 책임진다.
이 패턴을 내부 이터레이터라고 한다. 소비자(콜백)가 순회를 제어하지 않고, 함수 내부에서 순회가 이루어진다.
data.filter(x => x % 2 === 0).map(x => x * 2);
(반대 개념인 외부 이터레이터는 ES6의 Symbol.iterator와 for...of다. 소비자가 직접 next()를 호출하며 순회를 제어한다.)
내부 이터레이터 방식이 단순히 가독성만 높이는 게 아니다. 순회 제어권이 함수 안에 있으므로, 앞서 다룬 Elements Kind 같은 배열 상태를 함수가 직접 활용해 최적화할 수 있다
이제 네 함수의 내부를 뜯어볼 준비가 됐다.
4. [각 함수의 내부구조]
forEach, map, filter, reduce는 배열의 이터레이터 메서드다. 배열을 순회하면서 각 요소에 콜백을 실행한다는 공통점이 있지만, 순회 외에 각자가 하는 일이 다르다.
먼저 네 함수의 순회 비용을 나란히 놓아보자. 배열은 도입부와 동일한 Array(100000).fill(0)이다.
forEach → 0.732ms
map → 2.078ms
filter(전부통과) → 5.157ms
filter(전부탈락) → 0.734ms
reduce → 0.650ms
수치가 제각각이다. 같은 HOLEY_SMI 배열을 순회하는데 왜 이렇게 다를까. 각 함수가 순회 외에 무엇을 더 하는지가 다르기 때문이다.
[forEach]
forEach는 각 인덱스에서 콜백을 실행하고 끝이다. 결과를 저장하지 않고, 새 배열도 만들지 않는다. 반환값은 항상 undefined다.
네 함수 중 순수 순회 비용에 가장 가깝다.
[map]
forEach와 구조가 거의 같지만 한 가지가 다르다. 결과를 담을 배열을 새로 만들어야 한다.
const result = new Array(len); // ① 결과 배열 미리 할당
for (let k = 0; k < len; k++) {
if (k in arr) {
result[k] = fn(arr[k], k, arr); // ② 콜백 실행 + 결과 저장
}
}
return result;
빈 배열 100,000개짜리를 할당하는 비용만 약 1.3ms다. forEach(0.732ms)와 map(2.078ms)의 차이가 거기서 온다.
이것이 도입부 질문의 완전한 답이다.
- Elements Kind → HOLEY_SMI 배열이라 매 인덱스마다 HasProperty 체크가 붙는다
- 내부 동작 → map은 거기에 결과 배열 할당 비용이 얹힌다
forEach가 빠른 이유는 HOLEY 체크를 덜 해서가 아니다. 둘 다 똑같이 체크한다. map이 느린 건 결과 배열을 새로 만들기 때문이다.
여기서 Elements Kind가 다시 개입한다. map이 만드는 결과 배열의 Kind는 입력 배열의 Kind를 따른다. SMI 배열을 순회하면 결과도 SMI 배열로 할당되고, Double 배열을 순회하면 결과도 Double 배열로 할당된다. Double은 요소 하나당 SMI의 두 배 메모리를 쓰기 때문에, 100,000개짜리 결과 배열을 만드는 비용도 두 배로 뛴다. 앞서 측정한 [Double] map → 5.302ms가 [SMI] map → 2.131ms의 두 배를 넘는 이유가 여기에 있다. forEach는 결과 배열을 만들지 않으로 [Double] forEach → 1.183ms에서 이 차이가 나타나지 않는다.
[filter]
filter가 유독 느린 이유는 결과 배열의 최종 크기를 순회 전에 알 수 없다는 데 있다. map은 입력과 출력 크기가 항상 같으니 미리 할당할 수 있다. filter는 불가능하다.
V8은 이 문제를 이렇게 해결한다. 일단 원본과 같은 크기로 결과 배열을 할당하고, 조건을 통과한 것만 채운 뒤, 마지막에 실제 크기로 줄인다.
filter(전부 통과) → 5.157ms ← 원본 크기 할당 + 전체 복사
filter(전부 탈락) → 0.734ms ← 원본 크기 할당 + 복사 없음
전부 통과시킬 때가 가장 느리다. 반대로 전부 탈락시키면 복사 비용이 없어 forEach 수준으로 내려간다. filter는 결과가 적을수록 빠르다.
filter에는 한 가지 특성이 더 있다. map과 달리 hole을 제거한다.
const holey = [1, , 3, , 5];
holey.map(x => x); // [1, empty, 3, empty, 5] — hole 유지
holey.filter(x => true); // [1, 3, 5] — hole 사라짐
map은 hole을 만나면 그 자리를 비워두고 결과 배열에도 그대로 전파한다. filter는 조건을 통과한 요소만 순서대로 쌓으므로 hole이 결과에 포함될 수 없다. filter의 결과 배열은 항상 PACKED로 출발한다.
앞서 Elements Kind의 전환은 단방향이라고 했다. 한 번 HOLEY가 되면 되돌아오지 않는다고. filter는 그 원칙의 실용적인 우회로다. HOLEY 배열을 의도치 않게 들고 있다면 filter(x => x != null) 한 번으로 hole을 걷어내고 PACKED 배열을 새로 얻을 수 있다.
[reduce]
reduce의 순회 비용은 forEach와 거의 같다. 새 배열을 만들지 않고, 반환값은 누산기 하나뿐이다.
reduce는 누산기를 어떻게 쓰냐에 따라 비용이 크게 달라진다.
[결론 : V8최적화의 산물]
지금까지 살펴본 것처럼, forEach, map, filter, reduce는 배열을 순회한다는 공통점 아래에서 각자 다른 구조로 동작한다.
V8은 배열을 Elements Kind로 분류해 순회 경로를 결정한다. 같은 코드라도 배열이 어떻게 생성됐는지, 어떤 타입의 요소를 담고 있는지에 따라 V8이 선택하는 실행 경로가 달라진다. 그 위에서 각 함수는 순회 외에 하는 일이 다르다. map은 결과 배열을 만들고, filter는 크기를 모른 채 할당하고 줄이고, reduce는 누산기 하나만 들고 돈다. forEach는 그 모든 비용이 없다.
'개발 공부' 카테고리의 다른 글
| OpenAPI만 주면 API 요청 함수를 생성해준다고? (0) | 2026.06.07 |
|---|---|
| React Suspense는 Wakeable을 Throw한다. (0) | 2026.05.02 |
| TSC는 Type Narrowing을 어떻게 처리하는 걸까 (1) | 2026.04.28 |