06 — 프로세스 관계 & crash 격리
한 줄 답: Electron의 프로세스 트리는 Main을 루트로 한 부모-자식 관계다 — Main이 죽으면 자식이 모두 죽지만, 자식 하나가 죽어도 Main과 다른 자식은 살아남는다. 이게 crash 격리이고,
render-process-gone같은 이벤트로 부모가 자식의 죽음을 받아 복구할 수 있다.
Why — 왜 관계를 알아야 하는가
세 가지 실용 이유.
- 죽음의 전파를 예측해야 한다. “Renderer 하나가 죽으면 다른 창은? Main은? UtilityProcess는?” — 이 답을 모르면 부분 장애가 전체 장애가 된다.
onclose처리를 어디에 둘지 결정해야 한다. Main이 자식의 죽음을 받는다 — 그래서 로그도, 재시작 정책도 Main에 있다.- 앱 종료 시 데이터 손실을 막아야 한다. 사용자가
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— 비정상 종료killed—SIGKILL받음 (외부에서 죽임)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-quit은 macOS 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이 반드시 알게 되어 있다.
이 디자인의 함의가 둘.
- Main은 영원해야 한다. Main이 죽으면 입양할 부모가 없다 → 모두 같이 죽음.
- 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 — 창과 라이프사이클에서는,BrowserWindow가 OS 창 + webContents + Renderer 프로세스를 어떻게 한 묶음으로 추상화하는지 본다.