04 — Mental Model

이 문서가 답하는 질문: “Electron 앱 한 개를 가장 작은 코드로 그려보면 어떻게 생겼는가? 그 코드의 각 줄이 어느 프로세스에서 동작하고 어떤 권한을 가지는가?” 한 줄 답 (Pyramid Top): “Electron 앱은 main.js · preload.js · renderer(HTML/JS) 세 파일이 한 프로세스 트리의 세 역할에 각각 매핑된다. 코드가 어느 파일에 있느냐가 곧 권한과 격리를 결정한다 — ‘한 앱’이지만 세 개의 다른 세계가 IPC로 연결된 것.”


Why — 왜 3파일 멘탈 모델이 핵심인가

Electron을 처음 짤 때 가장 흔히 막히는 지점은 *“이 코드를 어디에 둬야 하지?”*다. fs.readFile은 main.js에 둬야 하나 renderer.js에 둬야 하나? BrowserWindow는? ipcMain은? window.electronAPI.readFile()이라는 함수는 누가 만드는 건가?

이 질문들이 어려운 이유는 세 파일이 같은 프로세스에 있지 않기 때문이다. 같은 변수, 같은 import 시스템, 같은 모듈 그래프를 공유하지 않는다. 한 파일에서 만든 객체를 다른 파일이 직접 참조할 수 없다.

세 파일을 세 개의 다른 컴퓨터로 상상하면 이해가 쉽다.

파일비유권한
main.js서버Node 전부 + Electron 전부 + OS 전부
preload.js프록시 게이트웨이 — 서버와 클라이언트 둘 다 안다Node 일부 + Web 일부, 둘 사이를 잇는 함수만 노출
renderer (HTML/JS)클라이언트Web API만 (브라우저 페이지처럼)

서버-게이트웨이-클라이언트가 세 컴퓨터인 것처럼, main-preload-renderer는 세 V8 isolate다. 그 사이엔 직렬화된 메시지만 다닌다.

이 멘탈 모델만 받아들이면 모든 디자인 결정이 자연스러워진다 — 어디서 하면 권한이 충분한가? bridge는 무엇을 노출해야 하나? 비동기를 어디서 처리하나?


How — 세 파일이 어떻게 연결되는가

흐름 한 줄 요약

  1. npx electron . → OS가 Electron binary를 실행
  2. binary가 package.json"main" 필드를 보고 main.js 실행 — Main 프로세스 시작
  3. main.js가 app.whenReady()를 기다린 뒤 new BrowserWindow({ webPreferences: { preload: 'preload.js' } })
  4. Electron이 Renderer 프로세스를 fork → 그 안에서 preload.js먼저 실행
  5. preload가 contextBridge.exposeInMainWorld('electronAPI', { ... })좁은 API만 window에 노출
  6. 그 다음 Renderer가 index.html을 로드 → renderer.jswindow.electronAPI.xxx()로 노출된 함수만 호출 가능
  7. 노출된 함수가 내부적으로 ipcRenderer.invoke('channel', args)로 Main에 메시지 → Main이 ipcMain.handle('channel', handler)로 처리

What — 가장 작은 Electron 앱 (3파일 + package.json)

파일 구조

hello-electron/
├── package.json
├── main.js
├── preload.js
└── index.html

package.json

{
  "name": "hello-electron",
  "version": "1.0.0",
  "main": "main.js",
  "scripts": { "start": "electron ." },
  "devDependencies": { "electron": "^33.0.0" }
}

"main": "main.js"Electron이 부팅할 때 실행할 진입점. 이게 Main 프로세스의 entry다.

main.js — Main 프로세스

// main.js  ← Main 프로세스에서 실행됨
// 권한: Node 전부 + Electron API + OS
 
const { app, BrowserWindow, ipcMain } = require('electron');
const path = require('node:path');
const fs = require('node:fs/promises');
const os = require('node:os');
 
