Skip to main content
Summary

IPC가 필요한 이유

Electron 앱은 하나의 JavaScript 파일 안에서 모두 실행되지 않는다. 화면을 그리는 renderer 프로세스와 운영체제 기능에 가까운 main 프로세스가 분리되어 있다. 이 분리는 안정성과 보안을 위한 구조다. renderer는 사용자가 입력한 값, 외부에서 가져온 데이터, 프런트엔드 상태를 다룬다. main은 창, 파일 시스템, 메뉴, 대화상자, 네이티브 API처럼 권한이 큰 작업을 맡는다.

문제는 두 프로세스가 서로 직접 함수를 호출할 수 없다는 점이다. renderer에서 저장 버튼을 눌렀다고 해서 곧바로 Node.js의 fs.writeFile을 호출해서는 안 된다. 대신 renderer는 저장 요청을 보내고, main은 그 요청을 검증한 뒤 실제 파일 작업을 수행한다. 이 통신 통로가 IPC, 즉 Inter-Process Communication이다.

Electron은 IPC를 위해 ipcMain과 ipcRenderer를 제공한다. 공식 문서에서도 renderer에서 main으로 보내는 단방향 메시지, 요청-응답 메시지, main에서 renderer로 보내는 메시지를 구분해 설명한다1. 실무에서는 여기에 preload와 contextBridge를 더해 renderer가 Electron API 전체에 접근하지 못하도록 제한한다.

IPC로 프로세스 사이를 안전하게 연결하기AI 생성 이미지

세 가지 통신 형태

IPC는 모양을 먼저 정해야 한다. 모든 통신을 하나의 방식으로 처리하면 코드가 불필요하게 복잡해진다.

첫째, 단방향 이벤트다. renderer가 main에게 알림을 보내고 결과를 기다리지 않는 경우에 쓴다. 예를 들어 사용자가 특정 버튼을 눌렀다는 로그를 main으로 보내거나, 창 위치 저장을 요청하지만 화면 흐름은 기다리지 않아도 되는 경우다.

둘째, 요청-응답 통신이다. renderer가 main에게 작업을 요청하고 결과를 받아야 할 때 사용한다. 파일 읽기, 설정 불러오기, 저장 성공 여부 확인처럼 결과가 필요한 작업은 ipcMain.handle과 ipcRenderer.invoke 조합이 맞다.

셋째, main에서 renderer로 보내는 알림이다. 다운로드 진행률, 백그라운드 작업 상태, 자동 업데이트 이벤트처럼 main이 먼저 화면에 알려야 할 때 사용한다. 이때 main은 특정 창의 webContents.send를 통해 메시지를 보낸다.

정리하면 다음과 같다.

상황Electron API사용 예
renderer → main, 응답 불필요ipcRenderer.send / ipcMain.on로그 기록, 단순 알림
renderer → main, 응답 필요ipcRenderer.invoke / ipcMain.handle파일 읽기, 설정 저장
main → rendererwebContents.send / ipcRenderer.on진행률, 상태 변경 알림

가능하면 결과가 필요한 작업에는 invoke와 handle을 기준으로 삼는다. Promise 흐름으로 성공과 실패를 다룰 수 있고, 호출한 쪽에서 await로 읽기 쉽다. 반대로 sendSync처럼 renderer를 멈춰 세우는 동기 IPC는 특별한 이유가 없다면 피하는 편이 좋다.

안전한 기본 설정

창을 만들 때는 renderer가 Node.js와 Electron API에 직접 접근하지 못하도록 설정한다. 이전에 창 생성 구조를 만들었다면, 이제 webPreferences에 preload 경로를 연결한다.

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

