05 — Utility & GPU 프로세스
한 줄 답: 무거운 Node 작업을 Main에 두면 UI가 freeze한다 — UtilityProcess(v22+)로 분리하면 별도 OS 프로세스에서 안전하게 돌릴 수 있다. GPU 프로세스·zygote는 Chromium이 자동으로 관리하는 보조 프로세스로, 개발자가 직접 다룰 일은 적지만 메모리 차지자로서 인식해야 한다.
Why — 왜 Main이 모든 일을 하면 안 되는가
Main 프로세스는 앱의 운영체제다 (02). OS가 모든 사용자 프로세스를 직접 실행하지 않듯이 — Main도 모든 무거운 일을 직접 하면 안 된다.
// main.js — 안티패턴
ipcMain.handle('compress', async (event, filePath) => {
// 1GB zip 압축
return await syncCompress(filePath) // ← Main 이벤트 루프 점유
})이 핸들러가 도는 동안 — 모든 BrowserWindow가 멈춘다. 메뉴 클릭도 무반응, 다이얼로그도 안 뜸, 다른 창의 IPC 요청도 큐에 쌓임. 사용자가 보기엔 앱 전체가 죽은 것.
해법은 셋.
worker_threads— Node 표준. 같은 프로세스 안의 스레드. 가볍지만 OS 격리 없음.UtilityProcess— Electron 전용. 별도 OS 프로세스. v22+ 정식.child_process.fork— Node 표준. 별도 프로세스. 단 Electron 환경 변수 누락 문제가 있어 비추천.
이 중 Electron 권장은 UtilityProcess다.
How — UtilityProcess 사용법
// main.js
const { app, utilityProcess, MessageChannelMain } = require('electron')
const path = require('node:path')
app.whenReady().then(() => {
// 자식 프로세스 spawn
const child = utilityProcess.fork(
path.join(__dirname, 'workers/compressor.js'),
[], // argv
{
// 옵션
serviceName: 'compressor',
stdio: 'pipe',
env: { ...process.env, LOG_LEVEL: 'debug' },
},
)
// IPC — MessageChannel 패턴
const { port1, port2 } = new MessageChannelMain()
child.postMessage({ message: 'init' }, [port2])
port1.on('message', (event) => {
console.log('from child:', event.data)
})
port1.start()
// 자식이 죽었을 때
child.on('exit', (code) => {
console.log('compressor exited', code)
})
})// workers/compressor.js — 별도 OS 프로세스에서 도는 Node 스크립트
const fs = require('node:fs/promises')
const zlib = require('node:zlib')
process.parentPort.once('message', (event) => {
const [port] = event.ports
port.on('message', async ({ data }) => {
const { type, payload } = data
if (type === 'compress') {
const buf = await fs.readFile(payload.input)
const gz = await new Promise((res, rej) =>
zlib.gzip(buf, (err, out) => err ? rej(err) : res(out))
)
await fs.writeFile(payload.output, gz)
port.postMessage({ type: 'done', bytes: gz.length })
}
})
port.start()
})핵심 차이.
| 비교 | worker_threads | UtilityProcess | child_process.fork |
|---|---|---|---|
| OS 프로세스 | 같음 (스레드) | 별도 | 별도 |
| crash 격리 | 같이 죽음 | Main 살아 있음 | Main 살아 있음 |
| 시작 비용 | ~10ms | ~50ms | ~100ms |
| 메모리 (베이스) | ~10MB | ~50MB | ~50MB |
| Electron 환경 변수 | 자동 | 자동 | 수동 (조심) |
| 통신 | postMessage | MessageChannelMain | IPC channel |
| 권장 | CPU-bound 짧은 작업 | Node-bound 긴 작업 | 비추천 |
언제 UtilityProcess? Node 모듈이 필요하고 crash가 일어날 수도 있는 작업 — PDF 렌더, 이미지 트랜스코드, 큰 파일 압축, 외부 binary 호출(
spawn).
GPU 프로세스 — 자동 운영되는 그림자
ps로 Electron 앱을 보면 항상 GPU Helper 한 개가 있다.
Electron Helper (GPU).app/Contents/MacOS/Electron Helper (GPU)이건 Chromium이 알아서 띄운다. 개발자가 직접 다룰 일은 거의 없다. 책임:
- WebGL / WebGPU 렌더링
- CSS 트랜스폼·애니메이션 합성 (compositing)
- 비디오 디코드 가속
없으면 — Chromium이 fallback으로 Renderer 안에서 CPU 합성을 한다. 더 느리고 더 무거움.
GPU 가속을 끄고 싶을 때.
// main.js — app.ready 전에 호출
app.disableHardwareAcceleration()이걸 끄는 경우:
- 가상머신에서 GPU가 불안정.
- 화면 캡처가 검정 화면으로 나오는 (특정 OS·GPU 조합 버그).
- 임베디드 디스플레이.
일반 데스크톱에서는 절대 끄지 마라. 끄면 UI 애니메이션이 즉시 끊긴다.
Zygote 프로세스 — Linux 전용 템플릿
Linux에서 ps를 찍으면 zygote라는 이름이 보인다.
electron --type=zygote
electron --type=zygote --no-zygote-sandbox이건 Chromium의 fork 최적화다. 새 Renderer를 띄울 때마다 전체 Chromium을 처음부터 초기화하는 비용을 피하려고, 템플릿 프로세스를 미리 fork-ready 상태로 둔다. 새 Renderer가 필요하면 zygote를 fork() 해서 만든다.
- macOS/Windows: zygote 없음 (각 OS의 spawn 모델이 다름).
- Linux: zygote 1~2개.
개발자가 직접 다룰 일은 없다. 프로세스 개수가 1~2개 더 보인다는 것만 알면 됨.
What — 전체 프로세스 트리
ps aux | grep -i myapp 했을 때 보이는 행 수는 대략:
1 (Main)
+ N (Renderer · 창마다 1개)
+ 1 (GPU)
+ M (UtilityProcess · 워커마다 1개)
+ 1~2 (zygote, Linux만)
+ extras (Plugin host 등)
= 보통 5~12개What-if — 무거운 작업을 어디에 두느냐의 시나리오
| 작업 | 잘못된 위치 | 권장 위치 | 이유 |
|---|---|---|---|
| 1GB 파일 압축 | Main | UtilityProcess | 이벤트 루프 점유 막음 |
| JSON 파싱 (10MB) | Renderer | Renderer (충분히 빠름) | IPC 직렬화가 더 비쌈 |
| OCR (외부 binary spawn) | Main | UtilityProcess | crash 가능성 격리 |
| 이미지 thumbnail 100장 생성 | Main 동기 루프 | UtilityProcess + worker pool | 병렬 활용 |
| WebGL 셰이더 컴파일 | (해당 없음) | GPU 프로세스가 알아서 | 자동 |
| SQLite 쓰기 | 어디든 가능 | Main (단일 라이터) | 락 충돌 방지 |
| AI 모델 추론 (CPU) | Renderer | UtilityProcess | DOM 막힘 방지 + 격리 |
| WebAssembly 실행 | Renderer | Renderer | 같은 isolate에 두는 게 빠름 |
법칙: CPU-bound면서 결과를 다른 Renderer/창과 공유해야 하면 → UtilityProcess. 그 Renderer만 쓰는 결과면 → Renderer 안의 Web Worker.
Web Worker vs UtilityProcess — 헷갈리는 둘
같은 *“백그라운드 실행”*이라도 다르다.
[Renderer 프로세스]
└─ 메인 월드 (React)
└─ isolated world (preload)
└─ Web Worker N개 ← Renderer 안의 OS 스레드[OS]
└─ Main 프로세스
└─ Renderer 프로세스 #1, #2, ...
└─ UtilityProcess #1, #2, ... ← 완전히 별도 프로세스| 축 | Web Worker | UtilityProcess |
|---|---|---|
| 위치 | Renderer 안 | OS 별도 프로세스 |
| API 접근 | DOM 없음, Web API 일부 | Node 전부 + Electron 일부 |
| 사용처 | CPU-bound JS (이미지 필터, 정렬) | Node 의존 작업 (fs, child_process) |
| crash 영향 | Renderer 같이 죽음 | Main 영향 없음 |
| 생성 비용 | ~ms | ~50ms |
혼동 포인트: “Renderer에서 무거운 일이면 무조건 Web Worker”가 틀린 케이스 — Node 모듈이 필요하면 Web Worker는 못 쓴다 (Web Worker는 sandbox 안). UtilityProcess로 분리해서 결과만 IPC로 받아야 한다.
비용 — 프로세스 한 개의 값
| 프로세스 | 베이스 메모리 (대략) | 시작 시간 (대략) |
|---|---|---|
| Main | 80~150MB | 200ms (Chromium 초기화 포함) |
| Renderer (빈 페이지) | 50~80MB | 100ms |
| Renderer (React 앱) | 100~200MB | 200~400ms |
| GPU Helper | 80~150MB | (Main과 동시) |
| UtilityProcess | 40~60MB | 50ms |
| Web Worker | 5~10MB | 10ms |
빈 Electron 앱 하나가 300MB+ 인 건 Main + Renderer + GPU 셋의 합이다. UtilityProcess를 추가할 때마다 50MB씩 더해진다. 과도하게 분리하면 메모리 폭증이라는 반대 함정도 있음 (07 — 성능 & 메모리).
Insight — 프로세스는 비용이지만 보험이다
프로세스를 분리하는 건 비용이다 (메모리, 직렬화). 하지만 동시에 보험이다 (crash 격리, 권한 분리, OS 자원 분할).
이 트레이드오프의 균형점이 시간이 지나며 분리 쪽으로 이동해왔다.
- 2013년 Electron 0.x — Renderer 하나에 모두 몰아넣음 (
nodeIntegration: true). - 2018년 v4 — contextIsolation 등장.
- 2022년 v22 — UtilityProcess 정식. Node를 Main 바깥으로 분리하는 게 권장됨.
- 2024+ — VS Code 같은 대형 앱은 Extension Host, Shared Process, Pty Host 등 여러 UtilityProcess를 가진다.
즉, Electron의 발전 방향은 프로세스를 더 잘게 쪼개는 쪽이다. 그게 보안과 안정성의 답이 되었기 때문 — Spectre 이후로 프로세스 격리만이 진짜 격리라는 게 합의가 됐다. 그래서 Main을 가볍게 유지하는 게 현대 Electron 아키텍처의 핵심 가치가 됐다.
요약
Main에 무거운 Node 작업을 두면 모든 창이 freeze한다 — UtilityProcess(v22+)로 분리해 별도 OS 프로세스에서 돌려라. 통신은
MessageChannelMain으로. GPU 프로세스·zygote는 Chromium이 자동 관리하는 보조 프로세스 — 직접 다룰 일은 없지만 메모리 차지자임을 인지하라. Web Worker는 Renderer 안의 스레드, UtilityProcess는 OS 별도 프로세스 — Node API가 필요하면 UtilityProcess가 정답이다. Electron 발전 방향은 프로세스를 더 잘게 쪼개는 쪽이다. Main은 오케스트레이션만, 무거운 일은 자식 프로세스로.