이미지 로딩 중...
AI Generated
2025. 11. 7. · 5 Views
배열 재할당과 복사 비용 최적화 완벽 가이드
배열 재할당 시 발생하는 메모리 복사 비용을 줄이는 다양한 최적화 기법을 다룹니다. 실무에서 자주 마주치는 성능 병목을 해결하고, 효율적인 메모리 관리 전략을 통해 애플리케이션의 성능을 극대화하는 방법을 배웁니다.
목차
- 배열_사전_할당
- Array.from과_길이_지정
- TypedArray_활용
- 연결_리스트_패턴
- 버퍼_풀링_패턴
- push_vs_인덱스_할당
- Array.prototype.concat_대안
- 청크_단위_처리
- Object_Pool_패턴
- 불변성과_성능_트레이드오프
1. 배열_사전_할당
시작하며
여러분이 대량의 데이터를 처리하는 API 응답을 받아서 배열에 저장할 때, 갑자기 애플리케이션이 느려지거나 메모리 사용량이 급증하는 경험을 해본 적 있나요? 예를 들어, 수천 개의 상품 정보를 불러와서 필터링하고 변환하는 과정에서 브라우저가 버벅이기 시작하는 상황입니다.
이런 문제는 배열이 자동으로 크기를 조정하면서 발생합니다. JavaScript 배열은 동적 크기를 가지기 때문에, 요소를 추가할 때마다 내부적으로 메모리 공간이 부족하면 더 큰 메모리 영역을 할당하고 기존 데이터를 모두 복사합니다.
이 과정이 수백, 수천 번 반복되면서 성능 병목이 발생하는 것이죠. 바로 이럴 때 필요한 것이 배열 사전 할당입니다.
최종적으로 필요한 배열의 크기를 미리 알고 있다면, 처음부터 그 크기로 배열을 생성하여 재할당 비용을 완전히 제거할 수 있습니다.
개요
간단히 말해서, 배열 사전 할당은 배열의 최종 크기를 미리 예측하여 처음부터 그 크기로 메모리를 확보하는 기법입니다. 배열에 요소를 하나씩 추가하면 JavaScript 엔진은 내부 버퍼가 가득 찰 때마다 새로운 메모리를 할당하고 기존 데이터를 복사해야 합니다.
일반적으로 배열이 가득 차면 현재 크기의 1.5배에서 2배 정도로 확장되는데, 이 과정이 반복되면 불필요한 메모리 복사가 여러 번 발생합니다. 예를 들어, 최종적으로 10,000개의 요소가 필요한 경우, 사전 할당 없이는 수십 번의 재할당이 발생할 수 있습니다.
기존에는 빈 배열을 만들고 push()로 하나씩 추가했다면, 이제는 처음부터 필요한 크기로 배열을 생성하고 인덱스를 통해 직접 값을 할당할 수 있습니다. 이 기법의 핵심 특징은 첫째, 재할당 횟수를 0으로 만들어 메모리 복사 비용을 완전히 제거하고, 둘째, 메모리 단편화를 줄여 가비지 컬렉션 부담을 감소시키며, 셋째, 예측 가능한 메모리 사용 패턴으로 시스템 안정성을 높인다는 것입니다.
이러한 특징들이 대용량 데이터 처리 시 성능과 안정성 모두를 크게 개선시킵니다.
코드 예제
// 일반적인 방식 (비효율적)
function processDataSlow(count) {
const result = [];
for (let i = 0; i < count; i++) {
result.push(i * 2); // 재할당 발생 가능
}
return result;
}
// 사전 할당 방식 (효율적)
function processDataFast(count) {
const result = new Array(count); // 크기 미리 할당
for (let i = 0; i < count; i++) {
result[i] = i * 2; // 직접 인덱스 할당
}
return result;
}
// 성능 비교
const start = performance.now();
processDataFast(100000);
console.log(`사전 할당: ${performance.now() - start}ms`);
설명
이것이 하는 일: 배열 사전 할당은 메모리 재할당과 데이터 복사를 방지하여 대용량 데이터 처리 성능을 극적으로 향상시킵니다. 첫 번째로, new Array(count)는 지정된 크기만큼의 메모리 공간을 한 번에 확보합니다.
이때 JavaScript 엔진은 내부적으로 연속된 메모리 블록을 할당하고, 각 요소를 위한 슬롯을 준비합니다. 배열의 length 속성이 즉시 설정되기 때문에, 엔진은 추가적인 메모리 확장이 필요 없다는 것을 알고 최적화를 적용할 수 있습니다.
그 다음으로, 반복문에서 result[i] = value 형태로 직접 인덱스에 값을 할당할 때, 이미 확보된 메모리 위치에 값을 쓰기만 하면 됩니다. push() 메서드는 내부적으로 배열의 현재 크기를 확인하고, 필요시 재할당을 검토하며, length를 업데이트하는 등의 추가 작업이 필요하지만, 직접 인덱스 할당은 이런 오버헤드가 없습니다.
마지막으로, 메모리가 처음부터 연속적으로 할당되어 있기 때문에 CPU 캐시 효율성도 향상됩니다. 현대 프로세서는 메모리 접근 패턴이 예측 가능할 때 프리페칭을 통해 성능을 높이는데, 사전 할당된 배열은 이상적인 메모리 레이아웃을 제공합니다.
여러분이 이 코드를 사용하면 10만 개 이상의 요소를 처리할 때 30~50%의 성능 향상을 경험할 수 있습니다. 특히 실시간 데이터 처리, 대용량 CSV 파싱, 이미지 픽셀 데이터 처리 같은 시나리오에서 체감 가능한 차이를 만들어냅니다.
또한 메모리 사용 패턴이 일정해져서 메모리 누수 디버깅도 쉬워지고, 가비지 컬렉션으로 인한 프레임 드롭도 줄어듭니다.
실전 팁
💡 배열 크기를 정확히 모른다면 예상치의 110~120% 정도로 여유있게 할당하고, 마지막에 실제 사용한 길이로 잘라내세요 (array.length = actualSize)
💡 초기화되지 않은 슬롯(empty slots)과 undefined는 다릅니다. Array(n)은 empty slots를 만들므로 map/filter 같은 메서드가 건너뛸 수 있습니다. Array(n).fill(undefined)로 명시적 초기화를 고려하세요
💡 V8 엔진은 배열 크기가 100,000개를 넘어가면 다른 메모리 전략을 사용합니다. 매우 큰 배열은 청크 단위로 나누어 처리하는 것이 더 효율적일 수 있습니다
💡 배열 요소가 객체나 배열인 경우, 사전 할당만으로는 부족합니다. 내부 객체들도 풀링 패턴을 적용해야 진정한 최적화가 됩니다
💡 Node.js 환경에서는 --max-old-space-size 옵션으로 힙 크기를 늘리면 대용량 배열 사전 할당이 더 안정적입니다
2. Array.from과_길이_지정
시작하며
여러분이 API에서 받은 데이터를 변환해야 하는데, 데이터의 개수는 알지만 실제 값은 계산이나 비동기 처리를 통해 얻어야 하는 상황을 생각해보세요. 예를 들어, 100개의 사용자 ID만 있고, 각 사용자의 상세 정보는 별도로 fetch해야 하는 경우입니다.
단순히 new Array(n)을 사용하면 빈 슬롯들이 생기고, map이나 forEach 같은 배열 메서드들이 제대로 작동하지 않는 문제가 발생합니다. 빈 배열을 만들고 push로 채우자니 사전 할당의 이점이 사라지고, for 루프로 일일이 할당하자니 코드가 장황해집니다.
바로 이럴 때 필요한 것이 Array.from의 길이 지정 기능입니다. 배열의 크기를 지정하면서 동시에 각 요소를 초기화하거나 변환할 수 있어, 선언적이고 효율적인 코드를 작성할 수 있습니다.
개요
간단히 말해서, Array.from은 이터러블 객체나 유사 배열 객체를 실제 배열로 변환하는데, {length: n} 형태로 길이만 지정하면 사전 할당된 배열을 만들면서 매핑 함수로 각 요소를 초기화할 수 있습니다. 이 기법이 필요한 이유는 선언적 프로그래밍 스타일을 유지하면서도 성능 최적화를 달성할 수 있기 때문입니다.
함수형 프로그래밍 패러다임에서는 명령형 for 루프보다 map, filter 같은 고차 함수를 선호하는데, Array.from은 이런 스타일을 배열 생성 시점부터 적용할 수 있게 해줍니다. 예를 들어, 0부터 99까지의 제곱수 배열이 필요할 때, 이 방법을 사용하면 한 줄로 깔끔하게 표현할 수 있습니다.
기존에는 빈 배열을 만들고 push로 채우거나, new Array(n).fill(0).map()처럼 중간 단계를 거쳤다면, 이제는 Array.from으로 생성과 초기화를 원자적으로 처리할 수 있습니다. 이 기법의 핵심 특징은 첫째, 메모리 사전 할당의 이점을 유지하면서 선언적 코드를 작성할 수 있고, 둘째, 매핑 함수에서 인덱스와 배열 자체를 참조할 수 있어 복잡한 초기화 로직도 깔끔하게 표현되며, 셋째, fill() + map()처럼 배열을 두 번 순회하지 않아 성능상 이점이 있다는 것입니다.
이러한 특징들이 코드의 가독성과 성능을 동시에 향상시킵니다.
코드 예제
// 0부터 n-1까지의 제곱수 배열 생성
const squares = Array.from(
{ length: 100 },
(_, index) => index * index
);
// 비동기 작업을 위한 Promise 배열 생성
const userIds = [1, 2, 3, 4, 5];
const userPromises = Array.from(
{ length: userIds.length },
(_, i) => fetch(`/api/users/${userIds[i]}`)
);
// 복잡한 초기화 로직
const matrix = Array.from(
{ length: 5 },
(_, row) => Array.from(
{ length: 5 },
(_, col) => row * 5 + col
)
);
설명
이것이 하는 일: Array.from은 길이 정보만 가진 객체를 배열로 변환하면서, 각 요소를 매핑 함수로 초기화하여 메모리 효율성과 코드 가독성을 모두 달성합니다. 첫 번째로, {length: n} 객체는 유사 배열 객체(array-like object)입니다.
JavaScript에서 length 속성을 가진 객체는 배열처럼 취급될 수 있는데, Array.from은 이를 감지하고 내부적으로 n개의 요소를 가진 실제 배열을 사전 할당합니다. 이 시점에 메모리가 한 번에 확보되므로 재할당 비용이 없습니다.
그 다음으로, 두 번째 인자로 전달된 매핑 함수가 각 인덱스에 대해 한 번씩 실행됩니다. 함수는 (element, index) 두 개의 인자를 받는데, element는 undefined(초기값이 없으므로)이고, index는 0부터 n-1까지의 값입니다.
이 index를 활용하여 원하는 값을 계산하고 반환하면, 그 값이 배열의 해당 위치에 저장됩니다. 이 과정은 단일 패스로 완료되어 중간 배열이 생성되지 않습니다.
마지막으로, 매핑 함수 내부에서 비동기 작업이나 복잡한 계산을 수행할 수 있습니다. 예를 들어 Promise 배열을 만들 때, 각 Promise는 독립적으로 생성되고 즉시 실행을 시작합니다.
Promise.all(userPromises)로 모든 비동기 작업을 병렬로 기다릴 수 있어, 순차적 처리보다 훨씬 빠른 데이터 로딩이 가능합니다. 여러분이 이 코드를 사용하면 배열 생성 코드가 한 줄로 간결해지면서도, 성능은 명령형 for 루프와 동등하거나 더 나은 수준을 유지할 수 있습니다.
특히 팀 프로젝트에서 코드 리뷰 시 의도가 명확히 드러나고, 함수형 프로그래밍에 익숙한 개발자들이 쉽게 이해할 수 있습니다. 또한 TypeScript와 함께 사용할 때 타입 추론이 잘 작동하여 타입 안정성도 높아집니다.
실전 팁
💡 매핑 함수가 없이 Array.from({length: n})만 사용하면 undefined로 채워진 배열이 됩니다. empty slots가 아니므로 map/filter가 정상 작동합니다
💡 매핑 함수에서 외부 변수를 참조할 때는 클로저에 주의하세요. 특히 비동기 작업에서는 루프 변수를 캡처하는 실수가 발생하기 쉽습니다
💡 Array.from은 이터러블 프로토콜을 지원하므로 Set, Map, 문자열 등 다양한 자료구조를 배열로 변환할 때도 사용할 수 있습니다
💡 성능이 극도로 중요한 경우, 단순 숫자 배열은 TypedArray(Float64Array 등)를 사용하는 것이 Array.from보다 더 빠릅니다
💡 2차원 배열을 만들 때 fill(Array(n))은 같은 배열 참조를 공유하는 버그를 만듭니다. 반드시 Array.from으로 각 행을 개별 생성하세요
3. TypedArray_활용
시작하며
여러분이 실시간 오디오 처리, 3D 그래픽스, 또는 대량의 센서 데이터를 다루는 웹 애플리케이션을 개발할 때, 일반 JavaScript 배열의 성능 한계를 느껴본 적 있나요? 예를 들어, WebGL에서 수십만 개의 정점 데이터를 처리하거나, WebAudio API로 실시간 음성 처리를 할 때 프레임이 떨어지는 상황입니다.
이런 문제의 근본 원인은 일반 JavaScript 배열이 동적 타입 시스템 위에 구축되어 있다는 것입니다. 각 요소가 어떤 타입이든 될 수 있기 때문에, JavaScript 엔진은 각 요소마다 타입 정보를 추가로 저장하고, 접근할 때마다 타입 체크를 수행해야 합니다.
숫자만 다루는 상황에서도 이 오버헤드를 피할 수 없어서 메모리도 낭비되고 속도도 느려집니다. 바로 이럴 때 필요한 것이 TypedArray입니다.
특정 숫자 타입만 저장하는 고정 크기 배열로, C/C++의 배열과 유사한 메모리 레이아웃을 가지며, 성능이 일반 배열보다 몇 배에서 수십 배까지 빠를 수 있습니다.
개요
간단히 말해서, TypedArray는 특정 숫자 타입(예: 32비트 정수, 64비트 부동소수점)의 값만 저장할 수 있는 고정 크기 배열로, 내부적으로 ArrayBuffer라는 원시 바이너리 데이터 버퍼 위에 구축됩니다. 이 기법이 필요한 이유는 숫자 집약적인 작업에서 메모리 효율성과 처리 속도를 극대적으로 끌어올릴 수 있기 때문입니다.
일반 배열에서 숫자는 IEEE 754 배정밀도(64비트)로 저장되고, 추가로 타입 태그와 포인터 정보까지 필요합니다. 반면 Float32Array는 각 요소가 정확히 4바이트만 차지하며, 타입 정보가 배열 레벨에 한 번만 저장됩니다.
예를 들어, 100만 개의 좌표를 저장할 때 메모리 사용량이 1/4로 줄어들 수 있습니다. 기존에는 일반 배열에 숫자를 저장하고 반복문으로 처리했다면, 이제는 적절한 TypedArray를 선택하여 메모리와 CPU 캐시를 효율적으로 사용할 수 있습니다.
이 기법의 핵심 특징은 첫째, 고정 크기이므로 재할당이 원천적으로 불가능하여 메모리 복사 비용이 없고, 둘째, 연속된 메모리 블록을 사용하여 CPU 캐시 적중률이 높으며, 셋째, SIMD(Single Instruction Multiple Data) 최적화를 JavaScript 엔진이 자동으로 적용할 수 있다는 것입니다. 이러한 특징들이 수치 계산 성능을 네이티브 코드에 가깝게 만들어줍니다.
코드 예제
// 일반 배열 vs TypedArray 비교
const regularArray = new Array(1000000).fill(0);
const typedArray = new Float32Array(1000000);
// 3D 좌표 데이터 처리 예제
const vertexCount = 10000;
const vertices = new Float32Array(vertexCount * 3); // x, y, z
// 정점 데이터 초기화
for (let i = 0; i < vertexCount; i++) {
vertices[i * 3] = Math.random() * 100; // x
vertices[i * 3 + 1] = Math.random() * 100; // y
vertices[i * 3 + 2] = Math.random() * 100; // z
}
// ArrayBuffer 공유를 통한 다중 뷰
const buffer = new ArrayBuffer(16);
const int32View = new Int32Array(buffer);
const float32View = new Float32Array(buffer);
// 같은 메모리를 다른 타입으로 해석 가능
설명
이것이 하는 일: TypedArray는 JavaScript에서 네이티브 수준의 숫자 처리 성능을 제공하기 위해 고정된 메모리 레이아웃과 타입 제약을 적용한 특수 배열입니다. 첫 번째로, new Float32Array(n)을 호출하면 내부적으로 n * 4 바이트 크기의 ArrayBuffer가 생성됩니다(Float32는 4바이트).
이 버퍼는 순수한 바이너리 데이터 저장소이며, 어떤 JavaScript 객체 오버헤드도 없습니다. TypedArray는 이 버퍼를 특정 타입의 숫자 시퀀스로 해석하는 뷰(view) 역할을 합니다.
메모리가 정확히 요청한 크기만큼만 할당되고, 추가 메타데이터가 최소화되어 메모리 효율성이 극대화됩니다. 그 다음으로, 배열 요소에 접근할 때 타입 변환이 자동으로 일어납니다.
vertices[i] = 3.7에서 3.7은 JavaScript 숫자(64비트 float)이지만, Float32Array에 저장될 때 자동으로 32비트로 변환됩니다. 읽을 때는 반대로 32비트에서 64비트로 확장됩니다.
이 과정이 매우 빠르고, 타입이 고정되어 있어서 JavaScript 엔진이 JIT 컴파일 시 최적화된 기계어 코드를 생성할 수 있습니다. 마지막으로, ArrayBuffer를 여러 TypedArray가 공유할 수 있어서 같은 메모리를 다른 방식으로 해석할 수 있습니다.
예를 들어, 네트워크에서 받은 바이너리 데이터를 Uint8Array로 파싱하다가, 중간의 특정 영역을 Float64Array로 해석하여 부동소수점 값을 추출할 수 있습니다. 이는 복사 없이 메모리 뷰만 바꾸는 것이므로 제로 카피(zero-copy) 작업이 가능합니다.
여러분이 이 코드를 사용하면 WebGL 애플리케이션에서 정점 버퍼를 직접 GPU로 전송할 수 있고, WebAudio에서 실시간 오디오 샘플을 처리할 수 있으며, 대용량 CSV 파일의 숫자 데이터를 빠르게 파싱할 수 있습니다. 특히 게임 개발, 데이터 시각화, 과학 계산 같은 분야에서 성능 병목을 해결하는 핵심 기술입니다.
또한 Web Workers 간 데이터 전송 시 Transferable Objects로 사용하면 복사 없이 소유권만 이전할 수 있어 멀티스레드 성능도 향상됩니다.
실전 팁
💡 TypedArray 종류는 Int8Array, Uint8Array, Int16Array, Uint16Array, Int32Array, Uint32Array, Float32Array, Float64Array 등이 있습니다. 필요한 최소 범위와 정밀도에 맞게 선택하세요
💡 TypedArray는 길이가 고정되어 있어 push, pop, splice 같은 메서드가 없습니다. 크기 조정이 필요하면 새 배열을 만들고 set() 메서드로 복사해야 합니다
💡 일반 배열과 달리 구멍(holes)이 없고, 초기화되지 않은 요소는 0으로 자동 초기화됩니다
💡 ArrayBuffer를 직접 다룰 때는 DataView를 사용하면 엔디안(바이트 순서)을 제어할 수 있어 네트워크 프로토콜 구현에 유용합니다
💡 대부분의 브라우저에서 TypedArray는 최대 2^32-1 바이트(약 4GB)까지 지원하지만, 실제로는 사용 가능한 메모리에 제한됩니다. 매우 큰 데이터는 여러 개로 분할하세요
4. 연결_리스트_패턴
시작하며
여러분이 실시간 채팅 애플리케이션에서 메시지를 저장하고 관리할 때, 새 메시지가 계속 추가되고 오래된 메시지는 삭제해야 하는 상황을 생각해보세요. 배열을 사용하면 shift()로 앞에서 제거하거나, splice()로 중간 요소를 삭제할 때마다 나머지 모든 요소를 이동시켜야 하는 문제가 있습니다.
이런 문제는 배열의 메모리 구조가 연속적이기 때문에 발생합니다. 배열의 중간 요소를 삭제하면 그 뒤의 모든 요소를 한 칸씩 앞으로 이동시켜야 하고, 앞에서 삭제하면 전체 배열이 이동됩니다.
메시지가 수천, 수만 개가 되면 이 작업이 매번 수백 밀리초씩 걸릴 수 있습니다. 바로 이럴 때 필요한 것이 연결 리스트 패턴입니다.
각 노드가 다음 노드를 가리키는 포인터만 저장하여, 삽입과 삭제가 O(1) 시간에 가능하고, 메모리 재할당이나 복사가 전혀 필요 없습니다.
개요
간단히 말해서, 연결 리스트는 각 요소(노드)가 데이터와 다음 노드에 대한 참조를 포함하는 선형 자료구조로, JavaScript에서는 객체의 참조를 활용하여 구현합니다. 이 패턴이 필요한 이유는 동적으로 크기가 변하는 컬렉션에서 삽입/삭제가 빈번할 때, 배열의 선형 시간 복잡도를 상수 시간으로 줄일 수 있기 때문입니다.
큐(Queue)나 스택(Stack) 같은 자료구조를 구현할 때도 연결 리스트가 배열보다 효율적입니다. 예를 들어, 작업 큐에서 작업을 추가하고 완료된 작업을 제거하는 시나리오에서, 배열은 매번 O(n) 시간이 걸리지만 연결 리스트는 O(1)입니다.
기존에는 배열로 모든 순차 데이터를 관리했다면, 이제는 접근 패턴을 분석하여 순차 접근만 필요한 경우 연결 리스트를 선택할 수 있습니다. 이 패턴의 핵심 특징은 첫째, 삽입과 삭제가 포인터 조작만으로 완료되어 O(1) 시간 복잡도를 가지며, 둘째, 메모리 재할당과 데이터 복사가 전혀 발생하지 않아 가비지 컬렉션 압력이 낮고, 셋째, 각 노드가 독립적으로 할당되어 메모리 단편화는 있지만 큰 연속 메모리가 필요 없다는 것입니다.
이러한 특징들이 특정 사용 사례에서 배열보다 훨씬 우수한 성능을 제공합니다.
코드 예제
// 단순 연결 리스트 구현
class Node {
constructor(value) {
this.value = value;
this.next = null;
}
}
class LinkedList {
constructor() {
this.head = null;
this.tail = null;
this.size = 0;
}
// O(1) 시간에 끝에 추가
append(value) {
const node = new Node(value);
if (!this.head) {
this.head = this.tail = node;
} else {
this.tail.next = node;
this.tail = node;
}
this.size++;
}
// O(1) 시간에 앞에서 제거
shift() {
if (!this.head) return null;
const value = this.head.value;
this.head = this.head.next;
if (!this.head) this.tail = null;
this.size--;
return value;
}
// 순회
*[Symbol.iterator]() {
let current = this.head;
while (current) {
yield current.value;
current = current.next;
}
}
}
// 사용 예제
const messages = new LinkedList();
messages.append("Hello");
messages.append("World");
messages.shift(); // "Hello" 제거 - O(1)
설명
이것이 하는 일: 연결 리스트는 메모리 연속성을 포기하는 대신 포인터 기반 연결로 삽입/삭제의 시간 복잡도를 획기적으로 개선한 자료구조입니다. 첫 번째로, append() 메서드에서 새 노드를 생성할 때, 개별 객체로 할당되므로 기존 노드들의 메모리 위치에 영향을 주지 않습니다.
tail.next = node 한 줄로 연결이 완료되며, 이는 단순히 참조 값을 복사하는 것이므로 노드가 몇 개든 상관없이 동일한 시간이 걸립니다. 배열의 push()가 내부 버퍼가 가득 차면 전체를 재할당해야 하는 것과 대조적입니다.
그 다음으로, shift() 메서드는 head 포인터를 다음 노드로 이동시키기만 하면 됩니다. 삭제된 노드는 더 이상 참조되지 않으므로 JavaScript의 가비지 컬렉터가 자동으로 메모리를 회수합니다.
배열의 shift()는 첫 요소를 제거한 후 나머지 모든 요소를 한 칸씩 앞으로 이동시켜야 하므로 O(n) 시간이 걸리지만, 연결 리스트는 O(1)입니다. 10만 개의 요소가 있어도 성능은 동일합니다.
마지막으로, Symbol.iterator를 구현하여 for...of 루프와 스프레드 연산자를 지원합니다. 순회는 head에서 시작하여 각 노드의 next를 따라가며 진행되므로, 메모리가 연속적이지 않아도 문제없이 작동합니다.
다만 인덱스 기반 접근(list[5])은 불가능하고, 특정 위치의 요소에 접근하려면 처음부터 순차적으로 탐색해야 하므로 O(n) 시간이 걸립니다. 여러분이 이 코드를 사용하면 이벤트 큐, 작업 스케줄러, 실행 취소(Undo) 기능, 브라우저 히스토리 관리 같은 기능을 효율적으로 구현할 수 있습니다.
특히 Node.js의 이벤트 루프 내부 구현이나, React의 Fiber 아키텍처도 연결 리스트 개념을 활용합니다. 메모리 사용 패턴이 예측 가능해지고, 최악의 경우 성능 저하가 없어서 실시간 시스템에 적합합니다.
실전 팁
💡 이중 연결 리스트(Doubly Linked List)는 각 노드가 prev와 next를 모두 가져서 양방향 순회와 끝에서의 제거(O(1))가 가능합니다
💡 JavaScript의 가비지 컬렉션은 순환 참조를 처리하지만, 대용량 연결 리스트는 clear() 메서드에서 명시적으로 next를 null로 설정하여 GC를 돕는 것이 좋습니다
💡 CPU 캐시 친화성이 배열보다 낮아서, 순회가 주된 작업이라면 배열이 더 빠를 수 있습니다. 연결 리스트는 삽입/삭제가 빈번한 경우에 강력합니다
💡 노드 객체 생성 비용이 있으므로, 매우 짧은 리스트(<100 요소)에서는 배열이 더 효율적일 수 있습니다. 프로파일링으로 확인하세요
💡 TypeScript를 사용한다면 제네릭으로 타입 안정성을 확보하세요: class LinkedList<T> { ... }
5. 버퍼_풀링_패턴
시작하며
여러분이 실시간 데이터 스트림을 처리하는 애플리케이션을 개발할 때, 예를 들어 WebSocket으로 초당 수백 개의 메시지를 받아서 파싱하고 처리하는 상황을 생각해보세요. 각 메시지마다 새로운 배열이나 버퍼를 생성하면, 가비지 컬렉터가 계속해서 메모리를 회수해야 하고, 그 과정에서 주기적인 프레임 드롭이 발생합니다.
이런 문제는 짧은 생명주기를 가진 객체들이 대량으로 생성되고 폐기될 때 발생합니다. JavaScript의 가비지 컬렉션은 자동이지만 무료가 아닙니다.
GC가 실행되는 동안 메인 스레드가 멈추거나 느려져서, 사용자 인터랙션이 끊기거나 애니메이션이 버벅이는 현상이 나타납니다. 바로 이럴 때 필요한 것이 버퍼 풀링 패턴입니다.
미리 일정 개수의 버퍼를 생성해두고 재사용함으로써, 객체 생성/소멸 빈도를 획기적으로 줄여 가비지 컬렉션 압력을 최소화하고 성능을 안정화시킵니다.
개요
간단히 말해서, 버퍼 풀링은 자주 사용되는 버퍼나 배열을 미리 생성하여 풀(pool)에 보관하고, 필요할 때 빌려 쓴 후 사용이 끝나면 다시 풀에 반환하여 재사용하는 메모리 관리 패턴입니다. 이 패턴이 필요한 이유는 고빈도 할당/해제 패턴에서 GC 오버헤드를 제거할 수 있기 때문입니다.
가비지 컬렉션은 특히 젊은 세대(young generation) 객체들이 많을 때 자주 발생하는데, 풀링을 사용하면 객체들이 오래 살아남아 old generation으로 승격되고, 이후로는 GC 대상에서 제외됩니다. 예를 들어, 게임에서 매 프레임마다 수백 개의 임시 배열을 만들어야 하는 경우, 풀링으로 GC 일시 정지를 거의 제거할 수 있습니다.
기존에는 필요할 때마다 new Array()나 new TypedArray()로 생성했다면, 이제는 풀에서 가져다 쓰고 초기화하여 재사용할 수 있습니다. 이 패턴의 핵심 특징은 첫째, 메모리 할당 횟수를 초기화 시점으로 제한하여 런타임 할당을 제거하고, 둘째, 가비지 컬렉션 빈도와 일시 정지 시간을 크게 줄이며, 셋째, 메모리 사용량이 예측 가능하여 메모리 누수를 조기에 발견할 수 있다는 것입니다.
이러한 특징들이 실시간 애플리케이션의 안정성과 성능을 보장합니다.
코드 예제
// 버퍼 풀 구현
class BufferPool {
constructor(bufferSize, poolSize = 10) {
this.bufferSize = bufferSize;
this.available = [];
this.inUse = new Set();
// 풀 초기화
for (let i = 0; i < poolSize; i++) {
this.available.push(new Float32Array(bufferSize));
}
}
// 버퍼 빌리기
acquire() {
let buffer;
if (this.available.length > 0) {
buffer = this.available.pop();
} else {
// 풀이 비었으면 새로 생성 (경고 로깅 권장)
console.warn('Buffer pool exhausted, creating new buffer');
buffer = new Float32Array(this.bufferSize);
}
this.inUse.add(buffer);
return buffer;
}
// 버퍼 반환
release(buffer) {
if (!this.inUse.has(buffer)) {
throw new Error('Buffer not from this pool');
}
this.inUse.delete(buffer);
buffer.fill(0); // 재사용 전 초기화
this.available.push(buffer);
}
// 통계
getStats() {
return {
available: this.available.length,
inUse: this.inUse.size,
total: this.available.length + this.inUse.size
};
}
}
// 사용 예제
const pool = new BufferPool(1024, 20);
function processData(data) {
const buffer = pool.acquire();
try {
// buffer로 작업 수행
for (let i = 0; i < data.length; i++) {
buffer[i] = data[i] * 2;
}
return buffer.slice(0, data.length);
} finally {
pool.release(buffer); // 반드시 반환
}
}
설명
이것이 하는 일: 버퍼 풀링은 메모리 할당을 시간축에서 분산시키는 것이 아니라 초기화 시점으로 앞당겨서, 런타임 중에는 이미 할당된 메모리만 재사용하는 전략입니다. 첫 번째로, 생성자에서 poolSize만큼의 버퍼를 미리 생성하여 available 배열에 저장합니다.
이 시점에 모든 메모리 할당이 완료되며, 이 객체들은 애플리케이션 생명주기 동안 계속 살아있습니다. JavaScript 엔진은 이들을 old generation으로 승격시키고, 이후 full GC에서만 스캔하므로 young generation GC의 영향을 받지 않습니다.
그 다음으로, acquire() 메서드는 사용 가능한 버퍼를 꺼내서 반환합니다. 풀이 비었을 때만 새로운 버퍼를 생성하는데, 이는 예외 상황이므로 경고를 로깅합니다.
프로덕션에서 이 경고가 자주 나타난다면 poolSize를 늘려야 한다는 신호입니다. inUse Set으로 현재 사용 중인 버퍼를 추적하여, 같은 버퍼를 두 번 빌려주는 버그를 방지합니다.
마지막으로, release() 메서드에서 buffer.fill(0)으로 초기화하여 이전 데이터가 남지 않도록 합니다. 이는 보안과 정확성 모두에 중요합니다.
try-finally 블록으로 예외가 발생해도 반드시 버퍼를 반환하도록 보장하는 것이 핵심입니다. 버퍼를 반환하지 않으면 풀이 고갈되어 성능 저하나 메모리 누수가 발생합니다.
여러분이 이 코드를 사용하면 실시간 오디오/비디오 처리, WebGL 렌더링, 대용량 데이터 스트리밍 같은 시나리오에서 일정한 프레임레이트를 유지할 수 있습니다. 특히 모바일 기기나 저사양 환경에서 GC로 인한 버벅임을 제거하여 사용자 경험을 크게 개선합니다.
Node.js 서버에서도 높은 처리량을 유지하면서 메모리 사용량을 예측 가능하게 관리할 수 있습니다.
실전 팁
💡 풀 크기는 동시에 필요한 최대 버퍼 개수의 120~150% 정도로 설정하세요. 너무 크면 메모리 낭비, 너무 작으면 풀 고갈이 자주 발생합니다
💡 WeakMap을 사용하면 버퍼가 실수로 외부에 누출되어도 GC가 회수할 수 있지만, 풀링의 이점은 사라집니다. 명시적 생명주기 관리가 더 안전합니다
💡 다양한 크기의 버퍼가 필요하면 여러 개의 풀을 만들어 크기별로 관리하세요. 작은 버퍼를 위해 큰 버퍼를 낭비하지 마세요
💡 풀 통계(getStats())를 모니터링하여 런타임 중 풀 사용 패턴을 분석하고, 최적의 풀 크기를 실험적으로 찾으세요
💡 Web Workers에서 버퍼를 사용할 때는 Transferable Objects로 소유권을 이전하되, 풀 관리는 각 Worker마다 독립적으로 유지하는 것이 안전합니다
6. push_vs_인덱스_할당
시작하며
여러분이 반복문 안에서 배열을 채울 때, 습관적으로 array.push(value)를 사용하고 있지는 않나요? 예를 들어, API 응답 데이터를 변환하여 새 배열을 만들거나, 계산 결과를 수집할 때 말이죠.
push() 메서드는 편리하고 직관적이지만, 내부적으로 여러 가지 체크와 작업을 수행합니다. length 속성 확인, 내부 버퍼 크기 체크, 필요시 재할당, length 업데이트 등의 오버헤드가 있습니다.
소량의 데이터에서는 무시할 만하지만, 수만, 수십만 개의 요소를 처리할 때는 누적된 오버헤드가 성능 차이를 만듭니다. 바로 이럴 때 필요한 것이 인덱스 직접 할당입니다.
배열 크기를 미리 알고 있다면, 인덱스를 통한 직접 할당이 push()보다 빠르고 예측 가능한 성능을 제공합니다.
개요
간단히 말해서, push() 메서드 대신 array[index] = value 형태로 직접 인덱스에 값을 할당하는 것이 더 빠르며, 특히 사전 할당된 배열에서 그 차이가 명확합니다. 이 기법이 필요한 이유는 메서드 호출 오버헤드와 내부 검사를 생략할 수 있기 때문입니다.
push()는 범용 메서드로 설계되어 있어 배열 객체가 손상되지 않았는지, length가 유효한지, 프로토타입 체인이 수정되지 않았는지 등을 확인합니다. 반면 인덱스 할당은 메모리 주소 계산과 값 쓰기만 수행하므로, 현대 JavaScript 엔진이 매우 효율적인 기계어 코드로 컴파일할 수 있습니다.
예를 들어, V8 엔진은 타입이 안정적인 배열에 대해 인덱스 할당을 단일 어셈블리 명령으로 최적화합니다. 기존에는 편의성을 위해 push()를 사용했다면, 이제는 성능이 중요한 루프에서 인덱스 할당을 선택할 수 있습니다.
이 기법의 핵심 특징은 첫째, 메서드 호출 스택이 없어 함수 호출 오버헤드가 제거되고, 둘째, 컴파일러 최적화가 더 적극적으로 적용될 수 있으며, 셋째, 사전 할당과 결합하면 최고의 성능을 발휘한다는 것입니다. 이러한 특징들이 성능 집약적인 코드에서 중요한 차이를 만듭니다.
코드 예제
// push() 사용 (느림)
function transformWithPush(data) {
const result = [];
for (let i = 0; i < data.length; i++) {
result.push(data[i] * 2);
}
return result;
}
// 인덱스 할당 (빠름)
function transformWithIndex(data) {
const result = new Array(data.length);
for (let i = 0; i < data.length; i++) {
result[i] = data[i] * 2;
}
return result;
}
// 성능 비교
const testData = Array.from({length: 100000}, (_, i) => i);
console.time('push');
transformWithPush(testData);
console.timeEnd('push');
console.time('index');
transformWithIndex(testData);
console.timeEnd('index');
// 일반적으로 index가 30-50% 더 빠름
설명
이것이 하는 일: 인덱스 직접 할당은 메서드 호출과 내부 검사를 건너뛰고 메모리에 직접 값을 쓰는 가장 빠른 배열 업데이트 방법입니다. 첫 번째로, push() 메서드를 호출하면 JavaScript 엔진은 Array.prototype.push 함수를 찾고, this 바인딩을 설정하고, 함수 컨텍스트를 생성합니다.
함수 내부에서는 this.length를 읽고, 내부 배열 버퍼에 여유 공간이 있는지 확인하며, 필요하면 재할당을 수행합니다. 마지막으로 this.length를 증가시키고 새 length를 반환합니다.
이 모든 과정이 단일 요소 추가를 위해 반복됩니다. 그 다음으로, array[i] = value 형태의 인덱스 할당은 훨씬 단순합니다.
JavaScript 엔진은 배열의 시작 주소와 인덱스를 기반으로 메모리 위치를 계산하고(address = base + i * elementSize), 해당 위치에 값을 직접 씁니다. JIT 컴파일러는 배열의 타입이 안정적이면(예: 모든 요소가 숫자) 이를 단일 기계어 명령(MOV 등)으로 컴파일합니다.
타입 체크, length 업데이트, 재할당 검사가 모두 생략됩니다. 마지막으로, 사전 할당과 결합하면 시너지 효과가 발생합니다.
new Array(n)으로 배열을 생성하면 JavaScript 엔진은 "이 배열은 n개의 요소를 가질 것"이라는 힌트를 얻습니다. 이후 인덱스 할당 시 재할당 검사를 완전히 건너뛸 수 있고, 메모리 접근 패턴이 순차적이므로 CPU 캐시 프리페칭도 최대한 활용됩니다.
여러분이 이 코드를 사용하면 대용량 데이터 변환, 행렬 연산, 이미지 처리 같은 루프 집약적인 작업에서 체감 가능한 속도 향상을 경험할 수 있습니다. 특히 게임의 업데이트 루프나 실시간 데이터 파이프라인처럼 매 프레임/매 틱마다 실행되는 코드에서는 작은 최적화가 큰 차이를 만듭니다.
코드도 더 명시적이어서 의도가 명확하게 드러납니다.
실전 팁
💡 push()는 가변 인자를 받을 수 있어(push(a, b, c)) 여러 요소를 한 번에 추가할 때는 여전히 유용합니다. 하지만 루프 안에서 단일 요소 추가는 인덱스 할당이 낫습니다
💡 TypeScript나 정적 분석 도구는 배열 범위를 벗어난 접근을 감지하기 어렵습니다. 인덱스 할당 시 i < array.length를 확실히 보장하세요
💡 sparse array(구멍이 있는 배열)를 만들지 않으려면 순차적으로 할당하세요. array[0] = 1; array[100] = 2; 같은 패턴은 피하세요
💡 unshift()나 splice()로 앞/중간에 삽입할 때는 인덱스 할당으로 대체할 수 없습니다. 이런 경우는 연결 리스트나 다른 자료구조를 고려하세요
💡 JSPerf나 Benchmark.js로 실제 환경에서 성능을 측정하세요. 브라우저와 JavaScript 엔진마다 최적화 전략이 다를 수 있습니다
7. Array.prototype.concat_대안
시작하며
여러분이 여러 개의 배열을 하나로 합쳐야 할 때, 예를 들어 페이지네이션된 API 결과를 모두 합치거나, 여러 데이터 소스의 결과를 결합할 때 Array.prototype.concat()을 사용하고 있나요? concat()은 간결하고 불변성을 유지하는 좋은 메서드이지만, 내부적으로 새 배열을 생성하고 모든 요소를 복사합니다.
합치는 배열의 개수가 많거나 각 배열의 크기가 클 때, 이 복사 비용이 매우 커집니다. 예를 들어, 10개의 배열을 순차적으로 concat하면 중간 배열들이 여러 번 생성되고 폐기되면서 O(n²) 복잡도에 가까워질 수 있습니다.
바로 이럴 때 필요한 것이 concat 대안 기법들입니다. 최종 크기를 계산하여 한 번에 할당하거나, 스프레드 연산자를 효율적으로 사용하거나, push.apply()를 활용하여 복사 횟수를 최소화할 수 있습니다.
개요
간단히 말해서, 여러 배열을 합칠 때 최종 크기를 미리 계산하여 한 번에 메모리를 할당하고, 각 배열의 요소를 직접 복사하는 것이 concat()을 반복 호출하는 것보다 훨씬 효율적입니다. 이 기법이 필요한 이유는 중간 배열 생성을 제거하여 메모리 사용량과 GC 압력을 줄일 수 있기 때문입니다.
result = result.concat(arr) 패턴은 매번 새 배열을 만들므로, 10개의 배열을 합치면 9개의 중간 배열이 생성되었다가 즉시 버려집니다. 각 배열이 10만 개의 요소를 가진다면, 총 90만 개 요소의 중간 복사가 발생합니다.
예를 들어, 대용량 CSV 파일을 청크 단위로 읽어서 합칠 때 이 차이가 극명합니다. 기존에는 arrays.reduce((acc, arr) => acc.concat(arr), [])처럼 작성했다면, 이제는 최종 길이를 계산하고 한 번에 할당하여 각 배열을 순차적으로 복사할 수 있습니다.
이 기법의 핵심 특징은 첫째, 중간 배열 생성을 완전히 제거하여 O(n) 복잡도로 만들고, 둘째, 메모리 할당이 한 번만 발생하여 메모리 단편화를 줄이며, 셋째, 캐시 친화적인 순차 복사로 성능을 극대화한다는 것입니다. 이러한 특징들이 대용량 배열 병합을 실용적으로 만듭니다.
코드 예제
// 비효율적: concat 반복 (O(n²)에 가까움)
function mergeArraysSlow(arrays) {
let result = [];
for (const arr of arrays) {
result = result.concat(arr); // 매번 새 배열 생성
}
return result;
}
// 효율적: 사전 할당 후 직접 복사 (O(n))
function mergeArraysFast(arrays) {
// 최종 길이 계산
const totalLength = arrays.reduce((sum, arr) => sum + arr.length, 0);
const result = new Array(totalLength);
// 각 배열을 순차적으로 복사
let offset = 0;
for (const arr of arrays) {
for (let i = 0; i < arr.length; i++) {
result[offset++] = arr[i];
}
}
return result;
}
// 중간 방법: push.apply (간결하지만 스택 오버플로 주의)
function mergeArraysMedium(arrays) {
const result = [];
for (const arr of arrays) {
result.push(...arr); // 또는 Array.prototype.push.apply(result, arr)
}
return result;
}
// 성능 비교
const testArrays = Array.from({length: 100}, () =>
Array.from({length: 1000}, (_, i) => i)
);
console.time('slow');
mergeArraysSlow(testArrays);
console.timeEnd('slow');
console.time('fast');
mergeArraysFast(testArrays);
console.timeEnd('fast');
설명
이것이 하는 일: 효율적인 배열 병합은 메모리 할당을 한 번으로 제한하고 요소들을 직접 복사하여 중간 객체 생성과 가비지 컬렉션을 최소화합니다. 첫 번째로, reduce로 모든 배열의 길이를 합산하여 최종 크기를 계산합니다.
이는 O(m) 작업입니다(m은 배열 개수). 이 정보를 바탕으로 new Array(totalLength)로 정확한 크기의 배열을 한 번에 할당합니다.
JavaScript 엔진은 이 배열이 더 이상 확장되지 않을 것임을 알고, 최적화된 메모리 레이아웃을 사용합니다. 그 다음으로, 중첩 루프에서 각 배열의 요소를 result 배열에 순차적으로 복사합니다.
offset 변수로 현재 쓰기 위치를 추적하며, 각 배열이 끝나면 offset이 자동으로 다음 배열의 시작 위치를 가리킵니다. 이 과정은 모든 요소를 정확히 한 번씩만 복사하므로 O(n) 복잡도입니다(n은 총 요소 개수).
메모리 접근이 순차적이어서 CPU 캐시 효율성도 높습니다. 마지막으로, push(...arr) 방법은 코드가 간결하지만 주의가 필요합니다.
스프레드 연산자는 배열의 모든 요소를 함수 인자로 펼치는데, 배열이 너무 크면(수만 개 이상) 함수 호출 스택이 오버플로될 수 있습니다. 대부분의 JavaScript 엔진은 최대 인자 개수 제한이 있어서, 안전하게 사용하려면 배열 크기를 확인하거나 직접 복사 방법을 선택하는 것이 좋습니다.
여러분이 이 코드를 사용하면 대용량 데이터 처리 파이프라인에서 병합 단계의 성능을 10배 이상 향상시킬 수 있습니다. 특히 여러 Worker에서 병렬로 처리한 결과를 합치거나, 스트리밍 데이터를 청크 단위로 모을 때 필수적입니다.
메모리 사용량도 예측 가능해져서 대용량 데이터를 안정적으로 처리할 수 있습니다.
실전 팁
💡 합칠 배열의 개수가 2-3개로 적다면 스프레드 연산자([...arr1, ...arr2, ...arr3])가 가독성이 좋고 충분히 빠릅니다
💡 TypedArray를 병합할 때는 set() 메서드를 사용하세요: result.set(arr, offset)는 네이티브 메모리 복사로 매우 빠릅니다
💡 불변성이 중요한 함수형 코드에서는 성능보다 concat()의 명확성을 우선할 수 있습니다. 프로파일링 후 병목이 확인되면 최적화하세요
💡 Node.js에서 Buffer.concat()은 내부적으로 최적화되어 있어 직접 구현보다 빠를 수 있습니다. Buffer 작업은 내장 메서드를 우선 고려하세요
💡 배열이 이미 flat하지 않고 중첩되어 있다면(배열의 배열의 배열...), flatMap이나 재귀적 flatten을 먼저 적용한 후 병합하세요
8. 청크_단위_처리
시작하며
여러분이 수백만 개의 레코드를 가진 대용량 데이터셋을 메모리에 모두 로드하여 처리하려다가 브라우저가 멈추거나 "Out of Memory" 에러를 경험한 적이 있나요? 예를 들어, 대용량 CSV 파일을 읽어서 변환하거나, 수십만 개의 DOM 요소를 업데이트하는 상황입니다.
이런 문제는 모든 데이터를 한 번에 메모리에 올리려고 할 때 발생합니다. JavaScript는 단일 스레드이고 메모리 한계가 있기 때문에, 특정 크기 이상의 배열을 만들면 메모리 부족이나 GC의 긴 일시 정지로 인해 애플리케이션이 응답 불가 상태가 됩니다.
바로 이럴 때 필요한 것이 청크 단위 처리입니다. 전체 데이터를 작은 청크로 나누어서, 한 번에 하나의 청크만 메모리에 로드하고 처리한 후 다음 청크로 넘어가는 방식으로, 메모리 사용량을 일정하게 유지하면서 무한히 큰 데이터도 처리할 수 있습니다.
개요
간단히 말해서, 청크 단위 처리는 대용량 데이터를 고정된 크기의 작은 조각(청크)으로 분할하여 순차적으로 처리함으로써, 메모리 사용량을 제한하고 응답성을 유지하는 패턴입니다. 이 패턴이 필요한 이유는 메모리 제약을 극복하고 사용자 경험을 보호할 수 있기 때문입니다.
브라우저는 보통 2-4GB의 메모리 한계가 있고, 그 안에서 렌더링 엔진, 네트워크 버퍼, 다른 탭 등이 메모리를 공유합니다. 대용량 배열이 이 메모리를 독점하면 브라우저가 멈춥니다.
청크 처리를 사용하면 각 청크 처리 후 메모리를 해제할 수 있고, 중간에 다른 이벤트(사용자 입력, 애니메이션 등)를 처리할 기회도 줄 수 있습니다. 예를 들어, 10GB 크기의 로그 파일을 분석할 때도 실제로는 100MB씩만 메모리에 올리면 됩니다.
기존에는 전체 데이터를 한 번에 처리했다면, 이제는 스트림이나 이터레이터를 사용하여 필요한 만큼만 읽고 처리할 수 있습니다. 이 패턴의 핵심 특징은 첫째, 메모리 사용량이 데이터 크기에 무관하게 일정하여(O(1) 공간 복잡도) 무한 스트림도 처리 가능하고, 둘째, 각 청크 사이에 이벤트 루프로 제어를 반환하여 UI가 멈추지 않으며, 셋째, 병렬 처리와 결합하면 여러 청크를 동시에 처리하여 성능도 향상시킬 수 있다는 것입니다.
이러한 특징들이 대용량 데이터 처리를 실용적으로 만듭니다.
코드 예제
// 동기 청크 처리 (간단하지만 UI 블로킹 가능)
function processInChunksSync(data, chunkSize, processFn) {
const results = [];
for (let i = 0; i < data.length; i += chunkSize) {
const chunk = data.slice(i, i + chunkSize);
const chunkResult = processFn(chunk);
results.push(...chunkResult);
}
return results;
}
// 비동기 청크 처리 (UI 응답성 유지)
async function processInChunksAsync(data, chunkSize, processFn) {
const results = [];
for (let i = 0; i < data.length; i += chunkSize) {
const chunk = data.slice(i, i + chunkSize);
const chunkResult = await processFn(chunk);
results.push(...chunkResult);
// 다음 청크 전에 이벤트 루프에 제어 반환
await new Promise(resolve => setTimeout(resolve, 0));
}
return results;
}
// 제너레이터 기반 청크 처리 (메모리 효율 최대)
function* generateChunks(data, chunkSize) {
for (let i = 0; i < data.length; i += chunkSize) {
yield data.slice(i, i + chunkSize);
}
}
// 사용 예제
const largeData = Array.from({length: 1000000}, (_, i) => i);
processInChunksAsync(largeData, 10000, chunk => {
return chunk.map(x => x * 2);
}).then(results => {
console.log('Processing complete:', results.length);
});
// 또는 제너레이터 사용
for (const chunk of generateChunks(largeData, 10000)) {
// 각 청크를 순차 처리
processChunk(chunk);
}
설명
이것이 하는 일: 청크 단위 처리는 데이터를 시간과 공간 축에서 분산시켜, 한정된 자원으로 무한한 크기의 데이터를 처리 가능하게 만드는 핵심 패턴입니다. 첫 번째로, slice(i, i + chunkSize)로 원본 배열에서 청크를 추출합니다.
slice는 얕은 복사를 수행하므로 청크 생성 비용은 O(chunkSize)입니다. 각 청크는 독립적으로 처리되고, 처리가 끝나면 참조가 사라져 가비지 컬렉션될 수 있습니다.
이렇게 메모리가 순환 재사용되어, 전체 데이터 크기와 무관하게 일정한 메모리 사용량을 유지합니다. 그 다음으로, 비동기 버전에서는 await new Promise(resolve => setTimeout(resolve, 0))으로 각 청크 처리 후 이벤트 루프에 제어를 반환합니다.
setTimeout의 0ms 지연은 현재 매크로태스크를 끝내고 다음 매크로태스크를 시작하라는 의미로, 그 사이에 UI 렌더링, 이벤트 처리, 마이크로태스크 등이 실행될 수 있습니다. 이로써 수십 초가 걸리는 작업 중에도 버튼 클릭이나 스크롤이 반응합니다.
마지막으로, 제너레이터 기반 접근은 가장 메모리 효율적입니다. yield는 청크를 하나씩 지연 생성하므로, 전체 청크 배열을 메모리에 올리지 않습니다.
for...of 루프는 각 이터레이션마다 하나의 청크만 존재하고, 루프가 진행되면서 이전 청크는 자동으로 GC됩니다. 이 방식은 진정한 스트리밍 처리를 가능하게 하며, 데이터 소스가 파일, 네트워크, 데이터베이스 등 무엇이든 동일한 패턴으로 처리할 수 있습니다.
여러분이 이 코드를 사용하면 대용량 Excel 파일 파싱, 이미지 일괄 처리, 수백만 레코드의 데이터 변환 같은 작업을 브라우저 내에서 안정적으로 수행할 수 있습니다. 특히 프로그레스 바를 표시하거나 중간에 취소 기능을 추가하기도 쉬워집니다.
Node.js 서버에서도 스트림 API와 결합하여 메모리 효율적인 파이프라인을 구축할 수 있습니다.
실전 팁
💡 청크 크기는 트레이드오프입니다. 너무 작으면 오버헤드가 크고, 너무 크면 메모리와 응답성 이점이 줄어듭니다. 일반적으로 1,000~10,000개가 적당합니다
💡 실제 파일 처리에서는 FileReader API나 Node.js의 fs.createReadStream()을 사용하여 네이티브 스트리밍을 활용하세요
💡 Web Workers로 각 청크를 병렬 처리하면 멀티코어 CPU를 활용할 수 있습니다. Promise.all로 여러 청크를 동시에 처리하되, 메모리를 고려하여 동시 실행 개수를 제한하세요
💡 requestIdleCallback을 사용하면 브라우저가 유휴 상태일 때만 청크를 처리하여 성능 영향을 최소화할 수 있습니다
💡 에러 처리가 중요합니다. 한 청크에서 에러가 나면 전체를 중단할지, 그 청크만 건너뛸지 전략을 정하세요. 부분 결과를 저장하는 것도 고려하세요
9. Object_Pool_패턴
시작하며
여러분이 게임이나 실시간 애플리케이션을 개발할 때, 수백 개의 객체가 계속 생성되고 파괴되는 상황을 생각해보세요. 예를 들어, 슈팅 게임에서 총알 객체가 초당 수십 개씩 생성되었다가 화면 밖으로 나가면 삭제되는 경우입니다.
이런 패턴에서는 버퍼 풀링만으로는 부족합니다. 객체는 단순 배열이 아니라 여러 속성과 메서드를 가진 복잡한 구조이고, 생성자 호출과 속성 초기화에도 비용이 듭니다.
매번 new Bullet()을 호출하면 메모리 할당, 프로토타입 체인 설정, 속성 초기화가 반복되고, 가비지 컬렉터가 계속해서 이 객체들을 정리해야 합니다. 바로 이럴 때 필요한 것이 Object Pool 패턴입니다.
객체를 미리 생성하여 풀에 보관하고, 필요할 때 재활용하여 객체 생성/소멸 비용을 완전히 제거합니다.
개요
간단히 말해서, Object Pool 패턴은 자주 생성/소멸되는 객체들을 미리 만들어 풀에 보관하고, 사용이 끝나면 풀에 반환하여 재사용함으로써 객체 생성 비용과 가비지 컬렉션 압력을 제거하는 디자인 패턴입니다. 이 패턴이 필요한 이유는 객체 생성이 JavaScript에서 생각보다 비싼 연산이기 때문입니다.
특히 객체가 복잡한 초기화 로직을 가지거나, 내부적으로 다른 객체들을 참조하거나, 프로토타입 체인이 깊을 때 비용이 커집니다. 게임 엔진, 애니메이션 프레임워크, 실시간 데이터 시각화 같은 고성능 애플리케이션에서는 프레임당 수십에서 수백 개의 객체가 필요할 수 있습니다.
예를 들어, 파티클 이펙트에서 1000개의 파티클이 매 프레임마다 업데이트되고 일부는 사라지고 새로 생성되는 상황에서, 풀링 없이는 60fps를 유지하기 어렵습니다. 기존에는 필요할 때마다 new로 객체를 생성했다면, 이제는 풀에서 가져와서 reset 메서드로 초기화하여 재사용할 수 있습니다.
이 패턴의 핵심 특징은 첫째, 객체 생성이 초기화 시점으로 제한되어 런타임 할당이 제거되고, 둘째, 객체의 메모리 주소가 안정적이어서 캐시 효율성이 높으며, 셋째, 가비지 컬렉션이 거의 발생하지 않아 일정한 프레임레이트를 유지할 수 있다는 것입니다. 이러한 특징들이 실시간 애플리케이션의 성능을 보장합니다.
코드 예제
// 재사용될 객체 클래스
class Particle {
constructor() {
this.x = 0;
this.y = 0;
this.vx = 0;
this.vy = 0;
this.life = 0;
this.active = false;
}
// 재사용 전 초기화
reset(x, y, vx, vy) {
this.x = x;
this.y = y;
this.vx = vx;
this.vy = vy;
this.life = 1.0;
this.active = true;
}
update(dt) {
if (!this.active) return;
this.x += this.vx * dt;
this.y += this.vy * dt;
this.life -= dt * 0.1;
if (this.life <= 0) {
this.active = false;
}
}
}
// 객체 풀 구현
class ObjectPool {
constructor(ClassType, initialSize = 50) {
this.ClassType = ClassType;
this.available = [];
this.inUse = [];
// 초기 객체 생성
for (let i = 0; i < initialSize; i++) {
this.available.push(new ClassType());
}
}
acquire(...args) {
let obj;
if (this.available.length > 0) {
obj = this.available.pop();
} else {
console.warn('Pool exhausted, creating new object');
obj = new this.ClassType();
}
this.inUse.push(obj);
if (obj.reset) obj.reset(...args);
return obj;
}
release(obj) {
const index = this.inUse.indexOf(obj);
if (index > -1) {
this.inUse.splice(index, 1);
obj.active = false;
this.available.push(obj);
}
}
// 비활성 객체 자동 반환
releaseInactive() {
for (let i = this.inUse.length - 1; i >= 0; i--) {
const obj = this.inUse[i];
if (!obj.active) {
this.release(obj);
}
}
}
}
// 사용 예제
const particlePool = new ObjectPool(Particle, 100);
function emitParticle(x, y) {
const p = particlePool.acquire(x, y, Math.random() * 2 - 1, Math.random() * 2 - 1);
return p;
}
// 게임 루프
function gameLoop(dt) {
// 새 파티클 생성
emitParticle(100, 100);
// 모든 파티클 업데이트
for (const particle of particlePool.inUse) {
particle.update(dt);
}
// 비활성 파티클 반환
particlePool.releaseInactive();
}
설명
이것이 하는 일: Object Pool은 객체의 생명주기를 애플리케이션 레벨에서 명시적으로 관리하여, JavaScript 런타임의 자동 메모리 관리 오버헤드를 우회하는 고급 최적화 기법입니다. 첫 번째로, 생성자에서 initialSize만큼의 객체를 미리 생성합니다.
이 객체들은 애플리케이션이 시작될 때 한 번만 할당되고, 이후로는 계속 재사용됩니다. reset() 메서드는 생성자와 유사한 역할을 하지만, 새 메모리 할당 없이 기존 객체의 속성만 초기화합니다.
이는 단순히 숫자와 불리언 값을 대입하는 것이므로 매우 빠릅니다. 그 다음으로, acquire() 메서드는 available 배열에서 객체를 꺼내 inUse 배열로 이동시킵니다.
이는 배열 요소의 이동일 뿐 객체 자체는 같은 메모리 주소를 유지합니다. 객체의 메모리 위치가 안정적이면 CPU 캐시에 오래 머무를 확률이 높아지고, 메모리 접근 속도가 향상됩니다.
inUse 배열을 순회하면서 update를 호출할 때, 메모리 접근 패턴이 순차적이어서 캐시 프리페칭도 효과적입니다. 마지막으로, releaseInactive() 메서드는 자동으로 비활성 객체를 감지하여 풀로 반환합니다.
이는 명시적 release 호출을 잊어버리는 실수를 방지합니다. 객체가 풀로 돌아갈 때 active = false로 설정하여, 실수로 이미 반환된 객체를 사용하는 버그를 조기에 발견할 수 있습니다.
이 패턴은 C++의 스마트 포인터와 유사한 메모리 안전성을 JavaScript에 제공합니다. 여러분이 이 코드를 사용하면 게임, 데이터 시각화, 실시간 시뮬레이션 같은 고성능 애플리케이션에서 안정적인 60fps를 유지할 수 있습니다.
특히 모바일 기기나 저사양 환경에서 GC로 인한 프레임 드롭을 완전히 제거하여 부드러운 사용자 경험을 제공합니다. Canvas나 WebGL 렌더링과 결합하면 네이티브 앱에 가까운 성능을 달성할 수 있습니다.
실전 팁
💡 풀 크기는 동시에 활성화될 최대 객체 개수의 150% 정도로 설정하세요. 통계(getStats())를 모니터링하여 최적값을 찾으세요
💡 객체에 복잡한 중첩 객체가 있다면, 그것들도 별도 풀로 관리하거나 reset에서 재사용하세요. 깊은 객체 그래프는 풀링 효과를 감소시킵니다
💡 객체를 풀에 반환하기 전에 민감한 데이터를 지우세요. 보안이나 프라이버시 이슈가 있을 수 있습니다
💡 releaseInactive 대신 명시적 release를 사용하면 더 정밀한 제어가 가능하지만, 메모리 누수 위험이 있습니다. 프로젝트 특성에 맞게 선택하세요
💡 TypeScript를 사용한다면 제네릭으로 타입 안정성을 확보하고, reset 메서드를 인터페이스로 강제하세요: interface Poolable { reset(...args: any[]): void }
10. 불변성과_성능_트레이드오프
시작하며
여러분이 React, Redux, Immer 같은 불변성 기반 라이브러리를 사용할 때, 배열을 업데이트하려면 항상 새 배열을 만들어야 한다는 것을 알고 계시죠? 예를 들어, state 배열의 한 요소를 수정하려면 [...state.slice(0, index), newValue, ...state.slice(index + 1)]처럼 복잡한 코드를 작성해야 합니다.
불변성은 많은 이점을 제공합니다. 예측 가능한 상태 관리, 시간 여행 디버깅, 변경 감지 최적화 등이 가능합니다.
하지만 모든 업데이트마다 새 배열과 객체를 생성하므로, 빈번한 업데이트나 대용량 데이터에서는 성능 병목이 됩니다. 10만 개 요소의 배열에서 한 요소를 바꾸려고 전체를 복사하는 것은 비효율적입니다.
바로 이럴 때 필요한 것이 불변성과 성능의 균형을 맞추는 전략입니다. Structural sharing, Immer의 프록시 기반 최적화, 일시적 가변성 같은 기법으로 불변성의 이점을 유지하면서도 성능을 크게 개선할 수 있습니다.
개요
간단히 말해서, 불변성과 성능의 트레이드오프는 데이터 구조의 불변성을 유지하되, 구조적 공유(structural sharing)나 일시적 가변성(transient mutability)을 활용하여 불필요한 복사를 최소화하는 것입니다. 이 균형이 필요한 이유는 순수 불변성은 코드의 정확성과 유지보수성을 높이지만, 성능이 중요한 실무에서는 실용적인 타협이 필요하기 때문입니다.
함수형 프로그래밍의 이상과 JavaScript의 현실 사이에서 최선의 방법을 찾는 것이 중요합니다. 예를 들어, Redux의 리듀서에서 대량 업데이트를 수행할 때, 매번 새 배열을 만들면 성능이 저하되지만, Immer를 사용하면 일반 가변 코드를 작성해도 내부적으로 최적화된 불변 업데이트가 수행됩니다.
기존에는 순수 불변성과 순수 가변성 중 하나를 선택해야 했다면, 이제는 상황에 맞게 하이브리드 접근을 사용할 수 있습니다. 이 접근의 핵심 특징은 첫째, 외부에서는 불변성이 보장되어 예측 가능한 동작을 유지하고, 둘째, 내부적으로는 최적화된 가변 연산으로 성능을 확보하며, 셋째, 구조적 공유로 메모리 효율성도 높인다는 것입니다.
이러한 특징들이 현대 프론트엔드 애플리케이션의 복잡성을 관리 가능하게 만듭니다.
코드 예제
// 순수 불변 방식 (느림)
function updateItemPure(array, index, newValue) {
return [
...array.slice(0, index),
newValue,
...array.slice(index + 1)
];
}
// Immer를 사용한 방식 (빠르고 간결)
import produce from 'immer';
function updateItemImmer(array, index, newValue) {
return produce(array, draft => {
draft[index] = newValue; // 가변 스타일로 작성
});
}
// 일시적 가변성 패턴 (수동 최적화)
function batchUpdateOptimized(array, updates) {
// 함수 내부에서만 가변으로 작동
const result = array.slice(); // 얕은 복사 한 번만
for (const {index, value} of updates) {
result[index] = value; // 가변 업데이트
}
return result; // 불변 결과 반환
}
// 구조적 공유 예제 (Persistent Data Structures)
class PersistentArray {
constructor(data) {
this.data = data;
this.version = 0;
}
set(index, value) {
if (this.data[index] === value) {
return this; // 변경 없음, 같은 객체 반환
}
const newData = this.data.slice();
newData[index] = value;
return new PersistentArray(newData);
}
get(index) {
return this.data[index];
}
}
// 성능 비교
const largeArray = Array.from({length: 10000}, (_, i) => i);
console.time('pure');
let result1 = largeArray;
for (let i = 0; i < 100; i++) {
result1 = updateItemPure(result1, i, i * 2);
}
console.timeEnd('pure');
console.time('optimized');
const updates = Array.from({length: 100}, (_, i) => ({index: i, value: i * 2}));
const result2 = batchUpdateOptimized(largeArray, updates);
console.timeEnd('optimized');
설명
이것이 하는 일: 불변성과 성능의 균형 전략은 프로그래밍 모델의 단순성과 실행 효율성을 동시에 달성하기 위한 실용적 타협입니다. 첫 번째로, 순수 불변 방식은 세 번의 배열 복사가 발생합니다: slice(0, index), slice(index+1), 그리고 스프레드로 결합.
10만 개 요소 배열에서 중간 요소를 업데이트하면 약 20만 개 요소가 복사됩니다. 이를 100번 반복하면 2000만 개 요소 복사가 발생하여 수 초가 걸릴 수 있습니다.
그 다음으로, Immer는 Proxy 기반 copy-on-write를 사용합니다. draft 객체는 원본 배열의 프록시이고, draft[index] = value처럼 수정하면 Immer가 그 시점에만 해당 부분을 복사합니다.
변경되지 않은 부분은 원본을 그대로 재사용(structural sharing)합니다. 예를 들어, 100개 요소 중 10개만 변경하면 10개만 복사되고 나머지 90개는 공유됩니다.
이는 메모리와 CPU 모두 절약합니다. 마지막으로, 일시적 가변성 패턴은 함수 스코프 내부에서만 가변으로 작동합니다.
batchUpdateOptimized는 slice()로 한 번만 복사한 후, 여러 업데이트를 가변적으로 수행합니다. 함수가 반환하는 순간 결과는 불변이 되고, 원본은 변경되지 않았으므로 외부에서 보면 순수 함수입니다.
이는 Clojure의 transient 개념과 유사하며, 일괄 업데이트 시 매우 효율적입니다. 여러분이 이 코드를 사용하면 React 애플리케이션에서 대규모 리스트를 다룰 때 리렌더링 성능을 유지하면서도 상태 관리의 명확성을 확보할 수 있습니다.
Redux 리듀서에서 복잡한 상태 업데이트를 간결한 코드로 작성하면서도 성능 저하 없이 처리할 수 있습니다. Immer는 특히 중첩된 객체/배열 구조에서 빛을 발하며, 코드 가독성과 성능을 모두 개선합니다.
실전 팁
💡 Immer는 편리하지만 작은 업데이트에서는 오버헤드가 있을 수 있습니다. 단순 원시값 업데이트는 수동 스프레드가 더 빠를 수 있으니 프로파일링하세요
💡 Immutable.js나 Immer 같은 라이브러리는 번들 크기를 추가합니다. 프로젝트 규모와 성능 요구사항을 고려하여 선택하세요
💡 React의 useMemo와 useCallback은 불변성을 전제로 동작합니다. 가변 업데이트를 사용하면 이 최적화가 깨질 수 있으니 주의하세요
💡 일시적 가변성을 사용할 때는 복사된 배열이 외부로 유출되지 않도록 주의하세요. 함수 반환 전에 Object.freeze()를 적용하면 실수를 방지할 수 있습니다 (개발 환경에서만)
💡 성능이 정말 중요한 핫 패스에서는 완전히 가변적으로 작동하는 내부 구현을 만들고, 외부 API만 불변으로 노출하는 것도 좋은 전략입니다