10편. 퍼포먼스 & 메모리 최적화 이해하기
📚 목차
1. Spread와 구조 분해가 느린 이유: 내부 동작과 성능 비용
2. 메모리 누수의 진짜 원인: 클로저(Closure)와 WeakMap
3. 반복문 선택이 성능을 좌우하는 이유

📂 [GitHub 실습 코드 보러가기] (https://github.com/cericube/nodejs-tutorials) /JavaScript
1. Spread와 구조 분해가 느린 이유: 내부 동작과 성능 비용
Spread(...) 문법은 객체와 배열을 손쉽게 복사하거나 병합할 수 있어 매우 편리합니다.
그러나 이 편리함 뒤에는 잘 드러나지 않는 성능 비용이 숨어 있습니다.
특히 대규모 데이터를 반복해서 다루는 상황에서는 이러한 비용이 쉽게 누적되어 전체 코드의 성능을 떨어뜨릴 수 있습니다.
🔷 Spread 구문(...)의 숨겨진 비용과 성능 영향
Spread 문법이 실행될 때 자바스크립트 엔진은 다음과 같은 작업을 수행합니다.
1) 전체 순회(Iteration) 발생
Spread는 원본 객체의 모든 속성(key) 또는 배열의 모든 요소(element)를 처음부터 끝까지 하나씩 읽어와야 합니다.
즉, 배경에서는 완전한 순회 작업이 이루어집니다.
2) 새로운 객체 또는 배열 생성
Spread는 단순한 참조 복사가 아니라, 항상 새로운 객체 또는 배열을 생성합니다.
이 과정에서 속성 복사 비용이 추가로 발생합니다.
3) 얕은 복사(Shallow Copy)만 수행
Spread는 내부 객체까지 깊게 복사하지 않습니다.
중첩된 객체는 기존 객체의 참조를 그대로 공유합니다.
이러한 특성 때문에 수천 ~ 수만 개의 데이터를 다루는 상황에서 Spread를 남용하면 예상보다 큰 성능 저하로 이어질 수 있습니다.
✔️ 예시: Spread의 동작과 얕은 복사 확인하기
const user = {
id: 101,
name: 'Alice',
settings: { theme: 'dark' }
};
// ❌ 이 시점에서 user 객체의 모든 키('id', 'name', 'settings')를 순회해서 새 객체를 만듭니다.
const copy = { ...user, isLoggedIn: true };
console.log('원본 user:', user);
console.log('복사본 copy:', copy);
// settings는 얕은 복사이므로 같은 객체를 가리킵니다.
console.log('settings 동일 참조 여부:', copy.settings === user.settings); // true
// 복사본의 settings를 수정해 봅니다.
copy.settings.theme = 'light';
console.log('user.settings.theme:', user.settings.theme); // 'light'
console.log('copy.settings.theme:', copy.settings.theme); // 'light'
▸ const copy = { ...user, isLoggedIn: true }; 이 한 줄은
1. 새로운 객체를 하나 만들고
2. user의 모든 속성을 순회하면서
3. 각 속성을 copy에 다시 써 넣고
4. isLoggedIn 속성까지 추가하는 꽤 많은 일을 하는 표현입니다.
▸ copy.settings === user.settings 가 true인 것을 보면,
바깥쪽 객체만 새로 만들고, settings는 그대로 공유한다는 것을 알 수 있습니다.
▸ 따라서 copy.settings.theme을 변경하면 원본 user.settings.theme도 함께 바뀝니다.
→ 얕은 복사임을 직관적으로 확인할 수 있습니다.
🔷 실무 최적화: 복사 대신 참조 유지와 직접 수정
성능이 중요하고, 원본 데이터의 직접 수정이 허용되는 상황이라면 불필요한 복사를 만들지 않는 것이 가장 좋은 최적화입니다.
특히 대량 데이터에 대한 반복 처리에서 매번 Spread로 새 객체를 만드는 패턴은 피하는 것이 좋습니다.
✔️ 나쁜 예: Spread 남용으로 인한 성능 저하
// 10만 개의 데이터가 담긴 배열을 준비합니다.
const bigList = Array.from({ length: 100_000 }, (_, i) => ({
id: i,
name: `item-${i}`,
status: 'PENDING',
}));
console.time('spread-map');
// 매 순회마다 item 객체를 Spread로 복사하여 새로운 객체를 만듭니다.
const resultWithSpread = bigList.map(item => ({
...item, // 🔥 매번 전체 속성을 복사
status: 'DONE',
}));
console.timeEnd('spread-map');
이 코드의 문제점
▸ bigList 길이(여기선 10만 개)만큼 map이 실행되고, 각 요소마다 ...item이 수행됩니다.
▸ 즉, “10만 개의 객체 전체 속성을 매번 순회 + 10만 개의 새 객체 생성” 이라는 큰 비용이 발생합니다.
✔️ 좋은 예: 참조 기반 수정으로 복사 비용 제거
// 같은 bigList를 사용합니다.
const bigList2 = Array.from({ length: 100_000 }, (_, i) => ({
id: i,
name: `item-${i}`,
status: 'PENDING',
}));
console.time('mutate-map');
// 새로운 객체를 만들지 않고 기존 객체의 속성만 수정합니다.
const resultWithMutation = bigList2.map(item => {
item.status = 'DONE'; // 🔁 여기서만 값 변경
return item; // 기존 객체(참조)를 그대로 반환
});
console.timeEnd('mutate-map');
이 코드의 장점
▸ Spread를 사용하지 않으므로 추가 객체 생성과 전체 속성 순회 작업이 사라집니다.
▸ status 프로퍼티 한 개만 바꾸는 최소한의 작업만 수행합니다.
▸ 실제로 실행해 보면, 대부분의 환경에서 spread-map보다 mutate-map이 훨씬 빠르게 끝나는 것을 확인할 수 있습니다.
💡 단, 이 방식은 원본 데이터를 변경해도 괜찮은 상황에서만 사용해야 합니다.
원본 보존이 필요하다면, 여전히 복사 전략(Spread, structuredClone 등)이 필요합니다.
🔷 안전한 깊은 복사: structuredClone 사용하기
때로는 단순한 얕은 복사가 아니라, 객체 내부에 중첩된 객체들까지 완전히 분리된 복사본이 필요할 수 있습니다.
이럴 때는 깊은 복사(Deep Copy)를 사용해야 합니다.
과거에는 JSON.parse(JSON.stringify(obj)) 방식이 많이 사용되었지만, 이 방법에는 다음과 같은 문제가 있습니다.
▸ Date, Map, Set, RegExp, 함수 등 복잡한 타입이 제대로 복사되지 않음
▸ 순환 참조(circular reference)가 있을 경우 에러 발생
▸ 타입 정보 손실로 인해 버그를 유발할 수 있음
이러한 단점을 보완하기 위해 structuredClone(obj) (Node 17+, ES2022+) 이 표준으로 도입되었습니다.
✔️ 예시: JSON 방식과 structuredClone 비교
const complexData = {
id: 1,
time: new Date(), // Date 객체
cache: new Map([['key', 'value']]), // Map 객체
};
// ❌ JSON 방식: 타입 손상 발생
const jsonClone = JSON.parse(JSON.stringify(complexData));
console.log('JSON Clone - time 타입:', jsonClone.time instanceof Date); // false
console.log('JSON Clone - cache 타입:', jsonClone.cache instanceof Map); // false
console.log('JSON Clone - time 값:', jsonClone.time); // 문자열로 변환됨
console.log('JSON Clone - cache 값:', jsonClone.cache); // 일반 객체 형태
// ✅ structuredClone: 타입을 보존한 깊은 복사
const safeClone = structuredClone(complexData);
console.log('structuredClone - time 타입:', safeClone.time instanceof Date); // true
console.log('structuredClone - cache 타입:', safeClone.cache instanceof Map); // true
console.log('structuredClone - time 값:', safeClone.time); // Date 객체
console.log('structuredClone - cache.get("key"):', safeClone.cache.get('key')); // 'value'
2. 메모리 누수의 진짜 원인: 클로저(Closure)와 WeakMap
자바스크립트는 가비지 컬렉션(GC)이 자동으로 메모리를 관리하지만, 코드가 특정 객체를 계속 참조하고 있는 경우 GC는 그 객체를 해제할 수 없습니다.
이렇게 사용하지 않는 객체가 계속 메모리에 남아있는 현상을 바로 “메모리 누수(Memory Leak)”라고 합니다.
Node.js는 “장시간 실행되는 서버 환경”이기 때문에 작은 누수도 시간이 지나면 크게 쌓여 프로세스 메모리 부족, 심할 경우 서버 다운으로도 이어질 수 있습니다.
🔷 메모리 누수의 위험성과 클로저(Closure) 관리
“클로저는 함수가 선언될 당시의 스코프(변수 환경)를 기억하고,
그 함수가 실행되는 시점에도 그 환경에 접근할 수 있게 하는 기능”입니다.
클로저(Closure)는 자바스크립트의 매우 강력한 기능입니다.
함수가 선언 당시의 변수 환경을 기억하고 사용할 수 있게 해 줍니다.
하지만 이 환경 안에 대규모 데이터를 가진 변수가 포함되어 있을 경우, 그 데이터를 참조하는 함수가 유지되는 한 GC는 해당 데이터를 해제하지 못합니다.
✔️ 문제 코드: 클로저가 대형 데이터를 붙잡는 경우
function createCacheHandler() {
// 100만 개의 숫자를 가진 대형 배열 생성
const hugeData = new Array(1_000_000).fill(0);
// 이 함수가 hugeData를 참조 → GC가 hugeData를 해제하지 못함
return index => hugeData[index];
}
const handler = createCacheHandler();
// hugeData는 handler가 유지되는 동안 메모리에 계속 남습니다.
console.log(handler(0)); // 0
▸ 클로저로 인해 hugeData가 함수 외부에서 접근할 수 없는 상태임에도 여전히 “도달 가능한 참조”로 간주됨
▸ GC는 이를 수거할 수 없음 → 누수 발생
✔️ 클래스 기반으로 재작성 (명확하고 관리 용이)
클래스 방식은 상태가 인스턴스 내부에 명확하게 저장되기 때문에 초보 개발자에게 더 직관적이고, 메모리 구조를 이해하기도 쉽습니다.
class DataCache {
constructor(size) {
// 큰 데이터가 인스턴스 내부에 명시적으로 존재
this.hugeData = new Array(size).fill(0);
}
get(index) {
return this.hugeData[index];
}
clear() {
// 명시적으로 메모리 해제 가능
this.hugeData = null;
}
}
// 사용 예시
const cache = new DataCache(1_000_000);
console.log(cache.get(0)); // 0
// 더 이상 필요 없으면 메모리 해제
cache.clear();
✔️ 개선 코드: 필요한 값만 클로저에 남기기
대규모 데이터가 클로저 내부에 들어가지 않도록 “정말 필요한 최소한의 값만” 클로저 환경에 남기는 것이 가장 안전합니다.
function createCounter() {
let count = 0; // 매우 작은 데이터만 클로저에 저장
return () => ++count;
}
const counter = createCounter();
console.log(counter()); // 1
console.log(counter()); // 2
▸ 클로저는 유지되지만, 보관하는 데이터는 매우 작음
▸ 대규모 데이터는 클로저 바깥에서 필요할 때만 접근하는 구조로 재설계
✔️ 개선 코드: 클래스 기반 Counter 구현
class Counter {
constructor() {
// 상태를 인스턴스의 프로퍼티로 명확하게 저장
this.count = 0;
}
next() {
this.count += 1;
return this.count;
}
}
// 사용 예시
const counter = new Counter();
console.log(counter.next()); // 1
console.log(counter.next()); // 2
console.log(counter.next()); // 3
🔷 WeakMap을 활용한 자동 청소 캐시 패턴
서버 코드에서 메모리 누수가 자주 발생하는 또 다른 원인은 캐시(Cache)입니다.
🔸 문제: 일반 Map의 강한 참조
Map 객체는 key로 설정된 객체를 강하게 참조합니다.
→ 외부에서 해당 객체를 더 이상 사용하지 않더라도 Map이 붙잡고 있으면 GC가 제거하지 못함.
🔸 해결: WeakMap의 약한 참조
WeakMap은 key에 대해 약한 참조(Weak Reference)를 유지합니다.
→ WeakMap의 key를 다른 곳에서 참조하지 않게 되면, GC는 key와 그에 연결된 value를 자동으로 정리합니다.
✔️ 예시: WeakMap으로 캐시 만들기
// WeakMap을 캐시로 사용합니다.
const computeCache = new WeakMap();
/**
* 특정 객체를 key로 사용하여 무거운 계산 결과를 캐싱합니다.
*/
function getHeavyResult(inputObj) {
if (computeCache.has(inputObj)) {
console.log('캐시 히트!');
return computeCache.get(inputObj);
}
// 2. 실제로는 CPU 사용이 많은 작업이라고 가정합니다.
const result = inputObj.value * 123456;
computeCache.set(inputObj, result);
console.log('새로운 값 계산 완료.');
return result;
}
// 테스트용 객체 생성
let data = { value: 10 };
// 최초 호출 → 계산 수행
console.log('결과:', getHeavyResult(data)); // 계산 후 결과 출력
// 두 번째 호출 → 캐시 사용
console.log('결과:', getHeavyResult(data)); // 캐시 히트
// 객체를 더 이상 사용하지 않음
data = null;
// 이제 GC가 일어나면 WeakMap 내부의 캐시도 자동으로 제거됩니다.
// (GC는 환경에 따라 즉시 일어나지 않을 수 있습니다.)
3. 반복문 선택이 성능을 좌우하는 이유
대량의 데이터를 다루거나 반복적인 연산을 수행하는 경우, 반복문 선택은 자바스크립트 성능에 직접적인 영향을 미칩니다.
또한 반복 과정에서 생성되는 임시 객체의 양은 V8 엔진의 GC(Garbage Collection) 빈도와 관련되므로, 적절한 반복문 선택과 메모리 관리 전략은 매우 중요합니다.
🔷 성능에 최적화된 반복문 선택
자바스크립트 엔진(V8)의 최적화 관점에서 볼 때, 반복문은 아래 순서로 빠른 경향이 있습니다.
| 형태 | 특징 | 권장 예시 |
| for (let i = 0; i < len; i++) | 가장 빠른 반복문. 불필요한 함수 호출 없음 | 극한의 성능, 반복/합산 처리 |
| for...of | 빠름. 내부적으로 이터레이터를 사용하지만 가독성이 우수 | 가장 단순한 순회가 필요할 때 |
| forEach | 함수 호출 비용 존재 | 가독성이 중요할 때 |
| map / filter / reduce | 가장 느림. 콜백 호출 + 새로운 배열 생성 비용 발생 | 선언적 코드가 필요할 때 |
// 20만 개 숫자로 구성된 배열 생성
const numbers = new Array(200_000).fill(1);
// 1) reduce
console.time('reduce');
let sumReduce = numbers.reduce((acc, cur) => acc + cur, 0);
console.timeEnd('reduce');
// 2) forEach
console.time('forEach');
let sumForEach = 0;
numbers.forEach(num => (sumForEach += num));
console.timeEnd('forEach');
// 3) for...of
console.time('forOf');
let sumForOf = 0;
for (const num of numbers) {
sumForOf += num;
}
console.timeEnd('forOf');
// 4) for (기본형) → 가장 빠른 방식
console.time('for');
let sumFor = 0;
for (let i = 0; i < numbers.length; i++) {
sumFor += numbers[i];
}
console.timeEnd('for');
실행결과 : 엔진 최적화 상황에 따라 순위가 달라질 수 있다
#설명과 차이가 나는 이유
#"엔진 최적화 상황에 따라 순위가 달라질 수 있다" 입니다.
#특히 V8(Chrome, Node)에서는 특정 패턴에 대해 극단적인 최적화(inline + vectorization) 를 수행합니다.
reduce: 1.79ms ← 최적화 풀가동
forEach: 2.63ms ← 잘 최적화됨
forOf: 3.55ms ← iterator 특성상 최적화 부족
for: 1.24ms ← 가장 원초적 반복 → 가장 빠름
🔷 고차 함수(map, reduce) 사용 시 성능 저하 이유
▸ 각 요소마다 콜백 함수가 호출됨 → 함수 호출 스택이 반복적으로 생성·소멸됨
▸ map / filter는 기존 배열과 별도로 새로운 배열을 생성함 → 객체/배열 복사 비용 + 메모리 사용량 증가
// 10만 개의 숫자를 가진 배열 생성
const numbers = new Array(100_000).fill(1);
// ❌ reduce 사용: 매 요소마다 콜백이 호출되므로 비용이 큼
console.time('Reduce_Time');
const totalA = numbers.reduce((acc, cur) => acc + cur, 0);
console.timeEnd('Reduce_Time');
// ✅ for 루프 사용: 가장 빠른 연산
console.time('For_Time');
let totalB = 0;
for (let i = 0; i < numbers.length; i++) {
totalB += numbers[i];
}
console.timeEnd('For_Time');
▸ Reduce_Time은 일반적으로 For_Time보다 훨씬 오래 걸립니다.
▸ 이는 reduce가 콜백 호출 비용 + 스코프 생성 비용을 매번 지불하기 때문입니다.
▸ for 루프는 단순한 반복만 수행하므로 훨씬 빠르게 동작합니다.
🔷 GC Pause와 임시 객체 최소화
✔️ GC Pause란?
V8 가비지 컬렉터(GC)는 사용하지 않는 메모리를 회수하는 과정에서 자바스크립트 실행을 잠시 멈추게 합니다.
이를 GC Pause라고 하며, 서버 환경에서는 응답 지연의 주요 원인이 됩니다.
✔️ GC를 자주 유발하는 대표적인 코드 패턴
▸ 반복문 또는 map 내부에서 Spread로 대형 객체 복사
▸ 반복 호출마다 새로운 배열/객체 생성
▸ JSON.parse/stringify 반복 사용
▸ 매 요청마다 임시 객체를 대량 생성
이러한 코드들은 V8의 Young Space(일시적 객체 공간)를 빠르게 채우고, 결과적으로 GC를 빈번하게 발생시킵니다.
✔️ 문제 코드: 매번 새로운 대형 배열 생성 → GC 폭증
function processData(size) {
// ❌ 매 호출마다 큰 배열을 새로 생성
const temp = new Array(size).fill(0);
return temp[0];
}
console.time('bad');
for (let i = 0; i < 5000; i++) {
processData(50_000); // 반복적으로 큰 배열 생성
}
console.timeEnd('bad');
이 코드의 문제점
▸ 호출될 때마다 새로운 배열을 생성
▸ 배열 크기가 클수록 Young Space를 빠르게 소모
▸ 반복 호출 시 GC가 계속 발생하면서 서버 응답 지연 증가
✔️ 개선 코드: 배열 재사용
// 재사용 가능한 버퍼 풀
const bufferPool = {
buffer: [],
acquire(size) {
if (this.buffer.length >= size) return this.buffer;
this.buffer = new Array(size).fill(0);
return this.buffer;
}
};
function processOptimized(size) {
// ✔ 항상 같은 배열을 재사용
const temp = bufferPool.acquire(size);
return temp[0];
}
console.time('good');
for (let i = 0; i < 5000; i++) {
processOptimized(50_000);
}
console.timeEnd('good');
※ 게시된 글 및 이미지 중 일부는 AI 도구의 도움을 받아 생성되거나 다듬어졌습니다.
'4.Node.js > JavaScript&TypeScript' 카테고리의 다른 글
| [TypeScript] 2편. 객체 설계의 핵심, Interface vs Type Alias 완벽 정리와 타입 추론 원리 (0) | 2025.12.12 |
|---|---|
| [TypeScript] 1편. TypeScript 핵심 개념과 기본 타입 이해하기 (0) | 2025.12.10 |
| [JavaScript] 9편. 에러 처리 기법 이해하기 (0) | 2025.12.09 |
| [JavaScript] 8편. 반복문 이해하기: for, while, forEach, Iterable (0) | 2025.12.08 |
| [JavaScript] 7편. 컬렉션 이해하기: 배열,Map,Set,groupBy,WeakMap (0) | 2025.12.07 |