1. 미스테리 - 신데렐라 서버
작년에 운영하던 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비트 기준 옛날 버전)로 설정되어 있거든요.
이 사건을 계기로 저는 "메모리"라는 블랙박스를 열어보게 되었습니다.
2. 메모리의 두 얼굴 - 정리된 책상 vs 난장판 창고
우리가 코드를 짤 때 사용하는 변수들은 다 어디에 저장될까요? 크게 두 군데입니다. Stack(스택)과 Heap(힙).
가장 쉬운 비유는 이겁니다.
Stack = "내 책상 위 포스트잇"
- 특징: 아주 빠름, 크기가 작음, 정리가 잘 됨(LIFO).
- 용도: 지금 당장 처리해야 할 일들 (함수 실행 컨텍스트, 지역 변수).
- 수명: 일을 마치면(함수가 끝나면) 바로 꾸겨서 버림.
- 저장되는 것:
int,boolean같은 가벼운 원시 타입(Primitive). - 비유:
main()함수가 실행되면 책상에 포스트잇을 붙이고,add()함수를 부르면 그 위에 또 붙입니다.add()가 끝나면 맨 위 포스트잇을 뗍니다.
Heap = "거대한 물류 창고"
- 특징: 아주 큼, 찾느라 시간 좀 걸림, 정리가 안 됨(Fragmentation).
- 용도: 크기를 알 수 없는 큰 짐들 (객체, 배열, 클래스 인스턴스, 클로저).
- 수명: 누군가 "이거 버려"라고 할 때까지 영원히 남음. (C언어는
free(), JS는 Garbage Collector가 담당) - 저장되는 것:
Object,Array같은 무거운 참조 타입(Reference). - 비유: "새로운 유저 객체 하나 만들어와"(
new User()) 하면 창고 구석에 박스를 하나 둡니다. 그리고 책상(Stack)에는 "창고 A-12 구역에 있음"이라는 쪽지(주소)만 남깁니다.
3. 원인 분석 - 창고(Heap)가 터졌다
제 서버가 죽은 이유는 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에 저장됩니다.
그런데 이 배열을 비우는 코드는 어디에도 없었습니다.
- 사용자가 접속할 때마다 배열 크기가 늘어납니다.
- Stack에 있는 지역 변수(
req,res)는 응답 후 사라지지만, - Heap에 있는
requestLogs는 서버 프로세스가 살아있는 한 영원히 유지됩니다. - 밤 12시쯤 되면 데이터가 1.4GB를 넘기고, V8 엔진이 "더 이상 못 버티겠다"며 뻗어버리는 것이었죠.
4. 깊이 파기 - 참조(Reference)가 뭔가요?
여기서 중요한 개념인 "참조(Reference)"가 나옵니다. Stack에는 주소(포스트잇)만 있고, 실제 데이터는 Heap(창고)에 있습니다.
let user = { name: "Ratia" };
- Heap:
{ name: "Ratia" }라는 객체 생성. (메모리 주소: 0x1234) - Stack:
user라는 변수에0x1234라는 주소를 적음.
만약 user = null을 하면 어떻게 될까요?
Stack에 있는 포스트잇(user)은 지워집니다.
하지만 Heap에 있는 { name: "Ratia" }는 여전히 남아있습니다.
이때 등장하는 청소부, Garbage Collector(GC)가 주기적으로 창고를 돕니다.
"어? 이 박스(0x1234)를 가리키는 포스트잇이 하나도 없네? (Reachability Check)" "그럼 이건 쓰레기네. 갖다 버려!" (Memory Deallocation)
하지만 제 코드에서는 requestLogs라는 전역 변수가 계속 데이터를 가리키고(Reference) 있었습니다.
GC는 "아, 이 짐은 주인이 있구나. 버리면 안 돼."라고 판단합니다.
이것이 바로 메모리 누수(Memory Leak)입니다. 의도치 않게 쓰레기를 끌어안고 사는 거죠.
V8 GC의 동작 방식 (Mark-and-Sweep)
Node.js의 V8 엔진은 Mark-and-Sweep 알고리즘을 씁니다.
- Mark: "이거 쓰고 있나?" 루트(Root)에서부터 연결된 모든 객체에 표시를 합니다.
- Sweep: 표시 안 된 객체들을 쓸어버립니다.
서버가 죽기 직전 로그(Ineffective mark-compacts)는, GC가 "아무리 쓸어 담아도(Mark-Compact) 빈 공간이 안 나와!"라고 비명을 지른 겁니다.
5. 해결 - 창고 정리하기
해결책은 간단했습니다. 메모리(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);
6. 마무리 - "메모리는 공짜가 아니다"
JS나 Python, Java 같은 언어를 쓰면 메모리 관리를 몰라도 개발할 수 있습니다. (Managed Language) 하지만 "몰라도 된다"는 것이 "신경 안 써도 된다"는 뜻은 아닙니다.
- 전역 변수(Global Variable)는 시한폭탄이다. 최대한 피하자. (캐싱이 필요하면 Redis를 써라).
- 클로저(Closure)를 잘못 쓰면 스코프 체인이 메모리를 잡고 안 놔준다. 주의하자.
- Heap은 무한하지 않다. 대용량 데이터(엑셀 파일 업로드 등)를 처리할 땐 Stream을 써야 한다.
여러분의 서버 안에도 밤마다 조금씩 자라나는 괴물(메모리 누수)이 살고 있을지도 모릅니다.
지금 process.memoryUsage()를 찍어보세요.