이 글에서는 소프트웨어 최적화의 출발점인 메모리 구조를 파헤칩니다. 우편함 비유를 통해 RAM의 하드웨어적 동작 원리를 이해하고, 32/64비트 아키텍처의 차이, CPU 캐시 지역성(Cache Locality), 그리고 자바스크립트 엔진의 스택과 힙 참조 모델을 실무 아키텍처 관점을 알아봅니다.
1. 모든 소프트웨어 최적화의 종착지, 메모리
웹 개발자로 커리어를 시작하여 3~5년 차에 접어들면, ‘어떻게든 돌아가는 코드’를 작성하는 단계를 넘어 시스템의 한계와 마주하게 됩니다. Node.js 서버에서 원인을 알 수 없는 메모리 누수(Memory Leak)로 컨테이너가 재시작되거나, 브라우저에서 대량의 DOM이나 캔버스(Canvas) 데이터를 렌더링할 때 심각한 프레임 드롭(Jank)을 경험하는 식입니다.
아키텍처를 설계하고 코드를 작성하다 보면 우리는 종종 프레임워크가 제공하는 편리한 추상화 계층에 매몰되곤 합니다. 자바스크립트 V8 엔진이 대신 해주는 가비지 컬렉션(Garbage Collection)이나, 파이썬의 동적 타이핑에 익숙해지면 물리적인 메모리의 존재를 잊기 쉽습니다.
하지만 수백만 명의 트래픽을 처리하는 대규모 시스템에서 발생하는 병목 현상을 추적하다 보면 결국 가장 낮은 곳, 물리적 메모리(RAM)의 특성과 마주하게 됩니다. 하드웨어의 동작 방식을 이해하고 이에 맞춰 소프트웨어를 설계하는 것을 기계와의 교감(Mechanical Sympathy)이라고 부릅니다. 자료구조와 알고리즘의 모든 시간 복잡도(Time Complexity)와 공간 복잡도(Space Complexity) 계산은 바로 이 메모리가 어떻게 생겼고, 어떻게 작동하는지에 대한 깊은 이해에서 출발합니다.
1.2 메모리 셀과 주소의 1:1 매칭
컴퓨터의 주 메모리인 RAM(Random Access Memory)을 가장 직관적으로 이해하는 방법은 아파트 1층에 있는 거대한 ‘우편함 벽’을 상상하는 것입니다.
- 우편함 한 칸 (Cell): 컴퓨터 메모리는 수많은 작은 칸으로 나뉘어 있습니다. 이 하나의 칸을 ‘셀(Cell)’이라고 부르며, 현대 컴퓨터 아키텍처에서 일반적으로 하나의 셀은 1바이트(8비트)의 데이터를 저장합니다.
- 우편함 번호 (Memory Address): 우편 배달부가 특정 우편함에 편지를 넣기 위해서는 동/호수가 적힌 고유 번호가 필요합니다. 마찬가지로 메모리의 모든 셀은 0부터 시작하는 고유한 일련번호를 가지며, 이를 ‘메모리 주소(Memory Address)’라고 합니다.
![1.1 메모리 할당 원리와 포인터 [실무 아케틱트를 위한 자료구조와 알고리즘] 6 컴퓨터 RAM 메모리 구조를 설명하는 이미지입니다.](https://siwoolim.com/wp-content/uploads/2026/04/computer-ram-memory-structure-1024x253.png)
1.3 32비트와 64비트의 비밀
우편함 비유를 실제 컴퓨터 공학적 팩트와 명확히 연결해 보겠습니다. 이 과정에서 우리가 흔히 듣는 ‘OS 비트 수’의 실체를 알 수 있습니다.
- 연속성과 할당: 메모리 주소는 물리적으로 연속된 정수입니다. 개발자가 코드에서 변수를 선언하면, 운영체제와 컴파일러(또는 인터프리터)는 사용할 수 있는 빈 우편함(셀)들의 묶음을 찾아 그 시작 주소를 할당합니다.
- 데이터의 크기(Word): 숫자 하나를 저장하기 위해 1바이트 우편함 하나로는 부족합니다. 예를 들어 32비트 정수를 저장하려면 연속된 우편함 4개가 필요합니다. 이때 컴퓨터는 해당 데이터의 시작 주소만 기억하고, 타입(Type) 정보를 바탕으로 “여기서부터 4칸을 읽어라”라고 명령을 내립니다.
- 메모리 주소 공간의 한계: 우편함에 번호를 매기려면 번호를 적을 라벨지가 필요합니다. 32비트 운영체제는 주소를 표현하는 데 32비트(2^32)를 사용합니다. 2^32는 약 42억 개이며, 셀 하나가 1바이트이므로 총 4GB의 메모리 주소만 표현할 수 있습니다. 32비트 OS에서 RAM을 아무리 꽂아도 4GB까지만 인식하는 이유가 바로 이 우편함 번호표가 부족하기 때문입니다. 반면 64비트 시스템은 2^64(약 16엑사바이트)의 광활한 주소 공간을 가집니다.
2. 왜 RAM은 어디든 O(1) 시간에 접근할 수 있을까?
RAM의 가장 큰 특징은 이름 그대로 ‘Random Access(임의 접근)’가 가능하다는 점입니다. 배열의 첫 번째 요소를 읽든, 10억 번째 요소를 읽든 데이터에 접근하는 데 걸리는 시간은 이론상 동일합니다. 이를 시간 복잡도로 O(1)이라고 표현합니다.
우편함 비유로 돌아가 봅시다. 10만 개의 우편함이 일렬로 나열되어 있다고 가정할 때, 인간 배달부는 9만 번째 우편함에 가기 위해 1번부터 걸어가야 합니다(순차 접근, Sequential Access). 자기 테이프나 구형 HDD가 이러한 물리적 제약을 받습니다. 하지만 RAM은 어떻게 물리적 거리를 무시할까요?
이는 하드웨어에 내장된 메모리 컨트롤러(Memory Controller) 덕분입니다. 중앙처리장치(CPU)가 “메모리 주소 0x4F00에 있는 데이터를 가져와”라고 메모리 컨트롤러에 요청하면, 컨트롤러는 복잡한 트랜지스터 격자망을 통해 해당 번지로 직접 전기 신호를 쏴서 데이터를 즉시 꺼내옵니다. 물리적인 헤드 이동이나 탐색 과정이 생략된 순수한 전기적 스위칭이므로, 주소 값의 크기와 무관하게 일정한 O(1)의 속도를 보장합니다.
2.1 포인터가 없는 언어의 포인터
C나 C++ 같은 언어에는 메모리 주소 자체를 변수 값으로 저장하는 ‘포인터(Pointer)’라는 개념이 명시적으로 존재합니다. 우편함 비유를 다시 빌리자면, 포인터는 편지 내용 대신 “다른 우편함의 주소가 적힌 쪽지”를 담아두는 특별한 우편함입니다.
웹 개발 생태계의 주축인 자바스크립트나 자바(Java), 파이썬(Python) 등은 개발자가 메모리 주소를 직접 다룰 수 없도록 막아두었습니다. 그렇다면 이 언어들에는 포인터가 없을까요? 전혀 그렇지 않습니다. 자바스크립트의 객체(Object)와 배열(Array)은 내부적으로 철저하게 포인터(참조, Reference)로 동작합니다.
2.2 원시 타입(Primitive) vs 참조 타입(Reference)
자바스크립트 엔진(V8 등)은 실행 컨텍스트와 데이터를 관리하기 위해 메모리를 크게 스택(Stack)과 힙(Heap) 두 영역으로 나누어 사용합니다.
- 스택 (정적 할당): 함수 호출 시 생성되는 로컬 변수와 크기가 고정된 원시 타입(Number, String, Boolean 등)의 값 자체를 저장합니다. 속도가 매우 빠르지만 공간이 제한적입니다. 가장 중요한 점은, 힙 영역에 생성된 객체를 가리키는 메모리 주소(포인터) 역시 스택에 저장된다는 것입니다.
- 힙 (동적 할당): 런타임에 크기가 동적으로 변할 수 있는 객체, 배열, 클로저 함수 등이 저장되는 거대한 여유 공간입니다. 구조화되지 않고 자유롭게 데이터가 배치됩니다.
![1.1 메모리 할당 원리와 포인터 [실무 아케틱트를 위한 자료구조와 알고리즘] 7 메모리의 스택과 힙에서 주소참조가 어떻게 발생하는지 설명하는 이미지 입니다.](https://siwoolim.com/wp-content/uploads/2026/04/memory-stack-heap-1024x415.png)
우리가 const user = { id: 1 }라고 선언할 때, 변수 user가 객체 전체를 껴안고 있는 것이 아닙니다. 객체 데이터 자체는 힙(Heap) 어딘가에 생성되고, 스택에 있는 user라는 변수는 단지 힙에 있는 객체의 시작 주소(포인터, 예: 0x88F0)만을 들고 있을 뿐입니다.
자바스크립트에서 const로 선언한 객체의 내부 프로퍼티(user.id = 2)를 변경할 수 있는 이유도 여기에 있습니다. const는 스택에 저장된 ‘메모리 주소 값(0x88F0)’을 변경하지 못하게 막을 뿐, 그 주소가 가리키는 힙 메모리 내부의 데이터 수정까지 막지는 않기 때문입니다.
3. 무분별한 참조(Pointer)가 만드는 성능 병목
기본기가 중요한 이유는 아키텍처 설계 시 발생할 수 있는 병목을 미리 예측하기 위해서입니다. 데이터베이스에서 가져온 JSON을 아무 생각 없이 객체의 배열, 그 안에 다시 객체를 품는 복잡한 트리 구조로 변환하는 것은 개발하기에는 편하지만 컴퓨터 하드웨어 입장에서는 매우 고통스러운 구조입니다.
3.1 포인터 체이싱(Pointer Chasing)과 캐시 미스(Cache Miss)
앞서 RAM이 O(1)의 속도를 가진다고 했지만, 현대 CPU의 속도는 RAM보다 수백 배 이상 빠릅니다. CPU가 데이터를 요청할 때마다 RAM까지 다녀오면 병목이 발생하므로, CPU 내부에는 초고속 메모리인 캐시(L1, L2, L3 Cache)가 존재합니다.
CPU는 RAM에서 특정 데이터를 가져올 때, 요청한 주소의 데이터 하나만 쏙 빼오는 것이 아니라 그 주변의 인접한 데이터 블록(Cache Line, 보통 64바이트)을 한꺼번에 가져와 캐시에 저장합니다. 프로그램은 방금 접근한 데이터의 근처에 있는 데이터를 곧이어 사용할 확률이 높다는 통계적 사실에 기반한 것으로, 이를 공간적 지역성(Spatial Locality)이라고 합니다.
하지만 포인터(참조)로 얽힌 객체들은 힙 메모리 여기저기에 무작위로 흩어져 생성됩니다. 배열 안에 담긴 객체들을 순회할 때, 자바스크립트 배열은 실제 객체 데이터가 아닌 ‘객체의 메모리 주소(포인터)’만 연속해서 가지고 있습니다.
따라서 루프를 돌며 참조를 따라갈 때마다(Dereferencing) CPU 캐시에 필요한 데이터가 없는 캐시 미스(Cache Miss)가 연속적으로 발생합니다. CPU는 캐시를 비우고 수백 사이클을 대기하며 RAM까지 다시 다녀와야 하는 심각한 성능 저하를 겪게 됩니다.
4. 코드로 보는 메모리 참조 최적화 방안
실제 자바스크립트 애플리케이션에서 메모리 참조의 특성을 이해하고, 가비지 컬렉터(GC)의 부하를 줄이는 실무적인 코드 작성 패턴을 살펴보겠습니다.
4.1 무분별한 객체 생성 방지 (Object Pooling)
[최적화 전] 무분별한 객체 생성과 GC의 역습
이 방식은 실무에서 가장 흔하게 작성하는 패턴입니다. map이나 for 루프 안에서 새로운 객체 { x, y }를 계속 만들어 배열에 밀어 넣습니다.
- 비유: 정수기에서 물을 마실 때마다 새로운 일회용 종이컵을 꺼내 쓰고 바로 버리는 행동입니다.
- 실제(팩트): 루프가 10만 번 돌면, 메모리의 힙(Heap) 영역에 10만 개의 완전히 새로운 메모리 주소(포인터)가 발급됩니다.
- 파편화와 캐시 미스: 객체들이 메모리 공간 여기저기에 무작위로 흩어져서(파편화) 생성됩니다. CPU는 데이터를 연속해서 읽고 싶어 하지만, 주소가 튀기 때문에 캐시 메모리를 활용하지 못하고 느린 RAM을 매번 다녀와야 합니다.
- 프레임 드롭(Jank): 루프가 끝나거나 데이터를 갱신할 때, 방금 만든 10만 개의 객체는 쓸모없는 쓰레기(Garbage)가 됩니다. 자바스크립트 엔진의 가비지 컬렉터(GC)는 이 쓰레기를 치우기 위해 메인 스레드의 실행을 잠깐 멈춥니다(Stop-the-world). 화면에 버벅거림(Jank)이 발생하는 주된 원인이됩니다.
function processCoordinates_Bad(dataList) {
const result = [];
for (let i = 0; i < dataList.length; i++) {
// [비효율 원인] 루프가 돌 때마다 새로운 메모리 주소(포인터)를 힙에 요구함
const transformed = {
x: dataList[i].x * 2,
y: dataList[i].y * 2
};
result.push(transformed);
}
return result;
}
[최적화 1단계] 객체 풀링(Object Pooling)
1단계 최적화는 쓰레기를 아예 만들지 않는 전략입니다.
- 비유: 종이컵을 버리지 않고, 내 이름이 적힌 머그컵을 씻어서 계속 물만 바꿔 담아 마시는 행동입니다.
- 실제(팩트): 외부에서 미리 만들어둔 배열(
outputBuffer)을 주입받아, 새로운 메모리 주소를 할당받는(new Object) 행위를 멈춥니다. 대신 이미 존재하는 객체의 내부 값(.x,.y)만 덮어씁니다. - 장점: 가비지 컬렉터(GC)가 치울 쓰레기가 생기지 않으므로 메인 스레드가 멈추지 않습니다. 초당 60프레임을 렌더링해야 하는 게임이나 실시간 차트에서는 이 기법이 필수적입니다.
function processCoordinates_Better(dataList, outputBuffer) {
for (let i = 0; i < dataList.length; i++) {
// 배열 인덱스에 객체가 없다면 최초 1회만 할당
if (!outputBuffer[i]) {
outputBuffer[i] = { x: 0, y: 0 };
}
// 새로운 메모리를 할당받지 않음
outputBuffer[i].x = dataList[i].x * 2;
outputBuffer[i].y = dataList[i].y * 2;
}
}
[최적화 2단계] TypedArray와 극단적 캐시 지역성
이 단계는 객체지향의 편리함을 포기하고, 순수한 하드웨어의 성능을 극한으로 끌어올리는 단계입니다.
- 비유: 컵을 여러 개 쓰는 것이 아니라, 하나의 거대한 일자형 물통(연속된 공간)을 준비하고 정확한 눈금(Offset)에 맞춰 물을 채워 넣는 행동입니다.
- 실제(팩트): 자바스크립트의 일반 배열
[]은 내부적으로 메모리가 연속되어 있다는 보장이 없는 ‘포인터들의 모음’입니다. 반면Float64Array는 C언어의 배열처럼 물리적인 RAM 공간에 데이터가 완벽하게 일렬로 붙어 있도록 강제합니다. - 공간적 캐시 지역성(Spatial Cache Locality): CPU는 메모리에서 데이터를 가져올 때, 하나만 가져오지 않고 그 주변 데이터(예: 64바이트 블록)를 통째로 가져와서 초고속 캐시(Cache)에 넣어둡니다.
Float64Array를 사용하면 데이터가 완벽하게 연속되어 있으므로, CPU가 0번 인덱스(x1)를 가져올 때 이미 1번, 2번, 3번 인덱스의 데이터가 통째로 캐시에 딸려 올라옵니다. 메모리 주소를 찾아 헤맬 필요가 없어 속도가 폭발적으로 상승합니다.
function processCoordinates_Best(dataList) {
// 1개의 좌표당 x, y 두 개의 숫자가 필요하므로 length * 2 사이즈의 버퍼 할당
// 이 배열은 힙 메모리의 한 공간에 연속된 블록으로 꽉 차게 생성됨
const rawMemoryBuffer = new Float64Array(dataList.length * 2);
for (let i = 0; i < dataList.length; i++) {
// 객체의 프로퍼티(포인터)를 찾는 대신, 단순 수식 연산으로 메모리 오프셋(Offset) 계산
// CPU는 연속된 메모리를 순차적으로 읽으므로 공간적 지역성이 100% 발휘됨 (Cache Hit 폭발적 증가)
const offset = i * 2;
rawMemoryBuffer[offset] = dataList[i].x * 2; // x 좌표 기록
rawMemoryBuffer[offset + 1] = dataList[i].y * 2; // y 좌표 기록
}
return rawMemoryBuffer;
}
이처럼 포인터와 메모리의 물리적 동작 원리를 이해하면, 프레임워크나 언어가 숨겨놓은 비용(Cost)을 통제할 수 있습니다.
4.2 트레이드오프와 V8 엔진의 진화
위에서는 [최적화 전]의 코드를 나쁜 예시로 표현했지만 실제 웹 개발을 할 때 99%는 [최적화 전]의 코드가 가장 훌륭하고 권장되는 코드입니다. 이러한 모순이 발생한 이유는 소프트웨어 아키텍처가 항상 트레이드오프가 발생하기 때문입니다. 하드웨어의 효율성을 중요시하는 코드와 개발자가 읽고 유지보수하기 좋은 코드는 대개 정반대의 형태를 띱니다.
객체 풀링이나 Float64Array를 사용하는 코드는 성능은 좋지만 코드의 복잡도는 극단적으로 올라갑니다. 외부에서 선언된 배열을 함수 내부로 가져와 값을 덮어쓰는 방식은 함수 지향적 코드를 포기해야하며 사이드 이펙트를 발생시킬 수 있습니다.
실제로 코드를 작성하는 시간보다 남이 짠 코드를 읽고 버그를 추적하는데 훨씬 더 많은 시간이 소요됩니다. 하드웨어의 리소스를 조금 낭비하더라도 버그 발생 확률을 낮추고 예측 가능한 코드를 작성하는 것이 더 바랍직합니다.
흥미롭게도 프론트엔드의 React나 Vue 같은 프레임워크에서 상태의 불변성을 강조하는 이유도 결국 참조 기반의 아키텍처 때문입니다. 힙 영역의 데이터를 직접 수정하는 대신 아예 새로운 객체를 생성하여 메모리 주소 자체를 변경함으로써 프레임워크가 무거운 깊은 탐색 없이 주소 값 비교 단 한번만으로 변화를 알아채고 화면을 랜더링하게 됩니다.
최근 패러다임에 맞지 않을 수 있지만 객체 풀링이나 Float64Array를 설명하는 이유는 앞으로 코드를 작성하고 유지 보수하는 대상이 사람이 아닌 AI가 된다면 그때는 이야기가 달라질 수 있다고 생각하기 때문입니다. 즉 메모리를 다루는 방법론이 애플리케이션의 전체 패러다임을 결정짓는 것입니다.
함께 읽으면 좋은 글
-
1.1 메모리 할당 원리와 포인터 [실무 아케틱트를 위한 자료구조와 알고리즘]
이 글에서는 소프트웨어 최적화의 출발점인 메모리 구조를 파헤칩니다. 우편함 비유를 통해 RAM의 하드웨어적 동작 원리를 이해하고, 32/64비트 아키텍처의 차이, CPU 캐시 지역성(Cache Locality), 그리고 자바스크립트 엔진의 스택과 힙 참조 모델을 실무 아키텍처 관점을 알아봅니다.
-
실무 아키텍트를 위한 자료구조와 알고리즘 – 마스터 인덱스
단순한 코드 작성을 넘어 시스템의 뼈대를 설계하는 실무자를 위한 자료구조/알고리즘 지침서입니다. V8 엔진의 메모리 구조부터 대규모 분산 아키텍처까지, 프레임워크 이면에 숨겨진 최적화 원리를 파헤치고 엔지니어링 설계 역량을 한 단계 높일 수 있는 기회를 제공합니다.
이 글은 신뢰할 수 있는 정보 참조하여 작성하였습니다. 감사합니다.
누구나 자료구조와 알고리즘,
헤드퍼스트 디자인패턴 개정판,
알고리즘의 정석,
대규모 시스템 설계,
V8 엔진 내부 구조,
React 아키텍처,
Think Data Structure,
A Common Sense Guide to Data Structures,
siwoolim
