실제 예제로 알아보는 Electron IPC 통신 이해하기
Electron은 HTML, CSS, JavaScript와 같은 웹 기술을 사용하여 크로스 플랫폼 데스크톱 애플리케이션을 빌드하기 위한 가장 인기 있는 프레임워크 중 하나입니다. 내부적으로 Electron은 Node.js를 실행하는 **메인 프로세스(Main Process)**와 UI를 렌더링하기 위해 Chromium을 실행하는 하나 이상의 **렌더러 프로세스(Renderer Process)**로 구성된 멀티 프로세스 아키텍처로 작동합니다.
보안상의 위험 때문에 최신 Electron 애플리케이션은 렌더러 프로세스를 운영체제로부터 격리합니다. 이는 렌더러 UI에서 직접 Node.js 모듈이나 시스템 리소스(파일 읽기 또는 데이터베이스 쿼리 등)에 접근할 수 없음을 의미합니다.
이러한 격리를 안전하게 극복하기 위해 Electron은 **프로세스 간 통신(IPC, Inter-Process Communication)**을 활용합니다.
이 가이드에서는 Electron IPC가 어떻게 작동하는지 설명하고, 실제 프로덕션 환경에서 사용할 수 있는 코드 예제와 함께 세 가지 기본 통신 패턴을 살펴보겠습니다.
1. 렌더러에서 메인으로 (단방향 / One-Way)
이 패턴은 렌더러가 메인 프로세스에 어떤 응답도 기다리지 않고 명령이나 액션을 보내고자 할 때 사용됩니다. 일반적인 예로는 UI의 버튼을 클릭하여 애플리케이션 창을 최소화하거나 닫는 작업이 있습니다.
세 가지 주요 파일(main.js, preload.js, renderer.js)에서 이것이 어떻게 구현되는지 살펴보겠습니다.
메인 프로세스 (main.js)
렌더러 프로세스로부터 오는 이벤트를 수신하기 위해 ipcMain.on을 사용합니다.
const { app, BrowserWindow, ipcMain } = require('electron');
const path = require('path');
function createWindow() {
const win = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false
}
});
win.loadFile('index.html');
}
// 렌더러로부터 'close-app' 이벤트 수신
ipcMain.on('close-app', () => {
app.quit();
});
프리로드 스크립트 (preload.js)
ipcRenderer 모듈 전체를 렌더러에 노출하지 않고, contextBridge.exposeInMainWorld를 사용하여 렌더러에 안전한 래퍼 함수만 노출합니다.
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('electronAPI', {
closeApp: () => ipcRenderer.send('close-app')
});
렌더러 프로세스 (renderer.js)
window 객체에 노출된 함수를 호출합니다.
const closeButton = document.getElementById('close-btn');
closeButton.addEventListener('click', () => {
window.electronAPI.closeApp();
});
2. 렌더러에서 메인으로 (양방향 / 요청 - 응답)
이 패턴은 렌더러가 메인 프로세스에 데이터를 요청하거나 시스템 작업을 실행하고 그 결과를 기다려야 할 때(예: 파일 읽기 또는 데이터베이스의 세큐어 쿼리 실행 등) 사용됩니다.
메인 프로세스에서 ipcMain.handle을 사용하고 프리로드 스크립트에서 ipcRenderer.invoke를 사용합니다.
메인 프로세스 (main.js)
ipcMain.handle을 사용하여 요청을 기다리고 비동기식으로 데이터를 반환합니다.
const { ipcMain } = require('electron');
const fs = require('fs/promises');
// 'read-file' 호출을 비동기로 처리
ipcMain.handle('read-file', async (event, filePath) => {
try {
const data = await fs.readFile(filePath, 'utf-8');
return { success: true, content: data };
} catch (error) {
return { success: false, error: error.message };
}
});
프리로드 스크립트 (preload.js)
Promise를 반환하는 비동기 래퍼를 노출합니다.
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('electronAPI', {
readFile: (filePath) => ipcRenderer.invoke('read-file', filePath)
});
렌더러 프로세스 (renderer.js)
await를 사용하여 반환된 Promise가 해결되기를 기다립니다.
const readBtn = document.getElementById('read-btn');
readBtn.addEventListener('click', async () => {
const result = await window.electronAPI.readFile('/path/to/file.txt');
if (result.success) {
console.log('파일 내용:', result.content);
} else {
console.error('파일 읽기 실패:', result.error);
}
});
3. 메인에서 렌더러로 (단방향 알림)
이 패턴은 메인 프로세스에서 렌더러 프로세스로 업데이트나 알림을 보내야 할 때(예: 다운로드 진행률 업데이트, 앱 메뉴 클릭, 또는 백그라운드 서비스의 상태 업데이트 등) 사용됩니다.
메인 프로세스에서 webContents.send를 사용하고 프리로드 스크립트에서 ipcRenderer.on을 사용합니다.
메인 프로세스 (main.js)
활성 창의 webContents를 가져와 메시지를 보냅니다.
// 예: 다운로드 진행 상황 전달
function trackDownloadProgress(mainWindow) {
let progress = 0;
const interval = setInterval(() => {
progress += 10;
mainWindow.webContents.send('download-progress', progress);
if (progress >= 100) {
clearInterval(interval);
}
}, 1000);
}
프리로드 스크립트 (preload.js)
콜백 함수를 받는 구독 메서드를 노출합니다. 메모리 누수를 방지하기 위해 클린업(리스너 제거) 함수를 반환하는 것이 좋은 설계입니다.
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('electronAPI', {
onDownloadProgress: (callback) => {
const subscription = (event, value) => callback(value);
ipcRenderer.on('download-progress', subscription);
// 메모리 누수를 방지하기 위해 클린업 함수 반환
return () => {
ipcRenderer.removeListener('download-progress', subscription);
};
}
});
렌더러 프로세스 (renderer.js)
이벤트를 구독하고 UI를 업데이트합니다.
const progressBar = document.getElementById('progress-bar');
const unsubscribe = window.electronAPI.onDownloadProgress((progress) => {
progressBar.style.width = `${progress}%`;
progressBar.textContent = `${progress}%`;
if (progress === 100) {
console.log('다운로드 완료!');
unsubscribe(); // 메모리 누수 방지를 위해 리스너 제거
}
});
4. 보안 베스트 프랙티스
Electron IPC를 다룰 때 보안은 최우선 과제여야 합니다. IPC가 적절히 보호되지 않으면 렌더러 프로세스에 주입된 악성 코드로 인해 운영체제 전체가 위험에 처할 수 있습니다.
다음 핵심 규칙을 준수하세요.
ipcRenderer를 절대로 직접 노출하지 마십시오:contextBridge.exposeInMainWorld('electron', ipcRenderer)와 같이 작성하지 마십시오. 이렇게 하면 렌더러가 보안 경계를 우회하여 모든 IPC 메시지를 자유롭게 보낼 수 있게 됩니다.- 콘텍스트 격리(Context Isolation)를 항상 활성화하십시오:
webPreferences내에서 항상contextIsolation: true와nodeIntegration: false를 설정하십시오. - 입력값 검증: 메인 프로세스에서 수신한 매개변수(파일 경로 또는 데이터베이스 쿼리 등)를 실행하기 전에 반드시 검증하십시오.
ipcMain.on+webContents.send대신ipcMain.handle을 선호하십시오: 요청 - 응답 통신 패턴에서는invoke/handle을 사용하는 것이 코드가 깔끔하고, 비동기 Promise를 네이티브로 다룰 수 있어 여러 콜백 수신이 혼재되는 것을 막을 수 있습니다.
결론
IPC 통신을 이해하는 것은 안전하고 고성능의 견고한 Electron 애플리케이션을 구축하기 위한 핵심입니다. 적절한 패턴을 사용하고 콘텍스트 격리를 적용함으로써, 사용자의 안전을 유지하면서 데스크톱 환경에서 Node.js의 강력한 성능을 극대화할 수 있습니다.