프리로드 스크립트 사용하기
이 문서는 Electron 튜토리얼의 3부에 해당한다.
학습 목표
이번 튜토리얼에서는 프리로드 스크립트가 무엇인지, 그리고 이를 사용해 어떻게 렌더러 프로세스에 특권 API를 안전하게 노출시킬 수 있는지 배운다. 또한 Electron의 프로세스 간 통신(IPC) 모듈을 활용해 메인 프로세스와 렌더러 프로세스 간에 어떻게 통신하는지 알아본다.
프리로드 스크립트란?
Electron의 메인 프로세스는 운영체제에 완전히 접근할 수 있는 Node.js 환경에서 실행된다. Electron 모듈 외에도 Node.js 내장 API와 npm을 통해 설치한 모든 패키지에 접근할 수 있다. 반면 렌더러 프로세스는 보안상의 이유로 기본적으로 Node.js를 실행하지 않고 웹 페이지를 실행한다.
Electron의 다양한 프로세스 타입을 연결하기 위해 프리로드라는 특별한 스크립트를 사용한다.
프리로드 스크립트로 렌더러 기능 확장하기
BrowserWindow의 프리로드 스크립트는 HTML DOM과 제한된 Node.js 및 Electron API에 접근할 수 있는 환경에서 실행된다.
Electron 20부터 프리로드 스크립트는 기본적으로 샌드박싱되며, 더 이상 전체 Node.js 환경에 접근할 수 없다. 이는 polyfill된 require
함수만 사용할 수 있고, 제한된 API 집합에만 접근할 수 있다는 것을 의미한다.
사용 가능한 API | 세부 사항 |
---|---|
Electron 모듈 | 렌더러 프로세스 모듈 |
Node.js 모듈 | events , timers , url |
Polyfill된 전역 변수 | Buffer , process , clearImmediate , setImmediate |
더 자세한 내용은 Process Sandboxing 가이드를 참고한다.
프리로드 스크립트는 웹 페이지가 렌더러에 로드되기 전에 주입된다. 이는 Chrome 확장 프로그램의 콘텐츠 스크립트와 유사하다. 권한이 필요한 기능을 렌더러에 추가하려면 contextBridge API를 통해 전역 객체를 정의할 수 있다.
이 개념을 설명하기 위해, 앱의 Chrome, Node, Electron 버전을 렌더러에 노출하는 프리로드 스크립트를 만든다.
Electron의 process.versions
객체의 선택된 속성을 렌더러 프로세스에 versions
전역 변수로 노출하는 새로운 preload.js
스크립트를 추가한다.
const { contextBridge } = require('electron')
contextBridge.exposeInMainWorld('versions', {
node: () => process.versions.node,
chrome: () => process.versions.chrome,
electron: () => process.versions.electron
// 함수뿐만 아니라 변수도 노출할 수 있다
})
이 스크립트를 렌더러 프로세스에 연결하려면, BrowserWindow 생성자의 webPreferences.preload
옵션에 스크립트 경로를 전달한다.
const { app, BrowserWindow } = require('electron')
const path = require('node:path')
const createWindow = () => {
const win = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
preload: path.join(__dirname, 'preload.js')
}
})
win.loadFile('index.html')
}
app.whenReady().then(() => {
createWindow()
})
이제 렌더러는 versions
전역 변수에 접근할 수 있다. 이 정보를 윈도우에 표시해 보자. 이 변수는 window.versions
또는 단순히 versions
로 접근할 수 있다. document.getElementById
DOM API를 사용하여 info
를 id
속성으로 가진 HTML 엘리먼트의 텍스트를 대체하는 renderer.js
스크립트를 만든다.
const information = document.getElementById('info')
information.innerText = `This app is using Chrome (v${versions.chrome()}), Node.js (v${versions.node()}), and Electron (v${versions.electron()})`
그런 다음, index.html
을 수정하여 info
를 id
속성으로 가진 새 엘리먼트를 추가하고 renderer.js
스크립트를 연결한다.
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'"
/>
<meta
http-equiv="X-Content-Security-Policy"
content="default-src 'self'; script-src 'self'"
/>
<title>Hello from Electron renderer!</title>
</head>
<body>
<h1>Hello from Electron renderer!</h1>
<p>👋</p>
<p id="info"></p>
</body>
<script src="./renderer.js"></script>
</html>
위 단계를 따르면 앱은 다음과 같이 보일 것이다:
그리고 코드는 다음과 같을 것이다:
- main.js
- preload.js
- index.html
- renderer.js
const { app, BrowserWindow } = require('electron/main')
const path = require('node:path')
const createWindow = () => {
const win = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
preload: path.join(__dirname, 'preload.js')
}
})
win.loadFile('index.html')
}
app.whenReady().then(() => {
createWindow()
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow()
}
})
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
}
})
const { contextBridge } = require('electron/renderer')
contextBridge.exposeInMainWorld('versions', {
node: () => process.versions.node,
chrome: () => process.versions.chrome,
electron: () => process.versions.electron
})
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'"
/>
<meta
http-equiv="X-Content-Security-Policy"
content="default-src 'self'; script-src 'self'"
/>
<title>Hello from Electron renderer!</title>
</head>
<body>
<h1>Hello from Electron renderer!</h1>
<p>👋</p>
<p id="info"></p>
</body>
<script src="./renderer.js"></script>
</html>
const information = document.getElementById('info')
information.innerText = `This app is using Chrome (v${window.versions.chrome()}), Node.js (v${window.versions.node()}), and Electron (v${window.versions.electron()})`
프로세스 간 통신
앞서 언급했듯이, Electron의 메인 프로세스와 렌더러 프로세스는 서로 다른 역할을 담당하며, 서로 바꿔 사용할 수 없다. 즉, 렌더러 프로세스에서 Node.js API에 직접 접근할 수 없고, 메인 프로세스에서 HTML DOM에 직접 접근할 수 없다.
이 문제를 해결하기 위해 Electron의 ipcMain
과 ipcRenderer
모듈을 사용해 프로세스 간 통신(IPC)을 구현한다. 웹 페이지에서 메인 프로세스로 메시지를 보내려면, 먼저 ipcMain.handle
을 사용해 메인 프로세스 핸들러를 설정한 다음, 프리로드 스크립트에서 ipcRenderer.invoke
를 호출하는 함수를 노출한다.
예를 들어, 렌더러 프로세스에서 ping()
이라는 전역 함수를 추가하고, 이 함수가 메인 프로세스에서 문자열을 반환하도록 만들어 보자.
먼저, 프리로드 스크립트에서 invoke
호출을 설정한다:
const { contextBridge, ipcRenderer } = require('electron')
contextBridge.exposeInMainWorld('versions', {
node: () => process.versions.node,
chrome: () => process.versions.chrome,
electron: () => process.versions.electron,
ping: () => ipcRenderer.invoke('ping')
// 함수뿐만 아니라 변수도 노출할 수 있다
})
ipcRenderer.invoke('ping')
호출을 헬퍼 함수로 감싸고, ipcRenderer
모듈 전체를 직접 노출하지 않는 점에 주목하자. 프리로드를 통해 ipcRenderer
모듈 전체를 직접 노출해서는 절대 안 된다. 이렇게 하면 렌더러 프로세스가 메인 프로세스에 임의의 IPC 메시지를 보낼 수 있게 되어, 악성 코드에 대한 강력한 공격 벡터가 될 수 있다.
그 다음, 메인 프로세스에서 handle
리스너를 설정한다. 이 작업은 HTML 파일을 로드하기 전에 수행해야 한다. 이렇게 하면 렌더러에서 invoke
호출을 보내기 전에 핸들러가 준비된 상태임을 보장할 수 있다.
const { app, BrowserWindow, ipcMain } = require('electron/main')
const path = require('node:path')
const createWindow = () => {
const win = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
preload: path.join(__dirname, 'preload.js')
}
})
win.loadFile('index.html')
}
app.whenReady().then(() => {
ipcMain.handle('ping', () => 'pong')
createWindow()
})
이제 송신자와 수신자를 설정했으므로, 방금 정의한 'ping'
채널을 통해 렌더러에서 메인 프로세스로 메시지를 보낼 수 있다.
const func = async () => {
const response = await window.versions.ping()
console.log(response) // 'pong' 출력
}
func()
ipcRenderer
와 ipcMain
모듈 사용에 대한 더 자세한 설명은 프로세스 간 통신 가이드를 참조하자.
요약
프리로드 스크립트는 웹 페이지가 브라우저 윈도우에 로드되기 전에 실행되는 코드를 포함한다. DOM API와 Node.js 환경에 모두 접근할 수 있으며, 주로 contextBridge
API를 통해 렌더러 프로세스에 특권 API를 노출하는 데 사용된다.
메인 프로세스와 렌더러 프로세스는 서로 다른 책임을 지니기 때문에, Electron 앱은 종종 프리로드 스크립트를 사용해 두 프로세스 간의 임의 메시지를 전달하기 위한 IPC(프로세스 간 통신) 인터페이스를 설정한다.
튜토리얼의 다음 부분에서는 앱에 더 많은 기능을 추가하는 방법에 대한 리소스를 소개하고, 앱을 사용자에게 배포하는 방법을 안내할 것이다.