Node.js 인터뷰 질문 57
질문: Node.js 애플리케이션에서 메모리 누수를 탐지하고 해결하는 방법은 무엇인가요?
답변:
메모리 누수(Memory Leak)는 애플리케이션이 더 이상 필요하지 않은 메모리를 해제하지 못해 시간이 지남에 따라 메모리 사용량이 계속 증가하는 현상입니다. Node.js는 JavaScript를 기반으로 하며 가비지 컬렉션을 통해 메모리를 자동으로 관리하지만, 부적절한 코딩 패턴으로 인해 메모리 누수가 발생할 수 있습니다. 이러한 메모리 누수를 탐지하고 해결하는 방법에 대해 알아보겠습니다.
1. 메모리 누수의 일반적인 원인
Node.js 애플리케이션에서 메모리 누수가 발생하는 주요 원인은 다음과 같습니다:
1.1 글로벌 변수 및 클로저
// 글로벌 배열에 계속 데이터 추가
let leakyData = [];
function addData(data) {
leakyData.push(data); // 계속 증가하고 비워지지 않음
}
// 서버 요청마다 호출됨
app.get("/data", (req, res) => {
addData(req.body);
res.send("Data added");
});
1.2 이벤트 리스너 미제거
function setupListener(emitter) {
// 이벤트 리스너 등록
emitter.on("data", function processData(data) {
console.log(data);
});
// 해당 리스너를 제거하지 않고 함수 종료
}
1.3 타이머 및 인터벌 미정리
function startDataPolling() {
// 10초마다 데이터 폴링
setInterval(() => {
fetchData();
}, 10000);
// 인터벌이 정리되지 않음
}
1.4 순환 참조
function createCircularReference() {
let objA = {};
let objB = {};
// 서로를 참조하는 객체 생성
objA.reference = objB;
objB.reference = objA;
return objA;
}
2. 메모리 사용량 모니터링 도구
2.1 Node.js 내장 메모리 사용량 확인
// 현재 메모리 사용량 로깅
function logMemoryUsage() {
const memoryUsage = process.memoryUsage();
console.log({
rss: `${Math.round(memoryUsage.rss / 1024 / 1024)} MB`,
heapTotal: `${Math.round(memoryUsage.heapTotal / 1024 / 1024)} MB`,
heapUsed: `${Math.round(memoryUsage.heapUsed / 1024 / 1024)} MB`,
external: `${Math.round(memoryUsage.external / 1024 / 1024)} MB`,
});
}
// 정기적으로 메모리 사용량 로깅
const interval = setInterval(logMemoryUsage, 5000);
// 애플리케이션 종료 시 정리
process.on("SIGINT", () => {
clearInterval(interval);
process.exit(0);
});
2.2 외부 모니터링 도구
clinic.js: Node.js 애플리케이션의 성능 문제를 진단하는 도구
# 설치 npm install -g clinic # 메모리 사용량 프로파일링 clinic doctor -- node app.js
node-memwatch: 메모리 누수를 탐지하기 위한 라이브러리
const memwatch = require("memwatch-next"); // 의심스러운 메모리 누수 이벤트 감지 memwatch.on("leak", (info) => { console.log("Memory leak detected:", info); }); // 가비지 컬렉션 후 힙 사용 증가량 측정 memwatch.on("stats", (stats) => { console.log("GC stats:", stats); });
heapdump: 힙 스냅샷을 생성하여 메모리 사용 패턴 분석
const heapdump = require("heapdump"); // 특정 요청에서 힙 스냅샷 생성 app.get("/heapdump", (req, res) => { const filename = `/tmp/heapdump-${Date.now()}.heapsnapshot`; heapdump.writeSnapshot(filename, (err) => { if (err) console.error(err); console.log(`Heap snapshot written to ${filename}`); res.send(`Heap snapshot created: ${filename}`); }); });
3. Chrome DevTools를 사용한 메모리 프로파일링
Node.js 애플리케이션을 --inspect
플래그로 실행하여 Chrome DevTools에서 메모리 프로파일링을 할 수 있습니다.
# 인스펙터 활성화
node --inspect app.js
Chrome DevTools에서 메모리 프로파일링 방법:
- Chrome 브라우저에서
chrome://inspect
접속 - 실행 중인 Node.js 애플리케이션 선택하여 DevTools 열기
- Memory 탭에서 "Take heap snapshot" 선택
- 메모리 누수 의심 지점 전후로 힙 스냅샷을 여러 개 생성
- 스냅샷 비교를 통해 메모리 증가 패턴 분석
4. 메모리 누수 해결 전략
4.1 글로벌 변수 및 클로저 관리
// 수정 전: 계속 자라는 글로벌 배열
let leakyData = [];
function addData(data) {
leakyData.push(data);
}
// 수정 후: 크기 제한 설정
const MAX_ITEMS = 100;
let boundedData = [];
function addDataWithLimit(data) {
boundedData.push(data);
// 최대 크기 유지
if (boundedData.length > MAX_ITEMS) {
boundedData.shift(); // 가장 오래된 항목 제거
}
}
4.2 이벤트 리스너 정리
// 수정 전: 리스너가 제거되지 않음
function setupListener(emitter) {
emitter.on("data", function processData(data) {
console.log(data);
});
}
// 수정 후: 리스너 참조를 유지하고 적절히 제거
function setupListenerProperly(emitter) {
function processData(data) {
console.log(data);
}
// 리스너 등록
emitter.on("data", processData);
// 리스너 제거 함수 반환
return function cleanupListener() {
emitter.removeListener("data", processData);
};
}
// 사용 예
const cleanup = setupListenerProperly(eventEmitter);
// 필요 없을 때 정리
cleanup();
4.3 타이머 및 인터벌 정리
// 수정 전: 인터벌이 정리되지 않음
function startDataPolling() {
setInterval(() => {
fetchData();
}, 10000);
}
// 수정 후: 인터벌 참조 유지 및 정리 함수 제공
function startDataPollingProperly() {
const intervalId = setInterval(() => {
fetchData();
}, 10000);
// 정리 함수 반환
return function stopPolling() {
clearInterval(intervalId);
};
}
// 사용 예
const stopPolling = startDataPollingProperly();
// 필요 없을 때 정리
stopPolling();
4.4 약한 참조(WeakMap, WeakSet) 사용
// 수정 전: 강한 참조로 인한 메모리 누수 가능성
const cache = new Map();
function storeData(key, value) {
cache.set(key, value);
}
// 수정 후: 약한 참조를 사용하여 가비지 컬렉션 허용
const weakCache = new WeakMap();
function storeDataWeakly(keyObject, value) {
weakCache.set(keyObject, value);
}
4.5 스트림 및 버퍼 관리
// 대용량 파일 처리 시 메모리 효율적인 방법
const fs = require("fs");
// 수정 전: 전체 파일을 메모리에 로드
function processLargeFile(path) {
const content = fs.readFileSync(path, "utf8");
// 큰 파일 처리 - 메모리에 전체 로드됨
processContent(content);
}
// 수정 후: 스트림을 사용하여 메모리 효율적으로 처리
function processLargeFileStream(path) {
const stream = fs.createReadStream(path, { encoding: "utf8" });
stream.on("data", (chunk) => {
// 한 번에 일부만 처리
processChunk(chunk);
});
stream.on("end", () => {
console.log("파일 처리 완료");
});
stream.on("error", (err) => {
console.error("파일 처리 오류:", err);
});
}
5. Express 앱에서 메모리 누수 방지
5.1 캐시 크기 제한
// 제한된 크기의 LRU 캐시 사용
const LRU = require("lru-cache");
// 최대 항목 수와 TTL(Time To Live) 설정
const cache = new LRU({
max: 500, // 최대 항목 수
maxAge: 1000 * 60 * 60, // 항목 유효 시간: 1시간
});
app.get("/api/data/:id", (req, res) => {
const id = req.params.id;
// 캐시에서 데이터 확인
if (cache.has(id)) {
return res.json(cache.get(id));
}
// 데이터 조회 및 캐시에 저장
fetchData(id)
.then((data) => {
cache.set(id, data);
res.json(data);
})
.catch((err) => {
res.status(500).json({ error: err.message });
});
});
5.2 요청 객체에 대용량 데이터 저장 방지
// 수정 전: 요청 객체에 큰 데이터 저장
app.use((req, res, next) => {
req.bigData = loadLargeDataSet(); // 메모리 누수 가능성
next();
});
// 수정 후: 필요한 최소한의 데이터만 저장
app.use((req, res, next) => {
// 저장할 데이터 결정 로직
const minimalData = extractNecessaryData(loadLargeDataSet());
req.essentialData = minimalData;
next();
});
5.3 데이터베이스 연결 관리
// 몽고DB 연결 풀 사용 예시
const mongoose = require("mongoose");
mongoose.connect("mongodb://localhost/myapp", {
useNewUrlParser: true,
useUnifiedTopology: true,
// 연결 풀 크기 설정
poolSize: 10,
// 연결 타임아웃 설정
connectTimeoutMS: 5000,
// 미사용 연결 제거 설정
socketTimeoutMS: 45000,
});
// 연결 오류 처리
mongoose.connection.on("error", (err) => {
console.error("MongoDB 연결 오류:", err);
});
// 애플리케이션 종료 시 연결 정리
process.on("SIGINT", () => {
mongoose.connection.close(() => {
console.log("MongoDB 연결 종료");
process.exit(0);
});
});
6. 실제 상황에서의 메모리 누수 사례 및 해결책
6.1 클로저로 인한 메모리 누수
// 문제: 클로저가 큰 데이터를 캡처하여 메모리 누수 발생
function createLeakyFunction() {
// 큰 데이터 배열 (수 MB 크기)
const largeData = new Array(1000000).fill("*");
return function leakyFunction() {
// largeData의 일부만 사용하지만 전체가 메모리에 유지됨
console.log(largeData[0]);
};
}
const fn = createLeakyFunction();
fn(); // 전체 largeData 배열이 메모리에 남음
// 해결: 필요한 데이터만 캡처
function createEfficientFunction() {
// 큰 데이터 배열
const largeData = new Array(1000000).fill("*");
// 필요한 데이터만 새 변수에 저장
const firstItem = largeData[0];
return function efficientFunction() {
// 전체 배열 대신 필요한 항목만 사용
console.log(firstItem);
};
}
const efficientFn = createEfficientFunction();
efficientFn(); // largeData는 가비지 컬렉션에 의해 제거될 수 있음
6.2 Promise 체인 누수
// 문제: 처리되지 않은 프로미스로 인한 메모리 누수
function leakyPromiseChain() {
let count = 0;
// 메모리 누수: 이 인터벌은 정리되지 않음
setInterval(() => {
// 새 프로미스 생성
new Promise((resolve, reject) => {
count++;
console.log(`프로미스 생성 #${count}`);
if (Math.random() > 0.5) {
resolve("성공");
} else {
reject("실패"); // 잠재적 에러 발생
}
}); // .catch()가 없어 거부된 프로미스가 메모리에 남음
}, 100);
}
// 해결: 프로미스 체인 완료 및 오류 처리
function fixedPromiseChain() {
let count = 0;
const intervalId = setInterval(() => {
// 프로미스 체인 완성
new Promise((resolve, reject) => {
count++;
console.log(`프로미스 생성 #${count}`);
if (Math.random() > 0.5) {
resolve("성공");
} else {
reject("실패");
}
})
.then((result) => {
console.log(`프로미스 성공: ${result}`);
})
.catch((err) => {
console.log(`프로미스 실패: ${err}`);
});
// 적절한 시점에 인터벌 정리
if (count >= 100) {
clearInterval(intervalId);
}
}, 100);
// 정리 함수 반환
return function cleanup() {
clearInterval(intervalId);
};
}
const cleanup = fixedPromiseChain();
// 나중에 필요 시 정리
// cleanup();
6.3 파일 업로드 핸들링 개선
// 문제: 대용량 파일을 메모리에 완전히 로드
app.post("/upload", (req, res) => {
let fileData = Buffer.alloc(0);
req.on("data", (chunk) => {
// 모든 청크를 메모리에 누적 (메모리 누수 위험)
fileData = Buffer.concat([fileData, chunk]);
});
req.on("end", () => {
// 대용량 파일 전체가 메모리에 로드됨
processFile(fileData);
res.send("업로드 완료");
});
});
// 해결: 스트림을 사용한 파일 처리
const fs = require("fs");
const path = require("path");
app.post("/upload", (req, res) => {
const filePath = path.join(__dirname, "uploads", `file-${Date.now()}.dat`);
const writeStream = fs.createWriteStream(filePath);
// 청크를 직접 디스크에 쓰기
req.pipe(writeStream);
writeStream.on("finish", () => {
// 파일이 디스크에 저장된 후 처리
processFileFromDisk(filePath);
res.send("업로드 완료");
});
writeStream.on("error", (err) => {
console.error("파일 쓰기 오류:", err);
res.status(500).send("업로드 실패");
});
});
7. 프로덕션 환경에서의 메모리 누수 관리
7.1 자동 재시작 및 헬스 체크
// pm2 생태계 파일 예시 (ecosystem.config.js)
module.exports = {
apps: [
{
name: "my-app",
script: "app.js",
instances: "max",
exec_mode: "cluster",
watch: false,
max_memory_restart: "500M", // 메모리 사용량이 500MB 초과 시 재시작
env: {
NODE_ENV: "production",
},
// 상태 확인을 위한 URL
exp_backoff_restart_delay: 100,
// 10번 이상 충돌 시 재시작 중지
max_restarts: 10,
},
],
};
7.2 주기적인 힙 덤프 생성 및 분석
const heapdump = require("heapdump");
const fs = require("fs");
const path = require("path");
// 주기적인 힙 덤프를 생성하는 함수
function setupHeapDumpSchedule(intervalMinutes = 60) {
// 힙 덤프 저장 디렉토리 생성
const dumpDir = path.join(__dirname, "heapdumps");
if (!fs.existsSync(dumpDir)) {
fs.mkdirSync(dumpDir);
}
// 오래된 덤프 정리 함수
function cleanupOldDumps() {
const files = fs.readdirSync(dumpDir);
// 최근 5개만 유지
if (files.length > 5) {
// 파일을 생성일 순으로 정렬
const sortedFiles = files
.map((file) => ({
name: file,
time: fs.statSync(path.join(dumpDir, file)).mtime.getTime(),
}))
.sort((a, b) => a.time - b.time);
// 가장 오래된 것부터 삭제
for (let i = 0; i < sortedFiles.length - 5; i++) {
fs.unlinkSync(path.join(dumpDir, sortedFiles[i].name));
console.log(`오래된 힙 덤프 삭제: ${sortedFiles[i].name}`);
}
}
}
// 정기적으로 힙 덤프 생성
return setInterval(() => {
const filename = path.join(dumpDir, `heapdump-${Date.now()}.heapsnapshot`);
console.log(`힙 덤프 생성 중...`);
heapdump.writeSnapshot(filename, (err) => {
if (err) {
console.error("힙 덤프 생성 오류:", err);
} else {
console.log(`힙 덤프 생성 완료: ${filename}`);
cleanupOldDumps();
}
});
}, intervalMinutes * 60 * 1000);
}
// 애플리케이션 시작 시 스케줄 설정
const heapDumpInterval = setupHeapDumpSchedule(120); // 2시간마다 덤프 생성
// 애플리케이션 종료 시 정리
process.on("SIGINT", () => {
clearInterval(heapDumpInterval);
console.log("힙 덤프 스케줄 정리됨");
process.exit(0);
});
요약
Node.js 애플리케이션에서 메모리 누수를 탐지하고 해결하기 위한 주요 전략:
- 모니터링 도구 활용:
process.memoryUsage()
, Chrome DevTools, clinic.js 등을 사용하여 메모리 사용량 추적 - 힙 스냅샷 분석: heapdump와 같은 도구로 힙 스냅샷을 생성하고 분석하여 메모리 누수 패턴 식별
- 이벤트 리스너 관리: 등록된 이벤트 리스너를 적절히 제거하고 참조 유지
- 타이머 및 인터벌 정리: 더 이상 필요하지 않은 타이머와 인터벌을 clearTimeout, clearInterval로 정리
- 클로저 최적화: 필요한 데이터만 클로저에 캡처하고 불필요한 참조 제거
- 약한 참조 사용: 적절한 경우 WeakMap과 WeakSet을 사용하여 가비지 컬렉션 허용
- 스트림 활용: 대용량 데이터 처리 시 메모리에 전체를 로드하지 않고 스트림으로 처리
- 캐시 크기 제한: LRU 캐시와 같은 전략으로 캐시 크기를 제한하고 오래된 항목 제거
- 프로덕션 환경 대비: 자동 재시작 정책 설정, 주기적인 메모리 모니터링 시스템 구축
메모리 누수는 Node.js 애플리케이션의 안정성과 성능에 중대한 영향을 미치므로, 주기적인 모니터링과 최적화된 코딩 패턴을 적용하는 것이 중요합니다. 특히 장기간 실행되는 서버 애플리케이션에서는 사전에 메모리 누수를 방지하고, 발생 시 신속하게 탐지하여 해결하는 방안을 마련해야 합니다.