"Preload는 렌더러와 메인 사이의 검문소" — 렌더러가 시스템 API에 무분별하게 접근하지 못하도록, 필요한 것만 골라서 노출하는 역할입니다.


Preload 스크립트란

Preload 스크립트는 렌더러 프로세스의 웹 콘텐츠가 로드되기 전에 실행되는 스크립트입니다.

PLAINTEXT
메인 프로세스 (Node.js)

    │  BrowserWindow 생성 시 preload 지정

Preload 스크립트 실행 (Node.js + DOM 접근 가능)

    │  contextBridge로 API 노출

렌더러 프로세스 (브라우저 환경)

    │  window.electronAPI 사용

핵심 특성

특성설명
Node.js API사용 가능 (require, process 등)
DOM API사용 가능 (window, document 등)
실행 시점렌더러 콘텐츠 로드 전
컨텍스트contextIsolation: true일 때 별도 컨텍스트

contextBridge 기본 사용법

JAVASCRIPT
// preload.js
const { contextBridge, ipcRenderer } = require('electron');

contextBridge.exposeInMainWorld('electronAPI', {
  // 메인 프로세스에 요청하고 결과를 받는 패턴
  getAppVersion: () => ipcRenderer.invoke('app:getVersion'),

  // 메인 프로세스에 단방향 메시지 전송
  sendLog: (message) => ipcRenderer.send('log:write', message),

  // 메인 프로세스의 이벤트를 렌더러에서 수신
  onUpdateAvailable: (callback) =>
    ipcRenderer.on('update:available', (_event, info) => callback(info)),
});
JAVASCRIPT
// 렌더러 (renderer.js)
// window.electronAPI를 통해 접근합니다
const version = await window.electronAPI.getAppVersion();
console.log(`앱 버전: ${version}`);

window.electronAPI.sendLog('앱 시작됨');

window.electronAPI.onUpdateAvailable((info) => {
  console.log('업데이트가 있습니다:', info.version);
});

contextIsolation이 왜 중요한가

contextIsolation: true(기본값)이면 preload 스크립트와 렌더러의 JavaScript 컨텍스트가 ** 완전히 분리 **됩니다.

JAVASCRIPT
// contextIsolation: true일 때
// preload.js에서 window에 직접 추가해도 렌더러에서 보이지 않음
window.myAPI = { doSomething: () => {} };
// 렌더러에서 window.myAPI는 undefined!

// contextBridge를 사용해야 함
contextBridge.exposeInMainWorld('myAPI', {
  doSomething: () => {},
});
// 이제 렌더러에서 window.myAPI.doSomething() 사용 가능

contextIsolation을 끄면 생기는 위험

JAVASCRIPT
// ⚠️ 위험: contextIsolation이 false일 때
// 악의적인 웹 콘텐츠가 prototype을 오염시킬 수 있음
Array.prototype.push = function() {
  // 모든 push 호출을 가로챌 수 있음
  // preload에서 사용하는 배열도 영향 받음
};

안전한 API 설계 패턴

1. 인자 검증 패턴

JAVASCRIPT
// preload.js — ipcRenderer.invoke를 직접 노출하지 않기
contextBridge.exposeInMainWorld('electronAPI', {
  // ❌ 위험: 어떤 채널이든 호출 가능
  // invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args),

  // ✅ 안전: 허용된 작업만 노출
  readFile: (filePath) => {
    // 인자 타입 검증
    if (typeof filePath !== 'string') {
      throw new Error('filePath must be a string');
    }
    return ipcRenderer.invoke('file:read', filePath);
  },

  writeFile: (filePath, content) => {
    if (typeof filePath !== 'string' || typeof content !== 'string') {
      throw new Error('Invalid arguments');
    }
    return ipcRenderer.invoke('file:write', filePath, content);
  },
});

2. 이벤트 리스너 정리 패턴

