0. 들어가며…
JavaScript로 반복 작업을 처리할 때, 우리는 주로 Array.map(), forEach(), for…in 같은 메서드를 사용한다.
특히 Array.map()은 filter(), some() 등과 연결할 수 있어 가독성 높은 코드를 작성할 수 있다.
그런데, 만약 데이터 크기가 수십만, 수백만 건에 이르거나 파일 스트림처럼 순차적으로 들어오는 데이터를 다룰 때는 어떨까? Array.map()을 사용하게 되면…
- 전체 배열을 한 번에 처리하면서 생성된 중간 결과들이 모두 메모리에 쌓이게 되고,
- 실제로 필요한 데이터는 극히 일부인데도 불필요한 연산을 수행해야 한다.
그럼
Array.map()말고 다른 방법은 없을까?
2025년 3월 TC39 Stage 3로 승격된 Iterator Helper API(링크)는 이런 문제를 지연(lazy) 평가로 해결할 수 있다.
Iterator Helper API는 필요한 순간에만 데이터를 계산하고, .take()나 .drop()으로 반복을 즉시 중단할 수 있다.
이번 시간에는 Array 메서드와 Iterator Helper API의 차이를 살펴보겠다.
1. Array.map() vs Iterator Helper API의 차이
1) 개념 비교해보기
(1) Array.map(): “한 번에 훑고, 한 번에 만들기” (Eager 평가)
Array.map()은 배열의 모든 요소를 한 번에 순회하면서, 각 요소에 콜백 함수를 적용한 새 배열을 즉시 생성한다.
| 단계 | 동작 |
|---|---|
| 1. 호출 순간 평가 시작 | arr.map(fn)을 호출하면 내부적으로 즉시 전체 배열을 순회한다. |
| 2. 새 배열 할당 | arr.length만큼 빈 배열을 미리 만들어 놓고(예: new Array(arr.length)), 그 크기만큼 메모리를 할당한다. |
| 3. 전체 요소 연산 | 인덱스 0부터 끝까지 차례로 콜백 함수를 실행해 결과를 저장한다. |
| 4. 배열 반환 | 모든 연산이 끝나면, 원본과 같은 길이의 새 배열을 반환한다. |
const arr = [1, 2, 3, 4];
const doubled = arr.map(x => x * 2);
// 내부: 길이 4짜리 배열 할당 → 4번 fn 호출 → [2,4,6,8] 반환
console.log(doubled); // [2, 4, 6, 8]
| 장단점 | 설명 |
|---|---|
| 🔍 장점 | 간단하고 직관적인 사용법filter(), slice() 등과 자연스러운 체이닝 |
| ⚠️ 단점 | 메모리 부담: 전체 결과 배열을 한꺼번에 저장 불필요한 연산: 앞 N개만 필요해도 뒤 요소까지 모두 처리 중단 제어 어려움: 도중에 멈추려면 slice() 같은 후처리 필요 |
(2) Iterator Helper API: “필요할 때만 하나씩” (Lazy 평가)
- Iterator는 JavaScript에서 값을 하나씩 꺼낼 수 있는 도구이다.
- 배열을 for문으로 돌릴 때처럼 한 개씩 처리하되, 필요한 만큼만 꺼낼 수 있다는 점이 특징이다.
- JavaScript에서는 배열, 문자열, Set 등 대부분이 반복 가능한 객체(Iterable)이고, 이를 Symbol.iterator를 통해 Iterator로 바꿔 값을 하나씩 꺼낼 수 있다.
- 최신 문법인 Iterator Helper API는 이 Iterator에 .map(), .filter(), .take() 같은 메서드를 추가해주는 기능이다.
const iter = [1, 2, 3][Symbol.iterator]();
console.log(iter.next()); // { value: 1, done: false }
console.log(iter.next()); // { value: 2, done: false }
- 이러한, Iterator Helper API는 Array.map()과 달리 값을 요청할 때만 연산을 수행한다.
| 단계 | 동작 |
|---|---|
| 1. Iterable → Iterator 생성 | 반복 가능한 객체(Iterable)에서 Iterator를 생성하면Iterator는 현재 어디까지 처리했는지를 기억하는 내부 상태를 갖게 된다. |
| 2. 변환 로직 연결 | 이 시점엔 아직 콜백 함수를 실행 하지 않고,, 나중에 값이 요청될 때( .next()) 계산하겠다는 정보만 담아, 새로운 Iterator를 반환한다. |
| 3. 값 요청 시점에 평가 | 실제로 next()가 호출될 때마다 한 요소씩 계산한다. (ex. mappedIter.next()) |
- 또한 즉시 중단을 제어할 수 있는데,
.take(n),.drop(n)으로 앞 N개만 처리하거나 처음 N개를 건너뛸 수 있다. - 그래서 요청이 멈추면 남은 원본 요소는 소비되지 않는다.
- 아래 콘솔 출력 결과를 보면,
take(2)로 지정했기에 앞 2개의 값만 실제로 처리되었음을 확인할 수 있다.
function* mapIterator(iter, fn) {
for (const x of iter) {
console.log('처리 중:', x);
yield fn(x);
}
}
const lazy = mapIterator([1,2,3,4][Symbol.iterator](), x => x * 2)
.take(2);
console.log([...lazy]); // 콘솔: 처리 중: 1, 처리 중: 2 → [2, 4]
| 장단점 | 설명 |
|---|---|
| 🔍 장점 | 메모리 절약: 전체 결과를 저장하지 않음 불필요 연산 제거: 필요한 만큼만 처리 깔끔한 중단 제어: .take(), .drop() |
| ⚠️ 단점 | iterable/iterator 개념 학습 필요 작은 배열엔 문법이 다소 복잡 |
2) 메모리·성능 비교 실험
그럼 정말로 메모리상 차이가 있는지 확인해보자! (실험한 레포지토리)
실험은 간단하게performance.now(),process.memoryUsage()를 사용해 10회 반복하여 실행속도와 메모리 사용량을 비교해보았다.
(1) 실험 시나리오
1부터 10,000,000까지의 숫자 중
- 짝수만 필터링 →
- 각 값을 2배 →
- 앞에서 N개만 추출한다.(
TAKE값에 따라 달라짐)
.filter(n => n % 2 === 0)
.map(n => n * 2)
.take(n)
(2) TAKE = 5 (소량의 데이터만 필요할 때)
- Array:
- 시간 (137.03 ms) / 메모리 (150.01 MB): 매우 비효율적이다.
- 이유: 단 5개의 결과를 얻기 위해, 불필요하게 1,000만 개의 숫자를 모두 배열로 만들고(Array.from), 이 거대한 배열을 전부 순회하며 filter와 map을 위한 중간 배열들을 또 생성한다. 최종적으로 필요한 데이터의 양과 상관없이 항상 전체 데이터를 처리하므로 비용이 고정적으로 높다.
- Iterator:
- 시간 (0.04 ms) / 메모리 (0.05 MB): 효율적이다.
- 이유: 지연 평가(Lazy Evaluation) 덕분이다. 이터레이터는 take(5)가 충족될 때까지만 데이터를 처리한다. 즉, 10개의 숫자(1~10)만 순회하여 짝수 5개(2, 4, 6, 8, 10)를 찾으면 즉시 작업을 중단한다. 1,000만 개 전체를 처리하지 않으므로 시간과 메모리 사용량이 극도로 적다.
핵심: 앞에서 몇 개만 가져오는(take, find 등) 작업에서는 이터레이터가 절대적으로 유리하다.
(3) TAKE = 100,000 (중간 규모의 데이터가 필요할 때)
- Array:
- 시간 (137.13 ms) / 메모리 (150.87 MB): TAKE=5일 때와 거의 변화가 없다.
- 이유: 여전히 1,000만 개 전체를 처리하는 비용이 지배적이다. 최종적으로 5개를 가져오든 10만 개를 가져오든, 그 전 단계에서 1,000만 개짜리 배열을 만들고 가공하는 비용은 동일하다.
- Iterator:
- 시간 (8.39 ms) / 메모리 (5.48 MB): 비용이 증가했지만, 여전히 배열 방식보다 월등히 뛰어나ㅏ다.
- 이유: 이터레이터의 비용은 최종적으로 생성되는 데이터의 양에 비례한다. 10만 개의 결과를 만들기 위해 약 20만 개의 숫자만 처리하면 되므로, 1,000만 개 전체를 처리하는 배열보다 훨씬 효율적이다.
핵심: 여전히 전체 데이터의 일부만 처리하는 경우, 이터레이터가 더 좋다.
(4) TAKE = 10,000,000 (모든 데이터를 처리할 때)
- Array:
- 시간 (155.36 ms) / 메모리 (91.23 MB): 이전과 비슷한 성능을 보인다. (메모리가 약간 줄어든 것은 GC 동작이나 측정 시점의 미세한 차이일 수 있음)
- 이유: 이 시나리오는 배열 메서드가 가장 잘하는 벌크(Bulk) 연산이다. filter, map이 대용량 데이터를 한 번에 효율적으로 처리한다.
- Iterator:
- 시간 (386.01 ms) / 메모리 (185.95 MB): 성능이 배열 방식에 비해 역전되어 크게 저하된다!
- 이유:
- 지연 평가의 장점 소멸: 어차피 모든 데이터를 다 처리해야 하므로, '필요할 때만 계산'하는 장점이 완전히 사라진다.
- 함수 호출 오버헤드:
[...iter]가 값 하나를 얻을 때마다take.next()→map.next()→filter.next()→range.next()같은 자바스크립트 함수 호출이 연쇄적으로 수백만 번 발생한다. 이 반복적인 호출 오버헤드가 쌓여, 네이티브 코드로 한 번에 처리하는 배열보다 훨씬 느려진다. - 메모리 오버헤드: 이터레이터 체인의 각 단계는 다음 값을 계산하기 위해 자신의 상태를 클로저(closure)와 같은 형태로 유지해야 한다. 수백만 개의 데이터를 처리하는 동안 이런 작은 상태 객체들과 함수 호출 스택이 반복적으로 생성/해제되면서 발생하는 메모리 관리 비용이, 큰 중간 배열 한두 개를 사용하는 것보다 더 커지게 된다.
핵심: 모든 데이터를 최종적으로 변환해야 할 때는, 이터레이터의 '한 땀 한 땀' 처리 방식이
네이티브 코드로 최적화된 배열의 '대량 일괄' 처리 방식보다 비효율적이다.
(5) 최종 결론
| 작업 유형 | 추천 방식 | 이유 |
|---|---|---|
| 스트림의 일부만 필요 (예: 첫 10개, 첫 번째 짝수) | 이터레이터 |
지연 평가: 불필요한 계산을 하지 않아 압도적으로 빠르고 메모리를 절약함. |
| 전체 데이터를 변환하여 새로운 전체 데이터 생성 | 배열 |
벌크 연산: 최적화된 네이티브 코드가 반복적인 함수 호출 오버헤드 없이 더 빠르게 처리함. |
| 무한한 데이터 스트림 처리 (예: 웹소켓, 파일 스트림) | 이터레이터 |
배열은 무한한 데이터를 메모리에 담을 수 없지만, 이터레이터는 가능함. |
3. 마치며…
이번 시간에는 Array.map()과 Iterator Helper API의 작동 방식부터 시작해, 성능과 메모리 측면에서의 실제 차이를 다양한 TAKE 값 실험을 통해 확인해봤다.
결론적으로, 둘 다 반복 작업을 위한 도구이지만, 처리 목적과 상황에 따라 선택 기준이 완전히 달라진다는 걸 알 수 있었다.
Array.map()은 전체 데이터를 일괄 처리할 때 강력하며, JavaScript 엔진의 최적화를 온전히 활용할 수 있다.- 반면
Iterator Helper API는 부분 데이터 처리, 대용량 스트림, 조건이 만족될 때까지만 순회하는 등 lazy 평가가 이점이 되는 상황에서 탁월한 성능과 효율성을 보인다.
특히 메모리 예산이 제한적이거나, 결과의 극히 일부분만 필요한 경우 Iterator.map().take(n) 조합은 기존 방식보다 수십~수천 배 효율적일 수 있었다.
오늘 확인한 차이점을 보고, 추후 API가 공식화(25.07.21 기준)되면 목적에 맞는 방식을 적용해보면 좋을 듯 하다.
| 체크리스트 | 선택 기준 |
|---|---|
| 전체 데이터를 모두 변환해야 하는가? | ✅ Array.map() |
| 일부 데이터만 필요하거나 중간에 멈출 수 있는가? | ✅ Iterator Helper API |
| 처리 대상이 매우 크거나 무한 스트림인가? | ✅ Iterator Helper API |
| 단순한 데이터 가공이 목적이고 성능 이슈가 적은가? | ✅ Array.map() |
'개발 기술 > 사소하지만 놓치기 쉬운 개발 지식' 카테고리의 다른 글
| [Vue] Fragment의 함정: 왜 $el은 Text Node가 될까? (0) | 2025.10.26 |
|---|---|
| [JS] 모바일 웹뷰에서 가상 키보드 감지하는 법: visualViewport·디바운스·rAF (8) | 2025.08.17 |
| [CSS] scrollIntoView를 사용하면 바운싱되는 이유(with. position의 차이) (0) | 2025.06.22 |
| [JS] 예외 처리와 성능: throw를 언제, 어떻게 사용해야할까? (1) | 2025.06.08 |
| [React] onBlur/onFocus가 버블링되는 이유와 PrimeReact 메뉴 버그 해결기 (0) | 2025.05.11 |
댓글