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 ↔ Renderer | main을 거치지 않는 직통이 성능 명확 |
Worker ↔ Main | Web Worker의 postMessage는 renderer 내부만 |
이 자리들에 Mojo의 양방향 파이프 — MessagePort가 직접 노출된다.
How — MessagePortMain 기본 패턴
작동 원리
- Main이
new MessageChannelMain()을 만든다 —{ port1, port2 }두 끝이 나온다. - Main이 한쪽을 renderer에게 보낸다 (
webContents.postMessage). - Main이 다른 한쪽을 직접 들고 있거나 Utility Process에 전달.
- 이후 두 끝은 직통 양방향 통신.
코드 — 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 — 길이 0Electron에서 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 Process는 Node 권한을 가진 별도 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 Process | utilityProcess.postMessage |
| Renderer ↔ Utility Process | MessagePort (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 — 메모리 누수
MessagePort는 GC 대상이 아니다 — 두 끝이 모두 명시적으로 close되어야 메모리에서 풀린다.
port.close(); // 양쪽 다 호출 필요renderer가 언로드될 때 port가 자동 닫히지 않을 수도 있다 — main에서 webContents.on('destroyed')로 cleanup하라.
Insight — 한 단락 이야기
“MessagePort는 사실 web 표준이다 — Electron이 Mojo로 OS 프로세스를 뚫었을 뿐”
MessageChannel/MessagePort는 2009년 HTML5 표준으로 들어왔다. 원래 iframe과 worker 사이의 양방향 통신을 위한 API였다. 그래서 renderer 안에서는 어디서나 동작한다 — 같은 origin iframe끼리, main thread와 worker끼리. Electron이 한 일은 — 이 같은 API를 OS 프로세스 경계 너머로 확장한 것이다.MessageChannelMain은 main 프로세스에서 만든 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() |
| transferable | ArrayBuffer, MessagePort, ImageBitmap, OffscreenCanvas |
| 적합 시나리오 | 양방향 스트림 · 대용량 · renderer 간 직통 · Utility Process |
| Utility Process | v22+ · Node 권한 가진 별도 프로세스 |
| 함정 | start() 누락 · transferable 후 원본 사용 · port를 contextBridge로 직접 노출 |
한 줄 결론 — invoke/handle은 RPC, MessagePort는 파이프다. 잦거나 크면 파이프, 1회성 호출이면 RPC. 다음 문서(06)는 이 모든 패턴의 반대편 — 안 하면 CVE가 되는 안티패턴들을 본다.