⚡ Electron3. IPC & Bridge05-messageport-and-streaming

05 · MessagePort & 스트리밍

이 문서가 답하는 질문: invoke/handle로 안 되는 양방향 스트림·대용량 Buffer·renderer 간 직통은 어떻게 푸나? v22+ Utility Process와의 통신은? 한 줄 답: MessagePortMain은 양 프로세스에 한쪽씩 들고 있는 양방향 파이프다 — Main이 ‘port 쌍’을 만들어 한쪽씩 나눠준 뒤로는 직통 통신이 가능하다.”


Why — invoke/handle부족한 자리

시나리오invoke/handle이 부족한 이유
양방향 스트림 (실시간 채팅, log tailing)매 메시지마다 요청-응답 1회씩이라 비효율 + 순서 어려움
대용량 Buffer (수십 MB 파일)structured clone이 전체 복사 — 메모리 2배 + 전송 지연
Renderer ↔ Renderer 직통sendTo 제거됨 — main 중계는 2 hop
Utility Process ↔ Renderermain을 거치지 않는 직통이 성능 명확
Worker ↔ MainWeb Worker의 postMessagerenderer 내부

이 자리들에 Mojo의 양방향 파이프MessagePort가 직접 노출된다.


How — MessagePortMain 기본 패턴

작동 원리

  1. Mainnew MessageChannelMain()을 만든다 — { port1, port2 } 두 끝이 나온다.
  2. Main이 한쪽을 renderer에게 보낸다 (webContents.postMessage).
  3. Main이 다른 한쪽을 직접 들고 있거나 Utility Process에 전달.
  4. 이후 두 끝은 직통 양방향 통신.

코드 — Main이 두 renderer를 연결

// main.ts
import { app, BrowserWindow, MessageChannelMain } from 'electron';
 
const winA = new BrowserWindow({ webPreferences: { preload: 'preload.js' } });
const winB = new BrowserWindow({ webPreferences: { preload: 'preload.js' } });
 
await Promise.all([winA.loadURL(...), winB.loadURL(...)]);
 
const { port1, port2 } = new MessageChannelMain();
winA.webContents.postMessage('peer-port', null, [port1]);
winB.webContents.postMessage('peer-port', null, [port2]);

Preload — port 받아서 contextBridge로 노출

// preload.ts
import { contextBridge, ipcRenderer } from 'electron';
 
let peerPort: MessagePort | null = null;
const listeners: ((msg: any) => void)[] = [];
 
ipcRenderer.on('peer-port', (event) => {
  peerPort = event.ports[0];
  peerPort.onmessage = (e) => listeners.forEach((l) => l(e.data));
  peerPort.start();
});
 
contextBridge.exposeInMainWorld('peer', {
  send: (msg: unknown) => peerPort?.postMessage(msg),
  onMessage: (cb: (msg: unknown) => void) => {
    listeners.push(cb);
    return () => {
      const i = listeners.indexOf(cb);
      if (i >= 0) listeners.splice(i, 1);
    };
  },
});
// renderer.ts (양쪽 동일 코드)
window.peer.onMessage((msg) => console.log('from peer:', msg));
window.peer.send({ greeting: 'hi from the other side' });

결과 — Main을 거치지 않는 직통

위 흐름에서 main은 port 쌍 발급 한 번만 한다. 이후 모든 메시지는 두 renderer 사이를 직통한다. 트래픽 폭증·실시간 동기화 시나리오에 적합.


What — Transferable Objects (대용량 전송)

structured clone은 복사다. 수십 MB 버퍼를 매번 복사하면 메모리·CPU 폭발. 답이 transferable 객체.

transferable이란

ArrayBuffer, MessagePort, ImageBitmap, OffscreenCanvas 등은 소유권을 옮길 수 있다. 이전 쪽에서는 사용 불가 상태가 되지만 — 복사 비용 0.

const buffer = new ArrayBuffer(50_000_000); // 50MB
 
