Skip to main content

Electron에서의 MessagePorts

MessagePort는 서로 다른 컨텍스트 간에 메시지를 전달할 수 있게 해주는 웹 기능이다. window.postMessage와 유사하지만, 다른 채널을 통해 통신한다. 이 문서는 Electron이 채널 메시징 모델을 어떻게 확장하는지 설명하고, 여러분의 앱에서 MessagePorts를 어떻게 사용할 수 있는지 몇 가지 예제를 제공한다.

다음은 MessagePort가 무엇인지, 그리고 어떻게 동작하는지에 대한 간단한 예제이다:

renderer.js (렌더러 프로세스)
// MessagePorts는 쌍으로 생성된다. 연결된 한 쌍의 MessagePort를 채널이라고 부른다.
const channel = new MessageChannel()

// port1과 port2의 유일한 차이는 사용 방법에 있다. port1로 보낸 메시지는 port2에서 받을 수 있고, 그 반대도 마찬가지다.
const port1 = channel.port1
const port2 = channel.port2

// 다른 쪽에서 리스너를 등록하기 전에 채널로 메시지를 보내도 문제없다. 메시지는 리스너가 등록될 때까지 대기열에 쌓인다.
port2.postMessage({ answer: 42 })

// 여기서는 채널의 다른 쪽인 port1을 메인 프로세스로 보낸다. MessagePorts를 다른 프레임이나 웹 워커 등으로 보내는 것도 가능하다.
ipcRenderer.postMessage('port', null, [port1])
main.js (메인 프로세스)
// 메인 프로세스에서는 port를 받는다.
ipcMain.on('port', (event) => {
// 메인 프로세스에서 MessagePort를 받으면, 이는 MessagePortMain이 된다.
const port = event.ports[0]

// MessagePortMain은 웹 스타일 이벤트 API 대신 Node.js 스타일 이벤트 API를 사용한다. 따라서 .on('message', ...)와 같은 방식으로 사용한다.
port.on('message', (event) => {
// data는 { answer: 42 }이다.
const data = event.data
})

// MessagePortMain은 .start() 메서드가 호출될 때까지 메시지를 대기열에 쌓아둔다.
port.start()
})

Channel Messaging API 문서는 MessagePorts가 어떻게 동작하는지 더 깊이 이해하는 데 도움이 될 것이다.

메인 프로세스에서의 MessagePorts

렌더러 프로세스에서 MessagePort 클래스는 웹에서와 동일하게 동작한다. 하지만 메인 프로세스는 웹 페이지가 아니며, Blink와 통합되지 않아 MessagePortMessageChannel 클래스를 가지고 있지 않다. 메인 프로세스에서 MessagePort를 처리하고 상호작용하기 위해 Electron은 두 가지 새로운 클래스인 MessagePortMainMessageChannelMain을 추가했다. 이 클래스들은 렌더러 프로세스의 동일한 클래스들과 유사하게 동작한다.

MessagePort 객체는 렌더러 프로세스나 메인 프로세스에서 생성할 수 있으며, ipcRenderer.postMessageWebContents.postMessage 메서드를 사용해 양방향으로 전달할 수 있다. 일반적으로 사용하는 sendinvoke 같은 IPC 메서드로는 MessagePort를 전달할 수 없고, 오직 postMessage 메서드만이 MessagePort를 전달할 수 있다는 점에 유의해야 한다.

메인 프로세스를 통해 MessagePort를 전달하면, 동일 출처 정책(same-origin restrictions)과 같은 제약으로 인해 통신할 수 없는 두 페이지를 연결할 수 있다.

확장 기능: close 이벤트

Electron은 웹 환경에서는 제공되지 않는 MessagePort에 한 가지 기능을 추가한다. 이는 채널의 반대편이 닫힐 때 발생하는 close 이벤트다. 포트는 가비지 컬렉션에 의해 암묵적으로 닫힐 수도 있다.

렌더러 프로세스에서는 port.onclose에 할당하거나 port.addEventListener('close', ...)를 호출해 close 이벤트를 감지할 수 있다. 메인 프로세스에서는 port.on('close', ...)를 호출해 close 이벤트를 감지할 수 있다.

예제 사용 사례

두 렌더러 간 MessageChannel 설정

이 예제에서는 메인 프로세스가 MessageChannel을 설정한 후, 각 포트를 다른 렌더러로 전송한다. 이를 통해 렌더러는 메인 프로세스를 거치지 않고 서로 메시지를 주고받을 수 있다.

main.js (메인 프로세스)
const { BrowserWindow, app, MessageChannelMain } = require('electron')

