Electron을 처음 접하면 묘한 감각을 느켰습니다. HTML, CSS, JS로 UI를 만드는 건 익숙한데, fs.readFileSync로 파일을 읽고 시스템 트레이에 아이콘을 넣는게 정말 새롭게 느겼습니다.
원인은 프로세스 모델에 있습니다. 브라우저에서는 하나의 탭 안에서 모든 걸 하지만, Electron에서는 역할이 둘로 나뉩니다. 이 구조를 이해하지 않으면 "왜 렌더러에서 fs를 못 쓰지?", "왜 메인에서 DOM에 접근이 안 되지?" 같은 혼란이 계속되었습니다.
이 글에서는 Electron의 두 프로세스가 각각 무엇을 담당하고, 어떻게 소통하는지를 브라우저와 비교하며 정리합니다.
Electron의 프로세스 모델
처음에는 Electron만의 새로운 개념이라고 생각했는데, 알고 보니 이미 알고 있는 구조였습니다.
Chrome에서 탭 하나가 죽어도 브라우저 전체가 안 죽는 이유를 생각하면 됩니다. Chrome 내부에서 브라우저 프로세스(탭 관리, 네트워크, 파일 시스템)와 렌더러 프로세스(각 탭의 웹 페이지)가 분리되어 있기 때문입니다.
Electron은 Chromium 기반이라 이 구조를 그대로 가져옵니다.
| Chrome | Electron |
|---|---|
| 브라우저 프로세스 | 메인 프로세스 |
| 렌더러 프로세스 (탭) | 렌더러 프로세스 (BrowserWindow) |
| 탭을 여러 개 열 수 있음 | BrowserWindow를 여러 개 만들 수 있음 |
다른 점은, Chrome의 브라우저 프로세스는 개발자가 손댈 수 없지만 Electron의 메인 프로세스는 Node.js 환경에서 직접 코드를 작성할 수 있다는 점입니다. 이 차이를 알고 나니 Electron이 데스크탑 앱을 만들 수 있는 이유가 이해됐습니다.
메인 프로세스
Electron 앱에는 메인 프로세스가 단 하나 존재합니다. 앱의 진입점(main.js 또는 main.ts)에서 실행됩니다.
처음에 메인 프로세스 코드를 열어봤을 때, 프론트엔드 코드와는 전혀 다른 느낌이었습니다. 이쪽에서 다루는 것들은:
- 앱 생명주기 관리: 앱 시작, 종료, 활성화/비활성화 이벤트 처리
- 윈도우 생성 및 관리:
BrowserWindow인스턴스 생성, 크기 조절, 위치 지정 - 네이티브 API 접근: 시스템 트레이, 메뉴바, 알림, 파일 다이얼로그
- Node.js API 전체 사용 가능:
fs,path,child_process등
const { app, BrowserWindow } = require('electron');
app.whenReady().then(() => {
const win = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
},
});
win.loadFile('index.html');
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});메인 프로세스에서 할 수 없는 것
처음에 메인 프로세스에서 document.getElementById를 쓰려고 했다가 안 되서 당황했습니다. 메인에서는 DOM에 접근할 수 없습니다. UI는 전적으로 렌더러 프로세스의 영역입니다.
나중에 깨달은 건데, 메인 프로세스는 사실상 백엔드 서버입니다. 파일을 읽고, 시스템 리소스를 관리하지만, 화면을 직접 그리지는 않습니다. 이렇게 생각하니까 코드를 어디에 넣어야 할지 훨씬 명확해졌습니다.
렌더러 프로세스
BrowserWindow를 하나 만들 때마다 렌더러 프로세스가 하나씩 생깁니다. 각 렌더러는 독립된 웹 페이지 환경입니다.
여기가 프론트엔드 개발자에게 가장 편한 영역입니다. React, Vue, Svelte 다 쓸 수 있고, fetch, localStorage, Canvas 같은 브라우저 API도 그대로 동작합니다. 기존에 만들던 웹 앱 코드를 거의 그대로 가져올 수 있었습니다.
렌더러 프로세스에서 할 수 없는 것
그런데 렌더러에서 require('fs')를 해보면 에러가 납니다. 기본적으로 Node.js API에 접근할 수 없습니다.
처음에는 이해가 안 됐는데, 이유를 알고 나니 납득이 됐습니다. 과거에는 nodeIntegration: true 옵션으로 렌더러에서 Node.js를 직접 쓸 수 있었지만, 렌더러가 외부 웹 페이지를 로드하는 경우 악성 스크립트가 fs로 파일 시스템을 조작할 수 있는 보안 구멍이었습니다. 그래서 현재는 기본적으로 비활성화되어 있습니다.
그러면 렌더러에서 파일을 읽고 싶을 때는 어떻게 할까? 여기서 Preload 스크립트와 IPC가 등장합니다.
프로세스 간 통신 (IPC)
메인은 Node.js를 쓸 수 있지만 DOM에 접근 못 하고, 렌더러는 DOM을 다루지만 Node.js를 못 씁니다. 실제 앱을 만들면 이 둘이 소통해야 하는 상황이 바로 옵니다.
제 경우에는 "파일 열기" 기능을 만들 때 처음 마주쳤습니다. 사용자가 버튼을 클릭(렌더러)하면 파일 다이얼로그를 열고 파일을 읽어서(메인) 내용을 화면에 표시(렌더러)해야 하는데, 한쪽에서 다 처리할 수가 없었습니다.
이때 사용하는 것이 IPC(Inter-Process Communication) 입니다.
렌더러 → 메인 (ipcRenderer.invoke)
가장 많이 쓰게 되는 패턴입니다. 렌더러에서 메인에 요청을 보내고 응답을 기다립니다. 프론트에서 fetch로 API를 호출하는 것과 거의 같은 감각이라 금방 익숙해졌습니다.
// 메인 프로세스
const { ipcMain, dialog } = require('electron');
const fs = require('fs');
ipcMain.handle('read-file', async () => {
const { canceled, filePaths } = await dialog.showOpenDialog({
properties: ['openFile'],
});
if (canceled || filePaths.length === 0) return null;
return fs.readFileSync(filePaths[0], 'utf-8');
});// 렌더러 프로세스 (preload를 통해 노출된 API 사용)
const content = await window.electronAPI.readFile();메인 → 렌더러 (webContents.send)
메인에서 렌더러로 단방향으로 메시지를 보내는 패턴입니다. 서버에서 클라이언트로 이벤트를 푸시하는 것과 비슷합니다.
// 메인 프로세스
win.webContents.send('update-available', { version: '2.0.0' });// 렌더러 프로세스 (preload를 통해 노출된 API 사용)
window.electronAPI.onUpdateAvailable((data) => {
showNotification(`새 버전 ${data.version}이 있습니다`);
});양방향 통신 정리
| 방향 | 메인 측 API | 렌더러 측 API | 용도 |
|---|---|---|---|
| 렌더러 → 메인 (응답 필요) | ipcMain.handle | ipcRenderer.invoke | 파일 읽기, 다이얼로그 등 |
| 렌더러 → 메인 (응답 불필요) | ipcMain.on | ipcRenderer.send | 로그 전송, 설정 변경 등 |
| 메인 → 렌더러 | webContents.send | ipcRenderer.on | 업데이트 알림, 상태 변경 등 |
Preload 스크립트
IPC 코드를 보면 렌더러에서 window.electronAPI를 사용하고 있습니다. 이걸 만들어주는 것이 Preload 스크립트입니다.
Preload 스크립트는 특수한 위치에 있습니다.
- 렌더러 프로세스에서 실행되지만
- Node.js API와
ipcRenderer에 접근 가능하고 contextBridge를 통해 렌더러의window객체에 안전하게 API를 노출할 수 있습니다
// preload.js
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('electronAPI', {
readFile: () => ipcRenderer.invoke('read-file'),
onUpdateAvailable: (callback) => {
ipcRenderer.on('update-available', (_event, data) => callback(data));
},
});이렇게 하면 렌더러의 웹 페이지에서는 window.electronAPI.readFile()만 보이고, ipcRenderer나 Node.js API에는 직접 접근할 수 없습니다.
프론트엔드 관점에서 보면, Preload는 BFF(Backend For Frontend) 같은 역할입니다. 렌더러(프론트엔드)가 메인(백엔드)의 내부 구현을 모르는 채로, 깔끔한 인터페이스만 사용할 수 있게 해줍니다.
전체 흐름 요약
[렌더러] → [Preload] → [메인]
실수하기 쉬운 부분
1. 렌더러에서 Node.js API를 직접 쓰려는 시도
// 렌더러에서 이렇게 하면 에러
const fs = require('fs');
fs.readFileSync('/some/path');저도 처음에 이 에러를 검색했더니 nodeIntegration: true로 해결하라는 오래된 답변이 잔뜩 나왔습니다. 쓰면 안 됩니다. 보안상 위험하고 Electron 공식 문서에서도 권장하지 않습니다. Preload + IPC 패턴을 쓰면 됩니다.
2. Preload에서 ipcRenderer를 통째로 노출
// 이렇게 하면 안 됩니다
contextBridge.exposeInMainWorld('electron', {
ipcRenderer: ipcRenderer,
});이러면 렌더러에서 ipcRenderer의 모든 메서드에 접근할 수 있어, nodeIntegration: true와 다를 바 없습니다. 필요한 채널만 함수로 감싸서 노출해야 합니다.
3. IPC 채널 이름 관리
앱이 커지면서 IPC 채널이 수십 개로 늘어났는데, 채널 이름을 문자열로 흩뿌려놓으니 오타 하나 때문에 한참을 디버깅한 적이 있습니다. 그 뒤로 채널 이름을 상수로 관리하기 시작했습니다.
// channels.ts
export const IPC_CHANNELS = {
READ_FILE: 'file:read',
WRITE_FILE: 'file:write',
SHOW_NOTIFICATION: 'notification:show',
} as const;4. 렌더러 간 직접 통신은 불가
BrowserWindow를 여러 개 열었을 때, 렌더러끼리 직접 통신할 수 없습니다. 항상 메인 프로세스를 경유해야 합니다. 웹에서 탭 간 통신에 BroadcastChannel을 쓰는 것과 달리, Electron에서는 메인이 메시지를 중계하는 구조입니다.
[렌더러 A] → IPC → [메인] → IPC → [렌더러 B]
정리
| 메인 프로세스 | 렌더러 프로세스 | |
|---|---|---|
| 개수 | 앱당 1개 | BrowserWindow당 1개 |
| 실행 환경 | Node.js | Chromium (브라우저) |
| DOM 접근 | 불가 | 가능 |
| Node.js API | 전체 사용 가능 | 기본적으로 불가 |
| 역할 | 앱 생명주기, 네이티브 API, 윈도우 관리 | UI 렌더링, 사용자 인터랙션 |
결국 Electron의 프로세스 모델은 클라이언트-서버 아키텍처입니다. 렌더러가 프론트엔드, 메인이 백엔드, IPC가 HTTP 요청, Preload가 BFF. 이 멘탈 모델이 잡히고 나니까 "이 코드는 어디에 넣어야 하지?"라는 질문에 바로 답할 수 있게 됐습니다.