Node.js 인터뷰 질문 80
질문: Node.js에서의 메모리 관리 방식과 메모리 누수를 식별하고 해결하는 방법에 대해 설명해주세요.
답변:
Node.js는 Chrome V8 엔진 위에서 작동하며, V8의 가비지 컬렉션 메커니즘을 활용하여 메모리를 관리합니다. 그러나 비효율적인 코드 패턴으로 인해 메모리 누수가 발생할 수 있으며, 이는 애플리케이션 성능 저하와 심각한 오류로 이어질 수 있습니다.
1. Node.js의 메모리 구조
1.1 V8 엔진의 메모리 구조
V8 엔진은 다음과 같은 주요 메모리 영역을 가지고 있습니다:
힙 메모리(Heap): 객체, 문자열, 클로저 등이 저장되는 공간
- 뉴 스페이스(Young Generation): 새로 생성된 객체 저장
- 올드 스페이스(Old Generation): 오래 살아남은 객체 저장
스택 메모리(Stack): 함수 호출 정보, 원시 타입 변수 등이 저장되는 공간
// 스택 메모리에 저장되는 원시 타입
const number = 123;
const bool = true;
// 힙 메모리에 저장되는 객체 타입
const obj = { name: "Node.js" };
const arr = [1, 2, 3];
1.2 메모리 제한
Node.js는 기본적으로 메모리 사용량에 제한이 있습니다:
// 메모리 제한 확인
console.log(process.memoryUsage());
/*
{
rss: 30679040, // 실제 물리 메모리 사용량
heapTotal: 6537216, // V8에 할당된 총 힙 메모리
heapUsed: 3971040, // 실제 사용 중인 힙 메모리
external: 1082961, // C++ 객체에 바인딩된 메모리
arrayBuffers: 9898 // ArrayBuffer 및 SharedArrayBuffer 메모리
}
*/
// 메모리 제한 설정 (예: 4GB로 설정)
// node --max-old-space-size=4096 app.js
2. 가비지 컬렉션 메커니즘
V8 엔진은 두 가지 주요 가비지 컬렉션 알고리즘을 사용합니다:
- Scavenge(Minor GC): 새로운 객체를 위한 빠른 메모리 수집
- Mark-Sweep & Mark-Compact(Major GC): 오래된 객체를 위한 전체 힙 스캔
// 가비지 컬렉션 예시
// 1. 객체 생성
let user = { name: "John", age: 30 };
// 2. 참조 제거 (가비지 컬렉션 대상이 됨)
user = null;
// 3. 강제 가비지 컬렉션 (주의: 실제로는 사용하지 않는 것이 좋음)
// global.gc(); // --expose-gc 플래그로 실행 시 사용 가능
3. 일반적인 메모리 누수 패턴과 해결 방법
3.1 전역 변수 누수
// 문제: 전역 변수에 대한 참조가 계속 유지됨
function leakyFunction() {
leakyData = []; // 'var', 'let', 'const' 키워드 없이 전역 변수로 선언
// 큰 데이터 추가
for (let i = 0; i < 10000; i++) {
leakyData.push({ index: i, data: new Array(10000).fill("x") });
}
}
// 해결: 적절한 변수 선언
function fixedFunction() {
const localData = []; // 지역 변수로 선언
for (let i = 0; i < 10000; i++) {
localData.push({ index: i, data: new Array(10000).fill("x") });
}
// 함수 종료 후 localData는 가비지 컬렉션의 대상이 됨
}
3.2 클로저로 인한 메모리 누수
// 문제: 클로저가 불필요한 참조를 유지
function createLargeObject() {
const largeData = new Array(10000).fill("x");
return function () {
// largeData의 일부만 사용함에도 전체 참조 유지
return largeData[0];
};
}
// 해결: 필요한 데이터만 캡처
function createOptimizedObject() {
const largeData = new Array(10000).fill("x");
const firstItem = largeData[0]; // 필요한 데이터만 추출
return function () {
return firstItem; // largeData 전체 대신 필요한 항목만 참조
};
}
3.3 이벤트 리스너 누수
// 문제: 등록된 이벤트 리스너가 제거되지 않음
class LeakyComponent {
constructor() {
this.data = new Array(10000).fill("x");
// 이벤트 리스너 등록
process.on("data", this.onData);
}
onData(data) {
// 데이터 처리 로직
console.log("Data received");
}
// 소멸자 부재 - 이벤트 리스너가 제거되지 않음
}
// 해결: 명시적으로 이벤트 리스너 제거
class FixedComponent {
constructor() {
this.data = new Array(10000).fill("x");
// this 바인딩을 유지하기 위해 바인딩된 함수 참조 저장
this.onDataBound = this.onData.bind(this);
process.on("data", this.onDataBound);
}
onData(data) {
console.log("Data received");
}
// 명시적인 정리 메서드
destroy() {
process.removeListener("data", this.onDataBound);
this.data = null; // 참조 해제
}
}
3.4 순환 참조
// 문제: 순환 참조 생성
let parent = {};
let child = {};
// 순환 참조 생성
parent.child = child;
child.parent = parent;
// parent나 child에 대한 외부 참조가 없어지면 가비지 컬렉션이 작동하지만,
// 순환 참조 자체는 메모리 누수를 일으키지 않음 (현대 GC 알고리즘)
// 해결: WeakMap 또는 WeakSet 사용 (필요한 경우)
const parentToChild = new WeakMap();
const childToParent = new WeakMap();
const parent = {};
const child = {};
parentToChild.set(parent, child);
childToParent.set(child, parent);
// parent 또는 child 객체에 대한 참조가 없어지면
// WeakMap의 해당 항목도 가비지 컬렉션의 대상이 됨
3.5 타이머와 콜백
// 문제: 계속 실행되는 타이머
function scheduleTask() {
const data = new Array(10000).fill("x");
// 타이머 참조가 저장되지 않고, 정리되지 않음
setInterval(() => {
console.log("Processing data:", data.length);
}, 1000);
}
// 해결: 타이머 참조 저장 및 필요 시 정리
function betterScheduleTask() {
const data = new Array(10000).fill("x");
// 타이머 참조 저장
const timerId = setInterval(() => {
console.log("Processing data:", data.length);
}, 1000);
// 정리 함수 반환
return function cleanup() {
clearInterval(timerId);
};
}
// 사용 예
const cleanup = betterScheduleTask();
// 나중에 정리
// cleanup();
4. 메모리 누수 감지 및 디버깅 도구
4.1 Node.js 내장 도구
// 현재 메모리 사용량 로깅
function logMemoryUsage() {
const memoryUsage = process.memoryUsage();
console.log(`Memory usage: ${JSON.stringify(memoryUsage, null, 2)}`);
}
// 주기적으로 메모리 사용량 확인
setInterval(logMemoryUsage, 5000);
// 힙 덤프 생성 (--inspect 플래그로 실행 시)
// 코드에서 힙 덤프 생성
const v8 = require("v8");
const fs = require("fs");
function saveHeapSnapshot() {
const snapshotStream = v8.getHeapSnapshot();
const fileName = `heap-${Date.now()}.heapsnapshot`;
const fileStream = fs.createWriteStream(fileName);
snapshotStream.pipe(fileStream);
}
4.2 외부 도구와 모듈
Chrome DevTools
node --inspect app.js
로 실행 후 Chrome의 devtools://inspect 사용
Clinic.js
- npm 패키지로, 메모리 사용량 시각화 및 분석 도구 제공
# 설치
npm install -g clinic
# 실행
clinic doctor -- node app.js
clinic heap -- node app.js
- Node.js 힙 프로파일러(heapdump)
// npm install heapdump
const heapdump = require("heapdump");
// 힙 덤프 생성
function createHeapDump() {
const filename = `heapdump-${Date.now()}.heapsnapshot`;
heapdump.writeSnapshot(filename, (err) => {
if (err) console.error("힙 덤프 생성 오류:", err);
else console.log(`힙 덤프가 ${filename}에 저장됨`);
});
}
// SIGUSR2 시그널을 받으면 힙 덤프 생성
process.on("SIGUSR2", createHeapDump);
console.log(`힙 덤프를 생성하려면: kill -USR2 ${process.pid}`);
5. 메모리 최적화 전략
5.1 스트리밍 API 사용
// 큰 파일 처리 - 잘못된 방법
const fs = require("fs");
function processLargeFileInefficient(filename) {
// 전체 파일을 메모리에 로드 (메모리 부족 위험)
const content = fs.readFileSync(filename, "utf8");
const lines = content.split("\n");
let count = 0;
for (const line of lines) {
if (line.includes("ERROR")) count++;
}
return count;
}
// 스트리밍으로 개선
const readline = require("readline");
function processLargeFileEfficient(filename) {
return new Promise((resolve) => {
const fileStream = fs.createReadStream(filename, { encoding: "utf8" });
const rl = readline.createInterface({ input: fileStream });
let count = 0;
rl.on("line", (line) => {
if (line.includes("ERROR")) count++;
});
rl.on("close", () => {
resolve(count);
});
});
}
5.2 객체 풀링(Object Pooling)
// 객체 풀 구현 예시
class ObjectPool {
constructor(objectFactory, size = 10) {
this.objectFactory = objectFactory;
this.pool = [];
// 풀 초기화
for (let i = 0; i < size; i++) {
this.pool.push(this.objectFactory());
}
}
// 객체 대여
acquire() {
if (this.pool.length > 0) {
return this.pool.pop();
}
// 풀이 비어있으면 새 객체 생성
return this.objectFactory();
}
// 객체 반환
release(obj) {
this.pool.push(obj);
}
}
// 사용 예시
const bufferPool = new ObjectPool(() => Buffer.alloc(1024 * 1024));
function processChunk() {
const buffer = bufferPool.acquire();
try {
// 버퍼 사용 로직
// ...
} finally {
// 처리 완료 후 버퍼 반환
bufferPool.release(buffer);
}
}
5.3 불변 객체 사용
// 불변 객체를 사용하여 메모리 효율성 높이기
const immutable = require("immutable");
// 불변 맵 생성
const map1 = immutable.Map({ a: 1, b: 2, c: 3 });
// 새 맵 생성 (내부적으로 구조 공유)
const map2 = map1.set("b", 50);
console.log(map1.get("b")); // 2
console.log(map2.get("b")); // 50
5.4 V8 힙 스냅샷 분석
// 힙 스냅샷 생성 후 분석하는 코드
const v8Profiler = require("v8-profiler-next");
const fs = require("fs");
// 스냅샷 생성
function takeHeapSnapshot() {
const snapshot = v8Profiler.takeSnapshot();
const fileName = `snapshot-${Date.now()}.heapsnapshot`;
snapshot
.export()
.pipe(fs.createWriteStream(fileName))
.on("finish", () => {
console.log(`스냅샷이 ${fileName}에 저장됨`);
snapshot.delete();
});
}
// API 요청에서 스냅샷 생성 트리거
app.get("/debug/heap-snapshot", (req, res) => {
takeHeapSnapshot();
res.send("힙 스냅샷 생성 중...");
});
요약
Node.js에서 메모리 관리를 위한 핵심 원칙:
메모리 누수 패턴 이해와 방지
- 전역 변수 최소화
- 불필요한 클로저 피하기
- 이벤트 리스너 제거 관리
- 타이머와 콜백 정리
메모리 사용량 모니터링
- process.memoryUsage() 주기적 확인
- 힙 스냅샷 생성 및 분석
- 외부 도구(Chrome DevTools, Clinic.js) 활용
메모리 최적화 전략
- 스트리밍 API 사용
- 객체 풀링 구현
- 불변 데이터 구조 활용
- 적절한 청소 패턴 적용
효과적인 메모리 관리는 Node.js 애플리케이션의 성능을 향상시키고 안정성을 높이는 데 매우 중요합니다. 메모리 누수를 방지하고 메모리 사용량을 최적화함으로써, 애플리케이션이 장시간 안정적으로 실행되도록 보장할 수 있습니다.