01 · Why IPC — 프로세스 격리의 대가

이 문서가 답하는 질문: 같은 앱 안의 코드끼리 왜 메시지 패싱으로만 대화해야 하나? 웹의 postMessage로는 안 되는가? 한 줄 답: “Renderer는 권한이 낮은 프로세스다. Main(권한 높음)에게 일을 부탁하려면 ‘함수 호출’이 아니라 ‘권한 위임 요청’이어야 한다 — 그래서 IPC가 별도 메커니즘으로 존재한다.”


Why — 왜 함수 호출이 아니라 메시지인가

프로세스가 다르면 메모리도 다르다

Electron의 첫 챕터(01-process-model)에서 본 것처럼, MainRenderer완전히 다른 OS 프로세스다. OS 프로세스가 다르다는 것은 — 같은 변수에 접근할 수 없다. process.exit을 renderer에서 호출해도 renderer 프로세스만 종료될 뿐 main은 멀쩡하다.

┌─────────────────────┐       ┌─────────────────────┐
│  Main Process       │       │  Renderer Process   │
│  (Node + Electron)  │       │  (Chromium + V8)    │
│                     │       │                     │
│  - fs, child_proc   │   ?   │  - DOM, fetch       │
│  - BrowserWindow    │ <───> │  - React app        │
│  - app.quit         │       │  - 권한: 거의 없음   │
└─────────────────────┘       └─────────────────────┘
       PID 1234                       PID 5678
       메모리 공간 A                   메모리 공간 B

두 메모리 공간 사이를 잇는 공식 통로가 IPC다. Chromium은 이 통로를 Mojo라는 자체 IPC 프레임워크로 구현했고, Electron은 그 위에 ipcMain/ipcRenderer라는 얇은 JS 래퍼를 얹었다.

권한이 다르면 통로가 게이트가 된다

여기서 핵심 — 두 프로세스는 권한도 다르다.

프로세스권한
MainNode 전부 (fs, child_process, os) + Electron OS API (shell, Tray, Menu)
Renderer브라우저 표준 Web API만 (fetch, DOM, localStorage) — nodeIntegration: false일 때
PreloadRenderer와 같은 컨텍스트 ID지만 Node 접근 가능유일한 다리

이 격차가 의도된 설계다. Renderer는 임의의 웹 콘텐츠를 띄울 수 있으므로 — XSS·악성 광고·CSRF의 위험을 그대로 안고 있다. 그래서 권한을 빼앗는 것이 디폴트가 됐다.

IPC는 단순히 데이터를 옮기는 도구가 아니라 권한 등급을 넘는 게이트다. 그래서 ipcMain.handle('fs:read', ...)을 짤 때는 언제든 악성 입력이 올 수 있다는 전제로 짠다 — 정확히 서버 API를 짤 때처럼.


How — 웹 postMessage와의 결정적 차이

웹에도 메시지 패싱이 있다 — window.postMessage, Worker.postMessage, BroadcastChannel. 그런데 왜 Electron은 새 메커니즘을 만들었나?

비교표 — 같은 “메시지 패싱”이지만 차원이 다르다

비교축window.postMessageWorker.postMessageElectron IPC (ipc*)
상대방같은 origin 또는 cross-origin iframeWeb Worker 스레드다른 OS 프로세스
권한 차이없음 (둘 다 같은 sandbox)없음 (worker도 web context) (Main이 OS 전권)
메모리같은 process 안같은 process 안 (스레드만 분리)완전 분리
직렬화structured clonestructured clone + transferablestructured clone (v9+)
동기 변형없음Atomics.waitsendSync (있지만 비추)
채널 이름origin + 메시지 내용단일 채널임의 문자열 채널
보안 모델same-origin policysandbox권한 게이트 — 메인이 입력 검증
표준W3C HTMLW3C HTMLElectron 전용 API
실패 모드메시지 무시스레드 죽음Main이 죽으면 앱 전체 종료

핵심 차이를 한 줄로

postMessage같은 권한끼리 데이터를 옮긴다. Electron IPC는 다른 권한 사이를 잇는다 — 그래서 메시지마다 권한 체크가 필요하다.

Mermaid — 세 종류 메시지 패싱


What — Electron에서 IPC가 필요한 정확한 자리

자리 1 — Renderer가 OS 자원을 원할 때

// ❌ Renderer (nodeIntegration: false) — 안 됨
const data = fs.readFileSync('/etc/passwd'); // ReferenceError: fs is not defined
// ✅ Renderer → IPC → Main이 대신 읽음
// renderer.ts
const data = await window.api.readUserFile('config.json');
 
// preload.ts
contextBridge.exposeInMainWorld('api', {
  readUserFile: (name: string) => ipcRenderer.invoke('user:read', name),
});
 
