실제 예제로 알아보는 Electron IPC 통신 이해하기

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가 적절히 보호되지 않으면 렌더러 프로세스에 주입된 악성 코드로 인해 운영체제 전체가 위험에 처할 수 있습니다.

다음 핵심 규칙을 준수하세요.

  1. ipcRenderer를 절대로 직접 노출하지 마십시오: contextBridge.exposeInMainWorld('electron', ipcRenderer)와 같이 작성하지 마십시오. 이렇게 하면 렌더러가 보안 경계를 우회하여 모든 IPC 메시지를 자유롭게 보낼 수 있게 됩니다.
  2. 콘텍스트 격리(Context Isolation)를 항상 활성화하십시오: webPreferences 내에서 항상 contextIsolation: truenodeIntegration: false를 설정하십시오.
  3. 입력값 검증: 메인 프로세스에서 수신한 매개변수(파일 경로 또는 데이터베이스 쿼리 등)를 실행하기 전에 반드시 검증하십시오.
  4. ipcMain.on + webContents.send 대신 ipcMain.handle을 선호하십시오: 요청 - 응답 통신 패턴에서는 invoke/handle을 사용하는 것이 코드가 깔끔하고, 비동기 Promise를 네이티브로 다룰 수 있어 여러 콜백 수신이 혼재되는 것을 막을 수 있습니다.

결론

IPC 통신을 이해하는 것은 안전하고 고성능의 견고한 Electron 애플리케이션을 구축하기 위한 핵심입니다. 적절한 패턴을 사용하고 콘텍스트 격리를 적용함으로써, 사용자의 안전을 유지하면서 데스크톱 환경에서 Node.js의 강력한 성능을 극대화할 수 있습니다.


Ghaznix 블로그에서 더 많은 개발자 인사이트 탐색하기 →