Skip to main content

프리로드 스크립트 사용하기

학습 목표

이번 튜토리얼에서는 프리로드 스크립트가 무엇인지, 그리고 이를 사용해 어떻게 렌더러 프로세스에 특권 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 스크립트를 추가한다.

preload.js
const { contextBridge } = require('electron')

contextBridge.exposeInMainWorld('versions', {
node: () => process.versions.node,
chrome: () => process.versions.chrome,
electron: () => process.versions.electron
// 함수뿐만 아니라 변수도 노출할 수 있다
})

이 스크립트를 렌더러 프로세스에 연결하려면, BrowserWindow 생성자의 webPreferences.preload 옵션에 스크립트 경로를 전달한다.

main.js
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()
})
info

여기서 사용된 두 가지 Node.js 개념은 다음과 같다:

  • __dirname 문자열은 현재 실행 중인 스크립트의 경로를 가리킨다 (이 경우 프로젝트의 루트 폴더).
  • path.join API는 여러 경로 세그먼트를 결합하여 모든 플랫폼에서 작동하는 결합된 경로 문자열을 생성한다.

이제 렌더러는 versions 전역 변수에 접근할 수 있다. 이 정보를 윈도우에 표시해 보자. 이 변수는 window.versions 또는 단순히 versions로 접근할 수 있다. document.getElementById DOM API를 사용하여 infoid 속성으로 가진 HTML 엘리먼트의 텍스트를 대체하는 renderer.js 스크립트를 만든다.

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을 수정하여 infoid 속성으로 가진 새 엘리먼트를 추가하고 renderer.js 스크립트를 연결한다.

index.html
<!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>

위 단계를 따르면 앱은 다음과 같이 보일 것이다:

Electron app showing This app is using Chrome (v102.0.5005.63), Node.js (v16.14.2), and Electron (v19.0.3)

그리고 코드는 다음과 같을 것이다:

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()
}
})

프로세스 간 통신

앞서 언급했듯이, Electron의 메인 프로세스와 렌더러 프로세스는 서로 다른 역할을 담당하며, 서로 바꿔 사용할 수 없다. 즉, 렌더러 프로세스에서 Node.js API에 직접 접근할 수 없고, 메인 프로세스에서 HTML DOM에 직접 접근할 수 없다.

이 문제를 해결하기 위해 Electron의 ipcMainipcRenderer 모듈을 사용해 프로세스 간 통신(IPC)을 구현한다. 웹 페이지에서 메인 프로세스로 메시지를 보내려면, 먼저 ipcMain.handle을 사용해 메인 프로세스 핸들러를 설정한 다음, 프리로드 스크립트에서 ipcRenderer.invoke를 호출하는 함수를 노출한다.

예를 들어, 렌더러 프로세스에서 ping()이라는 전역 함수를 추가하고, 이 함수가 메인 프로세스에서 문자열을 반환하도록 만들어 보자.

먼저, 프리로드 스크립트에서 invoke 호출을 설정한다:

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

contextBridge.exposeInMainWorld('versions', {
node: () => process.versions.node,
chrome: () => process.versions.chrome,
electron: () => process.versions.electron,
ping: () => ipcRenderer.invoke('ping')
// 함수뿐만 아니라 변수도 노출할 수 있다
})
IPC 보안

ipcRenderer.invoke('ping') 호출을 헬퍼 함수로 감싸고, ipcRenderer 모듈 전체를 직접 노출하지 않는 점에 주목하자. 프리로드를 통해 ipcRenderer 모듈 전체를 직접 노출해서는 절대 안 된다. 이렇게 하면 렌더러 프로세스가 메인 프로세스에 임의의 IPC 메시지를 보낼 수 있게 되어, 악성 코드에 대한 강력한 공격 벡터가 될 수 있다.

그 다음, 메인 프로세스에서 handle 리스너를 설정한다. 이 작업은 HTML 파일을 로드하기 전에 수행해야 한다. 이렇게 하면 렌더러에서 invoke 호출을 보내기 전에 핸들러가 준비된 상태임을 보장할 수 있다.

main.js
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' 채널을 통해 렌더러에서 메인 프로세스로 메시지를 보낼 수 있다.

renderer.js
const func = async () => {
const response = await window.versions.ping()
console.log(response) // 'pong' 출력
}

func()
info

ipcRendereripcMain 모듈 사용에 대한 더 자세한 설명은 프로세스 간 통신 가이드를 참조하자.

요약

프리로드 스크립트는 웹 페이지가 브라우저 윈도우에 로드되기 전에 실행되는 코드를 포함한다. DOM API와 Node.js 환경에 모두 접근할 수 있으며, 주로 contextBridge API를 통해 렌더러 프로세스에 특권 API를 노출하는 데 사용된다.

메인 프로세스와 렌더러 프로세스는 서로 다른 책임을 지니기 때문에, Electron 앱은 종종 프리로드 스크립트를 사용해 두 프로세스 간의 임의 메시지를 전달하기 위한 IPC(프로세스 간 통신) 인터페이스를 설정한다.

튜토리얼의 다음 부분에서는 앱에 더 많은 기능을 추가하는 방법에 대한 리소스를 소개하고, 앱을 사용자에게 배포하는 방법을 안내할 것이다.