// ❌ 복사 — buffer가 *두 곳에* 50MB씩
port.postMessage({ data: buffer });
 
// ✅ 전송 — buffer가 *한 곳에만* (소유권 이전)
port.postMessage({ data: buffer }, [buffer]);
// 이 시점 이후 sender에서 buffer는 detached — 길이 0

Electron에서 transferable 쓰는 패턴

// main.ts — 큰 파일을 renderer에 보낼 때
const data = await fs.promises.readFile(largeFile); // Buffer
const ab = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
const { port1, port2 } = new MessageChannelMain();
mainWindow.webContents.postMessage('chunk', null, [port1]);
port2.postMessage({ kind: 'file', name: largeFile, data: ab }, [ab]);
// preload.ts
ipcRenderer.on('chunk', (event) => {
  const port = event.ports[0];
  port.onmessage = (e) => {
    const { name, data } = e.data;
    // data는 transferred된 ArrayBuffer
    saveOrRender(name, new Uint8Array(data));
  };
  port.start();
});

적용 가능 시나리오

  • 이미지·동영상 thumbnail 처리
  • 오디오 PCM 데이터
  • 거대한 JSON 파싱 결과 (한번에 ArrayBuffer로 모은 뒤 전송)
  • WebAssembly 모듈 binary

What — Utility Process와 IPC (v22+)

v22(2022)에 추가된 Utility ProcessNode 권한을 가진 별도 child 프로세스다. main이 무거운 작업(파일 압축, 이미지 변환, 백그라운드 fetch)을 떠넘기는 자리.

기본 사용

// main.ts
import { utilityProcess, MessageChannelMain } from 'electron';
 
const worker = utilityProcess.fork(path.join(__dirname, 'worker.js'));
 
worker.on('spawn', () => {
  // 메시지 보내기
  worker.postMessage({ task: 'compress', file: '/path/to/big.json' });
});
 
worker.on('message', (msg) => {
  console.log('worker said:', msg);
});
 
worker.on('exit', (code) => {
  console.log('worker exited with', code);
});
// worker.js (utility process 안)
process.parentPort.on('message', (e) => {
  const { task, file } = e.data;
  // ... 무거운 작업
  process.parentPort.postMessage({ done: true, size: 1234 });
});

MessagePort로 worker ↔ renderer 직통

// main.ts
const worker = utilityProcess.fork('./worker.js');
const { port1, port2 } = new MessageChannelMain();
 
worker.postMessage({ init: true }, [port1]);
mainWindow.webContents.postMessage('worker-port', null, [port2]);
// worker.js
let appPort = null;
process.parentPort.on('message', (e) => {
  if (e.data.init) {
    appPort = e.ports[0];
    appPort.on('message', handleFromRenderer);
    appPort.start();
  }
});

이렇게 하면 — renderer가 worker에게 main을 거치지 않고 직접 메시지를 던진다. Main은 연결 brokering만 하고 빠진다.

왜 Worker 대신 Utility Process?

비교축Web Worker (renderer 안)Utility Process
스레드같은 프로세스의 별도 스레드별도 OS 프로세스
Node 권한없음 (Web API만)있음 (fs, child_process)
죽었을 때renderer는 살아남음worker만 죽음
메모리 누수 영향renderer 전체에 영향worker 프로세스만 — 격리
CPU 격리같은 CPU 코어 가능OS scheduler가 별도 할당

Node API가 필요한 백그라운드 작업은 Utility Process. 순수 계산은 Web Worker가 더 가벼움.


What — postMessage vs send/invoke 정리

시나리오권장
Renderer가 main에게 1회성 요청 + 응답 받기invoke/handle
Main이 renderer에 알림 (1회성, 작은 데이터)webContents.send + ipcRenderer.on
양방향 스트림 (계속 주고받기)MessagePort 한 쌍
대용량 Buffer 전송MessagePort + transferable
Renderer ↔ Renderer 직통MessagePort (main이 broker)
Main ↔ Utility ProcessutilityProcess.postMessage
Renderer ↔ Utility ProcessMessagePort (main이 broker)
매우 빈번한 작은 메시지MessagePort (자체 채널이라 다른 IPC와 안 섞임)

