⚡ Electron1. 프로세스 모델06 — 프로세스 관계 & crash 격리

06 — 프로세스 관계 & crash 격리

한 줄 답: Electron의 프로세스 트리는 Main을 루트로 한 부모-자식 관계다 — Main이 죽으면 자식이 모두 죽지만, 자식 하나가 죽어도 Main과 다른 자식은 살아남는다. 이게 crash 격리이고, render-process-gone 같은 이벤트로 부모가 자식의 죽음을 받아 복구할 수 있다.


Why — 왜 관계를 알아야 하는가

세 가지 실용 이유.

  1. 죽음의 전파를 예측해야 한다. “Renderer 하나가 죽으면 다른 창은? Main은? UtilityProcess는?” — 이 답을 모르면 부분 장애전체 장애가 된다.
  2. onclose 처리를 어디에 둘지 결정해야 한다. Main이 자식의 죽음을 받는다 — 그래서 로그도, 재시작 정책도 Main에 있다.
  3. 앱 종료 시 데이터 손실을 막아야 한다. 사용자가 Cmd+Q를 누른 그 0.5초 안에 모든 자식이 깨끗하게 정리되어야 한다. 종료 순서를 모르면 데이터 유실이 일어난다.

How — 프로세스 트리의 구조

각 프로세스의 부모-자식 관계는 OS 차원의 진짜 관계다. macOS의 Activity Monitor, Linux의 pstree, Windows의 작업 관리자에서 트리로 확인 가능하다.

# Linux/macOS
$ pstree -p $(pgrep -f "MyApp" | head -1)
MyApp(1000)
├─MyApp Helper(1003)            # GPU
├─MyApp Helper Renderer(1001)
├─MyApp Helper Renderer(1002)
└─MyApp Helper Utility(1004)
   └─git(1005)

crash 격리 — 자식의 죽음은 부모에게 알려진다

OS 차원의 부모-자식 관계는 — 자식이 종료되면 부모가 SIGCHLD를 받는다 (Unix). Electron은 이걸 자바스크립트 이벤트로 번역해준다.

render-process-gone (v9+)

// main.js
app.on('render-process-gone', (event, webContents, details) => {
  console.log('Renderer crashed:', {
    url: webContents.getURL(),
    reason: details.reason,        // 'crashed' | 'killed' | 'oom' | ...
    exitCode: details.exitCode,
  })
 
  // 복구 시도
  if (details.reason === 'crashed' || details.reason === 'oom') {
    const win = BrowserWindow.fromWebContents(webContents)
    if (win && !win.isDestroyed()) {
      // 사용자에게 알리고 새로고침
      dialog.showMessageBox(win, {
        type: 'error',
        message: '페이지가 응답을 멈췄습니다',
        buttons: ['새로고침', '닫기'],
      }).then(({ response }) => {
        if (response === 0) win.webContents.reload()
        else win.close()
      })
    }
  }
})

details.reason이 가질 수 있는 값:

  • clean-exit — 정상 종료 (e.g. window.close())
  • abnormal-exit — 비정상 종료
  • killedSIGKILL 받음 (외부에서 죽임)
  • crashed — 코드 실행 중 segfault 등
  • oom — Out Of Memory (V8 heap 초과 가능)
  • launch-failed — 시작 자체 실패

프로덕션 앱의 가장 흔한 사고 패턴: 사용자가 흰 화면을 본다 → Renderer가 죽었는데 Main이 모름 → 사용자가 앱 전체를 강제 종료. 이 이벤트 처리 하나가 체감 안정성을 크게 바꾼다.

child-process-gone (v22+) — UtilityProcess 포함

app.on('child-process-gone', (event, details) => {
  console.log('Child process gone:', details)
  // details.type: 'GPU' | 'Utility' | 'Zygote' | ...
  // details.reason: 'crashed' | 'killed' | ...
  // details.serviceName: UtilityProcess의 serviceName (있으면)
 
  if (details.type === 'Utility' && details.serviceName === 'compressor') {
    // 다시 띄우기
    spawnCompressor()
  }
})

GPU 프로세스의 죽음도 여기로 온다. GPU가 죽으면 자동으로 재시작되는 게 디폴트지만, 반복되면 하드웨어 가속 비활성화 등의 폴백을 선택할 수 있다.


종료 순서 — Main이 죽으면

Main이 app.quit()을 호출하면:

1. app.emit('before-quit')
2. 모든 BrowserWindow에 'close' 이벤트 발화
   ↓ (preventDefault 안 하면)
3. webContents 종료 → Renderer 프로세스 종료
4. Main → UtilityProcess.kill() 자동 호출 (정리)
5. GPU Helper 종료
6. app.emit('will-quit')
7. Main 프로세스 종료
8. (남아 있는 자식이 있다면) OS가 SIGTERM/SIGKILL

여기서 데이터 손실 위험은 4번. UtilityProcess가 디스크 쓰기 중이면 중간에 잘릴 수 있다. 안전하게 하려면:

// main.js
let pendingShutdown = false
 
app.on('before-quit', async (event) => {
  if (pendingShutdown) return  // 두 번째 호출은 통과
  event.preventDefault()
  pendingShutdown = true
 
  // 자식들에게 "이제 마무리해" 알림
  for (const child of utilityChildren) {
    child.postMessage({ message: 'shutdown' })
  }
 
  // 모든 자식이 깨끗이 종료할 때까지 기다림 (타임아웃 포함)
  await Promise.race([
    Promise.all(utilityChildren.map(c =>
      new Promise(res => c.once('exit', res))
    )),
    new Promise(res => setTimeout(res, 5000)),
  ])
 
  app.quit()
})
// workers/compressor.js
process.parentPort.on('message', async ({ data }) => {
  if (data.message === 'shutdown') {
    await flushBuffersToDisk()
    process.exit(0)
  }
})