app.whenReady().then(async () => {
// 윈도우 생성
const mainWindow = new BrowserWindow({
show: false,
webPreferences: {
contextIsolation: false,
preload: 'preloadMain.js'
}
})

const secondaryWindow = new BrowserWindow({
show: false,
webPreferences: {
contextIsolation: false,
preload: 'preloadSecondary.js'
}
})

// 채널 설정
const { port1, port2 } = new MessageChannelMain()

// webContents가 준비되면 각 webContents에 포트를 전송
mainWindow.once('ready-to-show', () => {
mainWindow.webContents.postMessage('port', null, [port1])
})

secondaryWindow.once('ready-to-show', () => {
secondaryWindow.webContents.postMessage('port', null, [port2])
})
})

그런 다음, 프리로드 스크립트에서 IPC를 통해 포트를 수신하고 리스너를 설정한다.

preloadMain.js 및 preloadSecondary.js (프리로드 스크립트)
const { ipcRenderer } = require('electron')

ipcRenderer.on('port', e => {
// 포트를 수신하고 전역적으로 사용 가능하게 설정
window.electronMessagePort = e.ports[0]

window.electronMessagePort.onmessage = messageEvent => {
// 메시지 처리
}
})

이 예제에서는 messagePort가 window 객체에 직접 바인딩된다. contextIsolation을 사용하고 각 예상 메시지에 대해 contextBridge 호출을 설정하는 것이 더 좋지만, 이 예제에서는 간단함을 위해 그렇게 하지 않았다. context isolation의 예제는 이 페이지의 메인 프로세스와 컨텍스트 분리 페이지의 메인 세계 간 직접 통신에서 찾을 수 있다.

즉, window.electronMessagePort는 전역적으로 사용 가능하며, 앱 내 어디에서든 postMessage를 호출하여 다른 렌더러에 메시지를 보낼 수 있다.

renderer.js (렌더러 프로세스)
// 코드 내 다른 곳에서 다른 렌더러의 메시지 핸들러로 메시지를 보낼 때
window.electronMessagePort.postMessage('ping')

Worker Process

이 예제에서는 앱이 숨겨진 윈도우로 구현된 worker process를 사용한다. 앱 페이지가 메인 프로세스를 거치지 않고 직접 worker process와 통신할 수 있도록 설정한다. 이를 통해 메인 프로세스를 경유하는 오버헤드를 줄이고 성능을 향상시킬 수 있다.

main.js (Main Process)
const { BrowserWindow, app, ipcMain, MessageChannelMain } = require('electron')

app.whenReady().then(async () => {
// Worker process는 숨겨진 BrowserWindow로 구현된다. 이렇게 하면 Blink 컨텍스트 전체에 접근할 수 있다.
// (<canvas>, audio, fetch() 등 포함)
const worker = new BrowserWindow({
show: false,
webPreferences: { nodeIntegration: true }
})
await worker.loadFile('worker.html')

// 메인 윈도우는 MessagePort를 통해 worker process에 작업을 보내고 결과를 받는다.
const mainWindow = new BrowserWindow({
webPreferences: { nodeIntegration: true }
})
mainWindow.loadFile('app.html')

// 여기서는 ipcMain.handle()을 사용할 수 없다. 왜냐하면 응답에 MessagePort를 전달해야 하기 때문이다.
// 최상위 프레임에서 보낸 메시지를 수신한다.
mainWindow.webContents.mainFrame.ipc.on('request-worker-channel', (event) => {
// 새로운 채널을 생성한다.
const { port1, port2 } = new MessageChannelMain()
// 한쪽 끝은 worker로 보낸다.
worker.webContents.postMessage('new-client', null, [port1])
// 다른 한쪽 끝은 메인 윈도우로 보낸다.
event.senderFrame.postMessage('provide-worker-channel', null, [port2])
// 이제 메인 윈도우와 worker는 메인 프로세스를 거치지 않고 직접 통신할 수 있다!
})
})
worker.html
<script>
const { ipcRenderer } = require('electron')

const doWork = (input) => {
// CPU 집약적인 작업을 수행한다.
return input * 2
}

// 여러 클라이언트가 있을 수 있다. 예를 들어 여러 윈도우가 있거나 메인 윈도우가 리로드된 경우.
ipcRenderer.on('new-client', (event) => {
const [ port ] = event.ports
port.onmessage = (event) => {
// 이벤트 데이터는 직렬화 가능한 객체일 수 있다. (심지어 다른 MessagePort도 포함할 수 있다!)
const result = doWork(event.data)
port.postMessage(result)
}
})
</script>
app.html
<script>
const { ipcRenderer } = require('electron')

// 메인 프로세스에 worker와 통신할 수 있는 채널을 요청한다.
ipcRenderer.send('request-worker-channel')

ipcRenderer.once('provide-worker-channel', (event) => {
// 응답을 받으면 포트를 가져온다.
const [ port ] = event.ports
// 결과를 받을 핸들러를 등록한다.
port.onmessage = (event) => {
console.log('received result:', event.data)
}
// 그리고 작업을 보내기 시작한다!
port.postMessage(21)
})
</script>

응답 스트림

