
매일 밤 12시, 서버가 죽는 이유 (Stack vs Heap)
잘 돌아가던 Node.js 서버가 매일 밤 12시만 되면 'Heap Out of Memory'를 뱉으며 죽었습니다. 원인은 전역 변수에 쌓인 데이터 더미였죠. 이 디버깅 과정을 통해 배운 Stack과 Heap의 차이, 그리고 메모리 누수를 막는 방법을 정리했습니다.

잘 돌아가던 Node.js 서버가 매일 밤 12시만 되면 'Heap Out of Memory'를 뱉으며 죽었습니다. 원인은 전역 변수에 쌓인 데이터 더미였죠. 이 디버깅 과정을 통해 배운 Stack과 Heap의 차이, 그리고 메모리 누수를 막는 방법을 정리했습니다.
내 서버는 왜 걸핏하면 뻗을까? OS가 한정된 메모리를 쪼개 쓰는 처절한 사투. 단편화(Fragmentation)와의 전쟁.

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

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

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

작년에 운영하던 Node.js 서버에 기이한 문제가 있었습니다. 낮에는 멀쩡하다가, 밤 12시만 되면 서버가 재시작되는 겁니다. 서버 로그를 열어보니 공포스러운 메시지가 찍혀 있었습니다.
FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory
"메모리가 부족하다고? 램이 16GB나 되는데?" AWS EC2 대시보드를 보니 메모리 사용량(RAM)은 넉넉했습니다. 하지만 프로세스가 사용하는 Heap 메모리가 가득 찬 거였습니다. Node.js(V8 엔진)는 기본적으로 Heap 메모리 한계가 약 1.4GB(64비트 기준 옛날 버전)로 설정되어 있거든요.
이 사건을 계기로 저는 "메모리"라는 블랙박스를 열어보게 되었습니다.
우리가 코드를 짤 때 사용하는 변수들은 다 어디에 저장될까요? 크게 두 군데입니다. Stack(스택)과 Heap(힙).
가장 쉬운 비유는 이겁니다.
int, boolean 같은 가벼운 원시 타입(Primitive).main() 함수가 실행되면 책상에 포스트잇을 붙이고, add() 함수를 부르면 그 위에 또 붙입니다. add()가 끝나면 맨 위 포스트잇을 뗍니다.free(), JS는 Garbage Collector가 담당)Object, Array 같은 무거운 참조 타입(Reference).new User()) 하면 창고 구석에 박스를 하나 둡니다. 그리고 책상(Stack)에는 "창고 A-12 구역에 있음"이라는 쪽지(주소)만 남깁니다.제 서버가 죽은 이유는 Stack이 아니라 Heap 때문이었습니다. Stack은 함수가 끝나면 자동으로 청소되지만, Heap은 개발자가 짠 로직에 따라 쓰레기가 쌓일 수 있습니다.
코드를 샅샅이 뒤져보니 범인은 아주 사소한 곳에 있었습니다.
// 범인 검거
const requestLogs = []; // 전역 변수 (Heap에 영구 저장됨)
app.use((req, res, next) => {
// 요청이 올 때마다 배열에 1KB씩 추가됨
requestLogs.push({
url: req.url,
time: Date.now(),
headers: req.headers // 생각보다 큼
});
next();
});
"디버깅용으로 로그 좀 남겨놔야지" 하고 만든 전역 배열 requestLogs가 문제였습니다.
이 배열은 Heap에 저장됩니다.
그런데 이 배열을 비우는 코드는 어디에도 없었습니다.
req, res)는 응답 후 사라지지만,requestLogs는 서버 프로세스가 살아있는 한 영원히 유지됩니다.여기서 중요한 개념인 "참조(Reference)"가 나옵니다. Stack에는 주소(포스트잇)만 있고, 실제 데이터는 Heap(창고)에 있습니다.
let user = { name: "Ratia" };
{ name: "Ratia" }라는 객체 생성. (메모리 주소: 0x1234)user라는 변수에 0x1234라는 주소를 적음.만약 user = null을 하면 어떻게 될까요?
Stack에 있는 포스트잇(user)은 지워집니다.
하지만 Heap에 있는 { name: "Ratia" }는 여전히 남아있습니다.
이때 등장하는 청소부, Garbage Collector(GC)가 주기적으로 창고를 돕니다.
"어? 이 박스(0x1234)를 가리키는 포스트잇이 하나도 없네? (Reachability Check)" "그럼 이건 쓰레기네. 갖다 버려!" (Memory Deallocation)
하지만 제 코드에서는 requestLogs라는 전역 변수가 계속 데이터를 가리키고(Reference) 있었습니다.
GC는 "아, 이 짐은 주인이 있구나. 버리면 안 돼."라고 판단합니다.
이것이 바로 메모리 누수(Memory Leak)입니다. 의도치 않게 쓰레기를 끌어안고 사는 거죠.
Node.js의 V8 엔진은 Mark-and-Sweep 알고리즘을 씁니다.
서버가 죽기 직전 로그(Ineffective mark-compacts)는, GC가 "아무리 쓸어 담아도(Mark-Compact) 빈 공간이 안 나와!"라고 비명을 지른 겁니다.
해결책은 간단했습니다. 메모리(Heap) 대신 디스크(로그 파일)나 외부 저장소(Redis)를 쓰면 됩니다.
// 해결: 파일에 쓰거나 DB에 저장 (Heap 메모리 안 씀)
const fs = require('fs');
const path = require('path');
// Stream을 사용해 메모리 효율적으로 작성
const logStream = fs.createWriteStream(path.join(__dirname, 'access.log'), { flags: 'a' });
app.use((req, res, next) => {
// 비동기로 파일에 씀. Node.js 메모리에 남지 않음.
logStream.write(`${new Date().toISOString()} ${req.url}\n`);
next();
});
이렇게 고치자 Heap 메모리 사용량 그래프는 평평해졌고, 서버는 다시는 죽지 않았습니다.
만약 꼭 배열에 저장해야 했다면, Circular Buffer를 써서 크기를 제한했어야 합니다.
// 대안: 최근 100개만 저장
if (requestLogs.length > 100) {
requestLogs.shift(); // 오래된 것 버리기
}
requestLogs.push(data);
JS나 Python, Java 같은 언어를 쓰면 메모리 관리를 몰라도 개발할 수 있습니다. (Managed Language) 하지만 "몰라도 된다"는 것이 "신경 안 써도 된다"는 뜻은 아닙니다.
여러분의 서버 안에도 밤마다 조금씩 자라나는 괴물(메모리 누수)이 살고 있을지도 모릅니다.
지금 process.memoryUsage()를 찍어보세요.