TIP: before-quitmacOS Cmd+Q, Windows Alt+F4, 시스템 재시작 모두에서 호출된다. 하지만 시스템 재시작몇 초 안에 강제 종료이므로, 비동기 작업이 5초 이상이면 결국 잘릴 수 있다. 그래서 주기적 flush가 데이터 안전의 1차 방어선이다.


What — 한 페이지 관계 지도

누가 죽었나결과복구
Renderer 하나그 창만 흰 화면webContents.reload()
UtilityProcess그 워커가 처리 중인 작업 잃음다시 spawn
GPU Helper화면 합성 일시 멈춤 → Chromium 자동 재시작자동 (반복되면 가속 off 권장)
Main앱 자체 종료OS 차원에서 재시작 (auto-update의 squirrel 등)

What-if — crash 핸들러 없이 운영하면

증상진짜 원인사용자 체감
창이 흰 화면Renderer가 OOM으로 죽음”앱이 멈췄어요” → 강제 종료
시간이 지나면 UI가 끊김GPU 자동 재시작이 반복됨”느려요”
파일 저장이 사라짐UtilityProcess가 도중에 죽음”내 작업이 날아갔어요”
Cmd+Q 누르면 종료 안 됨before-quit에서 무한 대기”강제 종료해야 꺼져요”

대부분의 Electron 앱 첫 출시가 이 함정에 빠진다. crash 핸들러를 나중에 붙이려고 미뤘다가, 실제 사용자가 사고를 보고 그제야 알게 된다. 이건 처음부터 박아두는 게 정답이다.


실전 — 견고한 Main 골격

// main.js — production-grade skeleton
const { app, BrowserWindow, dialog } = require('electron')
const log = require('electron-log')
 
// 1. 단일 인스턴스 — [02 Main]에서 다룸
const lock = app.requestSingleInstanceLock()
if (!lock) { app.quit(); return }
 
// 2. 자식 프로세스 추적
const children = new Set()  // UtilityProcess set
 
function spawnWorker(name, script) {
  const child = utilityProcess.fork(script, [], { serviceName: name })
  children.add(child)
  child.once('exit', () => children.delete(child))
  return child
}
 
// 3. Renderer crash 복구
app.on('render-process-gone', (event, webContents, details) => {
  log.error('renderer gone', details)
  if (details.reason === 'oom' || details.reason === 'crashed') {
    const win = BrowserWindow.fromWebContents(webContents)
    if (win && !win.isDestroyed()) win.webContents.reload()
  }
})
 
// 4. Utility crash 자동 재시작
app.on('child-process-gone', (event, details) => {
  log.error('child gone', details)
  if (details.type === 'Utility' && details.serviceName === 'compressor') {
    setTimeout(() => spawnWorker('compressor', './workers/compressor.js'), 1000)
  }
})
 
// 5. 깨끗한 종료
let shuttingDown = false
app.on('before-quit', async (event) => {
  if (shuttingDown) return
  event.preventDefault()
  shuttingDown = true
 
  for (const c of children) c.postMessage({ message: 'shutdown' })
  await Promise.race([
    Promise.all([...children].map(c => new Promise(res => c.once('exit', res)))),
    new Promise(res => setTimeout(res, 5000)),
  ])
  app.quit()
})
 
// 6. 예측 못 한 예외도 로그로 — 죽지는 않음
process.on('uncaughtException', (err) => {
  log.error('main uncaught', err)
})

이 골격이 프로덕션 앱의 최소 안정선이다. 셋(crash 핸들러 둘 + 깨끗한 종료)이 빠지면 데이터 손실 사고시간 문제가 된다.


Insight — 프로세스 트리는 시스템의 신뢰 경계

Unix의 프로세스 트리는 단순히 부모-자식 관계가 아니라 책임 위계다. 자식이 죽으면 부모가 알게 되고, 부모는 복구하거나 보고할 의무가 있다. init(pid 1)이 모든 고아 프로세스를 입양하는 패턴은 이 책임 위계의 출발점이다.

Electron의 Main은 앱 내부의 init이다. 모든 Renderer·UtilityProcess가 Main의 자식이고, 누가 죽든 Main이 반드시 알게 되어 있다.

이 디자인의 함의가 둘.

  1. Main은 영원해야 한다. Main이 죽으면 입양할 부모가 없다 → 모두 같이 죽음.
  2. Main의 코드는 최대한 단순해야 한다. 복잡하면 Main 자신이 버그로 죽을 위험. 무거운 로직은 자식으로 밀어내야 Main이 안전해진다.

그래서 Main은 라우터가 되어야 한다. 자식의 죽음을 듣고, 자식을 다시 띄우고, 자식 간 메시지를 중개하고, OS 이벤트를 자식에 전파한다. 자기는 최대한 일을 하지 않는다. 이게 Main을 작은 init으로 다루는 사고방식이다.


요약

Electron 프로세스 트리는 Main을 루트로 한 OS 차원의 부모-자식 관계다. 자식이 죽으면 Main이 알게 되고(render-process-gone, child-process-gone), 복구 정책을 결정할 수 있다. 종료 순서는 Main이 자식을 정리한 뒤 자기 종료. before-quit에서 비동기 정리가 필요하면 preventDefault() 패턴을 쓴다. 프로덕션 Main의 최소 안정선은 — 단일 인스턴스 + Renderer crash 복구 + Utility 자동 재시작 + 깨끗한 종료. 이 넷이 시간 문제로 발생하는 사고를 막는다. 다음 02 — 창과 라이프사이클에서는, BrowserWindowOS 창 + webContents + Renderer 프로세스를 어떻게 한 묶음으로 추상화하는지 본다.