#JavaScript Array 메소드 - Map과 Reduce
배열 메소드의 올바른 사용법과 성능 최적화 전략에 대해 알아보자
#들어가며
JavaScript에서 배열 처리에 사용되는 여러 메소드들이 있다.. 특히 map()과 reduce() 메소드는 함수형 프로그래밍 패러다임을 활용한 배열 처리의 강력한 도구이다. 이 글에서는 두 메소드의 올바른 사용법과 성능 최적화 전략에 대해 자세히 알아본다.
#Map 메소드
#기본 사용법
map() 메소드는 원본 배열의 각 요소를 변환하여 새로운 배열을 반환한다. 원본 배열은 변경되지 않는다.
array.map(callback(currentValue, index, array), thisArg);
콜백 함수의 파라미터:
- currentValue: 현재 처리 중인 배열의 요소
- index (선택적): 현재 처리 중인 요소의 인덱스
- array (선택적): map()을 호출한 원본 배열
- thisArg (선택적): 콜백 함수 내에서 this로 사용할 값
간단한 예제:
const numbers = [1, 2, 3, 4, 5];
const doubled = numbers.map((num) => num * 2);
console.log(doubled); // [2, 4, 6, 8, 10]
#실전 예제
영화 정보가 담긴 배열에서 추가 속성을 생성해야 하는 상황을 생각해보자.
const movies = [
{
title: "Rent",
year: 2005,
genres: ["Musical", "Drama"],
},
{
title: "Tick",
year: 2011,
genres: ["Biography", "Drama"],
},
];
이 데이터에 genre 속성을 추가하고, genres 배열을 문자열로 변환해보자.
#스프레드 연산자와 속성 복사
이 작업을 수행하는 두 가지 방법이 있다.
방법 1: 모든 속성을 명시적으로 나열
const result1 = movies.map((movie) => {
return {
title: movie.title,
year: movie.year,
genres: movie.genres,
genre: movie.genres.join(" / "),
};
});
방법 2: 스프레드 연산자 사용
const result2 = movies.map((movie) => {
return {
...movie,
genre: movie.genres.join(" / "),
};
});
두 방법 모두 동일한 결과를 반환하지만, 동작 방식에는 차이가 있다.
#구현 방식별 차이점
-
코드 작성 방식
- 첫 번째 방식은 모든 속성을 명시적으로 나열한다.
- 두 번째 방식은 스프레드 연산자를 사용해 기존 객체의 모든 속성을 새 객체에 복사한다.
-
유지보수성
- 첫 번째 방식은 필요한 속성만 선택할 수 있어 더 명확할 수 있다.
- 두 번째 방식은 코드가 더 간결하고, 원본 객체에 속성이 추가되더라도 코드를 수정할 필요가 없다.
-
성능
- 일반적인 경우 성능 차이는 미미하다.
- 객체에 많은 속성이 있을 경우 첫 번째 방식이 더 효율적일 수 있다.
추천 방식: 기존 속성을 모두 유지하면서 추가 속성만 다루는 경우 스프레드 방식이 더 적합하다.
- 간결성: 코드가 짧고 읽기 쉽다.
- 유지보수성: 원본 객체 구조가 변경되어도 코드 수정이 필요 없다.
- 오류 가능성 감소: 속성을 누락할 가능성이 없다.
- 의도 명확성: "원본 객체의 모든 속성을 유지하며 새 속성을 추가한다"는 의도가 명확하다.
#Reduce 메소드
#기본 구문
reduce() 메소드는 배열의 각 요소에 대해 주어진 리듀서(reducer) 함수를 실행하고, 하나의 결과값을 반환한다.
array.reduce(callback(accumulator, currentValue, index, array), initialValue);
#콜백 함수의 파라미터
- accumulator: 누적값. 이전 반복에서 반환된 값이 저장된다.
- currentValue: 현재 처리 중인 요소의 값.
- index (선택적): 현재 처리 중인 요소의 인덱스.
- array (선택적): reduce()를 호출한 원본 배열.
#initialValue (선택적)
- 첫 번째 콜백 호출에서 accumulator로 사용되는 값이다.
- 제공하지 않으면 배열의 첫 번째 요소가 초기 accumulator가 되고, currentValue는 두 번째 요소부터 시작한다.
- 빈 배열에서 initialValue 없이 reduce()를 호출하면 오류가 발생한다. (TypeError: Reduce of empty array with no initial value)
- 안전하고 의도된 동작을 보장하기 위해 항상 initialValue를 제공하는 것이 좋은 습관이다.
#주요 특징과 활용
- 다양한 출력 타입: 배열을 숫자, 문자열, 객체, 새 배열 등 어떤 형태로든 변환 가능
- 체이닝 대체: 여러 배열 메소드를 체이닝하는 대신 하나의 reduce로 대체 가능
- 조기 종료 불가: 모든 배열 요소를 순회한다 (중간에 중단할 수 없음)
#언제 Reduce를 사용해야 할까?
-
입/출력 데이터 타입이 다를 때
// 배열을 객체로 변환 (id를 키로 사용) const arrayToObject = users.reduce((acc, user) => { acc[user.id] = user; return acc; }, {});
-
누적 계산이 필요할 때:
- 합계, 평균, 최대/최소값 등을 구할 때 사용한다.
-
복잡한 데이터 변환 과정이 필요할 때:
- 여러 단계의 필터링, 그룹화, 변환이 함께 필요한 경우 사용한다.
-
체이닝을 줄이고 싶을 때:
- map().filter().sort() 같은 체이닝 대신 하나의 reduce로 처리할 때 사용한다.
#실전 예제
영화를 장르별로 그룹화하는 예제를 살펴보자.
// 영화를 장르별로 그룹화
const moviesByGenre = movies.reduce((acc, movie) => {
movie.genres.forEach((genre) => {
if (!acc[genre]) acc[genre] = [];
acc[genre].push(movie);
});
return acc;
}, {});
결과는 다음과 같다:
{
"Musical": [{ title: "Rent", year: 2005, genres: ["Musical", "Drama"] }],
"Drama": [
{ title: "Rent", year: 2005, genres: ["Musical", "Drama"] },
{ title: "Tick", year: 2011, genres: ["Biography", "Drama"] }
],
"Biography": [{ title: "Tick", year: 2011, genres: ["Biography", "Drama"] }]
}
#성능 비교
참고: 다음 성능 측정 결과는 Node.js v20.10.0 환경에서 테스트되었다. 결과는 하드웨어, 브라우저, JavaScript 엔진 등 실행 환경에 따라 달라질 수 있다.
배열 처리 메소드의 성능을 실제로 비교해보자.
#메소드 체이닝 vs. Reduce
대량의 데이터(100만 개 항목)를 처리할 때 다양한 접근 방식의 성능을 비교해보자. 아래 코드로 테스트를 수행했다:
// 대량의 테스트 데이터 생성
const generateLargeData = (size) => {
return Array.from({ length: size }, (_, index) => ({
category: ["A", "B", "C", "D"][Math.floor(Math.random() * 4)],
id: index,
isActive: Math.random() > 0.3,
value: Math.floor(Math.random() * 1000),
}));
};
// 데이터 생성 (100만 항목)
const data = generateLargeData(1000000);
console.log(`테스트 데이터 크기: ${data.length}개 항목`);
// 예제 1: 활성 상태인 항목 중 카테고리가 'A'인 항목들의 value 합계 계산
console.log("\n예제 1: 조건에 맞는 항목의 합계 계산");
// 방법 1: 메소드 체이닝 (최적화하지 않은 버전)
console.time("체이닝 (비최적화)");
const resultChaining1 = data
.filter((item) => item.isActive)
.filter((item) => item.category === "A")
.map((item) => item.value)
.reduce((sum, value) => sum + value, 0);
console.timeEnd("체이닝 (비최적화)");
// 방법 2: 메소드 체이닝 (최적화 버전)
console.time("체이닝 (최적화)");
const resultChaining2 = data
.filter((item) => item.isActive && item.category === "A")
.reduce((sum, item) => sum + item.value, 0);
console.timeEnd("체이닝 (최적화)");
// 방법 3: 단일 reduce 사용
console.time("단일 reduce");
const resultReduce = data.reduce((sum, item) => {
if (item.isActive && item.category === "A") {
return sum + item.value;
}
return sum;
}, 0);
console.timeEnd("단일 reduce");
// 결과 확인
console.log("체이닝 (비최적화) 결과:", resultChaining1);
console.log("체이닝 (최적화) 결과:", resultChaining2);
console.log("단일 reduce 결과:", resultReduce);
// 예제 2: 카테고리별 활성 항목의 value 합계를 객체로 반환
console.log("\n예제 2: 카테고리별 합계 계산");
// 방법 1: 체이닝 방식 (비최적화)
console.time("복합 체이닝 (비최적화)");
const activeItems = data.filter((item) => item.isActive);
const categories = [...new Set(activeItems.map((item) => item.category))];
const categoryTotals1 = categories.reduce((result, category) => {
result[category] = activeItems
.filter((item) => item.category === category)
.reduce((sum, item) => sum + item.value, 0);
return result;
}, {});
console.timeEnd("복합 체이닝 (비최적화)");
// 방법 2: 체이닝 방식 (최적화)
console.time("복합 체이닝 (최적화)");
const categoryTotals2 = data
.filter((item) => item.isActive)
.reduce((result, item) => {
if (!result[item.category]) {
result[item.category] = 0;
}
result[item.category] += item.value;
return result;
}, {});
console.timeEnd("복합 체이닝 (최적화)");
// 방법 3: 단일 reduce만 사용
console.time("복합 단일 reduce");
const categoryTotalsReduce = data.reduce((result, item) => {
if (item.isActive) {
if (!result[item.category]) {
result[item.category] = 0;
}
result[item.category] += item.value;
}
return result;
}, {});
console.timeEnd("복합 단일 reduce");
실행 결과:
예제 1: 조건에 맞는 항목의 합계 계산
체이닝 (비최적화): 21.243ms
체이닝 (최적화): 13.388ms
단일 reduce: 7.668ms
예제 2: 카테고리별 합계 계산
복합 체이닝 (비최적화): 54.133ms
복합 체이닝 (최적화): 26.637ms
복합 단일 reduce: 19.557ms
결론: 복잡한 데이터 처리에서는 단일 reduce가 일반적으로 더 빠르다. 하지만 성능 차이는 실행 환경(브라우저, Node.js 버전 등)에 따라 달라질 수 있다.
#Push vs. 스프레드 연산자
배열에 요소를 추가할 때 두 방식의 성능 차이를 알아보자. 다음 코드로 테스트했다:
// 성능 테스트를 위한 대량의 데이터 생성
const generateTestData = (size) => {
return Array.from({ length: size }, (_, index) => ({
id: index,
value: Math.random() * 1000,
text: `Item ${index}`,
isValid: index % 3 === 0,
}));
};
// 테스트 데이터 크기
const testSizes = [10000, 100000, 500000];
testSizes.forEach((size) => {
console.log(`\n==== 테스트 크기: ${size.toLocaleString()} 항목 ====`);
const data = generateTestData(size);
// 테스트 1: 조건에 맞는 항목만 배열에 포함 (filter와 유사한 기능)
console.log("\n----- 테스트 1: 조건부 필터링 -----");
// Push 방식
console.time("1. Push 방식");
const resultPush = data.reduce((acc, item) => {
if (item.isValid) {
acc.push(item);
}
return acc;
}, []);
console.timeEnd("1. Push 방식");
// Spread 방식
console.time("2. Spread 방식");
const resultSpread = data.reduce((acc, item) => {
return item.isValid ? [...acc, item] : acc;
}, []);
console.timeEnd("2. Spread 방식");
// 결과 검증
console.log(`결과 배열 길이: ${resultPush.length}`);
console.log(
`두 결과가 동일한가: ${resultPush.length === resultSpread.length}`
);
// ========================================================================
// 테스트 2: 모든 항목을 변환하여 새 배열 생성 (map과 유사한 기능)
console.log("\n----- 테스트 2: 모든 항목 변환 -----");
// Push 방식
console.time("1. Push 방식");
const transformedPush = data.reduce((acc, item) => {
acc.push({
id: item.id,
formattedValue: `$${item.value.toFixed(2)}`,
category: item.isValid ? "valid" : "invalid",
});
return acc;
}, []);
console.timeEnd("1. Push 방식");
// Spread 방식
console.time("2. Spread 방식");
const transformedSpread = data.reduce((acc, item) => {
return [
...acc,
{
id: item.id,
formattedValue: `$${item.value.toFixed(2)}`,
category: item.isValid ? "valid" : "invalid",
},
];
}, []);
console.timeEnd("2. Spread 방식");
console.log(`결과 배열 길이: ${transformedPush.length}`);
// ========================================================================
// 테스트 3: 배열 분할 (특정 크기의 청크로 나누기)
console.log("\n----- 테스트 3: 배열 청킹 -----");
const chunkSize = 1000;
// Push 방식
console.time("1. Push 방식");
const chunksPush = data.reduce((chunks, item, index) => {
const chunkIndex = Math.floor(index / chunkSize);
if (!chunks[chunkIndex]) {
chunks[chunkIndex] = [];
}
chunks[chunkIndex].push(item);
return chunks;
}, []);
console.timeEnd("1. Push 방식");
// Spread 방식
console.time("2. Spread 방식");
const chunksSpread = data.reduce((chunks, item, index) => {
const chunkIndex = Math.floor(index / chunkSize);
if (!chunks[chunkIndex]) {
chunks[chunkIndex] = [];
}
chunks[chunkIndex] = [...chunks[chunkIndex], item];
return chunks;
}, []);
console.timeEnd("2. Spread 방식");
console.log(`청크 개수: ${chunksPush.length}`);
// ========================================================================
// 메모리 사용량 비교 (Node.js에서만 정확함)
try {
if (typeof process !== "undefined" && process.memoryUsage) {
console.log("\n----- 메모리 사용량 -----");
console.log(
`초기: ${Math.round(process.memoryUsage().heapUsed / 1024 / 1024)}MB`
);
// 강제 가비지 컬렉션 (Node.js에서)
if (global.gc) {
global.gc();
console.log(
`GC 후: ${Math.round(process.memoryUsage().heapUsed / 1024 / 1024)}MB`
);
}
}
} catch (e) {
console.log("메모리 측정은 Node.js에서만 가능합니다.");
}
});
실행 결과:
10,000개 항목 처리 시:
----- 테스트 1: 조건부 필터링 -----
1. Push 방식: 0.582ms
2. Spread 방식: 6.308ms
결과 배열 길이: 3334
두 결과가 동일한가: true
----- 테스트 2: 모든 항목 변환 -----
1. Push 방식: 1.500ms
2. Spread 방식: 39.152ms
결과 배열 길이: 10000
----- 테스트 3: 배열 청킹 -----
1. Push 방식: 0.772ms
2. Spread 방식: 4.342ms
청크 개수: 10
----- 메모리 사용량 -----
초기: 7MB
GC 후: 6MB
100,000개 항목 처리 시:
----- 테스트 1: 조건부 필터링 -----
1. Push 방식: 0.772ms
2. Spread 방식: 1.376s
결과 배열 길이: 33334
두 결과가 동일한가: true
----- 테스트 2: 모든 항목 변환 -----
1. Push 방식: 8.321ms
2. Spread 방식: 11.174s
결과 배열 길이: 100000
----- 테스트 3: 배열 청킹 -----
1. Push 방식: 1.552ms
2. Spread 방식: 35.95ms
청크 개수: 100
----- 메모리 사용량 -----
초기: 95MB
GC 후: 32MB
500,000개 항목 처리 시:
500,000개의 경우 많은 테스트를 해보지 못함
----- 테스트 1: 조건부 필터링 -----
1. Push 방식: 3.48ms
2. Spread 방식: 29.570s
결과 배열 길이: 166667
두 결과가 동일한가: true
----- 테스트 2: 모든 항목 변환 -----
1. Push 방식: 45.211ms
2. Spread 방식: 6:05.839 (m:ss.mmm)
결과 배열 길이: 500000
----- 테스트 3: 배열 청킹 -----
1. Push 방식: 5.473ms
2. Spread 방식: 187.348ms
청크 개수: 500
----- 메모리 사용량 -----
초기: 515MB
GC 후: 149MB
결론: 대용량 데이터 처리 시 push 방식이 압도적으로 빠르다. 하지만 소규모 데이터(수백 개 이하)에서는 그 차이가 크게 체감되지 않을 수 있으며, 코드 가독성을 위해 스프레드 연산자를 사용하는 것도 합리적인 선택일 수 있다.
#가비지 컬렉션(GC)과 배열 처리
JavaScript의 가비지 컬렉션은 메모리 관리에 중요한 역할을 한다. 배열 메소드 사용 방식에 따라 GC 동작과 메모리 사용 패턴이 크게 달라질 수 있다.
#스프레드 연산자와 GC의 관계
스프레드 연산자(...)를 사용하여 배열을 조작할 때, 매 연산마다 새로운 배열이 생성되고 이전 배열은 GC의 대상이 된다.
// 스프레드 방식
const resultSpread = data.reduce((acc, item) => {
return [...acc, item]; // 매번 새 배열 생성
}, []);
이 방식은 다음과 같은 영향을 미친다:
- 메모리 사용량 증가: 일시적으로 많은 메모리를 사용한다.
- GC 부하 증가: 가비지 컬렉터가 더 자주, 더 많은 작업을 수행해야 한다.
- 성능 저하 가능성: GC가 실행될 때 JavaScript 실행이 잠시 중단될 수 있다(특히 대규모 메모리 정리 시).
#Push 메소드와 GC의 관계
반면, push() 메소드는 동일한 배열 객체를 계속 사용하므로 추가 메모리 할당이나 GC가 거의 필요하지 않다.
// Push 방식
const resultPush = data.reduce((acc, item) => {
acc.push(item); // 동일한 배열 객체 재사용
return acc;
}, []);
이 방식의 장점:
- 안정적인 메모리 사용: 단일 배열 객체만 유지한다.
- GC 부하 감소: 임시 객체가 적게 생성되어 GC 작업이 줄어든다.
- 성능 안정성: GC로 인한 성능 저하가 적다.
스프레드 방식은 처리 과정에서 메모리 사용량이 크게 증가했다가 GC 후에 감소한다. 반면 push 방식은 메모리 사용이 일정하게 유지된다.
#실제 애플리케이션에 미치는 영향
- 웹 애플리케이션 응답성: GC가 빈번하게 실행되면 UI 스레드가 차단될 수 있어 사용자 경험이 저하될 수 있다.
- 모바일 기기: 제한된 메모리를 가진 모바일 기기에서는 메모리 효율이 더욱 중요하다.
- 대규모 데이터 처리: 데이터 분석, 시각화 등 대량의 데이터를 다루는 애플리케이션에서 차이가 두드러진다.
#최적화 전략
성능 테스트 결과와 GC 영향을 고려하여 다음과 같은 최적화 전략을 적용할 수 있다:
-
reduce와 push 조합하기: 배열 결과를 반환하는 reduce 내에서는 스프레드 대신 push 사용하기
// 좋음 const filtered = data.reduce((acc, item) => { if (someCondition(item)) { acc.push(transformItem(item)); } return acc; }, []); // 피할 것 const filtered = data.reduce((acc, item) => { if (someCondition(item)) { return [...acc, transformItem(item)]; } return acc; }, []);
-
메소드 체이닝 최적화: 여러 번 필터링할 경우 조건을 결합하기
// 일반적으로 좋음 const result = data .filter((item) => condition1(item) && condition2(item)) .map((item) => transform(item)); // 일반적으로 피할 것 const result = data .filter((item) => condition1(item)) .filter((item) => condition2(item)) .map((item) => transform(item));
참고: 첫 번째 조건(condition1)이 복잡하고 계산 비용이 크며, 많은 항목을 필터링하는 반면, 두 번째 조건(condition2)이 간단하다면 별도의 filter 체인이 더 효율적일 수 있다. 각 상황에 맞게 판단해야 한다.
-
적절한 메소드 선택: 용도에 맞게 메소드 선택하기
- 단순 변환: map
- 필터링: filter
- 복잡한 변환/계산: reduce
-
메모리 사용량 고려: 대용량 데이터 처리 시 메모리 사용량이 중요할 수 있다.
- 스프레드 연산은 새 배열을 계속 생성하므로 메모리 사용량이 높다.
- push는 동일한 배열을 재사용하므로 메모리 효율이 좋다.
#마치며
JavaScript 배열 메소드는 데이터 처리의 핵심 도구다. 각 메소드의 특성과 성능 영향을 이해하면 더 효율적인 코드를 작성할 수 있다.
주요 포인트:
- 간단한 변환은 map을 사용
- 데이터 형태 변환이나 복잡한 처리는 reduce가 효율적
- 대용량 데이터 처리 시 스프레드 연산자보다 push가 성능적으로 유리
- 메소드 체이닝 시 필터 조건을 가능한 한 결합하여 처리
여러분의 프로젝트에서 이런 최적화 전략을 적용해보고, 실제 성능 향상을 경험해보기 바란다.