function createWindow() {
  const mainWindow = new BrowserWindow({
    width: 1000,
    height: 700,
    webPreferences: {
      preload: path.join(__dirname, 'preload.js'),
      nodeIntegration: false,
      contextIsolation: true,
      sandbox: true
    }
  });

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

app.whenReady().then(createWindow);

핵심은 nodeIntegration: false와 contextIsolation: true다. Electron 보안 문서는 원격 코드나 신뢰할 수 없는 콘텐츠에 Node.js 권한을 주지 말고, 모든 renderer에서 context isolation을 활성화하라고 권장한다2. Electron의 renderer는 웹 페이지처럼 XSS에 노출될 수 있다. 만약 XSS가 발생했는데 renderer가 파일 시스템 권한까지 갖고 있다면, 단순한 화면 조작이 로컬 파일 접근 문제로 커진다.

contextIsolation이 켜져 있으면 preload와 실제 화면 코드가 서로 다른 JavaScript 문맥에서 실행된다. 그래서 화면 코드는 require('electron')이나 ipcRenderer에 직접 접근하지 못하고, preload가 contextBridge로 공개한 함수만 사용할 수 있다. sandbox: true까지 켜면 preload에서도 전체 Node.js 환경을 마음대로 쓰는 것이 아니라 제한된 API만 사용할 수 있다. 예제처럼 contextBridge와 ipcRenderer를 가져와 얇은 다리를 만드는 용도에는 맞지만, 복잡한 파일 처리나 데이터베이스 접근 로직은 main으로 보내는 편이 안전하다.

따라서 renderer에는 필요한 기능만 좁게 열어 준다. 이 역할을 하는 파일이 preload다.

contextBridge로 좁은 API 만들기

피해야 할 패턴부터 보자. preload에서 ipcRenderer 전체를 renderer에 공개하려고 하면 renderer는 임의의 채널로 메시지를 보낼 수 있는 구조가 된다.

// 피해야 할 예
contextBridge.exposeInMainWorld('electron', {
  ipcRenderer
});

이 구조에서는 화면 코드 어디서든 window.electron.ipcRenderer.send('delete-all-files') 같은 호출을 만들 수 있다. 실제로 해당 채널이 main에 없더라도, 앱이 커지면 어떤 채널이 열려 있는지 추적하기 어려워진다. 최신 Electron에서는 ipcRenderer 전체를 contextBridge 너머로 그대로 넘기는 동작 자체가 막혀 빈 객체처럼 보일 수 있지만, 중요한 원칙은 같다. IPC 모듈 전체를 넘기지 말고, 허용한 작업만 감싼 함수로 공개해야 한다3.

대신 기능 단위의 함수만 공개한다. 작업 노트 앱을 예로 들면 renderer에 필요한 것은 IPC 자체가 아니라 노트 목록을 읽고, 노트를 저장하고, 저장 상태 이벤트를 구독하는 기능이다.

// preload.js
const { contextBridge, ipcRenderer } = require('electron');

contextBridge.exposeInMainWorld('notesAPI', {
  listNotes: () => ipcRenderer.invoke('notes:list'),
  saveNote: (note) => ipcRenderer.invoke('notes:save', note),
  onSaveStatus: (callback) => {
    const listener = (_event, status) => callback(status);
    ipcRenderer.on('notes:save-status', listener);

    return () => {
      ipcRenderer.removeListener('notes:save-status', listener);
    };
  }
});

renderer는 이제 Electron API를 모른다. 단지 window.notesAPI.saveNote(note)를 호출할 뿐이다.

// renderer.js
async function saveCurrentNote() {
  const note = {
    id: document.querySelector('#note-id').value,
    title: document.querySelector('#title').value,
    body: document.querySelector('#body').value
  };

  try {
    const result = await window.notesAPI.saveNote(note);
    console.log('saved at', result.savedAt);
  } catch (error) {
    console.error('save failed', error.message);
  }
}

이 설계의 장점은 책임이 분명하다는 점이다. renderer는 UI와 사용자 입력을 담당한다. preload는 공개 API의 모양을 결정한다. main은 권한이 필요한 실제 작업을 수행한다.

main에서 요청 처리하기

이제 main에서 notes:save 채널을 처리한다. 여기서 중요한 것은 저장 로직보다 검증이다. renderer에서 넘어온 값은 같은 앱에서 온 것처럼 보여도 신뢰하지 않는다. renderer는 웹 환경이고, 개발자 도구나 XSS, 외부 콘텐츠 삽입의 영향을 받을 수 있다.

// main.js
const { ipcMain } = require('electron');

function validateNote(input) {
  if (!input || typeof input !== 'object') {
    throw new Error('INVALID_NOTE');
  }

  if (typeof input.id !== 'string' || input.id.length > 100) {
    throw new Error('INVALID_NOTE_ID');
  }

  if (typeof input.title !== 'string' || input.title.length > 200) {
    throw new Error('INVALID_NOTE_TITLE');
  }

  if (typeof input.body !== 'string' || input.body.length > 100000) {
    throw new Error('INVALID_NOTE_BODY');
  }
}

function assertTrustedSender(event) {
  const url = event.senderFrame?.url || event.sender.getURL();

  if (!url.startsWith('file://')) {
    throw new Error('UNTRUSTED_SENDER');
  }
}

ipcMain.handle('notes:save', async (event, note) => {
  assertTrustedSender(event);
  validateNote(note);

  // 다음 장에서 실제 파일 저장으로 확장한다.
  const savedAt = new Date().toISOString();

  event.sender.send('notes:save-status', {
    type: 'saved',
    noteId: note.id,
    savedAt
  });

  return {
    ok: true,
    savedAt
  };
});

ipcMain.handle('notes:list', async (event) => {
  assertTrustedSender(event);

  return [
    { id: 'welcome', title: '첫 노트', updatedAt: new Date().toISOString() }
  ];
});

ipcMain.handle의 반환값은 renderer의 ipcRenderer.invoke 결과가 된다. main에서 예외가 발생하면 renderer의 Promise는 reject된다. 다만 Electron은 에러 객체 전체를 그대로 넘기지 않는다. 민감한 내부 정보가 renderer로 흘러가지 않도록 에러 응답 형식을 직접 정하는 편이 좋다.

또 하나 기억할 점은 IPC로 오가는 값이 직렬화된다는 것이다. 일반 객체, 배열, 문자열, 숫자처럼 복사 가능한 값은 자연스럽게 전달되지만, 함수, DOM 객체, 복잡한 클래스 인스턴스는 기대한 모습 그대로 넘어가지 않는다. 그래서 IPC payload는 가능하면 단순한 JSON 형태로 만든다.

실제 프로젝트에서는 IPC handler를 창을 로드하기 전에 등록해 둔다. 그래야 renderer가 초기화되자마자 invoke를 호출하더라도 main 쪽 handler가 준비되어 있다.

채널 이름과 메시지 구조 정하기

IPC 채널은 문자열이다. 그래서 처음에는 자유로워 보이지만, 앱이 커지면 문자열이 가장 먼저 흐트러진다. saveNote, note:save, notes:save, save-note가 섞이면 어느 채널이 실제로 쓰이는지 찾기 어려워진다.

권장하는 규칙은 도메인:동작 형식이다.

const IPC_CHANNELS = {
  NOTES_LIST: 'notes:list',
  NOTES_SAVE: 'notes:save',
  NOTES_SAVE_STATUS: 'notes:save-status',
  SETTINGS_GET: 'settings:get',
  SETTINGS_SET: 'settings:set'
};

메시지 데이터도 규칙을 둔다. 성공 응답은 작업 결과를 담고, 실패 응답은 사용자에게 보여 줄 메시지와 개발자가 추적할 코드를 분리한다.

// 성공 예
{
  ok: true,
  data: {
    id: 'welcome',
    title: '첫 노트'
  }
}

// 실패 예
{
  ok: false,
  error: {
    code: 'INVALID_NOTE_TITLE',
    message: '제목을 확인해 주세요.'
  }
}

이 방식을 쓰면 renderer는 모든 IPC 호출을 같은 흐름으로 처리할 수 있다. TypeScript를 사용한다면 채널 이름과 payload 타입을 공유 타입으로 분리할 수 있다. 예를 들어 SaveNotePayload, SaveNoteResult 같은 타입을 정의하고 preload의 window.notesAPI 선언에 연결하면, 채널 이름 오타와 잘못된 데이터 구조를 빌드 단계에서 줄일 수 있다.

단, 타입은 개발 중 실수를 줄이는 도구일 뿐이다. 런타임 검증을 대체하지 않는다. JavaScript로 실행되는 순간 외부 입력은 언제든 예상과 다를 수 있으므로 main handler 안에서 다시 확인해야 한다.

이벤트 구독은 해제까지 설계한다

main에서 renderer로 보내는 이벤트는 구독 해제를 함께 설계해야 한다. React나 Vue 같은 프레임워크에서 화면이 다시 마운트될 때 listener가 중복 등록되면 같은 이벤트가 여러 번 처리된다. 진행률 이벤트라면 UI가 튀고, 저장 이벤트라면 알림이 반복된다.

preload에서 구독 함수가 cleanup 함수를 반환하도록 만들면 renderer 코드가 안정된다.

const unsubscribe = window.notesAPI.onSaveStatus((status) => {
  console.log(status.type, status.noteId);
});

// 컴포넌트가 사라질 때 호출
unsubscribe();

또 하나의 주의점은 callback에 event 객체를 그대로 넘기지 않는 것이다. ipcRenderer.on의 첫 번째 인자인 event에는 IPC 관련 정보가 들어 있다. renderer에는 필요한 데이터만 넘긴다. 앞의 preload 예제에서 _event를 버리고 status만 callback으로 전달한 이유가 여기에 있다.

성능: IPC는 함수 호출이 아니다

IPC는 프로세스 사이의 메시지 전달이다. 같은 파일 안의 함수 호출보다 비용이 크다. 작은 설정 값이나 저장 요청을 주고받는 데는 문제가 없지만, 대용량 데이터나 매우 빈번한 이벤트를 그대로 IPC에 태우면 성능 문제가 생긴다.

피해야 할 예는 다음과 같다.

  • 큰 이미지나 긴 파일 내용을 여러 번 쪼개지 않고 반복 전송한다.

  • 입력창의 keydown마다 main으로 메시지를 보낸다.

  • 스크롤 위치나 마우스 이동 좌표를 초당 수백 번 IPC로 전달한다.

  • renderer 여러 개에 같은 대용량 객체를 계속 broadcast한다.

대안은 상황에 따라 다르다. 파일은 경로나 식별자만 IPC로 전달하고 실제 읽기·쓰기는 main에서 처리한다. 진행률처럼 자주 바뀌는 값은 100ms 또는 250ms 단위로 묶어 보낸다. 자동 저장은 입력마다 실행하지 말고 debounce를 적용한다. 큰 데이터가 필요하다면 전체 객체를 매번 보내기보다 변경된 부분만 전달한다.

IPC 설계의 기준은 명확하다. renderer가 화면을 그리는 데 필요한 최소 데이터만 주고받는다.

사용자 입력은 반드시 main에서 다시 확인한다

renderer에서 검증을 했더라도 main에서 다시 검증해야 한다. renderer 검증은 사용자 경험을 위한 것이다. 예를 들어 제목이 너무 길면 즉시 안내 메시지를 보여 주는 역할을 한다. main 검증은 보안과 데이터 무결성을 위한 것이다.

파일 저장 기능을 붙일 때는 더 중요해진다. renderer가 전달한 파일명이나 경로를 그대로 사용하면 경로 조작 문제가 생길 수 있다. 예를 들어 ../../important-file 같은 값이 들어오면 의도하지 않은 위치에 접근할 수 있다. 그래서 main은 다음을 확인해야 한다.

  • 요청한 작업이 현재 사용자에게 허용되는가

  • payload의 타입과 크기가 예상 범위 안에 있는가

  • 파일 경로가 앱이 허용한 디렉터리 안에 있는가

  • 외부 명령 실행, shell 열기, URL 열기에 사용자 입력이 직접 들어가지 않는가

  • 요청을 보낸 webContents가 신뢰할 수 있는 창인가

Electron 보안 체크리스트에도 모든 IPC 메시지의 sender를 검증하라는 항목이 있다2. 여러 창을 사용하는 앱이라면 설정 창에서만 가능한 작업, 메인 창에서만 가능한 작업을 구분해야 한다. 단순히 채널 이름이 맞다는 이유만으로 요청을 처리해서는 안 된다.

실무에서 유지되는 IPC 구조

처음에는 main 파일 하나에 ipcMain.handle을 모두 적어도 동작한다. 그러나 기능이 늘어나면 IPC는 앱의 내부 API가 된다. 유지보수를 위해 다음 구조를 권장한다.

src/
  main/
    ipc/
      notes.js
      settings.js
    main.js
  preload/
    preload.js
  renderer/
    renderer.js
  shared/
    ipcChannels.js

shared/ipcChannels.js에는 채널 이름을 모은다. main/ipc/notes.js에는 노트 관련 handler를 등록한다. preload/preload.js에는 renderer에 공개할 API만 둔다. renderer는 window.notesAPI 같은 좁은 인터페이스만 사용한다.

다만 sandbox: true를 사용하는 구성에서는 preload가 임의의 공유 파일이나 npm 패키지를 마음대로 require할 수 없을 수 있다. 이때는 preload를 번들링하거나, preload에는 아주 얇은 API 래퍼만 남기고 실제 구현은 main 쪽 모듈에 두는 방식이 좋다. 핵심은 renderer에 넓은 권한을 넘기지 않는 것이다.

이 구조를 지키면 다음 장에서 파일 시스템 저장을 붙일 때도 흐름이 흔들리지 않는다. renderer는 여전히 저장을 요청할 뿐이고, main은 검증 후 안전한 위치에 파일을 쓴다. IPC는 단순한 연결선이 아니라 Electron 앱의 권한 경계다. 경계를 좁게 만들수록 앱은 예측 가능해지고, 문제가 생겼을 때 원인을 찾기 쉬워진다.

정리

IPC를 설계할 때는 먼저 통신 형태를 고른다. 결과가 필요 없으면 send와 on, 결과가 필요하면 invoke와 handle, main이 화면에 알려야 하면 webContents.send를 사용한다. renderer에는 ipcRenderer 전체를 노출하지 않고 contextBridge로 기능 단위 API만 제공한다.

채널 이름은 일관된 규칙으로 관리하고, payload와 응답 구조를 정한다. 사용자 입력은 renderer와 main 양쪽에서 검증하되, 최종 권한 판단은 main에서 수행한다. IPC는 비용이 있는 통신이므로 대용량 데이터와 빈번한 이벤트는 줄이거나 묶어서 처리한다.

이 기준을 갖추면 Electron의 핵심 구조가 안정된다. 다음 장에서 파일 시스템과 로컬 데이터 저장을 다룰 때도 같은 원칙이 이어진다. 화면은 요청하고, main은 검증하고, 권한이 필요한 작업은 제한된 경로로만 실행한다.

참고

1Electron Inter-Process Communication

2Electron Security

3Electron contextBridge

책 목록

Electron으로 배우는 데스크톱 앱 개발

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기