// main.ts
ipcMain.handle('user:read', async (_, name: string) => {
  // 권한 체크 — userData 폴더 안만 허용
  const safe = path.join(app.getPath('userData'), path.basename(name));
  return fs.promises.readFile(safe, 'utf8');
});

여기서 Main에서의 권한 체크가 핵심이다. Renderer가 '../../../../etc/passwd'를 보내도 — path.basename이 그것을 파일 이름만 추출해 막는다.

자리 2 — Main이 Renderer에게 알릴 때

// main.ts
mainWindow.webContents.send('app:update-available', { version: '2.0.0' });
 
// preload.ts
contextBridge.exposeInMainWorld('updates', {
  onAvailable: (cb) => ipcRenderer.on('app:update-available', (_, info) => cb(info)),
});
 
// renderer.ts
window.updates.onAvailable((info) => showBanner(info.version));

자리 3 — Renderer가 다른 Renderer와 대화

직접은 못 한다. Main을 중계로 써야 한다.

// renderer A
window.api.notifyOther('hello');
 
// main이 받아서 다른 webContents에 전달
ipcMain.handle('notify', (_, msg) => {
  rendererBWindow.webContents.send('msg-from-a', msg);
});

(예외: MessagePortMain을 쓰면 Main을 거치지 않고 두 renderer가 직통할 수 있다. → 05 문서)


What-if — IPC가 없었다면

가상 실험: 만약 Electron이 IPC 없이 공유 메모리 방식으로 두 프로세스를 이었다면?

가정귀결
Renderer가 Main 메모리에 직접 접근Renderer = Main 권한 → XSS = RCE
공유 메모리에 mutex로 동기화OS 차원의 stable한 IPC 표준이 없음 → cross-platform 깨짐
Chromium의 멀티프로세스 모델을 거스름Chromium 보안 모델 전체가 무용지물

→ 그래서 메시지 패싱이 유일한 합리적 선택이었다. Chromium이 이미 Mojo로 그 길을 깔아놨고, Electron은 그것을 JS 친화적으로 노출했을 뿐이다.

또 다른 What-if — nodeIntegration: true로 IPC를 건너뛰면?

이게 바로 Electron 초기(v1~v4 시절)의 디폴트였다. Renderer에서 require('fs')가 그대로 됐다. 편리했지만 — 모든 Electron CVE의 절반 이상이 이 설계 결함에서 나왔다.

// 2017년경 흔했던 코드
<webview src="https://untrusted.com" nodeIntegration></webview>
// 외부 사이트의 XSS 한 줄이 require('child_process').exec(...)로 직결

2018년 Discord, Signal, VS Code가 모두 이 패턴 관련 CVE를 맞았다. v5부터 contextIsolation권장되었고, v12부터 디폴트가 되었다. v14에서는 remote 모듈도 제거되었다. 이 역사가 곧 왜 IPC가 별도 메커니즘이어야 하는가의 증명이다.


Insight — 한 단락 이야기

“Mojo는 Chromium의 ‘단일 통화’다 — Electron은 그 통화를 JS로 환전했을 뿐”

20142015년 Chromium 팀은 Site Isolation을 본격적으로 도입하면서 Mojo라는 IPC 프레임워크를 만들었다. 그 전에는 Chrome IPC라는 자체 메시지 포맷이 있었지만, 프로세스 수가 폭증하면서 (탭마다, iframe마다 별도 프로세스가 되면서) 표준화된 IPC가 시급했다. Electron은 이 Mojo 위에 얇은 JS 래퍼를 얹은 것에 가깝다. ipcMain.send 한 줄이 내부적으로는 Mojo message → IPC channel → Renderer Mojo handler의 45단계를 거친다. 그래서 성능적으로는 WebWorker보다 살짝 느리다 (직렬화·역직렬화 비용). 흥미로운 반전은 — Chromium이 Site Isolation보안 때문에 만들었는데, Electron은 그 보안 메커니즘을 그대로 물려받았다는 점이다. 즉 웹의 보안 진화가 곧 Electron의 보안 진화다. 같은 통화를 쓰는 두 경제권처럼.


요약 + Mermaid

핵심 키
왜 별도 메커니즘인가프로세스가 다르고 권한이 다르다 — 그 게이트가 필요
웹 postMessage와 차이같은 권한 vs 다른 권한
직렬화structured clone (v9+) — 함수·DOM·Symbol 못 보냄
동기/비동기invoke(비동기·권장) vs sendSync(UI freeze — 금기)
우회로가 있는가없다contextIsolation: false사실상 폐기

한 줄 결론 — IPC는 함수 호출의 비동기 버전이 아니라 권한 게이트다. 다음 문서(02)는 그 게이트의 정확한 APIipcMainipcRenderer의 모든 메서드를 본다.