04 — Preload 스크립트
한 줄 답: preload는 Renderer 프로세스 안의 isolated world에서, Renderer가 시작되기 직전에 단 한 번 실행되는 특권 컨텍스트다 —
contextBridge.exposeInMainWorld로 좁은 함수만 메인 월드에 노출하여, Renderer가 OS에 닿는 유일한 합법 통로가 된다.
Why — preload가 왜 필요한가
Main과 Renderer 사이에는 거리가 있다. Main은 Node 전권, Renderer는 sandbox. 이 둘이 어떻게 대화해야 하는가의 답이 IPC인데, IPC를 Renderer 코드가 직접 호출하려면 ipcRenderer 객체가 필요하다.
문제는 — ipcRenderer를 Renderer의 메인 월드에 그대로 노출하면 그게 곧 공격 표면이 된다. 외부에서 로드한 광고·CDN 스크립트가 ipcRenderer.send('exec', 'rm -rf /')을 할 수 있게 된다.
그래서 preload라는 중간 단계가 필요하다.
[메인 월드 (React 코드)]
↑↓ ← contextBridge로 노출된 좁은 함수만
[isolated world (preload)]
↑↓ ← ipcRenderer (전권)
[IPC channel]
↑↓
[ipcMain (Main 프로세스)]
↑↓
[OS / fs / shell]preload는 두 isolate 사이의 다리다. 그리고 그 다리는 내가 통과시키고 싶은 것만 통과시킨다.
How — preload의 실제 코드
가장 단순한 형태
// preload.js (CommonJS)
const { contextBridge, ipcRenderer } = require('electron')
contextBridge.exposeInMainWorld('electronAPI', {
openFile: () => ipcRenderer.invoke('dialog:openFile'),
saveFile: (data) => ipcRenderer.invoke('file:save', data),
onLog: (callback) => {
const handler = (_event, msg) => callback(msg)
ipcRenderer.on('log', handler)
return () => ipcRenderer.off('log', handler) // cleanup 반환
},
})// renderer.js (메인 월드)
// preload가 노출한 함수만 보인다
const file = await window.electronAPI.openFile()
console.log(file.contents)
// 이런 건 안 보인다 — preload는 격리된 isolate
console.log(typeof require) // 'undefined'
console.log(typeof ipcRenderer) // 'undefined'// main.js
const { ipcMain, dialog } = require('electron')
const fs = require('node:fs/promises')
ipcMain.handle('dialog:openFile', async () => {
const r = await dialog.showOpenDialog({ properties: ['openFile'] })
if (r.canceled) return null
const contents = await fs.readFile(r.filePaths[0], 'utf8')
return { path: r.filePaths[0], contents }
})이 세 파일이 모든 Electron 앱의 기본 골격이다.
왜 contextBridge인가 — 그냥 window.x = ...이면 안 되나
// preload.js — 절대 이렇게 하지 마세요
window.api = {
openFile: () => ipcRenderer.invoke('dialog:openFile')
}이게 작동하기는 한다 — contextIsolation이 false일 때만. true(디폴트)일 때는 window가 다른 isolate다. preload의 window와 메인 월드의 window는 같은 DOM을 가리키지만 같은 객체가 아니다.
contextBridge.exposeInMainWorld는 isolate를 가로질러 객체를 안전하게 복제하는 공식 API다. 내부적으로:
- 노출할 객체의 함수만 추출.
- 메인 월드에 프록시 함수를 만든다 (실제 함수가 아니라 호출하면 isolated world로 라우팅).
- 인자/반환값은 *구조화 복제(structured clone)*로 복사.
즉 — 메인 월드 코드가
window.electronAPI.openFile의 원본 함수 객체를 가져갈 수 없다. 함수 정의를 보고 그걸 흉내 낸 우회도 막힌다. 이게 프로토타입 오염 공격을 차단하는 디자인.
Preload의 수명 — 정확히 언제 뛰는가
핵심 시점.
- preload는 Renderer가 시작될 때 1회만 평가된다.
- preload가 끝난 다음에 메인 월드(
index.html의<script>)가 평가된다. - 그래서
window.electronAPI는 메인 월드 첫 줄부터 이미 존재한다. - 사용자가 새 페이지로 navigate하면? — preload는 다시 평가된다 (새 메인 월드가 생기므로).
ESM preload (v28+) — import 가능
v28부터 ES Module preload가 지원된다.
// preload.mjs
import { contextBridge, ipcRenderer } from 'electron/renderer'
contextBridge.exposeInMainWorld('electronAPI', {
openFile: () => ipcRenderer.invoke('dialog:openFile'),
})// main.js
new BrowserWindow({
webPreferences: {
preload: path.join(__dirname, 'preload.mjs'),
sandbox: false, // ESM preload는 현재 sandbox와 함께 못 씀 (v28 기준)
},
})주의: ESM preload는 sandbox와의 호환이 현재진행형. 가능한 한 최소한의 preload를 유지하고 무거운 로직은 Main에 두는 게 안전.
노출 패턴 — 좁게, 더 좁게
안티패턴: ipcRenderer 통째로 노출
// preload.js — 이러면 contextBridge를 쓰는 의미가 없다
contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer, // ← 통째 노출. 사실상 'nodeIntegration: true'와 같음
})
// → 메인 월드에서 window.electronAPI.ipcRenderer.send('exec', '...') 가능안티패턴: 채널명을 인자로 받기
// preload.js
contextBridge.exposeInMainWorld('electronAPI', {
invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args),
})
// → 메인 월드가 임의 채널 호출 가능. allow-list가 없으면 RCE 통로.만약 꼭 동적 채널이 필요하면 allow-list를 둔다.
const ALLOWED = ['dialog:openFile', 'file:save', 'log:append']
contextBridge.exposeInMainWorld('electronAPI', {
invoke: (channel, ...args) => {
if (!ALLOWED.includes(channel)) {
throw new Error(`channel not allowed: ${channel}`)
}
return ipcRenderer.invoke(channel, ...args)
},
})좋은 패턴: 함수 단위로 노출
contextBridge.exposeInMainWorld('electronAPI', {
openFile: () => ipcRenderer.invoke('dialog:openFile'),
saveFile: (data) => {
// 타입 체크도 여기서
if (typeof data !== 'string') throw new Error('data must be string')
return ipcRenderer.invoke('file:save', data)
},
onLog: (cb) => {
if (typeof cb !== 'function') return
const h = (_e, msg) => cb(msg)
ipcRenderer.on('log', h)
return () => ipcRenderer.off('log', h)
},
})공격자가 메인 월드 JS를 통째로 장악해도
electronAPI로 할 수 있는 일이 정확히 이 세 함수로 제한된다. 그게 좁힘의 효과.
What — preload의 책임 한 페이지
| preload가 해야 하는 것 | preload가 하면 안 되는 것 |
|---|---|
| 좁은 API 노출 | ipcRenderer 통째 노출 |
| 인자 타입·범위 검증 | 동적 채널명 그대로 통과 |
| cleanup 함수 반환 | 리스너 무한 등록 |
| 짧고 단순하게 유지 | 비즈니스 로직 |
electron/renderer import (ESM) | DOM 조작 (DOM은 메인 월드 책임) |
What-if — preload 없이 쓰면
| 시도 | 결과 |
|---|---|
preload 옵션을 안 줌 | Renderer가 외부 OS와 단절. 순수 웹앱처럼 됨. 단순한 위젯엔 OK. |
preload는 두는데 contextBridge를 안 씀 | contextIsolation 디폴트 true이므로 메인 월드가 노출 객체를 못 봄. 아무 일도 안 일어남. |
contextIsolation: false로 끄고 window.x = ... | 작동은 함. 단 XSS = Node 전권. 옛 디폴트, 현재 절대 금지. |
nodeIntegration: true로 켜고 Renderer에서 require('fs') 직접 | 작동은 함. XSS = RCE. 강한 사용자 입력 노출이면 즉시 사고. |
preload 없이 Electron 앱을 완성도 있게 만드는 건 거의 불가능하다. 다이얼로그·파일·메뉴 — 모두 Main에 있는데 Renderer가 닿으려면 preload가 필요하다.
멀티 창의 preload — 공유하되 분리
여러 창이 같은 preload를 쓰는 게 흔하다.
// main.js
const PRELOAD = path.join(__dirname, 'preload.js')
const editorWin = new BrowserWindow({ webPreferences: { preload: PRELOAD } })
const settingsWin = new BrowserWindow({ webPreferences: { preload: PRELOAD } })하지만 창마다 다른 권한이 필요할 때도 있다.
// 에디터 창은 파일 저장 가능
const editorWin = new BrowserWindow({
webPreferences: { preload: path.join(__dirname, 'preload-editor.js') }
})
// 설정 창은 설정만 읽기/쓰기
const settingsWin = new BrowserWindow({
webPreferences: { preload: path.join(__dirname, 'preload-settings.js') }
})이 패턴이 권한 최소화의 실전 적용이다. 각 창이 자기에게 꼭 필요한 함수만 보게 만든다.
TypeScript — 메인 월드 타입 선언
// types/electron-api.d.ts
export interface ElectronAPI {
openFile: () => Promise<{ path: string; contents: string } | null>
saveFile: (data: string) => Promise<void>
onLog: (cb: (msg: string) => void) => () => void
}
declare global {
interface Window {
electronAPI: ElectronAPI
}
}이렇게 두면 Renderer 코드(window.electronAPI.openFile())에 완전 타입 보호가 붙는다.
Insight — preload는 백엔드의 BFF다
웹 백엔드 패턴에 *BFF (Backend For Frontend)*가 있다 — 프론트가 직접 마이크로서비스 N개를 호출하면 표면이 크니, 그 사이에 프론트 전용 API 게이트웨이를 둔다.
preload는 Electron의 BFF다. Renderer가 OS의 모든 API를 직접 보면 표면이 너무 크니, 내 앱이 진짜 필요한 함수만 모은 좁은 facade를 둔다. preload는:
- 프록시: 메인 월드의 호출을 IPC 메시지로 번역
- 검증자: 인자 타입·범위 확인
- 적응자: 메인의 raw 반환을 UI 친화적 형태로 변환
- 제한자: 사용 가능한 함수의 허용 목록
이 비유가 정확한 이유는 — 둘 다 신뢰 경계를 명확히 하기 위해 존재하기 때문이다. BFF가 “프론트는 백엔드를 직접 모름”을 강제하듯, preload는 “Renderer는 Node를 직접 모름”을 강제한다. 그 강제가 공격 표면을 코드 한 파일에 모으는 효과를 만든다.
요약
preload는 Renderer 프로세스 안의 isolated world에서 한 번 실행되는 특권 컨텍스트다.
contextBridge.exposeInMainWorld로 좁은 함수만 메인 월드에 노출하며, 그 함수가 Renderer가 OS에 닿는 유일한 합법 통로가 된다. 좋은 preload는 — 함수 단위로, 인자 검증을 끼우고, cleanup을 반환하고, 비즈니스 로직 없이 짧게 유지된다. 다음 05 — Utility & GPU Process에서는, Main에 무거운 짐을 덜기 위한 Node 워커 프로세스를 본다.