Electron 앱의 메모리 누수 디버깅 — Chrome DevTools 실전 절차
앱을 8시간 켜놓으면 메모리가 512MB에서 2GB로 증가했다. 사용자 보고가 들어왔다. "앱이 점점 느려져요."
이것이 우리 팀이 경험한 최악의 메모리 누수였다. 2주간 디버깅을 한 끝에 찾은 원인은 정말 황당했다. IPC listener를 제거하지 않고 있었던 것이다. 이 경험에서 배운 실전 디버깅 절차를 공유한다.
문제: 점진적인 메모리 증가
Electron 앱의 메모리 누수는 두 가지 특징이 있다:
1. Renderer 프로세스의 메모리는 작지만, Main 프로세스가 터진다
2. 한 번의 급증이 아니라 계속해서 조금씩 증가한다
우리 앱은 음성 채팅 앱이었다. 사용자가 2시간 통화하면 메모리가 1GB 증가했다. 8시간 근무 시간 내내 켜져있으면 메모리 부족으로 OS가 앱을 죽였다.
Step 1: Main 프로세스 vs Renderer 프로세스 구분
먼저 어디서 누수가 발생하는지 파악해야 한다.
Main 프로세스의 메모리를 체크:
// main.ts
const { app } = require('electron');
const checkMemory = () => {
const mainProcMem = process.memoryUsage();
console.log('Main process memory:', {
heapUsed: Math.round(mainProcMem.heapUsed / 1024 / 1024) + 'MB',
heapTotal: Math.round(mainProcMem.heapTotal / 1024 / 1024) + 'MB',
rss: Math.round(mainProcMem.rss / 1024 / 1024) + 'MB',
});
};
// 1분마다 로그
setInterval(checkMemory, 60000);
Renderer 프로세스의 메모리를 체크:
// preload.ts (혹은 renderer에서)
const { ipcRenderer } = require('electron');
setInterval(() => {
const mem = performance.memory;
ipcRenderer.send('renderer-memory', {
usedJSHeapSize: Math.round(mem.usedJSHeapSize / 1024 / 1024),
totalJSHeapSize: Math.round(mem.totalJSHeapSize / 1024 / 1024),
});
}, 60000);
// main.ts에서 수신
ipcMain.on('renderer-memory', (event, memory) => {
console.log('Renderer memory:', memory);
});
우리의 경우, Main 프로세스가 100MB → 1.2GB로 증가했다. Renderer는 안정적이었다. 따라서 Main 프로세스에 문제가 있었다.
Step 2: Chrome DevTools로 Main 프로세스 분석
Electron은 Main 프로세스도 DevTools로 디버깅할 수 있다.
// main.ts
const { app } = require('electron');
const isDev = require('electron-is-dev');
if (isDev) {
app.on('ready', () => {
const mainWindow = createWindow();
// Main 프로세스 DevTools 열기
const { default: installExtension, REACT_DEVELOPER_TOOLS } = require('electron-devtools-installer');
// 또는 더 간단하게
require('electron-debug')({ showDevTools: false });
});
}
더 직접적인 방법:
// main.ts에서 자체 DevTools 실행
const { BrowserWindow, app } = require('electron');
app.on('ready', () => {
// Main 프로세스를 위한 DevTools window
const inspectWindow = new BrowserWindow({
webPreferences: {
nodeIntegration: true,
},
});
inspectWindow.webContents.openDevTools();
});
실제로는 이 명령어를 쓰자:
NODE_ENV=development npx electron . --remote-debugging-port=9223
그 다음 브라우저에서 chrome://inspect로 접속하면 Main 프로세스가 보인다.
Step 3: Heap Snapshot 비교 분석
DevTools를 열었다면, Memory 탭에서 Heap Snapshot을 찍는다:
1. 앱을 시작한 직후 Snapshot 1 찍기
2. 특정 작업 반복 (우리의 경우 음성 통화 시작/종료)
3. Snapshot 2 찍기
4. 다시 같은 작업 반복
5. Snapshot 3 찍기
이 세 개의 스냅샷을 비교하면 누수 원인이 보인다.
우리의 경우:
Snapshot 1 (시작): 45 MB
Snapshot 2 (통화 1회): 120 MB
Snapshot 3 (통화 2회): 200 MB
통화 1회당 약 80MB가 증가했다. Snapshot 2 vs Snapshot 3의 차이를 비교했다.
DevTools의 "Comparison" 탭을 선택하면, 두 스냅샷 간의 차이를 볼 수 있다. 커지는 객체를 확인하자.
Step 4: 원인 찾기 — 우리의 실제 사례
스냅샷 비교에서 보인 객체:
EventEmitterListeners (×50 increase)
AudioStreamObject (×100 increase)
IpcMainInternalHandle (×25 increase)
처음에는 AudioStreamObject를 의심했다. 하지만 코드를 보니 제대로 cleanup되고 있었다.
문제는 IpcMainInternalHandle이었다. 이건 뭔가?
// main.ts - 통화 시작
ipcMain.handle('audio:start-call', async (event, callId) => {
const stream = await getAudioStream();
// ...
});
// main.ts - 통화 종료
ipcMain.handle('audio:end-call', async (event, callId) => {
// 여기서 cleanup은 했지만...
// handle을 제거하지 않았다!
});
통화가 반복될 때마다 같은 이름의 handler를 계속 등록했다. Electron은 새로운 handler를 등록하되, 이전 handler는 제거하지 않는다.
수정 방법:
// main.ts
let currentCallId = null;
ipcMain.handle('audio:start-call', async (event, callId) => {
// 이전 call 정리
if (currentCallId) {
ipcMain.removeHandler(`audio:end-call:${currentCallId}`);
}
currentCallId = callId;
const stream = await getAudioStream();
// ...
});
또는 더 깔끔하게:
// main.ts
const audioHandlers = new Map();
ipcMain.handle('audio:start-call', async (event, callId) => {
// 이전 핸들러 정리
if (audioHandlers.has(callId)) {
ipcMain.removeHandler(audioHandlers.get(callId));
}
const handlerName = `audio:end-call:${callId}`;
ipcMain.handle(handlerName, async () => {
// cleanup
});
audioHandlers.set(callId, handlerName);
});
이 수정 후 메모리 증가가 중단되었다.
Step 5: BrowserWindow 정리
또 다른 일반적인 메모리 누수 패턴:
// ❌ 나쁨: window가 가비지 수집되지 않음
let mainWindow;
app.on('ready', () => {
mainWindow = new BrowserWindow();
});
mainWindow.on('closed', () => {
// mainWindow를 null로 안 만듦
});
올바른 방법:
// ✅ 좋음
let mainWindow = null;
app.on('ready', () => {
mainWindow = new BrowserWindow({
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
},
});
mainWindow.on('closed', () => {
mainWindow = null; // 명시적으로 null 할당
});
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
Step 6: Listener 정리 체크리스트
메모리 누수의 거의 대부분은 listener를 제거하지 않았기 때문이다:
// ❌ 정리 안 함
window.webContents.on('did-finish-load', () => {
console.log('loaded');
});
// ✅ 정리함
const handler = () => {
console.log('loaded');
};
window.webContents.on('did-finish-load', handler);
// cleanup 시
window.webContents.off('did-finish-load', handler);
우리가 만든 정리 함수:
// utils/cleanup.ts
export function cleanupWindow(window: BrowserWindow) {
// IPC handlers 정리
const handlers = ['audio:start', 'audio:stop', 'video:capture'];
handlers.forEach(h => ipcMain.removeHandler(h));
// Window listeners 정리
window.removeAllListeners();
// WebContents listeners 정리
window.webContents.removeAllListeners();
// Renderer process cleanup
window.webContents.send('cleanup');
}
// main.ts에서
mainWindow.on('closed', () => {
cleanupWindow(mainWindow);
mainWindow = null;
});
Step 7: React Renderer 프로세스에서의 정리
Renderer 프로세스도 listener를 정리해야 한다:
// React component
const MyComponent = () => {
useEffect(() => {
const handler = (event, data) => {
// 처리
};
ipcRenderer.on('some-event', handler);
return () => {
// cleanup!
ipcRenderer.off('some-event', handler);
};
}, []);
};
모니터링: 지속적인 메모리 체크
수정 후에도 메모리 누수가 다시 생기지 않는지 확인하자:
// 프로덕션에서도 메모리를 로깅
const logMemory = () => {
const mem = process.memoryUsage();
const logData = {
timestamp: new Date().toISOString(),
heapUsed: Math.round(mem.heapUsed / 1024 / 1024),
heapTotal: Math.round(mem.heapTotal / 1024 / 1024),
external: Math.round(mem.external / 1024 / 1024),
};
// 서버로 보내거나 로컬 파일에 저장
writeLog(logData);
};
setInterval(logMemory, 300000); // 5분마다
결론: 메모리 누수 체크리스트
□ Main 프로세스에서 메모리 계속 증가하는가?
□ Heap snapshot으로 어디가 증가하는가 파악했는가?
□ IPC handler가 제대로 정리되는가?
□ BrowserWindow 종료 시 null 할당했는가?
□ Event listener를 모두 제거했는가?
□ 프로덕션에서도 메모리 모니터링을 하는가?
이 절차를 따르면 메모리 누수의 95%를 잡을 수 있다. 우리 팀의 음성 채팅 앱은 이제 24시간 켜져도 메모리가 안정적이다.