// 앱 라이프사이클 이벤트
app.whenReady().then(() => {
  const win = new BrowserWindow({
    width: 900,
    height: 600,
    webPreferences: {
      // 보안 디폴트 — 모두 명시
      preload: path.join(__dirname, 'preload.js'),
      contextIsolation: true,   // Renderer와 preload가 다른 컨텍스트
      sandbox: true,            // Renderer는 Chromium sandbox 안
      nodeIntegration: false,   // Renderer에서 require 못 함
    }
  });
 
  win.loadFile('index.html');
});
 
// macOS 관례 — 창 다 닫혀도 앱은 살아 있음
app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') app.quit();
});
 
// IPC handler — Renderer가 호출하면 여기서 처리
ipcMain.handle('read-home-dir', async () => {
  const home = os.homedir();
  const files = await fs.readdir(home);
  return { home, count: files.length };
});
 
ipcMain.handle('get-system-info', () => {
  return {
    platform: process.platform,
    arch: process.arch,
    chromeVersion: process.versions.chrome,
    nodeVersion: process.versions.node,
    electronVersion: process.versions.electron,
  };
});

이 파일에서 주목할 것

  • require('electron'): Electron의 메인 API. Renderer에서는 못 쓴다.
  • require('node:fs/promises'): Node API. Main에서만 자유롭게 쓴다.
  • webPreferences: BrowserWindow 보안 옵션. 디폴트가 점점 빡빡해지고 있지만 명시하는 게 좋다.
  • ipcMain.handle(channel, handler): 비동기 요청-응답. Renderer의 invoke와 짝.

preload.js — Renderer의 게이트웨이

// preload.js  ← Renderer 프로세스에서 실행됨 (Renderer JS보다 먼저)
// 권한: Node 일부(contextIsolation 켜져 있으면 제한적) + Web 일부
// 역할: 좁은 API만 Renderer에 노출
 
const { contextBridge, ipcRenderer } = require('electron');
 
// window.electronAPI.xxx() 형태로 노출됨
contextBridge.exposeInMainWorld('electronAPI', {
  // 비동기 함수 — 내부적으로 IPC invoke
  readHomeDir: () => ipcRenderer.invoke('read-home-dir'),
  getSystemInfo: () => ipcRenderer.invoke('get-system-info'),
});

이 파일에서 주목할 것

  • contextBridge.exposeInMainWorld: 유일한 안전한 노출 방법. window.electronAPI = { ... } 같은 직접 할당이 아니다. contextIsolation이 켜져 있으면 직접 할당은 renderer 쪽에서 안 보인다.
  • 노출하는 함수는 함수만. ipcRenderer 자체를 노출하면 renderer가 임의 채널 호출 가능 → 큰 보안 구멍.
  • preload는 Renderer마다 1개. BrowserWindow 여러 개면 각각의 preload가 따로 실행된다.

index.html — Renderer가 로드하는 페이지

<!doctype html>
<html>
<head>
  <meta charset="utf-8" />
  <meta http-equiv="Content-Security-Policy"
        content="default-src 'self'; script-src 'self'" />
  <title>Hello Electron</title>
</head>
<body>
  <h1>Hello, Electron</h1>
  <button id="info">시스템 정보</button>
  <button id="home">홈 디렉터리 파일 개수</button>
  <pre id="out"></pre>
  <script src="renderer.js"></script>
</body>
</html>

CSP meta 태그가 Renderer 보안의 일부. 외부 스크립트·inline <script>·eval을 막아둔다.

renderer.js — Renderer 프로세스의 JS

// renderer.js  ← Renderer 프로세스에서 실행됨 (Chromium 페이지로)
// 권한: Web API만 + window.electronAPI (preload가 노출한 것)
// 못 하는 것: require('fs'), require('child_process'), process.versions 등
 
document.getElementById('info').addEventListener('click', async () => {
  const info = await window.electronAPI.getSystemInfo();
  document.getElementById('out').textContent = JSON.stringify(info, null, 2);
});
 
document.getElementById('home').addEventListener('click', async () => {
  const { home, count } = await window.electronAPI.readHomeDir();
  document.getElementById('out').textContent = `${home} → ${count}개`;
});

이 파일에서 주목할 것

  • require·process·__dirname 없음. 여긴 브라우저 페이지처럼 동작한다.
  • window.electronAPI.xxx()만 가능. preload가 노출한 것뿐.
  • 모든 IPC 호출은 비동기(Promise). await을 잊으면 값이 아니라 Promise가 들어온다.

