먼저 프로세스 구조를 잡자
Electron 앱은 하나의 JavaScript 파일이 모든 일을 처리하는 구조가 아니다. 웹 화면을 띄우지만, 일반 브라우저 탭과도 다르다. Electron은 크게 main 프로세스, renderer 프로세스, preload 스크립트로 책임을 나눈다.
이 구분은 Electron을 배울 때 가장 먼저 익혀야 한다. 화면에서 버튼을 눌렀는데 파일이 저장되지 않거나, fs 모듈을 렌더러에서 불러오려다 실패하거나, 보안 설정 때문에 window.api가 보이지 않는 문제는 대부분 이 구조를 이해하지 못해서 생긴다.
Electron 공식 문서는 애플리케이션에 하나의 main 프로세스가 있고, 각 BrowserWindow가 별도의 renderer 프로세스에서 웹 페이지를 렌더링한다고 설명한다.1 즉, Electron 앱은 대략 다음과 같이 나뉜다.
| 구성 요소 | 주요 책임 | 웹 개발에 비유하면 |
|---|---|---|
| main 프로세스 | 앱 시작, 창 생성, 앱 생명주기, 네이티브 기능 접근 | 백엔드와 실행 관리자 |
| renderer 프로세스 | HTML, CSS, JavaScript 기반 UI 표시 | 프런트엔드 화면 |
| preload 스크립트 | main과 renderer 사이의 제한된 API 통로 | 안전하게 설계한 클라이언트 SDK |
이 장의 목표는 코드를 많이 작성하는 것이 아니다. 각 코드가 어디에 있어야 하는지 판단할 기준을 세우는 것이다.
AI 생성 이미지
main 프로세스: 앱을 시작하고 운영체제와 가까운 일을 맡는다
main 프로세스는 Electron 앱의 시작점이다. 보통 main.js 또는 src/main/index.js 같은 파일이 이 역할을 한다. 이곳에서는 Electron의 app, BrowserWindow, Menu, dialog, Tray 같은 모듈을 사용한다.
main 프로세스가 맡는 일은 다음과 같다.
앱이 준비되었을 때 창을 만든다.
모든 창이 닫혔을 때 앱을 종료할지 결정한다.
macOS에서 Dock 동작이나 앱 활성화 방식을 처리한다.
메뉴, 트레이, 파일 선택 대화상자 같은 네이티브 기능을 다룬다.
파일 시스템, 로컬 데이터베이스, 운영체제 정보처럼 권한이 큰 작업을 수행한다.
작업 노트 앱을 만든다고 가정해 보자. 사용자가 작성한 노트를 화면에 보여 주는 일은 renderer의 책임이다. 하지만 노트를 실제 파일로 저장하거나, 저장 위치를 선택하는 대화상자를 여는 일은 main 프로세스에서 처리하는 편이 안전하다.
// main.js
const { app, BrowserWindow } = require('electron');
const path = require('node:path');
function createWindow() {
const win = new BrowserWindow({
width: 1000,
height: 700,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
},
});
win.loadFile('index.html');
}
app.whenReady().then(() => {
createWindow();
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});여기서 핵심은 new BrowserWindow()다. main 프로세스는 직접 UI를 그리지 않는다. 대신 창을 만들고, 그 창이 어떤 HTML을 로드할지 정한다. 창 안에서 실행되는 웹 페이지는 renderer 프로세스가 담당한다.
main 프로세스에는 중요한 주의점이 있다. 앱 전체에 하나뿐인 중심 프로세스이므로 무거운 작업을 오래 붙잡고 있으면 전체 앱 반응성이 나빠질 수 있다. 대용량 파일 분석, 이미지 변환, 검색 인덱싱처럼 시간이 오래 걸리는 작업은 별도 워커나 백그라운드 프로세스로 분리하는 설계를 고려해야 한다.
renderer 프로세스: 사용자가 보는 화면을 담당한다
renderer 프로세스는 창 안의 웹 페이지를 실행한다. HTML을 그리고, CSS를 적용하고, 버튼 클릭 이벤트를 처리한다. React, Vue, Svelte 같은 프런트엔드 프레임워크를 쓴다면 대부분의 컴포넌트 코드는 renderer에서 실행된다.
renderer는 브라우저와 비슷하게 생각하면 이해하기 쉽다. 다음과 같은 코드는 renderer에 어울린다.
// renderer.js
const saveButton = document.querySelector('#save');
const editor = document.querySelector('#editor');
saveButton.addEventListener('click', async () => {
const content = editor.value;
await window.notes.save(content);
});이 코드에서 renderer는 사용자가 입력한 값을 읽고 window.notes.save()를 호출한다. 하지만 직접 fs.writeFile()을 호출하지 않는다. renderer는 UI의 상태와 사용자 상호작용을 관리하고, 권한이 필요한 작업은 미리 허용된 API를 통해 요청한다.
이 원칙을 지키면 구조가 선명해진다.
버튼, 입력창, 목록, 라우팅, 화면 상태는 renderer에 둔다.
파일 저장, 메뉴 구성, 알림, 대화상자, 자동 업데이트는 main에 둔다.
renderer가 main의 기능을 필요로 할 때는 preload가 노출한 제한된 API를 사용한다.
웹 개발 경험이 있다면 renderer를 "브라우저에서 실행되는 프런트엔드"로 생각하면 된다. 다만 Electron에서는 같은 앱 안에 main이라는 더 강한 권한의 영역이 함께 존재한다. 그래서 renderer에 모든 권한을 주는 방식은 피해야 한다.
preload 스크립트: 보안 경계에 놓인 통로
preload 스크립트는 BrowserWindow의 webPreferences.preload로 지정한다. 이 스크립트는 renderer의 웹 페이지가 로드되기 전에 먼저 실행된다.2 위치는 renderer 쪽에 가깝지만 역할은 renderer와 main 사이의 통로에 가깝다.
preload가 필요한 이유는 보안 때문이다. renderer에 Node.js 권한을 직접 주면 화면 코드가 파일 시스템이나 운영체제 기능에 접근할 수 있다. 만약 renderer에 XSS 취약점이 생기면, 공격자가 사용자의 로컬 파일에 접근하는 길이 열릴 수 있다.
그래서 권장 구조는 다음과 같다.
renderer에는 Node.js 권한을 주지 않는다.
preload에서 필요한 기능만
contextBridge로 노출한다.preload는 IPC를 통해 main에 작업을 요청한다.
main은 요청을 검증한 뒤 실제 작업을 수행한다.
예를 들어 노트 저장 기능을 renderer에 제공하려면 다음처럼 좁은 API를 만든다.
// preload.js
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('notes', {
save: (content) => ipcRenderer.invoke('notes:save', content),
load: () => ipcRenderer.invoke('notes:load'),
});renderer는 이제 window.notes.save(content)만 호출할 수 있다. ipcRenderer 전체를 직접 사용할 수는 없다. 이 차이가 중요하다.
다음과 같은 방식은 피해야 한다.
// 피해야 할 방식
contextBridge.exposeInMainWorld('electron', {
send: ipcRenderer.send,
});이렇게 노출하면 renderer가 임의의 IPC 채널로 메시지를 보낼 수 있다. API 표면이 너무 넓어진다. preload는 "무엇이든 할 수 있는 다리"가 아니라 "승인된 기능만 통과시키는 좁은 출입구"여야 한다. Electron 공식 문서도 contextBridge를 사용할 때 원시 IPC 메서드를 그대로 노출하지 말고, 필요한 동작별 메서드를 제한적으로 제공하라고 권장한다.3
main 쪽에서는 이 요청을 받는다.
// main.js 일부
const { ipcMain } = require('electron');
const fs = require('node:fs/promises');
const path = require('node:path');
ipcMain.handle('notes:save', async (event, content) => {
if (typeof content !== 'string') {
throw new Error('Invalid note content');
}
const filePath = path.join(app.getPath('userData'), 'note.txt');
await fs.writeFile(filePath, content, 'utf8');
return { ok: true };
});이 예제에서 검증은 작지만 중요하다. renderer는 같은 앱 안에 있지만, 보안 관점에서는 신뢰하지 않는 입력으로 다루는 편이 좋다. 나중에 원격 콘텐츠를 표시하거나, 외부 라이브러리에서 취약점이 생기거나, 사용자가 만든 HTML을 렌더링할 수도 있기 때문이다.
contextIsolation, nodeIntegration, sandbox의 의미
Electron의 보안 옵션은 단순한 설정값이 아니다. 앱 구조를 결정한다.
contextIsolation
contextIsolation: true는 preload와 renderer 페이지가 서로 다른 JavaScript 세계에서 실행되도록 한다. preload에서 사용하는 전역 객체와 웹 페이지의 전역 객체가 분리된다. 그래서 renderer가 preload 내부의 강한 권한에 직접 접근하기 어렵다. Electron에서는 이 옵션이 기본적으로 켜져 있으며, 모든 앱에서 사용하는 것이 권장된다.3
이 옵션이 켜져 있으면 preload에서 window.foo = ...처럼 값을 넣어도 renderer에서 그대로 보이지 않는다. 대신 contextBridge.exposeInMainWorld()를 사용해야 한다.
nodeIntegration
nodeIntegration: false는 renderer에서 require, fs, path 같은 Node.js 기능을 직접 쓰지 못하게 한다. 화면 코드가 로컬 파일 시스템에 접근하지 못하게 막는 중요한 경계다.
예전 예제 중에는 renderer에서 require('electron')이나 require('fs')를 바로 사용하는 코드가 있다. 학습용으로는 동작할 수 있지만, 실무 구조로 가져가면 위험하다. 특히 원격 콘텐츠를 로드하는 창에서 Node.js 통합을 켜면 XSS가 곧 사용자 컴퓨터의 코드 실행 문제로 커질 수 있다. Electron 보안 문서도 원격 코드를 Node.js 통합이 켜진 상태로 실행하지 말라고 강하게 경고한다.4
sandbox
sandbox: true는 renderer를 Chromium의 샌드박스 안에서 실행하도록 한다. 샌드박스는 프로세스가 접근할 수 있는 운영체제 자원을 제한하는 보호막이다. 이 옵션을 켜면 renderer의 권한은 더 줄어든다.
그 결과 구조는 더 엄격해진다. renderer와 preload에서 직접 처리하던 권한 있는 작업을 main으로 옮기고, IPC를 통해 요청해야 한다. 작성할 코드는 조금 늘지만, 문제가 생겼을 때 피해 범위를 줄일 수 있다. Electron은 renderer에서 샌드박스를 활성화하는 방향을 권장한다.4
이 책에서는 특별한 이유가 없는 한 다음 설정을 기본 출발점으로 삼는다.
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
}단일 창 앱과 다중 창 앱의 설계
단일 창 앱에서는 구조가 비교적 단순하다. 하나의 main 프로세스가 하나의 BrowserWindow를 만들고, 그 창에 하나의 renderer가 붙는다. preload도 그 창에 하나 연결된다.
main process
└─ BrowserWindow
├─ preload script
└─ renderer process작업 노트 앱의 첫 버전은 이 구조로 충분하다. 노트 목록, 편집기, 설정 화면을 하나의 renderer 안에서 라우팅으로 나눌 수 있다.
다중 창 앱에서는 생각할 것이 늘어난다. 예를 들어 메인 창, 설정 창, 미리보기 창을 따로 띄운다면 각 창은 별도의 renderer를 가진다.
main process
├─ MainWindow
│ ├─ preload script
│ └─ renderer process
├─ SettingsWindow
│ ├─ preload script
│ └─ renderer process
└─ PreviewWindow
├─ preload script
└─ renderer process이때 모든 창에 같은 preload API를 줄 필요는 없다. 설정 창에는 설정 읽기와 저장 API만 있으면 된다. 미리보기 창에는 파일 삭제 API가 필요하지 않다. 창마다 필요한 권한을 다르게 설계하면 실수와 공격 가능성을 줄일 수 있다.
다중 창 앱에서는 상태 관리 위치도 중요하다. 모든 renderer가 각자 파일을 읽고 쓰게 만들면 충돌이 생기기 쉽다. 보통은 main이 공유 상태의 기준점 역할을 하고, renderer들은 필요한 변경을 요청한다. main은 변경이 끝난 뒤 각 창에 업데이트를 알려 줄 수 있다. 이 방식은 이후 IPC 장에서 더 자세히 다룬다.
책임을 나누는 기준
Electron 아키텍처를 판단할 때는 다음 질문을 사용하자.
이 코드는 화면을 그리는가? 그렇다면 renderer에 둔다.
이 코드는 운영체제 기능이나 파일 시스템에 접근하는가? 그렇다면 main에 둔다.
renderer가 그 기능을 호출해야 하는가? 그렇다면 preload에 제한된 API를 만든다.
외부 입력을 받는가? 그렇다면 main에서 다시 검증한다.
여러 창이 함께 써야 하는 상태인가? 그렇다면 main을 기준점으로 둘지 검토한다.
Electron 개발에서 구조가 흐려지는 순간은 대개 "잠깐만 편하게"라는 선택에서 시작된다. renderer에서 Node.js를 켜면 당장은 코드가 짧아진다. 하지만 화면 코드와 시스템 권한이 섞이고, 보안 경계가 사라진다. 반대로 main, renderer, preload의 책임을 처음부터 나누면 이후 기능이 늘어나도 앱의 형태가 무너지지 않는다.
이제 우리는 Electron 앱의 기본 골격을 잡았다. 다음 단계에서는 창을 만들고 앱 생명주기를 다루면서 이 구조가 실제 실행 흐름에서 어떻게 작동하는지 살펴본다.
참고
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.