Skip to main content

프로세스 간 통신(IPC)

프로세스 간 통신(IPC)은 Electron에서 기능이 풍부한 데스크톱 애플리케이션을 구축하는 데 핵심적인 역할을 한다. Electron의 프로세스 모델에서 메인 프로세스와 렌더러 프로세스는 서로 다른 책임을 지고 있기 때문에, IPC는 UI에서 네이티브 API를 호출하거나 네이티브 메뉴에서 웹 콘텐츠의 변경을 트리거하는 등 다양한 일반적인 작업을 수행할 수 있는 유일한 방법이다.

IPC 채널

Electron에서 프로세스는 개발자가 정의한 "채널"을 통해 메시지를 주고받는다. 이때 ipcMainipcRenderer 모듈을 사용한다. 이러한 채널은 임의적으로 설정할 수 있다. 즉, 원하는 이름을 자유롭게 지정할 수 있다. 또한 양방향 통신이 가능하다. 동일한 채널 이름을 양쪽 모듈에서 사용할 수 있다.

이 가이드에서는 앱 코드를 작성할 때 참고할 수 있는 기본적인 IPC 패턴과 구체적인 예제를 살펴본다.

컨텍스트 격리 프로세스 이해하기

구현 세부 사항으로 넘어가기 전에, 컨텍스트 격리된 렌더러 프로세스에서 preload script를 사용해 Node.js와 Electron 모듈을 가져오는 개념을 먼저 이해해야 한다.

  • Electron의 프로세스 모델에 대한 전체 개요는 process model docs를 참고한다.
  • contextBridge 모듈을 사용해 preload script에서 API를 노출하는 방법에 대한 기본 지식은 context isolation tutorial에서 확인할 수 있다.

패턴 1: 렌더러에서 메인으로 (단방향)

렌더러 프로세스에서 메인 프로세스로 단방향 IPC 메시지를 보내려면 ipcRenderer.send API를 사용하면 된다. 이 메시지는 ipcMain.on API에서 수신된다.

이 패턴은 주로 웹 콘텐츠에서 메인 프로세스의 API를 호출할 때 사용한다. 윈도우 제목을 프로그래밍 방식으로 변경할 수 있는 간단한 앱을 만들어 이 패턴을 설명한다.

이 데모를 위해 메인 프로세스, 렌더러 프로세스, 그리고 프리로드 스크립트에 코드를 추가해야 한다. 전체 코드는 아래와 같지만, 다음 섹션에서 각 파일을 개별적으로 설명한다.

const { app, BrowserWindow, ipcMain } = require('electron/main')
const path = require('node:path')

function createWindow () {
const mainWindow = new BrowserWindow({
webPreferences: {
preload: path.join(__dirname, 'preload.js')
}
})

ipcMain.on('set-title', (event, title) => {
const webContents = event.sender
const win = BrowserWindow.fromWebContents(webContents)
win.setTitle(title)
})

mainWindow.loadFile('index.html')
}