같은 코드를 권한 매트릭스로 정리

코드main.jspreload.jsrenderer.js
require('electron').appOK불가 (Renderer에 app 없음)불가
require('electron').contextBridge불가OK불가
require('node:fs')OK제한적 (sandbox=true면 불가)불가
BrowserWindow 생성OK불가불가
ipcMain.handleOK불가불가
ipcRenderer.invoke불가OKOK (preload가 함수로 노출했을 때만)
document.querySelector불가 (DOM 없음)OK (페이지 DOM 보임)OK
window.alert불가OK (디스커리지)OK
console.logOK (터미널)OK (Renderer DevTools)OK (Renderer DevTools)
process.versions.electronOKOK불가 (contextIsolation이면)

추가 — DevTools 두 종류

어디서DevTools 위치
Main 프로세스 디버깅electron --inspect=5858 . + Chrome chrome://inspect
Renderer 디버깅win.webContents.openDevTools() 또는 Cmd/Ctrl+Opt/Shift+I

Main과 Renderer는 다른 DevTools를 본다. 같은 console.log가 다른 곳에 찍힌다 — 이게 처음엔 가장 헷갈리는 부분.


What-if — 잘못 쓰면

1) preload에서 window.electronAPI = {...} 직접 할당

증상: renderer에서 window.electronAPIundefined. 원인: contextIsolation: true는 preload와 renderer를 다른 V8 컨텍스트에 둔다. preload의 window는 renderer의 window와 다른 객체다. 대응: 무조건 contextBridge.exposeInMainWorld를 쓴다.

2) preload에서 ipcRenderer 자체를 노출

// 위험!
contextBridge.exposeInMainWorld('ipc', ipcRenderer);

증상: 외부 스크립트(XSS)가 window.ipc.invoke('exec', 'rm -rf ~') 같은 임의 채널 호출. 원인: ipcRenderer를 통째로 주면 renderer가 어떤 채널이든 호출 가능. Main의 handler가 위험한 일을 하면 끝. 대응: 함수만 노출. 가능하다면 인자 검증도 함께.

// OK
contextBridge.exposeInMainWorld('electronAPI', {
  readFile: (path) => {
    // 인자 검증 — 임의 경로 차단
    if (typeof path !== 'string') throw new TypeError('path must be string');
    return ipcRenderer.invoke('read-file', path);
  }
});

3) Main에서 동기 IPC 사용

// 비추천
const result = ipcRenderer.sendSync('do-thing', data);

증상: Renderer UI가 완전히 멈춤. 사용자가 클릭해도 반응 없음. 원인: sendSync는 Main의 응답까지 Renderer 스레드 차단. Chromium 페이지가 프레임을 그리지 못함. 대응: 항상 invoke/handle (비동기). UI는 낙관적으로 먼저 그리고, 결과가 오면 갱신.

4) 한 IPC channel에 handler 두 개 등록

ipcMain.handle('read-file', handlerA);
ipcMain.handle('read-file', handlerB);  // 에러 — 같은 채널에 두 번

증상: 두 번째 handle 호출에서 throws. 또는 메모리 누수. 원인: ipcMain.handle채널당 핸들러 1개. 여러 모듈이 같은 채널을 쓰려면 한 모듈에서 합쳐서 등록. 대응: 채널 이름을 모듈별로 prefix (fs:read-file, os:get-info). 등록을 한 곳에서 관리.

5) app.on('ready') 전에 BrowserWindow 생성

// 잘못
const win = new BrowserWindow();  // app이 ready되기 전
app.whenReady().then(() => { ... });

증상: 에러 — “BrowserWindow must be created after app is ready.” 원인: Electron의 OS 의존 부품(Chromium 초기화)이 ready 이전엔 준비 안 됨. 대응: 항상 app.whenReady().then(...) 안에서.

6) Renderer의 console.log를 Main 터미널에서 찾는다

