
가비지 컬렉션(GC): 자동 메모리 관리
개발자가 어지럽힌 쓰레기(메모리)를 치워주는 야간 청소부. 편하지만 가끔 청소한다고 복도를 막아서 서버를 멈추게 함(Stop The World).

개발자가 어지럽힌 쓰레기(메모리)를 치워주는 야간 청소부. 편하지만 가끔 청소한다고 복도를 막아서 서버를 멈추게 함(Stop The World).
내 서버는 왜 걸핏하면 뻗을까? OS가 한정된 메모리를 쪼개 쓰는 처절한 사투. 단편화(Fragmentation)와의 전쟁.

미로를 탈출하는 두 가지 방법. 넓게 퍼져나갈 것인가(BFS), 한 우물만 팔 것인가(DFS). 최단 경로는 누가 찾을까?

이름부터 빠릅니다. 피벗(Pivot)을 기준으로 나누고 또 나누는 분할 정복 알고리즘. 왜 최악엔 느린데도 가장 많이 쓰일까요?

매번 3-Way Handshake 하느라 지쳤나요? 한 번 맺은 인연(TCP 연결)을 소중히 유지하는 법. HTTP 최적화의 기본.

내가 처음 C++로 게임 엔진 비슷한 걸 만들어보려던 시절이 있었다. 객체를 동적으로 생성하고(new), 다 쓰고 나면 해제하는(delete) 단순한 작업이었는데, 문제는 "다 쓰고 나면"을 판단하는 게 생각보다 너무 어려웠다는 점이다.
어떤 객체는 여러 곳에서 참조하고 있고, 어떤 객체는 이미 필요 없어졌는지 판단이 애매했다. 결국 프로그램을 30분만 돌리면 메모리가 2GB를 넘어가면서 시스템이 느려지기 시작했다. 메모리 누수(Memory Leak)였다. delete를 깜빡한 객체들이 좀비처럼 메모리에 남아서 점점 쌓여간 것이다.
그때 나는 이렇게 생각했다. "왜 메모리를 내가 직접 관리해야 하지? 쓰레기 버리는 것까지 프로그래머가 신경 써야 하나?" 그 질문의 답이 바로 가비지 컬렉션(Garbage Collection, GC)이었다.
C나 C++에서는 메모리를 수동으로 관리한다. 필요할 때 malloc이나 new로 메모리를 빌리고, 다 쓰면 free나 delete로 반납해야 한다. 이게 왜 지옥인지 정리해본다.
// C 스타일 메모리 관리
void process_data() {
int* data = (int*)malloc(1000 * sizeof(int));
if (data == NULL) {
return; // 메모리 할당 실패
}
// 데이터 처리...
// 까먹으면? 메모리 누수 발생!
free(data);
}
// 더 복잡한 예시
char* create_string() {
char* str = (char*)malloc(100);
strcpy(str, "Hello");
return str; // 누가 free 할 책임이 있나?
}
void use_string() {
char* msg = create_string();
printf("%s\n", msg);
// 여기서 free(msg)를 호출해야 하는데 깜빡하면?
}
첫 번째 문제는 메모리 누수(Memory Leak)다. free를 깜빡하면 메모리가 계속 쌓인다. 두 번째 문제는 이중 해제(Double Free)나 댕글링 포인터(Dangling Pointer)다. 이미 해제한 메모리를 다시 해제하거나 접근하면 프로그램이 크래시된다.
세 번째 문제는 소유권 혼란이다. 여러 함수가 같은 메모리를 가리킬 때, 누가 해제할 책임이 있는지 불명확하다. 이런 문제들이 나를 밤잠 못 이루게 만들었다.
그래서 나온 첫 번째 자동화 시도가 레퍼런스 카운팅(Reference Counting)이다. 각 객체마다 "누가 나를 참조하고 있나" 카운터를 달아놓는 방식이다. 참조가 생기면 카운터를 +1, 참조가 끊기면 -1. 카운터가 0이 되면 자동으로 메모리를 해제한다.
// C++ 스타일 레퍼런스 카운팅 (스마트 포인터)
#include <memory>
void reference_counting_example() {
std::shared_ptr<int> ptr1 = std::make_shared<int>(42);
// 레퍼런스 카운트: 1
{
std::shared_ptr<int> ptr2 = ptr1;
// 레퍼런스 카운트: 2
std::cout << *ptr2 << std::endl;
} // ptr2 스코프 종료, 카운트: 1
std::cout << *ptr1 << std::endl;
} // ptr1 스코프 종료, 카운트: 0 -> 자동 해제
나는 이 방식이 처음엔 천재적이라고 생각했다. 하지만 곧 치명적인 문제를 발견했다. 순환 참조(Circular Reference)다.
// JavaScript에서의 순환 참조 예시
class Node {
constructor(value) {
this.value = value;
this.next = null;
}
}
let node1 = new Node(1);
let node2 = new Node(2);
node1.next = node2;
node2.next = node1; // 순환 참조 발생!
// node1이 node2를 참조, node2가 node1을 참조
// 둘 다 레퍼런스 카운트가 0이 안 됨
// 영원히 해제되지 않음
A가 B를 참조하고, B가 A를 참조하면? 둘 다 카운트가 0이 안 된다. 외부에서는 더 이상 접근할 수 없는데 메모리는 해제되지 않는다. 이게 레퍼런스 카운팅의 한계였다.
진짜 해결책은 발상의 전환이었다. "몇 개가 참조하는가"가 아니라 "루트에서 도달 가능한가"를 기준으로 삼는 것이다. 이게 현대 GC의 핵심 아이디어다.
비유하자면 이렇다. 아파트 단지에서 쓰레기를 치운다고 생각해보자. 레퍼런스 카운팅은 "이 쓰레기를 몇 명이 만졌나"를 세는 방식이다. 하지만 GC는 "현관문(Root)에서 걸어가서 닿을 수 있는가"를 확인한다. 현관에서 출발해서 복도, 엘리베이터, 각 집을 따라가면서 도달할 수 있는 물건은 "사용 중", 도달할 수 없는 물건은 "쓰레기"로 분류한다.
Mark and Sweep 알고리즘이 바로 이 방식이다.
Mark and Sweep은 두 단계로 나뉜다.
1단계: Mark (마킹)// 간단한 Mark and Sweep 개념 코드
class GarbageCollector {
constructor() {
this.heap = [];
this.roots = [];
}
mark() {
const marked = new Set();
const stack = [...this.roots];
while (stack.length > 0) {
const obj = stack.pop();
if (marked.has(obj)) continue;
marked.add(obj);
// 객체가 참조하는 다른 객체들을 스택에 추가
for (let ref of obj.references) {
stack.push(ref);
}
}
return marked;
}
sweep(marked) {
this.heap = this.heap.filter(obj => {
if (marked.has(obj)) {
return true; // 유지
} else {
obj.free(); // 해제
return false;
}
});
}
collect() {
const marked = this.mark();
this.sweep(marked);
}
}
이 방식은 순환 참조 문제를 완벽하게 해결한다. A와 B가 서로를 참조하더라도, 루트에서 도달할 수 없으면 둘 다 쓰레기로 분류된다.
Mark and Sweep은 훌륭하지만, 매번 전체 힙을 스캔하는 건 비효율적이다. 여기서 나온 아이디어가 세대별 GC(Generational GC)다.
이건 "대부분의 객체는 젊어서 죽는다(Die Young)"는 관찰에서 출발한다. 방금 생성된 객체는 곧 필요 없어질 가능성이 높다. 반면 오래 살아남은 객체는 앞으로도 계속 필요할 가능성이 높다.
그래서 힙을 세대(Generation)로 나눈다.
Young Generation (젊은 세대)// V8 엔진의 세대별 GC 동작 (개념적 설명)
// Young Generation은 두 영역으로 나뉜다
// 1. From Space (살아있는 객체)
// 2. To Space (비어있음)
// Minor GC (Scavenger 알고리즘):
function minorGC() {
// From Space의 살아있는 객체를 To Space로 복사
for (let obj of fromSpace) {
if (isReachable(obj)) {
copyTo(toSpace, obj);
obj.age++; // 나이 증가
if (obj.age > PROMOTION_THRESHOLD) {
promoteToOldGen(obj); // Old Gen으로 승격
}
}
}
// From과 To를 교환
swap(fromSpace, toSpace);
clear(toSpace); // 이전 From Space 비우기
}
// Major GC (Mark-Compact 알고리즘):
function majorGC() {
// Old Generation 전체를 대상으로 Mark and Sweep
mark(roots);
compact(); // 메모리 단편화 해소를 위해 압축
}
V8 엔진(Chrome, Node.js)은 Young Gen에 Scavenger 알고리즘을 사용한다. 살아있는 객체를 새 공간으로 복사하는 방식이다. Old Gen에는 Mark-Compact 알고리즘을 사용한다. 마킹 후 살아있는 객체들을 한쪽으로 모아서 메모리 단편화를 줄인다.
이 방식 덕분에 대부분의 GC는 작은 Young Gen만 처리하면 되므로 빠르다. 나는 이 아이디어가 "자주 쓰는 물건은 책상 위에, 가끔 쓰는 물건은 창고에" 정리하는 원리와 같다고 받아들였다.
GC가 아무리 영리해도, 청소하는 동안은 프로그램을 멈춰야 한다. 이게 Stop-The-World (STW) 현상이다.
왜 멈춰야 할까? 청소하는 중간에 새로운 객체가 생기거나 참조가 바뀌면, GC가 잘못된 판단을 할 수 있다. 마치 청소하는 중간에 사람들이 계속 물건을 옮기면 청소가 불가능한 것과 같다.
문제는 이 멈춤 시간이 사용자에게 느껴진다는 점이다. 게임에서 갑자기 프레임이 뚝 떨어지거나, 웹 앱이 순간적으로 버벅이는 경험을 해본 적 있다면, 그게 바로 GC 때문일 가능성이 높다.
// GC로 인한 퍼포먼스 문제 예시
function processLargeData() {
const start = Date.now();
for (let i = 0; i < 1000000; i++) {
// 매 반복마다 새로운 객체 생성 (Young Gen 압박)
const temp = {
index: i,
data: new Array(100).fill(i)
};
// 이 객체는 루프가 끝나면 바로 쓰레기가 됨
}
const end = Date.now();
console.log(`소요 시간: ${end - start}ms`);
// Minor GC가 여러 번 발생하면서 STW 시간이 누적됨
}
그래서 나온 개선책이 Concurrent GC와 Incremental GC다.
Concurrent GC: 애플리케이션 스레드와 GC 스레드를 동시에 실행한다. 완전히 멈추지 않고 백그라운드에서 청소한다. 하지만 구현이 매우 복잡하고, 일부 단계에서는 여전히 STW가 필요하다.
Incremental GC: GC 작업을 작은 단위로 쪼개서 조금씩 실행한다. 한 번에 긴 멈춤 대신, 여러 번의 짧은 멈춤으로 분산시킨다. V8의 최신 버전은 이 방식을 사용한다.
"GC가 있으면 메모리 누수가 없는 거 아닌가?" 나도 그렇게 생각했다. 하지만 GC는 "도달 가능한" 객체는 절대 지우지 않는다. 문제는 실수로 도달 가능한 상태로 남겨둔 객체들이다.
// 메모리 누수 예시
function setupUserSession(userData) {
// setInterval이 userData를 클로저로 캡처
const intervalId = setInterval(() => {
console.log(`User ${userData.name} is active`);
}, 1000);
// 문제: intervalId를 어디에도 저장하지 않음
// clearInterval을 호출할 방법이 없음
// userData는 영원히 메모리에 남음
}
// 올바른 방법
function setupUserSession(userData) {
const intervalId = setInterval(() => {
console.log(`User ${userData.name} is active`);
}, 1000);
// 세션 종료 시 타이머 정리
return () => clearInterval(intervalId);
}
const cleanup = setupUserSession({ name: 'John', data: bigData });
// 나중에...
cleanup(); // 메모리 해제
나는 이 패턴으로 프로덕션에서 메모리 누수를 일으킨 적이 있다. 사용자가 페이지를 떠나도 setInterval은 계속 돌아가면서 큰 객체를 붙잡고 있었다.
// 메모리 누수 예시
function createHeavyProcessor() {
const hugeArray = new Array(1000000).fill('data');
// 이 함수는 hugeArray를 실제로 사용하지 않음
// 하지만 클로저 특성상 전체 스코프를 캡처함
return function processLight() {
console.log('Processing...');
// hugeArray는 여기서 안 쓰지만 메모리에 남음
};
}
const processor = createHeavyProcessor();
// hugeArray는 영원히 메모리에 남음
// 올바른 방법
function createHeavyProcessor() {
const hugeArray = new Array(1000000).fill('data');
// 필요한 데이터만 추출
const summary = hugeArray.length;
return function processLight() {
console.log(`Processing ${summary} items...`);
// hugeArray는 이제 GC 대상이 됨
};
}
클로저는 편리하지만 의도치 않게 큰 객체를 붙잡고 있을 수 있다. 나는 이 문제를 겪고 나서, 클로저 내부에서 실제로 사용하는 변수만 명시적으로 추출하는 습관을 들였다.
// 메모리 누수 예시
let detachedNodes = [];
function createAndRemoveElement() {
const div = document.createElement('div');
div.innerHTML = '<p>' + new Array(10000).join('text') + '</p>';
document.body.appendChild(div);
// DOM에서 제거하지만 JavaScript 변수는 여전히 참조
document.body.removeChild(div);
detachedNodes.push(div); // 메모리 누수!
}
// 올바른 방법
function createAndRemoveElement() {
const div = document.createElement('div');
div.innerHTML = '<p>content</p>';
document.body.appendChild(div);
document.body.removeChild(div);
// 참조를 유지하지 않음 - GC가 회수 가능
}
DOM 노드를 JavaScript 변수에 저장하면, 브라우저가 DOM 트리에서 제거해도 메모리에 남는다. 이 패턴은 SPA에서 특히 흔하다.
ES2021에서 WeakRef와 FinalizationRegistry가 추가되었다. 이건 "GC에게 힌트를 주는" 기능이다.
// WeakRef 사용 예시
class ImageCache {
constructor() {
this.cache = new Map();
}
add(key, image) {
// WeakRef로 감싸서 저장
this.cache.set(key, new WeakRef(image));
}
get(key) {
const ref = this.cache.get(key);
if (!ref) return null;
// deref()로 실제 객체에 접근
const image = ref.deref();
if (!image) {
// GC가 이미 회수함
this.cache.delete(key);
return null;
}
return image;
}
}
// 사용 예
const cache = new ImageCache();
let image = new Image();
image.src = 'large-image.png';
cache.add('hero', image);
// 이미지를 더 이상 사용하지 않을 때
image = null;
// GC가 메모리 압박을 느끼면 이미지를 회수할 수 있음
// 하지만 메모리가 충분하면 캐시에 남아있을 수도 있음
FinalizationRegistry는 객체가 GC될 때 콜백을 실행할 수 있게 해준다.
// FinalizationRegistry 사용 예시
const registry = new FinalizationRegistry((heldValue) => {
console.log(`객체 ${heldValue}가 GC되었습니다`);
});
function createUser(name) {
const user = { name };
// user 객체가 GC될 때 콜백 실행
registry.register(user, name);
return user;
}
let user = createUser('Alice');
// user를 사용...
user = null;
// 나중에 GC가 실행되면 "객체 Alice가 GC되었습니다" 출력
나는 처음에 이 기능들을 "메모리 누수 디버깅 도구"로 받아들였는데, 실제로는 캐시 구현에 유용하다는 걸 나중에 깨달았다.
프로덕션 환경에서는 GC 동작을 튜닝해야 할 때가 있다. Node.js는 V8 플래그로 GC를 제어할 수 있다.
# 힙 크기 조정
node --max-old-space-size=4096 app.js # Old Gen 크기를 4GB로
# GC 로그 출력
node --trace-gc app.js
# 출력 예시:
# [12345:0x123456] 123 ms: Scavenge 2.3 (3.1) -> 1.9 (3.1) MB, 0.5 / 0.0 ms
# [12345:0x123456] 456 ms: Mark-sweep 15.2 (20.1) -> 12.3 (18.5) MB, 12.3 / 0.0 ms
로그를 보면 GC 타입(Scavenge, Mark-sweep), 힙 크기 변화, 소요 시간을 알 수 있다. 나는 이 정보로 "언제 GC가 너무 자주 일어나는지", "힙 크기가 부족한지"를 판단하는 법을 배웠다.
// 메모리 사용량 모니터링
function logMemoryUsage() {
const usage = process.memoryUsage();
console.log({
rss: `${Math.round(usage.rss / 1024 / 1024)}MB`, // 전체 메모리
heapTotal: `${Math.round(usage.heapTotal / 1024 / 1024)}MB`, // 힙 전체 크기
heapUsed: `${Math.round(usage.heapUsed / 1024 / 1024)}MB`, // 힙 사용량
external: `${Math.round(usage.external / 1024 / 1024)}MB` // C++ 객체 메모리
});
}
setInterval(logMemoryUsage, 5000);
서버가 메모리를 계속 늘려가면 메모리 누수의 징후다. 안정적인 서버는 힙 사용량이 일정 범위 내에서 톱니바퀴 패턴(GC로 줄었다가 다시 늘었다가)을 보인다.
가비지 컬렉션은 결국 "편의성과 성능의 트레이드오프"였다. C/C++의 수동 관리는 고통스럽지만 성능을 완벽히 제어할 수 있다. GC는 편하지만 STW 같은 비용이 따른다.
나는 GC를 이해하고 나서야 비로소 "왜 게임 엔진은 C++를 쓰는가", "왜 서버 개발자들은 GC 튜닝에 집착하는가"를 받아들일 수 있었다. GC는 마법이 아니라 정교한 알고리즘이고, 그 동작 원리를 이해하면 메모리 누수를 피하고 성능을 최적화할 수 있다.
가장 중요한 교훈은 "GC가 있다고 메모리 관리를 무시해도 되는 게 아니다"라는 점이다. 타이머를 정리하고, 불필요한 참조를 끊고, DOM 노드를 조심하는 습관이 결국 안정적인 애플리케이션을 만든다. 나는 이제 "GC는 청소부지만, 쓰레기를 안 만드는 건 내 책임"이라고 정리해본다.