app.whenReady().then(() => {
createWindow()

app.on('activate', function () {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})

app.on('window-all-closed', function () {
if (process.platform !== 'darwin') app.quit()
})

1. ipcMain.on으로 이벤트 수신하기

메인 프로세스에서 ipcMain.on API를 사용해 set-title 채널에 IPC 리스너를 설정한다:

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

// ...

function handleSetTitle (event, title) {
const webContents = event.sender
const win = BrowserWindow.fromWebContents(webContents)
win.setTitle(title)
}

function createWindow () {
const mainWindow = new BrowserWindow({
webPreferences: {
preload: path.join(__dirname, 'preload.js')
}
})
mainWindow.loadFile('index.html')
}

app.whenReady().then(() => {
ipcMain.on('set-title', handleSetTitle)
createWindow()
})
// ...

위의 handleSetTitle 콜백 함수는 두 개의 매개변수를 가진다: IpcMainEvent 구조체와 title 문자열. set-title 채널을 통해 메시지가 전달될 때마다, 이 함수는 메시지 발신자에 연결된 BrowserWindow 인스턴스를 찾아 win.setTitle API를 사용한다.

info

다음 단계를 진행할 때 index.htmlpreload.js 진입점을 로드하는지 확인하자!

2. ipcRenderer.send를 preload를 통해 노출하기

위에서 생성한 리스너에 메시지를 보내려면 ipcRenderer.send API를 사용할 수 있다. 기본적으로 렌더러 프로세스는 Node.js나 Electron 모듈에 접근할 수 없다. 앱 개발자는 contextBridge API를 사용해 preload 스크립트에서 어떤 API를 노출할지 선택해야 한다.

preload 스크립트에 다음 코드를 추가하면 렌더러 프로세스에서 전역 window.electronAPI 변수에 접근할 수 있다.

preload.js (Preload Script)
const { contextBridge, ipcRenderer } = require('electron')

contextBridge.exposeInMainWorld('electronAPI', {
setTitle: (title) => ipcRenderer.send('set-title', title)
})

이제 렌더러 프로세스에서 window.electronAPI.setTitle() 함수를 사용할 수 있다.

보안 경고

보안상의 이유로 ipcRenderer.send API 전체를 직접 노출하지 않는다. 가능한 한 렌더러가 Electron API에 접근하는 것을 제한해야 한다.

3. 렌더러 프로세스 UI 구축

BrowserWindow에서 로드된 HTML 파일에 텍스트 입력 필드와 버튼으로 구성된 기본 사용자 인터페이스를 추가한다:

index.html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'">
<title>Hello World!</title>
</head>
<body>
Title: <input id="title"/>
<button id="btn" type="button">Set</button>
<script src="./renderer.js"></script>
</body>
</html>

이 엘리먼트들을 상호작용 가능하게 만들기 위해, preload 스크립트에서 노출된 window.electronAPI 기능을 활용해 renderer.js 파일에 몇 줄의 코드를 추가한다:

renderer.js (Renderer Process)
const setButton = document.getElementById('btn')
const titleInput = document.getElementById('title')
setButton.addEventListener('click', () => {
const title = titleInput.value
window.electronAPI.setTitle(title)
})

이제 데모가 완전히 동작할 준비가 되었다. 입력 필드를 사용해 BrowserWindow의 제목이 어떻게 바뀌는지 확인해 보자!

패턴 2: 렌더러에서 메인으로 (양방향)

양방향 IPC의 일반적인 사용 사례는 렌더러 프로세스 코드에서 메인 프로세스 모듈을 호출하고 결과를 기다리는 것이다. 이는 ipcRenderer.invokeipcMain.handle을 함께 사용하여 구현할 수 있다.

다음 예제에서는 렌더러 프로세스에서 네이티브 파일 다이얼로그를 열고 선택된 파일의 경로를 반환하는 방법을 보여준다.

이 데모를 실행하려면 메인 프로세스, 렌더러 프로세스, 그리고 프리로드 스크립트에 코드를 추가해야 한다. 전체 코드는 아래와 같지만, 각 파일을 개별적으로 설명할 것이다.

const { app, BrowserWindow, ipcMain, dialog } = require('electron/main')
const path = require('node:path')

async function handleFileOpen () {
const { canceled, filePaths } = await dialog.showOpenDialog()
if (!canceled) {
return filePaths[0]
}
}

function createWindow () {
const mainWindow = new BrowserWindow({
webPreferences: {
preload: path.join(__dirname, 'preload.js')
}
})
mainWindow.loadFile('index.html')
}

app.whenReady().then(() => {
ipcMain.handle('dialog:openFile', handleFileOpen)
createWindow()
app.on('activate', function () {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})

app.on('window-all-closed', function () {
if (process.platform !== 'darwin') app.quit()
})

1. ipcMain.handle로 이벤트 수신하기

메인 프로세스에서 dialog.showOpenDialog를 호출하고 사용자가 선택한 파일 경로를 반환하는 handleFileOpen() 함수를 생성한다. 이 함수는 렌더러 프로세스에서 dialog:openFile 채널을 통해 ipcRender.invoke 메시지가 전송될 때마다 콜백으로 사용된다. 반환값은 원래 invoke 호출에 Promise로 반환된다.

오류 처리에 대해

메인 프로세스에서 handle을 통해 발생한 오류는 투명하지 않다. 오류는 직렬화되며 원래 오류의 message 속성만 렌더러 프로세스에 제공된다. 자세한 내용은 #24427을 참고한다.

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

// ...

async function handleFileOpen () {
const { canceled, filePaths } = await dialog.showOpenDialog({})
if (!canceled) {
return filePaths[0]
}
}

function createWindow () {
const mainWindow = new BrowserWindow({
webPreferences: {
preload: path.join(__dirname, 'preload.js')
}
})
mainWindow.loadFile('index.html')
}

app.whenReady().then(() => {
ipcMain.handle('dialog:openFile', handleFileOpen)
createWindow()
})
// ...
채널 이름에 대해

IPC 채널 이름의 dialog: 접두사는 코드에 영향을 미치지 않는다. 이는 코드 가독성을 높이는 네임스페이스 역할만 한다.

info

다음 단계를 위해 index.htmlpreload.js 진입점을 로드하고 있는지 확인한다!

2. ipcRenderer.invoke를 preload를 통해 노출하기

preload 스크립트에서 ipcRenderer.invoke('dialog:openFile')를 호출하고 그 결과를 반환하는 간단한 openFile 함수를 노출한다. 이 API는 다음 단계에서 렌더러의 사용자 인터페이스에서 네이티브 다이얼로그를 호출할 때 사용할 것이다.

preload.js (Preload Script)
const { contextBridge, ipcRenderer } = require('electron')

contextBridge.exposeInMainWorld('electronAPI', {
openFile: () => ipcRenderer.invoke('dialog:openFile')
})
보안 경고

보안상의 이유로 전체 ipcRenderer.invoke API를 직접 노출하지 않는다. 가능한 한 렌더러의 Electron API 접근을 제한해야 한다.

3. 렌더러 프로세스 UI 구축

마지막으로, BrowserWindow에 로드할 HTML 파일을 작성한다.

index.html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'">
<title>Dialog</title>
</head>
<body>
<button type="button" id="btn">파일 열기</button>
파일 경로: <strong id="filePath"></strong>
<script src='./renderer.js'></script>
</body>
</html>

이 UI는 프리로드 API를 트리거할 #btn 버튼 엘리먼트와 선택한 파일의 경로를 표시할 #filePath 엘리먼트로 구성된다. 이 기능을 구현하기 위해 렌더러 프로세스 스크립트에 몇 줄의 코드를 추가한다.

renderer.js (Renderer Process)
const btn = document.getElementById('btn')
const filePathElement = document.getElementById('filePath')

btn.addEventListener('click', async () => {
const filePath = await window.electronAPI.openFile()
filePathElement.innerText = filePath
})

위 코드에서 #btn 버튼 클릭을 감지하고, window.electronAPI.openFile() API를 호출해 네이티브 파일 열기 대화상자를 활성화한다. 그런 다음 선택한 파일 경로를 #filePath 엘리먼트에 표시한다.

참고: 레거시 접근 방식

ipcRenderer.invoke API는 Electron 7에서 추가된 기능으로, 렌더러 프로세스에서 양방향 IPC를 처리하는 개발자 친화적인 방법이다. 하지만 이 IPC 패턴을 구현하는 데는 몇 가지 대안적인 접근 방식이 존재한다.

가능하면 레거시 접근 방식은 피하라

가능한 경우 ipcRenderer.invoke를 사용하는 것을 권장한다. 아래에 설명할 두 가지 양방향 렌더러-메인 패턴은 역사적 목적으로만 기록한다.

info

다음 예제에서는 코드를 간결하게 유지하기 위해 프리로드 스크립트에서 직접 ipcRenderer를 호출한다.

ipcRenderer.send 사용하기

단방향 통신에 사용했던 ipcRenderer.send API는 양방향 통신에도 활용할 수 있다. 이 방법은 Electron 7 이전에 IPC를 통한 비동기 양방향 통신을 구현하는 권장 방식이었다.

preload.js (Pre로드 스크립트)
// 이 코드를 `contextBridge` API를 통해 렌더러 프로세스에 노출할 수도 있다.
const { ipcRenderer } = require('electron')

ipcRenderer.on('asynchronous-reply', (_event, arg) => {
console.log(arg) // DevTools 콘솔에 "pong"이 출력된다.
})
ipcRenderer.send('asynchronous-message', 'ping')
main.js (메인 프로세스)
ipcMain.on('asynchronous-message', (event, arg) => {
console.log(arg) // Node 콘솔에 "ping"이 출력된다.
// `send`와 유사하게 동작하지만, 원본 메시지를 보낸 렌더러로 응답을 반환한다.
event.reply('asynchronous-reply', 'pong')
})

이 방식에는 몇 가지 단점이 있다:

  • 렌더러 프로세스에서 응답을 처리하기 위해 두 번째 ipcRenderer.on 리스너를 설정해야 한다. invoke를 사용하면 응답 값을 Promise로 반환받을 수 있다.
  • asynchronous-reply 메시지를 원본 asynchronous-message와 명확하게 연결할 방법이 없다. 이러한 채널을 통해 빈번하게 메시지가 오가는 경우, 각 호출과 응답을 개별적으로 추적하기 위해 추가적인 앱 코드를 작성해야 한다.

ipcRenderer.sendSync 사용하기

ipcRenderer.sendSync API는 메인 프로세스로 메시지를 보내고, 응답을 동기적으로 기다린다.

main.js (메인 프로세스)
const { ipcMain } = require('electron')
ipcMain.on('synchronous-message', (event, arg) => {
console.log(arg) // Node 콘솔에 "ping" 출력
event.returnValue = 'pong'
})
preload.js (프리로드 스크립트)
// 이 코드를 `contextBridge` API를 사용해 렌더러 프로세스에 노출할 수도 있다
const { ipcRenderer } = require('electron')

const result = ipcRenderer.sendSync('synchronous-message', 'ping')
console.log(result) // DevTools 콘솔에 "pong" 출력

이 코드의 구조는 invoke 모델과 매우 유사하지만, 성능상의 이유로 이 API 사용을 피할 것을 권장한다. 동기적인 특성 때문에 응답을 받을 때까지 렌더러 프로세스가 블로킹된다.

패턴 3: 메인 프로세스에서 렌더러 프로세스로

메인 프로세스에서 렌더러 프로세스로 메시지를 보낼 때는 어떤 렌더러가 메시지를 받을지 명시해야 한다. 메시지는 렌더러 프로세스의 WebContents 인스턴스를 통해 전송된다. 이 WebContents 인스턴스에는 ipcRenderer.send와 동일한 방식으로 사용할 수 있는 send 메서드가 포함되어 있다.

이 패턴을 설명하기 위해, 운영체제의 네이티브 메뉴로 제어되는 숫자 카운터를 만들어 볼 것이다.

이 데모를 위해서는 메인 프로세스, 렌더러 프로세스, 그리고 프리로드 스크립트에 코드를 추가해야 한다. 전체 코드는 아래와 같지만, 다음 섹션에서 각 파일을 개별적으로 설명할 것이다.

const { app, BrowserWindow, Menu, ipcMain } = require('electron/main')
const path = require('node:path')

function createWindow () {
const mainWindow = new BrowserWindow({
webPreferences: {
preload: path.join(__dirname, 'preload.js')
}
})

const menu = Menu.buildFromTemplate([
{
label: app.name,
submenu: [
{
click: () => mainWindow.webContents.send('update-counter', 1),
label: 'Increment'
},
{
click: () => mainWindow.webContents.send('update-counter', -1),
label: 'Decrement'
}
]
}

])

Menu.setApplicationMenu(menu)
mainWindow.loadFile('index.html')

// Open the DevTools.
mainWindow.webContents.openDevTools()
}

app.whenReady().then(() => {
ipcMain.on('counter-value', (_event, value) => {
console.log(value) // will print value to Node console
})
createWindow()

app.on('activate', function () {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})

app.on('window-all-closed', function () {
if (process.platform !== 'darwin') app.quit()
})

1. webContents 모듈로 메시지 보내기

이 데모를 위해 먼저 Electron의 Menu 모듈을 사용해 메인 프로세스에서 커스텀 메뉴를 만들어야 한다. 이 메뉴는 webContents.send API를 사용해 메인 프로세스에서 대상 렌더러 프로세스로 IPC 메시지를 보낸다.

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

function createWindow () {
const mainWindow = new BrowserWindow({
webPreferences: {
preload: path.join(__dirname, 'preload.js')
}
})

const menu = Menu.buildFromTemplate([
{
label: app.name,
submenu: [
{
click: () => mainWindow.webContents.send('update-counter', 1),
label: 'Increment'
},
{
click: () => mainWindow.webContents.send('update-counter', -1),
label: 'Decrement'
}
]
}
])
Menu.setApplicationMenu(menu)

mainWindow.loadFile('index.html')
}
// ...

이 튜토리얼에서 중요한 점은 click 핸들러가 update-counter 채널을 통해 렌더러 프로세스로 메시지(1 또는 -1)를 보낸다는 것이다.

click: () => mainWindow.webContents.send('update-counter', -1)
info

다음 단계를 위해 index.htmlpreload.js 엔트리 포인트를 로드하고 있는지 확인하자!

2. ipcRenderer.on을 preload를 통해 노출하기

이전의 렌더러-메인 간 통신 예제와 마찬가지로, preload 스크립트에서 contextBridgeipcRenderer 모듈을 사용해 IPC 기능을 렌더러 프로세스에 노출한다:

preload.js (Preload Script)
const { contextBridge, ipcRenderer } = require('electron')

contextBridge.exposeInMainWorld('electronAPI', {
onUpdateCounter: (callback) => ipcRenderer.on('update-counter', (_event, value) => callback(value))
})

preload 스크립트를 로드한 후, 렌더러 프로세스는 window.electronAPI.onUpdateCounter() 리스너 함수에 접근할 수 있다.

보안 경고

보안상의 이유로 ipcRenderer.on API 전체를 직접 노출하지 않는다. 렌더러가 Electron API에 접근할 수 있는 권한을 최대한 제한해야 한다. 또한 ipcRenderer.on에 콜백을 직접 전달하지 않도록 주의한다. 이는 event.sender를 통해 ipcRenderer가 누출될 수 있다. 대신 원하는 인자만 콜백에 전달하는 커스텀 핸들러를 사용한다.

info

이 간단한 예제의 경우, context bridge를 통해 노출하지 않고 preload 스크립트에서 직접 ipcRenderer.on을 호출할 수도 있다.

preload.js (Preload Script)
const { ipcRenderer } = require('electron')

window.addEventListener('DOMContentLoaded', () => {
const counter = document.getElementById('counter')
ipcRenderer.on('update-counter', (_event, value) => {
const oldValue = Number(counter.innerText)
const newValue = oldValue + value
counter.innerText = newValue
})
})

하지만 이 방식은 context bridge를 통해 preload API를 노출하는 것에 비해 유연성이 떨어진다. 리스너가 렌더러 코드와 직접 상호작용할 수 없기 때문이다.

3. 렌더러 프로세스 UI 구축

모든 요소를 연결하기 위해, 로드된 HTML 파일에 #counter 엘리먼트를 포함한 인터페이스를 만든다. 이 엘리먼트는 값을 표시하는 데 사용된다.

index.html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'">
<title>Menu Counter</title>
</head>
<body>
Current value: <strong id="counter">0</strong>
<script src="./renderer.js"></script>
</body>
</html>

마지막으로, HTML 문서에서 값이 업데이트되도록 하기 위해 DOM 조작 코드를 몇 줄 추가한다. 이 코드는 update-counter 이벤트가 발생할 때마다 #counter 엘리먼트의 값을 업데이트한다.

renderer.js (Renderer Process)
const counter = document.getElementById('counter')

window.electronAPI.onUpdateCounter((value) => {
const oldValue = Number(counter.innerText)
const newValue = oldValue + value
counter.innerText = newValue.toString()
})

위 코드에서, 프리로드 스크립트에서 노출된 window.electronAPI.onUpdateCounter 함수에 콜백을 전달한다. 두 번째 value 매개변수는 네이티브 메뉴에서 webContents.send 호출로 전달한 1 또는 -1에 해당한다.

선택 사항: 응답 반환하기

메인 프로세스에서 렌더러 프로세스로의 IPC 통신에는 ipcRenderer.invoke와 동일한 기능이 없다. 대신 ipcRenderer.on 콜백 내에서 메인 프로세스로 응답을 보낼 수 있다.

이전 예제 코드를 약간 수정해 이를 확인해 보자. 렌더러 프로세스에서 counter-value 채널을 통해 메인 프로세스로 응답을 보내는 또 다른 API를 노출한다.

preload.js (Preload Script)
const { contextBridge, ipcRenderer } = require('electron')

contextBridge.exposeInMainWorld('electronAPI', {
onUpdateCounter: (callback) => ipcRenderer.on('update-counter', (_event, value) => callback(value)),
counterValue: (value) => ipcRenderer.send('counter-value', value)
})
renderer.js (Renderer Process)
const counter = document.getElementById('counter')

window.electronAPI.onUpdateCounter((value) => {
const oldValue = Number(counter.innerText)
const newValue = oldValue + value
counter.innerText = newValue.toString()
window.electronAPI.counterValue(newValue)
})

메인 프로세스에서는 counter-value 이벤트를 감지하고 적절히 처리한다.

main.js (Main Process)
// ...
ipcMain.on('counter-value', (_event, value) => {
console.log(value) // Node 콘솔에 값을 출력한다
})
// ...

패턴 4: 렌더러 간 통신

Electron에서 ipcMainipcRenderer 모듈을 사용해 렌더러 프로세스 간에 직접 메시지를 보낼 수 있는 방법은 없다. 이를 구현하려면 두 가지 방법을 고려할 수 있다:

  • 메인 프로세스를 렌더러 간의 메시지 브로커로 사용한다. 한 렌더러에서 메인 프로세스로 메시지를 보내고, 메인 프로세스가 이를 다른 렌더러로 전달하는 방식이다.
  • 메인 프로세스에서 두 렌더러로 MessagePort를 전달한다. 초기 설정 후에는 렌더러 간에 직접 통신이 가능해진다.

객체 직렬화

Electron의 IPC 구현은 프로세스 간에 전달되는 객체를 직렬화하기 위해 HTML 표준인 Structured Clone Algorithm을 사용한다. 이는 IPC 채널을 통해 특정 타입의 객체만 전달할 수 있음을 의미한다.

구체적으로, DOM 객체(예: Element, Location, DOMMatrix), C++ 클래스로 구현된 Node.js 객체(예: process.env, Stream의 일부 멤버), 그리고 C++ 클래스로 구현된 Electron 객체(예: WebContents, BrowserWindow, WebFrame)는 Structured Clone으로 직렬화할 수 없다.