Preload 스크립트와 contextBridge — 안전한 IPC 브릿지
"Preload는 렌더러와 메인 사이의 검문소" — 렌더러가 시스템 API에 무분별하게 접근하지 못하도록, 필요한 것만 골라서 노출하는 역할입니다.
Preload 스크립트란
Preload 스크립트는 렌더러 프로세스의 웹 콘텐츠가 로드되기 전에 실행되는 스크립트입니다.
메인 프로세스 (Node.js)
│
│ BrowserWindow 생성 시 preload 지정
▼
Preload 스크립트 실행 (Node.js + DOM 접근 가능)
│
│ contextBridge로 API 노출
▼
렌더러 프로세스 (브라우저 환경)
│
│ window.electronAPI 사용
▼
핵심 특성
| 특성 | 설명 |
|---|---|
| Node.js API | 사용 가능 (require, process 등) |
| DOM API | 사용 가능 (window, document 등) |
| 실행 시점 | 렌더러 콘텐츠 로드 전 |
| 컨텍스트 | contextIsolation: true일 때 별도 컨텍스트 |
contextBridge 기본 사용법
// 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)),
});
// 렌더러 (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 컨텍스트가 ** 완전히 분리 **됩니다.
// contextIsolation: true일 때
// preload.js에서 window에 직접 추가해도 렌더러에서 보이지 않음
window.myAPI = { doSomething: () => {} };
// 렌더러에서 window.myAPI는 undefined!
// contextBridge를 사용해야 함
contextBridge.exposeInMainWorld('myAPI', {
doSomething: () => {},
});
// 이제 렌더러에서 window.myAPI.doSomething() 사용 가능
contextIsolation을 끄면 생기는 위험
// ⚠️ 위험: contextIsolation이 false일 때
// 악의적인 웹 콘텐츠가 prototype을 오염시킬 수 있음
Array.prototype.push = function() {
// 모든 push 호출을 가로챌 수 있음
// preload에서 사용하는 배열도 영향 받음
};
안전한 API 설계 패턴
1. 인자 검증 패턴
// 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. 이벤트 리스너 정리 패턴
// 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. 채널 화이트리스트 패턴
// 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와 함께 사용하기
// 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;
}
}
// 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를 통째로 노출
// ❌ 절대 하지 마세요
contextBridge.exposeInMainWorld('ipcRenderer', ipcRenderer);
// 렌더러에서 모든 IPC 채널에 접근 가능해짐
2. 함수가 아닌 값을 전달하려고 할 때
// ❌ 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 통신의 다양한 패턴을 알아볼 차례입니다.
댓글 로딩 중...