⚡ Electron7. 성능 & 메모리메모리 누수 & 프로파일링

메모리 누수 & 프로파일링

이 문서가 답하는 질문: “Electron 앱의 메모리가 왜 시간이 지날수록 부풀고, 어떻게 추적·고치는가?” 한 줄 답 (Pyramid Top): “누수의 99%는 닫힌 창의 webContents 참조를 main이 계속 잡고 있는 것 — Chrome DevTools Heap Snapshot이 답을 가르쳐준다.”


Why — 왜 존재하는가

브라우저는 탭이 닫히면 그 메모리도 사라진다. Electron은 그렇지 않다 — Main 프로세스가 살아 있는 한 거기 잡고 있는 객체는 GC되지 않는다. 그래서 Electron은 순수 JS의 메모리 모델 + Chromium의 메모리 모델이 합쳐진 두 배 어려운 문제를 만든다.

누수 원천빈도추적 도구
닫힌 BrowserWindow 참조가장 흔함Heap Snapshot
ipcMain.on 누적 (off 안 함)자주listener count 추적
timers (setInterval 미정리)자주Chrome DevTools
DOM 노드 detached흔함Heap Snapshot diff
Native 모듈 native heap어려움OS 도구 (Instruments, Visual Studio Diagnostic)

How — 어떻게 동작하는가

3 snapshot 비교: 시작 → 작업 → GC 후. 작업 → GC 후에 살아남는 객체가 누수 후보.


What — 구체 사양·수치·예시

Renderer 메모리 — Chrome DevTools

  1. 앱 실행 후 DevTools 열기 (view.webContents.openDevTools())
  2. Memory 탭 → Heap snapshot
  3. 작업 수행 (창 열고 닫기, 컴포넌트 마운트/언마운트)
  4. GC 버튼 클릭 (recycle 아이콘)
  5. 다시 snapshot → “Comparison” 뷰

예시 — 닫힌 창 참조 발견:

Class       | Distance | Shallow Size | Retained Size
HTMLDivElement (Detached) | 5 | 1200 | 124,000

“Detached” prefix가 핵심 — DOM에서 떨어진 노드가 JS 참조로 살아 있음. Retainer Path를 따라가면 누수 코드 발견.

Main 메모리 — process.memoryUsage()

setInterval(() => {
  const usage = process.memoryUsage();
  log.info({
    rss: (usage.rss / 1024 / 1024).toFixed(1) + 'MB',
    heapTotal: (usage.heapTotal / 1024 / 1024).toFixed(1) + 'MB',
    heapUsed: (usage.heapUsed / 1024 / 1024).toFixed(1) + 'MB',
    external: (usage.external / 1024 / 1024).toFixed(1) + 'MB',
  });
}, 60000);  // 1분마다

증가 추세가 단조 증가면 누수, 톱니파면 정상 GC.

Main 프로세스 inspect

# Electron app을 inspect 모드로
electron --inspect=9229 .
 
# Chrome → chrome://inspect → "Configure" → localhost:9229
# Heap snapshot 가능

흔한 누수 패턴 5가지

1. BrowserWindow 참조 누수

// ❌ 누수
const windows = [];
function createWindow() {
  const win = new BrowserWindow({...});
  windows.push(win);   // 닫혀도 배열에서 안 빠짐
  win.loadFile('index.html');
}
 
// ✅ 정상
const windows = new Set();
function createWindow() {
  const win = new BrowserWindow({...});
  windows.add(win);
  win.on('closed', () => windows.delete(win));
  win.loadFile('index.html');
}

2. ipcMain.on 누적

// ❌ 매 창 생성마다 새 리스너 추가됨 (off 안 됨)
function createWindow() {
  const win = new BrowserWindow();
  ipcMain.on('get-data', (e) => e.reply('data', getData()));
  // ...
}
 
// ✅ handle 사용 (자동으로 1개만 유지) 또는 명시적 off
function createWindow() {
  const win = new BrowserWindow();
  const handler = (e) => e.reply('data', getData());
  ipcMain.on('get-data', handler);
  win.on('closed', () => ipcMain.off('get-data', handler));
}
 