Electron의 내장 IPC 메서드는 두 가지 모드만 지원한다. 하나는 전송 후 망각 방식(fire-and-forget, 예: send), 다른 하나는 요청-응답 방식(request-response, 예: invoke)이다. MessageChannels를 사용하면 "응답 스트림"을 구현할 수 있다. 이 방식은 단일 요청에 대해 데이터 스트림으로 응답한다.

renderer.js (Renderer Process)
const makeStreamingRequest = (element, callback) => {
// MessageChannels는 가볍기 때문에 요청마다 새로 생성해도 부담이 적다.
const { port1, port2 } = new MessageChannel()

// 포트의 한쪽 끝을 메인 프로세스로 보낸다.
ipcRenderer.postMessage(
'give-me-a-stream',
{ element, count: 10 },
[port2]
)

// 다른 한쪽 끝은 유지한다. 메인 프로세스는 포트의 자신 쪽 끝에 메시지를 보내고, 작업이 끝나면 포트를 닫는다.
port1.onmessage = (event) => {
callback(event.data)
}
port1.onclose = () => {
console.log('스트림 종료')
}
}

makeStreamingRequest(42, (data) => {
console.log('응답 데이터 수신:', data)
})
// "응답 데이터 수신: 42"가 10번 출력된다.
main.js (Main Process)
ipcMain.on('give-me-a-stream', (event, msg) => {
// 렌더러가 응답을 보낼 MessagePort를 전달했다.
const [replyPort] = event.ports

// 여기서는 동기적으로 메시지를 보내지만, 포트를 어딘가에 저장해 비동기적으로 메시지를 보내는 것도 가능하다.
for (let i = 0; i < msg.count; i++) {
replyPort.postMessage(msg.element)
}

// 작업이 끝나면 포트를 닫아 더 이상 메시지를 보내지 않음을 알린다. 이 작업은 필수는 아니다. 명시적으로 포트를 닫지 않아도 결국 가비지 컬렉션에 의해 포트가 닫히며, 이 경우에도 렌더러에서 'close' 이벤트가 발생한다.
replyPort.close()
})

컨텍스트 격리 페이지의 메인 프로세스와 메인 세계 간 직접 통신

컨텍스트 격리가 활성화된 상태에서, 메인 프로세스에서 렌더러로 보내는 IPC 메시지는 메인 세계가 아닌 격리된 세계로 전달된다. 때로는 격리된 세계를 거치지 않고 메인 세계로 직접 메시지를 전달하고 싶을 때가 있다.

main.js (메인 프로세스)
const { BrowserWindow, app, MessageChannelMain } = require('electron')
const path = require('node:path')

app.whenReady().then(async () => {
// 컨텍스트 격리가 활성화된 BrowserWindow를 생성한다.
const bw = new BrowserWindow({
webPreferences: {
contextIsolation: true,
preload: path.join(__dirname, 'preload.js')
}
})
bw.loadURL('index.html')

// 이 채널의 한쪽 끝을 컨텍스트 격리 페이지의 메인 세계로 보낸다.
const { port1, port2 } = new MessageChannelMain()

// 다른 쪽 끝에서 리스너를 등록하기 전에도 메시지를 보내는 것이 가능하다.
// 메시지는 리스너가 등록될 때까지 큐에 저장된다.
port2.postMessage({ test: 21 })

// 렌더러의 메인 세계에서 오는 메시지를 받을 수도 있다.
port2.on('message', (event) => {
console.log('렌더러 메인 세계에서:', event.data)
})
port2.start()

// 프리로드 스크립트는 이 IPC 메시지를 받고 포트를 메인 세계로 전달할 것이다.
bw.webContents.postMessage('main-world-port', null, [port1])
})
preload.js (프리로드 스크립트)
const { ipcRenderer } = require('electron')

// 포트를 보내기 전에 메인 세계가 메시지를 받을 준비가 될 때까지 기다려야 한다.
// 프리로드 스크립트에서 이 Promise를 생성해 로드 이벤트가 발생하기 전에
// onload 리스너를 등록할 수 있도록 보장한다.
const windowLoaded = new Promise(resolve => {
window.onload = resolve
})

ipcRenderer.on('main-world-port', async (event) => {
await windowLoaded
// 일반 window.postMessage를 사용해 포트를 격리된 세계에서 메인 세계로 전달한다.
window.postMessage('main-world-port', '*', event.ports)
})
index.html
<script>
window.onmessage = (event) => {
// event.source === window는 메시지가 프리로드 스크립트에서 온 것임을 의미한다.
// <iframe>이나 다른 소스에서 온 메시지와는 다르다.
if (event.source === window && event.data === 'main-world-port') {
const [ port ] = event.ports
// 포트를 얻으면 메인 프로세스와 직접 통신할 수 있다.
port.onmessage = (event) => {
console.log('메인 프로세스에서:', event.data)
port.postMessage(event.data.test * 2)
}
}
}
</script>