⚡ Electron1. 프로세스 모델05 — Utility & GPU 프로세스

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 요청도 큐에 쌓임. 사용자가 보기엔 앱 전체가 죽은 것.

해법은 셋.

  1. worker_threads — Node 표준. 같은 프로세스 안의 스레드. 가볍지만 OS 격리 없음.
  2. UtilityProcess — Electron 전용. 별도 OS 프로세스. v22+ 정식.
  3. 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_threadsUtilityProcesschild_process.fork
OS 프로세스같음 (스레드)별도별도
crash 격리같이 죽음Main 살아 있음Main 살아 있음
시작 비용~10ms~50ms~100ms
메모리 (베이스)~10MB~50MB~50MB
Electron 환경 변수자동자동수동 (조심)
통신postMessageMessageChannelMainIPC 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 파일 압축MainUtilityProcess이벤트 루프 점유 막음
JSON 파싱 (10MB)RendererRenderer (충분히 빠름)IPC 직렬화가 더 비쌈
OCR (외부 binary spawn)MainUtilityProcesscrash 가능성 격리
이미지 thumbnail 100장 생성Main 동기 루프UtilityProcess + worker pool병렬 활용
WebGL 셰이더 컴파일(해당 없음)GPU 프로세스가 알아서자동
SQLite 쓰기어디든 가능Main (단일 라이터)락 충돌 방지
AI 모델 추론 (CPU)RendererUtilityProcessDOM 막힘 방지 + 격리
WebAssembly 실행RendererRenderer같은 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 WorkerUtilityProcess
위치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로 받아야 한다.


비용 — 프로세스 한 개의

프로세스베이스 메모리 (대략)시작 시간 (대략)
Main80~150MB200ms (Chromium 초기화 포함)
Renderer (빈 페이지)50~80MB100ms
Renderer (React 앱)100~200MB200~400ms
GPU Helper80~150MB(Main과 동시)
UtilityProcess40~60MB50ms
Web Worker5~10MB10ms

빈 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은 오케스트레이션만, 무거운 일은 자식 프로세스로.