⚡ Electron1. 프로세스 모델04 — Preload 스크립트

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.exposeInMainWorldisolate를 가로질러 객체를 안전하게 복제하는 공식 API다. 내부적으로:

  1. 노출할 객체의 함수만 추출.
  2. 메인 월드에 프록시 함수를 만든다 (실제 함수가 아니라 호출하면 isolated world로 라우팅).
  3. 인자/반환값은 *구조화 복제(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 워커 프로세스를 본다.