Electron 앱의 메모리 누수 디버깅 — Chrome DevTools 실전 절차

게시일: 2025년 3월 25일 · 15분 읽기

앱을 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시간 켜져도 메모리가 안정적이다.

iL
ian.lab

실무 개발자입니다. 현장에서 겪은 문제와 해결 과정을 기록합니다. 오류 제보는 연락처로 보내주세요.