What-if — MessagePort를 잘못 쓰면

함정 1 — port.start() 호출 누락

ipcRenderer.on('port', (event) => {
  const port = event.ports[0];
  port.onmessage = (e) => console.log(e.data);
  // ❌ port.start() 누락 — 메시지가 *전혀 안 들어옴*
});

onmessage 설정만으로는 부족하다. port.start()명시적으로 활성화해야 한다. (addEventListener('message', ...)만 쓰는 경우 — 자동 start 안 됨.)

함정 2 — transferable 전송 후 원본 사용

const buf = new ArrayBuffer(1024);
port.postMessage(buf, [buf]);
// 이 시점 이후
console.log(buf.byteLength); // 0 — detached

소유권을 넘긴 순간 원본은 길이 0이 된다. 보내고 나서도 쓸 일이 있으면 — slice()로 복사본을 만들거나, 그냥 복사 전송(transferable 안 쓰기)로.

함정 3 — port를 contextBridge직접 노출

// ❌ port는 EventEmitter 같은 거라 contextBridge 통과 불가
contextBridge.exposeInMainWorld('port', port);

해결: port를 preload 안에서 닫고, 메시지 함수만 노출 (위 예시처럼).

함정 4 — 메모리 누수

MessagePortGC 대상이 아니다 — 두 끝이 모두 명시적으로 close되어야 메모리에서 풀린다.

port.close(); // 양쪽 다 호출 필요

renderer가 언로드될 때 port가 자동 닫히지 않을 수도 있다 — main에서 webContents.on('destroyed')로 cleanup하라.


Insight — 한 단락 이야기

“MessagePort는 사실 web 표준이다 — Electron이 Mojo로 OS 프로세스를 뚫었을 뿐”

MessageChannel/MessagePort2009년 HTML5 표준으로 들어왔다. 원래 iframe과 worker 사이의 양방향 통신을 위한 API였다. 그래서 renderer 안에서는 어디서나 동작한다 — 같은 origin iframe끼리, main thread와 worker끼리. Electron이 한 일은 — 이 같은 APIOS 프로세스 경계 너머로 확장한 것이다. MessageChannelMainmain 프로세스에서 만든 port 쌍이지만, 한쪽을 renderer에 전달하면renderer 안의 코드는 그것을 표준 MessagePort로 받는다. 즉 Web Worker용 코드그대로 Electron의 cross-process 통신에도 통한다. 흥미로운 반전 — 같은 시기 Cloudflare Workers·Deno Workers도 MessagePort를 cross-process 추상화로 채용했다. 원래 브라우저 안에 갇혀 있던 API서버 런타임의 IPC 표준으로 빠져나오는 흐름이다. Electron의 v7~v22 진화는 그 흐름의 데스크톱 버전이라고 봐도 된다.


요약 + Mermaid

핵심 키
생성new MessageChannelMain(){ port1, port2 }
전달webContents.postMessage(channel, msg, [port])
양 끝 메서드postMessage, on('message', ...), start(), close()
transferableArrayBuffer, MessagePort, ImageBitmap, OffscreenCanvas
적합 시나리오양방향 스트림 · 대용량 · renderer 간 직통 · Utility Process
Utility Processv22+ · Node 권한 가진 별도 프로세스
함정start() 누락 · transferable 후 원본 사용 · port를 contextBridge로 직접 노출

한 줄 결론invoke/handle은 RPC, MessagePort는 파이프다. 잦거나 크면 파이프, 1회성 호출이면 RPC. 다음 문서(06)는 이 모든 패턴의 반대편 — 안 하면 CVE가 되는 안티패턴들을 본다.