// 또는 ipcMain.handle (1번만 등록)
ipcMain.handle('get-data', () => getData());  // app 전역 1번

3. setInterval 정리 안 함

// ❌ 닫혀도 timer 살아 있음
class Window {
  open() {
    this.timer = setInterval(() => this.poll(), 1000);
  }
}
 
// ✅
class Window {
  open() {
    this.timer = setInterval(() => this.poll(), 1000);
    this.win.on('closed', () => clearInterval(this.timer));
  }
}

4. DOM 이벤트 리스너 (Renderer)

// ❌
function Component() {
  useEffect(() => {
    window.addEventListener('resize', onResize);
  }, []);
}
 
// ✅
function Component() {
  useEffect(() => {
    window.addEventListener('resize', onResize);
    return () => window.removeEventListener('resize', onResize);
  }, []);
}

5. Closures가 큰 객체 잡기

// ❌
function setup(bigBuffer) {
  button.onclick = () => console.log('click');
  // 'click' 핸들러가 bigBuffer를 closure로 참조 — GC 안 됨
}
 
// ✅
function setup(bigBuffer) {
  doStuff(bigBuffer);
  button.onclick = () => console.log('click');
  // bigBuffer를 사용하지 않으니 GC됨
}

Native heap (네이티브 모듈)

OS도구명령
macOSInstrumentsleaks <pid>, Instruments → Allocations
Linuxvalgrind / heaptrackheaptrack ./my-app
WindowsVisual Studio DiagnosticPerformance Profiler → Native

sharp, better-sqlite3 같은 모듈이 native heap을 잡으면 — V8 heap에는 안 보인다.

자동 누수 탐지 (개발용)

// 매 N분마다 RSS 비교
let baseline = null;
setInterval(() => {
  const rss = process.memoryUsage().rss;
  if (!baseline) baseline = rss;
  if (rss > baseline * 1.5) {
    log.warn(`Memory grew 50% from baseline: ${baseline} → ${rss}`);
    // heapdump 자동 저장
    require('heapdump').writeSnapshot();
  }
}, 10 * 60 * 1000);

What-if — 잘못 쓰면

  • 함정 1: Snapshot 안 찍고 추측 → 시간 낭비. 항상 측정부터.
  • 함정 2: Renderer 누수만 보고 Main 무시 → Main 누수가 훨씬 위험. Main은 절대 재시작 안 됨.
  • 함정 3: process.exit()로 누수 회피 → 사용자 데이터 손실 위험.
  • 함정 4: Production에서 --inspect 활성 → 외부에서 디버거 접근 가능. Fuses로 비활성.
  • 함정 5: heapdump 파일 거대 (수 GB) → 디스크 가득. 임시 디렉토리에 저장 + 자동 삭제.

Insight — 흥미로운 이야기

“VSCode의 Extension Host 분리는 누수 격리 전략이다”

Microsoft는 VS Code 초기에 익스텐션이 Renderer에서 직접 동작하게 만들었다. 결과는 나쁜 익스텐션 하나가 에디터 전체 메모리를 잡아먹음. 해결책은 Extension Host라는 별도 Node.js 프로세스에 익스텐션을 격리한 것 — 익스텐션이 누수해도 그 프로세스만 죽이면 에디터는 살아남는다.

같은 패턴이 Slack의 Worker Thread 분리, Discord의 별도 GPU 프로세스 강제에 보인다. Electron 메모리 관리의 핵심 통찰은 — 완벽한 누수 방지는 불가능하니, 누수가 일어나도 격리해서 죽일 수 있게 설계하라는 것. v22+의 UtilityProcess는 이 패턴을 Electron API 차원에서 지원하는 일반화다.

좋은 Electron 앱의 시그니처는 24시간 켜두고 RSS 그래프를 봤을 때 톱니파인 것. 단조 증가면 — 어딘가에 누수가 있다.


요약

  • Renderer 누수 = DevTools Memory 탭.
  • Main 누수 = process.memoryUsage() 모니터링 + --inspect.
  • 99% 누수는 5가지 패턴 — closed 창 참조·이벤트 리스너·timer·DOM·closure.
  • 누수 방지보다 격리가 최종 전략 — UtilityProcess.