JAVASCRIPT
// preload.js — 리스너 정리를 위한 패턴
contextBridge.exposeInMainWorld('electronAPI', {
  onProgress: (callback) => {
    const handler = (_event, progress) => callback(progress);
    ipcRenderer.on('download:progress', handler);

    // 리스너 제거 함수를 반환
    return () => {
      ipcRenderer.removeListener('download:progress', handler);
    };
  },
});

// 렌더러에서 사용
const removeListener = window.electronAPI.onProgress((progress) => {
  updateProgressBar(progress);
});

// 컴포넌트 언마운트 시 정리
removeListener();

3. 채널 화이트리스트 패턴

JAVASCRIPT
// preload.js
const validSendChannels = ['log:write', 'app:quit'];
const validInvokeChannels = ['file:read', 'file:write', 'app:getVersion'];
const validReceiveChannels = ['update:available', 'download:progress'];

contextBridge.exposeInMainWorld('electronAPI', {
  send: (channel, ...args) => {
    if (validSendChannels.includes(channel)) {
      ipcRenderer.send(channel, ...args);
    }
  },
  invoke: (channel, ...args) => {
    if (validInvokeChannels.includes(channel)) {
      return ipcRenderer.invoke(channel, ...args);
    }
    return Promise.reject(new Error(`Invalid channel: ${channel}`));
  },
  on: (channel, callback) => {
    if (validReceiveChannels.includes(channel)) {
      const handler = (_event, ...args) => callback(...args);
      ipcRenderer.on(channel, handler);
      return () => ipcRenderer.removeListener(channel, handler);
    }
  },
});

TypeScript와 함께 사용하기

TYPESCRIPT
// types/electron.d.ts
export interface ElectronAPI {
  getAppVersion: () => Promise<string>;
  readFile: (path: string) => Promise<string>;
  writeFile: (path: string, content: string) => Promise<void>;
  onProgress: (callback: (progress: number) => void) => () => void;
}

declare global {
  interface Window {
    electronAPI: ElectronAPI;
  }
}
TYPESCRIPT
// preload.ts
import { contextBridge, ipcRenderer } from 'electron';
import type { ElectronAPI } from './types/electron';

const api: ElectronAPI = {
  getAppVersion: () => ipcRenderer.invoke('app:getVersion'),
  readFile: (path) => ipcRenderer.invoke('file:read', path),
  writeFile: (path, content) => ipcRenderer.invoke('file:write', path, content),
  onProgress: (callback) => {
    const handler = (_e: Electron.IpcRendererEvent, progress: number) =>
      callback(progress);
    ipcRenderer.on('download:progress', handler);
    return () => ipcRenderer.removeListener('download:progress', handler);
  },
};

contextBridge.exposeInMainWorld('electronAPI', api);

흔한 실수들

1. ipcRenderer를 통째로 노출

JAVASCRIPT
// ❌ 절대 하지 마세요
contextBridge.exposeInMainWorld('ipcRenderer', ipcRenderer);
// 렌더러에서 모든 IPC 채널에 접근 가능해짐

2. 함수가 아닌 값을 전달하려고 할 때

JAVASCRIPT
// ❌ contextBridge는 함수만 전달 가능
contextBridge.exposeInMainWorld('api', {
  process: process,  // 에러! 객체 전체는 전달 불가
});

// ✅ 필요한 값만 함수로 감싸서 전달
contextBridge.exposeInMainWorld('api', {
  getPlatform: () => process.platform,
  getNodeVersion: () => process.versions.node,
});

면접 포인트 정리

  • Preload는 Node.js + DOM 양쪽 접근이 가능한 특수 스크립트
  • contextBridge.exposeInMainWorld()로 필요한 API만 안전하게 노출
  • contextIsolation: true는 prototype pollution 방지의 핵심
  • ipcRenderer를 통째로 노출하면 보안 의미가 없어짐
  • 이벤트 리스너는 반드시 정리 함수를 반환하는 패턴 사용

Preload와 contextBridge를 이해했으면, 다음은 IPC 통신의 다양한 패턴을 알아볼 차례입니다.

댓글 로딩 중...