Electron에서의 MessagePorts
MessagePort
는 서로 다른 컨텍스트 간에 메시지를 전달할 수 있게 해주는 웹 기능이다. window.postMessage
와 유사하지만, 다른 채널을 통해 통신한다. 이 문서는 Electron이 채널 메시징 모델을 어떻게 확장하는지 설명하고, 여러분의 앱에서 MessagePorts를 어떻게 사용할 수 있는지 몇 가지 예제를 제공한다.
다음은 MessagePort가 무엇인지, 그리고 어떻게 동작하는지에 대한 간단한 예제이다:
// 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])
// 메인 프로세스에서는 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와 통합되지 않아 MessagePort
나 MessageChannel
클래스를 가지고 있지 않다. 메인 프로세스에서 MessagePort를 처리하고 상호작용하기 위해 Electron은 두 가지 새로운 클래스인 MessagePortMain
과 MessageChannelMain
을 추가했다. 이 클래스들은 렌더러 프로세스의 동일한 클래스들과 유사하게 동작한다.
MessagePort
객체는 렌더러 프로세스나 메인 프로세스에서 생성할 수 있으며, ipcRenderer.postMessage
와 WebContents.postMessage
메서드를 사용해 양방향으로 전달할 수 있다. 일반적으로 사용하는 send
나 invoke
같은 IPC 메서드로는 MessagePort
를 전달할 수 없고, 오직 postMessage
메서드만이 MessagePort
를 전달할 수 있다는 점에 유의해야 한다.
메인 프로세스를 통해 MessagePort
를 전달하면, 동일 출처 정책(same-origin restrictions)과 같은 제약으로 인해 통신할 수 없는 두 페이지를 연결할 수 있다.
확장 기능: close
이벤트
Electron은 웹 환경에서는 제공되지 않는 MessagePort
에 한 가지 기능을 추가한다. 이는 채널의 반대편이 닫힐 때 발생하는 close
이벤트다. 포트는 가비지 컬렉션에 의해 암묵적으로 닫힐 수도 있다.
렌더러 프로세스에서는 port.onclose
에 할당하거나 port.addEventListener('close', ...)
를 호출해 close
이벤트를 감지할 수 있다. 메인 프로세스에서는 port.on('close', ...)
를 호출해 close
이벤트를 감지할 수 있다.
예제 사용 사례
두 렌더러 간 MessageChannel 설정
이 예제에서는 메인 프로세스가 MessageChannel을 설정한 후, 각 포트를 다른 렌더러로 전송한다. 이를 통해 렌더러는 메인 프로세스를 거치지 않고 서로 메시지를 주고받을 수 있다.
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를 통해 포트를 수신하고 리스너를 설정한다.
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
를 호출하여 다른 렌더러에 메시지를 보낼 수 있다.
// 코드 내 다른 곳에서 다른 렌더러의 메시지 핸들러로 메시지를 보낼 때
window.electronMessagePort.postMessage('ping')
Worker Process
이 예제에서는 앱이 숨겨진 윈도우로 구현된 worker process를 사용한다. 앱 페이지가 메인 프로세스를 거치지 않고 직접 worker 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는 메인 프로세스를 거치지 않고 직접 통신할 수 있다!
})
})
<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>
<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를 사용하면 "응답 스트림"을 구현할 수 있다. 이 방식은 단일 요청에 대해 데이터 스트림으로 응답한다.
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번 출력된다.
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 메시지는 메인 세계가 아닌 격리된 세계로 전달된다. 때로는 격리된 세계를 거치지 않고 메인 세계로 직접 메시지를 전달하고 싶을 때가 있다.
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])
})
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)
})
<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>