프로세스 메모리 구조 탐험기: 직접 만든 시뮬레이터와 함께한 탐구
스택과 힙 : 어떻게 메모리 안에서 서로 반대 방향에 있는 걸까?
처음 ‘스택과 힙이 서로 반대 방향으로 커진다’는 설명을 들었을 때, 추상적인 개념이라 잘 와닿지 않았습니다. 심지어는 개발 공부 초창기에는 메모리가 물리적으로 구역이 나뉘어 있다고 생각했습니다. 이번에 해당 개념을 깊게 학습하고 시뮬레이터를 구현하면서, 제가 얼마나 무지했는지를 깨달았습니다. 😂 애초에 메모리는 물리적인 주소값만 있으며, 그것을 논리적인 구역으로 나누고 관리하는 것은 운영체제의 내부 로직이 있기 때문이었습니다.
제 시뮬레이터에서는 **baseAddress
**를 기준으로 스택 영역은 높은 주소에서 낮은 주소로, 힙 영역은 메모리 낮은 주소에서 높은 주소로 커지게 설계했습니다.
여기서 ‘반대 방향으로 커진다’는 의미는 스택은 메모리 상단(높은 주소)에서 아래로(낮은 주소로) 데이터를 쌓아가고, 힙은 메모리 하단(낮은 주소)에서 위로(높은 주소로) 데이터를 확장해나간다는 것을 뜻합니다.
이렇게 두 영역이 서로의 경계를 향해 커져나가다가 결국 충돌하면 ‘스택 오버플로우’ 나 ‘힙 오버플로우’ 가 발생한다는 핵심 개념을 시각적으로 이해할 수 있었습니다. 이 충돌 지점을 효율적으로 관리하는 것이 메모리 관리의 중요한 부분이더군요.
자바스크립트 함수 호출과 메모리: 지역 변수가 참조타입이면 어떻게 스텍에 쌓일까?
그동안 자바스크립트 개발하면서 ‘함수 호출하면 콜 스택에 쌓인다’는 것은 당연하게 생각했습니다. 하지만 내부적으로 어떻게 쌓이고, 함수의 지역 변수,매개변수, 타입(원시,참조)에 따른 저장방식 등에 대해서 알지 못했습니다. 그냥 ‘함수 실행 순서’ 정도만 알았던 거죠.
이번 시뮬레이터를 만들면서 콜 스택 부분을 구현하고 학습하다 보니, 아, 이게 그냥 함수 이름만 쌓이는 게 아니었구나 꺠달았습니다. 함수가 호출되면 콜 스택에 해당 함수를 위한 ‘스택 프레임’ 이 만들어지는데, 그 안에는 다음과 같은 정보가 들어갑니다:
- 함수 매개변수: 함수에 전달된 값
- 지역 변수: 함수 안에서 선언된 변수
- 반환 주소: 함수 실행이 끝나면 어디로 돌아가야 하는지 알려주는 이정표
이 모든 정보들이 차곡차곡 스택에 쌓이고, 함수 실행이 끝나면 이 ‘스택 프레임’이 통째로 사라지며, LIFO(Last-In, First-Out) 구조라는 걸 직접 구현하면서 체감할 수 있었습니다. ‘아, 그래서 함수 호출이 많으면 스택 오버플로우가 나는 거구나!’ 싶었습니다.
공부하면서 가장 기억에 남는 것 중 하나는 바로 참조 타입(객체, 배열 등)의 저장 방식이었습니다. 원래 참조타입은 heap에 저장이 되고, heap 주소값이 변수에 담긴다는 것은 머리로 알고 있었으나 함수 프레임에 지역변수에 해당 포인터 변수가 스텍으로 쌓인다는 것까지 생각하진 못했거든요.
- 실제 데이터는 힙으로: 자바스크립트에서
{}
객체나[]
배열처럼 크기가 가변적이거나 함수 종료 후에도 유지될 필요가 있는 참조 타입 데이터는 콜 스택이 아닌 ‘힙(Heap)’ 메모리 영역에 저장된다는 걸 알게 됐습니다. 힙은 스택처럼 깔끔하게 LIFO로 관리되는 게 아니라, 필요한 만큼 썼다 지웠다 할 수 있는 자유로운 공간입니다. - 스택에 남는 건 ‘주소’: 그럼 스택에는 뭐가 남을까요? 바로 힙에 저장된 그 객체나 배열의 **‘시작 주소(포인터)’**만 남는다는 사실입니다. 마치 스택에 “내용은 힙의 저~~기 0x1234번지에 있어!“라는 메모만 적혀있는 것과 같습니다.
이 개념을 알고 나니 모든 퍼즐 조각이 맞춰졌습니다. 함수가 반환되어 스택에서 사라질 때, 해당 함수의 스택 프레임과 그 안에 있던 지역 변수(원시 값, 그리고 힙 데이터의 주소값)는 없어집니다. 하지만 힙에 저장되어 있던 실제 객체나 배열 데이터는 그대로 남아있다는 것이죠! 이걸 몰랐다니… 정말 CS 개념을 제대로 알아야 하는 이유를 뼈저리게 느꼈습니다. 힙에 남은 데이터는 나중에 가비지 컬렉터가 알아서 정리해 줄 때까지 존재하게 됩니다.
번외 : 클로저(Closure)는 어디에 저장될까?
메모리 구조를 공부하다 보니, 함수가 종료되었는데도 외부 변수에 접근할 수 있는 **클로저(Closure)**의 동작 원리가 궁금해졌습니다. 클로저가 외부 스코프의 변수를 기억하고 접근할 수 있는 이유는, 자바스크립트 엔진이 해당 변수들을 힙(Heap) 메모리에 저장하기 때문입니다. 함수 실행이 끝나 스택에서 사라진 후에도, 엔진은 그 변수들이 클로저에 의해 여전히 참조되고 있음을 파악합니다. 따라서 클로저가 유효한 동안에는 해당 변수들을 힙에서 회수하지 않고 유지하며, 이 모든 과정은 엔진이 렉시컬 환경을 분석하여 자동으로 수행합니다.
malloc
시뮬레이터로 이 흐름을 구현하다
이러한 스택과 힙의 상호작용을 제 시뮬레이터의 malloc
함수로 구현하면서 정말 많은 것을 배웠습니다.
저는 메모리 모델의 복잡한 전체 구조를 완벽하게 시뮬레이터로 구현하기보다는, 함수 호출 시 스택에 쌓이는 과정과 함수 내부의 참조 타입 변수가 힙에 저장되고 그 포인터 주소가 스택의 함수 프레임에 쌓이는 과정에 집중했습니다.
제 malloc
함수는 이렇게 작동했습니다:
- 힙에 공간 확보: 먼저 요청받은 타입과 개수만큼의 메모리를 힙 영역(Map으로 추상화된
this.heap
)에 실제로 할당하고 그 정보를 기록합니다. 여기까지는 실제malloc
과 동일한 역할이죠. - 스택에 ‘힙 포인터’ 기록: 그리고 나서, 힙에 할당된 그 메모리의 주소(힙 주소)를 담을 4바이트짜리 ‘포인터 변수 공간’을 스택 영역(
this.stack
배열)에 할당합니다. - 스택 주소 반환: 최종적으로,
malloc
함수는 힙 주소를 직접 반환하는 대신, 스택에 생성된 그 ‘포인터 변수’의 주소를 반환합니다.
이 구현 방식을 통해, 저는 ‘함수 내부의 지역 포인터 변수(스택)가 힙에 있는 실제 데이터를 가리키는’ C 언어의 핵심적인 메모리 흐름을 직접 모방하고 학습할 수 있었습니다. 코드를 통해 눈에 보이지 않던 메모리의 움직임을 구현해보니, 비로소 스택과 힙, 그리고 포인터의 관계가 머릿속에 확실히 자리 잡게 되었습니다.
free()
와 가비지 컬렉터, 같아 보였지만 달랐던 역할
free()
와 가비지 컬렉터(GC) 모두 ‘사용되지 않는 메모리를 해제한다’는 점에서 비슷하다고 막연히 생각했습니다. 그러다 보니 시뮬레이터를 구현하면서 free와 가비지 컬렉터를 구현할 때 혼란이 왔었습니다. 둘 다 heap 메모리를 해제하는 역할은 같은데, 무슨 차이가 있는 걸까…? 생각하며 학습을 진행했습니다.
이 둘은 메모리 해제 주체와 방식에서 결정적인 차이가 있었습니다.
free()
: 이는 개발자가 명시적으로 ‘이 메모리는 이제 필요 없으니 재사용하시오!‘라고 시스템에 직접 명령하는 것입니다. 마치 책임을 다한 작업자가 ‘제 일은 여기서 끝입니다!’ 하고 자리를 비우는 것과 같습니다. 따라서 해당 메모리가 다른 곳에서 여전히 참조되고 있는지 여부는free()
가 신경 쓰지 않습니다. 이 때문에 만약 다른 곳에서 아직 참조하고 있는 메모리를free()
하면 Use-After-Free(UAF) 버그와 같은 치명적인 문제가 발생할 수 있다는 것을 배웠습니다. 이는 프로그램 충돌, 데이터 손상, 심지어 보안 취약점으로 이어질 수 있는 무서운 오류입니다.가비지 컬렉터 (GC): GC는 프로그래머의 개입 없이 런타임 환경이 스스로 메모리 사용 현황을 ‘감시’하고 ‘판단’하여 작동합니다. 마치 뒤에서 조용히 청소하는 관리자처럼, ‘더 이상 어떤 코드에서도 접근할 수 없는’ 메모리 블록들을 찾아 자동으로 해제합니다. 이 과정에서 개발자는 메모리 해제 시점을 걱정할 필요가 없어져 생산성과 프로그램 안정성이 높아집니다.
결국, 둘 다 힙 메모리를 ‘재사용 가능한 상태’로 만든다는 목표는 같지만, free
는 개발자의 ‘수동적이고 즉각적인 지시’ 인 반면, GC는 런타임의 ‘자동적이고 지능적인 판단’ 이라는 근본적인 차이가 있었습니다. 이 차이를 명확히 이해하게 된 것이 이번 학습의 큰 의미 중 하나였습니다.
만약 제가 c언어를 알았다면 malloc과 free를 사용해보았으면 좋았을 텐데 하는 생각을 했습니다. 다음에 cs 스터디를 한다면 c언어와 함께 공부를 해보겠다는 목표가 세워봅니다…
힙 메모리 관리를 어떻게 해야할까? : Free List라는 존재의 필요성
힙 메모리 관리는 스택과 달리 훨씬 복잡했습니다. 스택은 LIFO 구조라 스택 포인터(Stack Pointer) 하나로 다음 위치를 쉽게 알 수 있지만, 힙은 임의의 크기, 임의의 순서로 할당/해제되니 메모리 단편화가 발생할 수밖에 없죠.
처음에는 이 힙 관리를 어떻게 시뮬레이션할지 고민이 많았습니다. 단순히 heapPointer
하나로 스택처럼 다음 할당될 위치를 관리하는 방식은 구현은 간단하겠지만, 실제 힙의 유동적인 특성을 제대로 반영하지 못할 것 같았습니다. 그렇다고 힙을 객체들의 집합으로만 두고, 각 객체에 메타데이터(크기, 할당 여부 불린 값 등)를 포함시켜 free
나 GC 시마다 힙 전체를 순회하며 해제된 블록을 찾아내는 방식은 너무 번거롭고 비효율적이라고 생각했습니다.
여기서 바로 ‘Free List’ 이라는 개념을 접하게 되었고, 눈이 번쩍 뜨였습니다.😮 Free List은 해제된 메모리 블록들을 효율적으로 관리하는 자료구조였습니다. 이 개념을 알게 되니, 앞서 고민했던 번거로운 힙 순회 없이도 해제된 메모리를 빠르게 찾아 재사용할 수 있는 방법이 있다는 것을 알게 되었습니다.
더 나아가, Free List 안에서도 First-Fit, Best-Fit과 같은 다양한 할당 알고리즘이 존재하며, 심지어 실제 시스템에서는 할당될 크기에 따라 여러 개의 Free List을 두어 특정 크기에 맞는 빈 공간을 더 빠르게 찾는 방식도 있다는 것을 학습했습니다. (예: 8바이트용 Free List, 16바이트용 Free List 등).
제 시뮬레이터에서는 지금 당장 이 모든 복잡한 알고리즘과 여러 Free List을 구현하지는 않았지만, 이러한 개념들이 ‘힙 포인터’ 하나로는 설명될 수 없는 힙 관리의 실제 복잡성과 그를 해결하기 위한 효율적인 방식들이라는 것을 이해하게 되었습니다.
마무리하며: 코드로 탐험하는 CS의 즐거움
이번 프로세스 메모리 시뮬레이터 구현은 단순히 글과 그림으로만 접했던 스택, 힙, 포인터, 그리고 메모리 관리자들의 역할을 직접 코드를 짜고 동작을 시켜보면서 이해하는 경험을 주었습니다.
특히, 제가 겪었던 혼란스러운 지점들을 하나하나 파헤치고 납득하는 과정 자체가 가장 큰 공부였습니다. malloc
의 반환 값, free
와 GC의 미묘한 차이, 그리고 힙 포인터의 의미 등. 머릿속으로만 알던 지식이 직접 구현하고 설계하는 과정을 거쳐 제것이 되는 과정이었습니다.