증상: console.log('xxx')를 renderer.js에 찍었는데 터미널에 안 나옴. 원인: Renderer console은 Renderer DevTools로 간다. 터미널은 Main 전용. 대응: win.webContents.openDevTools()를 main.js에 추가하거나 DevTools 단축키. 진짜로 터미널에 모으고 싶으면 win.webContents.on('console-message', ...)로 forward.


Insight — 흥미로운 이야기

”preload는 Renderer 안의 트로이 목마다”

preload.js는 Renderer 프로세스에서 실행되지만 Renderer JS와 다른 V8 컨텍스트에 산다. 같은 프로세스, 다른 세계. 이 분리된 컨텍스트 구조는 Chromium의 isolated worlds 기능 — 원래 Chrome 확장페이지 JS와 충돌하지 않게 따로 도는 메커니즘이다. Electron은 이걸 그대로 빌려와 preload에 적용했다.

isolated worlds 덕에 preload는 Renderer 페이지의 변수에 영향받지 않는다 — 페이지가 window.fetch를 다시 정의해도 preload의 fetch는 그대로. 공격자가 페이지 JS를 오염시켜도 preload는 안전. 이 비밀이 XSS → 임의 OS 명령을 가로막는 유일한 방어선이다.

”main과 renderer가 같은 코드를 공유하기 어려운 이유

require('./shared/utils.js')를 main.js와 renderer.js에서 같이 import한다고 가정. 동작은 한다 — 하지만 두 isolate에 각각 다른 객체가 만들어진다. main에서 utils.cache.set('k', 'v')를 한다고 renderer에서 보이지 않는다. 모듈 그래프는 프로세스마다 독립.

이 사실은 번들러 설정에도 영향을 준다. webpack/vite의 Electron 템플릿은 보통 main·preload·renderer를 각각 다른 entry로 빌드해서 세 번들을 만든다. 코드를 분리해서 빌드해야 분리된 실행 환경에 맞다.

세 파일이라기보다 세 프로세스의 entry다”

본 챕터의 “3파일 멘탈 모델”이라는 표현은 비유다. 실제로는 세 프로세스 역할의 entry point가 셋이라는 게 정확하다. 각 entry가 내부적으로 수십~수백 파일을 import해도 프로세스 경계는 entry 단위.

이 점이 큰 앱에서는 더 분명해진다. VS Code의 main entry는 수백 개 파일을 import하지만 모두 같은 Main 프로세스에서 실행된다. Renderer entry는 Monaco editor + Workbench UI 전체를 import하지만 Renderer 프로세스. 경계는 파일 수가 아니라 프로세스.

Chromium isolated world가 Electron 보안의 진짜 토대”

Electron 5(2019) 이전엔 contextIsolation: false가 디폴트였다. 이때는 preload와 renderer가 같은 컨텍스트. 페이지 JS가 preload의 변수를 덮어쓸 수 있고, 노출된 API의 동작을 바꿀 수 있었다. XSS 한 번에 모든 게 위험했다.

Electron 12(2021)부터 contextIsolation: true가 디폴트. 이건 디폴트 한 줄 변경이 아니라 Electron 보안 모델의 패러다임 전환이었다. 같은 시기에 remote 모듈 제거(Electron 14)·sandbox 기본화(Electron 20+)가 함께 일어났다.

이 변화는 수많은 기존 코드가 갑자기 깨지는 일을 초래했다 — 많은 앱이 preload에서 직접 할당을 쓰고 있었기 때문. 하지만 OpenJS Foundation 산하 Electron 팀은 보안 위에서 타협하지 않는 길을 선택했다. 그 결과 오늘날 Electron 앱이 예전보다 훨씬 안전해졌다.


요약

Electron 앱 한 개는 세 entry — main / preload / renderer — 가 한 프로세스 트리의 세 역할을 맡는다. 코드가 어느 entry에 있느냐가 권한과 격리를 결정하고, 그 사이를 잇는 유일한 통로contextBridge로 노출된 함수와 IPC 메시지다. 이 멘탈 모델만 받아들이면 *“어디에 코드를 둬야 하나?”*가 권한 매트릭스를 보는 일로 풀린다.

다음 문서: 05-when-to-choose-electron — 이제 그림이 잡혔으니 언제 이 그림을 고르고 언제 다른 도구로 가는가를 정한다.