[Electron] Electron이 웹 기술로 데스크탑 앱을 개발할 수 있는 이유

@eunjios·2024년 6월 30일
0
post-thumbnail

Electron이란?

웹 기술로 데스크탑 앱을 개발할 수 있게 하는 프레임워크

Electron은 HTML, CSS, JavaScript 등의 웹 기술을 가지고 데스크탑 앱을 개발할 수 있도록 하는 프레임워크다. 즉, 웹 기술에 익숙하고 이를 사용하여 네이티브 앱을 개발하고 싶다면 Electron이 좋은 선택지일 수 있다.

어떻게 웹 기술로 데스크탑 앱을 개발할 수 있을까?

Electron은 Chromium을 내장하고 있기 때문이다.

크롬의 렌더러 프로세스는 HTML, CSS, JavaScript를 해석하여 화면에 어떻게 보여져야 하는지를 계산하고 실제로 화면에 보여준다. Electron은 Chromium (브라우저) 을 내장하고 있다. 즉, 크롬의 렌더러 프로세스를 통해 데스크탑 앱에 보여지는 화면을 구성하기 때문에 웹 기술로 화면을 구성할 수 있다.

크롬과 Electron 모두 Chromium을 내장하고 있다면 뭐가 다를까?

Electron은 Chrome이 아니다.

Electron은 웹 브라우저 전체를 포함하지 않고 일부 모듈만을 포함한다. Electron으로 하고 싶은 것은 데스크탑 앱을 개발하는 것이지 브라우저를 만들기 위한 것이 아니다. 실제로 Electron은 브라우저의 주소창이나 북마크, 탭 등의 User Interface 부분은 포함하지 않고 일부만을 내장하고 있다.

웹 앱과 데스크탑 앱

Electron이 Chromium의 일부만을 내장하고 있다는 것을 알았다. 그럼 이제 웹 앱과 데스크탑 앱의 차이를 생각해 보자.

프론트엔드 공부를 했다면 알고 있겠지만, 자바스크립트 런타임 환경은 브라우저Node.js 두 가지로 나뉜다. 두 환경의 차이에 대해 간단히 정리해보면 다음과 같다.

V8을 내장하고 있기 때문에 자바스크립트 코드를 해석하여 실행할 수 있다는 것은 동일하다. 하지만 V8을 감싸고 있는 환경이 다르기 때문에 차이가 발생한다. DOM과 기타 Web API (setTimeout, Web Workers, fetch 등)는 브라우저 환경에서만 지원한다. 반면 Node.js 환경에서는 Node가 제공하는 API를 사용할 수 있다. 특히 브라우저 환경과 달리 Node.js 환경은 libuv 라는 라이브러리를 내장하여 운영체제 커널에서 제공하는 비동기 처리나 스레드 풀을 활용한 비동기 처리가 가능하다. 이벤트 루프에도 약간의 차이가 있다. 브라우저의 경우, 태스크 큐 (매크로 태스크 큐, 마이크로 태스크 큐) 에 콜백 함수들이 저장되어 있지만, Node의 경우, 여섯 개의 페이즈에 큐가 각각 존재하고, 페이즈 실행 순서도 정해져 있어 순차적으로 실행된다. 그 외에도 다양한 차이가 있지만 해당 내용은 이 내용을 참고하자. Differences between Node.js and the Browser

웹 앱은 웹 브라우저에서 실행된다. 반면 데스크탑 앱은 컴퓨터에 설치되어 로컬로 실행된다.

웹 앱은 웹 브라우저에서 실행된다. 반면 데스크탑 앱은 컴퓨터에 설치되어 로컬로 실행한다. 즉, React 등으로 개발한 웹 앱은 브라우저에서 실행되지만, Electron으로 개발한 데스크탑 앱은 Node.js 환경에서 실행되는 것이다.

Electron이 Chromium을 내장한다면서 어떻게 Node.js 환경에서 실행된다는 건지 의문이 생길 수 있다. 사실 Electron은 Chromium 뿐만 아니라 Node.js도 내장하고 있다. 이 부분은 아래의 멀티 프로세스 구조에서 더 자세히 다루겠지만, 간단히 말하자면 Electron 앱의 entry point가 Node.js 환경에서 실행되므로 Electron 앱을 로컬에서 실행할 수 있는 것이다.

Electron의 멀티 프로세스 구조

Electron은 하나의 메인 프로세스와 한 개 이상의 렌더러 프로세스로 구성되며, 메인 프로세스는 Node.js 환경에서 실행되고 렌더러 프로세스는 Chromium 환경에서 실행된다.

멀티 프로세스는 Electron의 가장 중요한 핵심 개념이다. Electron은 메인 프로세스와 렌더러 프로세스 두 가지 타입의 프로세스를 가지며, 하나의 메인 프로세스와 하나 이상의 렌더러 프로세스로 구성된다.

왜 멀티 프로세스 구조를 가질까?

프로세스가 분리되어 있다면, 하나의 프로세스에서 문제가 생겨도 다른 프로세스에는 영향을 미치지 않는다. 즉, 렌더러 프로세스가 죽어도 앱 전체를 관리하는 메인 프로세스는 살아있기 때문에 앱 전체가 죽지 않아 안정성이 커진다. 또한 child-process-gone 이벤트를 통해 렌더러 프로세스가 killed 또는 crashed 되었는지도 감지할 수 있다.

메인 프로세스와 렌더러 프로세스

메인 프로세스는 Electron 앱의 entry point 역할을 한다. 메인 프로세스는 Node.js 환경에서 실행되므로 모든 Node.js API를 사용할 수 있다. 메인 프로세스는 다음과 같은 역할을 한다.

  • 앱의 윈도우를 만들고 관리할 수 있다. 여기서 윈도우는 하나의 렌더러 프로세스가 된다.
  • 앱의 라이프사이클을 제어할 수 있다. 예를 들어 앱에서 특정 이벤트 가 발생하면 실행할 작업 등을 정의할 수 있다.
  • 앞서 잠깐 언급했는데, Node.js가 내장한 libuv 라이브러리는 운영체제와 상호작용을 가능하게 한다. 즉 Node.js 환경에서 실행되는 메인 프로세스 역시 운영체제와 상호작용할 수 있다.

렌더러 프로세스는 다음과 같이 메인 프로세스에서 새로운 BrowserWindow 인스턴스를 정의할 때 생성된다. 참고로 브라우저 윈도우는 매우 다양한 options 를 가진다.

// main.js

const { BrowserWindow } = require('electron');

// 렌더러 프로세스 생성
const win = new BrowserWindow({
  width: 800,
  height: 1500,
});

win.loadFile('index.html');

렌더러 프로세스는 웹 콘텐츠를 렌더링하는 역할을 한다. Electron의 렌더러 프로세스는 Chromium의 렌더링 과정처럼 동작하므로 HTML, CSS, JavaScript로 작성되어야 한다.

Electron의 IPC

Electron의 메인 프로세스와 렌더러 프로세스는 서로 IPC 방식으로 통신한다.

Electron에서의 메인 프로세스와 렌더러 프로세스는 서로 역할이 분리되어 있다. 예를 들어, 특정 로컬 파일을 읽고 해당 내용을 화면에 보여주는 데스크탑 앱을 생각해 보자. 렌더러 프로세스에서는 직접 파일 시스템에 접근할 수 없기 때문에 메인 프로세스에서 파일 시스템에 접근하여 파일을 읽어야 한다. 프로세스는 서로 독립적이기 때문에 메인 프로세스에서 읽은 파일의 내용을 렌더러 프로세스에게 전달해야 한다. 어떻게 전달할 수 있을까? Electron은 이 때 IPC 통신을 사용한다.

렌더러에서 메인으로 메시지 전달하기

ipcRenderer.send()ipcMain.on()

유저가 웹 콘텐츠에 있는 특정 버튼을 클릭하였다는 사실을 메인 프로세스로 전달하고 싶다면, 렌더러에서 메인으로 메시지를 전송해야 한다.

ipcRenderer.send(채널, arg1, arg2, ...);

그리고 메인에서는 해당 메시지를 받았을 때 실행할 함수를 정의한다.

ipcMain.on(채널, (event, arg1, arg2, ...) => {
  // ...
});

이렇게 바로 IPC 통신을 할 수 있으면 좋겠지만, 사실은 렌더러 프로세스에서 ipcRenderer.send 를 사용할 수 없다. 그럼 어떻게 써야 할까? 반드시 프리로드 스크립트를 통해서만 API를 노출해야 한다. 이를 위해 브라우저 윈도우의 options에 프리로드 스크립트를 등록하자.

// main.js

const { BrowserWindow } = require('electron')

const win = new BrowserWindow({
  width: 1200,
  height: 800,
  webPreferences: {
    preload: 'path/to/preload.js' // 프리로드 스크립트 등록
  }
})

프리로드 스크립트와 렌더러 프로세스는 전역 window 를 공유하기 때문에 프리로드에서 변수를 등록하고 렌더러에서 이를 사용할 수 있다. 단, 보안적 이유로 프리로드 스크립트는 렌더러로부터 격리되어 있기 때문에 변수를 직접 연결해서는 안 되고 contextIsolation 으로만 특정 값을 렌더러에 노출시켜야 한다. 아래와 같이 직접 연결할 경우 해당 변수가 undefined 임을 확인할 수 있다.

// preload.js

window.myAPI = {
  desktop: true
}
// renderer.js

console.log(window.myAPI); // undefined

따라서 다음과 같이 contextIsolation 을 사용해야 한다.

// preload

const { contextBridge } = require('electron');

contextBridge.exposeInMainWorld('myAPI', {
  desktop: true,
})
// renderer.js

console.log(window.myAPI); // { desktop: true }

이제 진짜 버튼을 클릭했을 때 이를 메인 프로세스에 알려 콘솔에 프린트하는 경우에 대해 생각해 보자. 전체 코드는 생략하고 IPC 통신 부분만 정리한다.

// main.js

const { ipcMain } = require('electron/main');

ipcMain.on('click-button', (event, text) => {
  console.log(text);
})
// preload.js

const { contextBridge, ipcRenderer } = require('electron/renderer');

contextBridge.exposeInMainWorld('electronAPI', {
  clickButton: (text) => ipcRenderer.send('click-button', text)
})
// renderer.js

const onClick = () => {
  window.electronAPI.clickButton('버튼 클릭됨');
}

IPC 부분을 위와 같이 구현하면 버튼을 클릭했을 때 Node.js 콘솔에 '버튼 클릭됨'이 출력된다.

렌더러에서 메인으로 메시지 전달 후 답장 받기

ipcRenderer.invoke()ipcMain.handle()

렌더러에서 메인으로 메시지를 보낸 후 메인에서 값을 return 해야 하는 경우가 있을 수 있다. 예를 들어 버튼을 클릭하면 (렌더러 프로세스) 선택한 파일의 경로 (메인 프로세스) 를 렌더러 프로세스에 보내 화면에 띄우고 싶을 수 있다. 이 경우 ipcRenderer.invoke()ipcMain.handle() 을 사용한다.

// main.js

async function handleFileOpen () {
  const { canceled, filePaths } = await dialog.showOpenDialog();
  if (!canceled) {
    return filePaths[0];
  }
}

app.whenReady.then(() => {
  ipcMain.handle('dialog:openFile', handleFileOpen);
  // ...
})
// preload.js

const { contextBridge, ipcRenderer } = require('electron/renderer');

contextBridge.exposeInMainWorld('electronAPI', {
  openFile: () => ipcRenderer.invoke('dialog:openFile')
});
// renderer.js

const onClick = async () => {
  const filePath = await window.electronAPI.openFile();
  setFilePath(filePath);
}

메인에서 렌더러로 메시지 전송

브라우저윈도우인스턴스.webContents.send()ipcRenderer.on() 을 사용한다.

렌더러에서 메인으로 메시지를 전송해야 하는 경우보다 흔하지는 않지만 메인에서 렌더러로 메시지를 전송해야 하는 경우도 있을 수 있다. 예를 들어 기본 운영체제 메뉴에 의해 렌더러를 조작해야 하는 경우 메인에서 렌더러로 메시지를 전송해야 한다.

아래 코드는 기본 운영체제 메뉴의 Increment 버튼을 누르면 카운터 증가, Decrement 버튼을 누르면 카운터 예시다. 이 역시 IPC 부분 외의 나머지 부분은 생략하였다. 전체 코드는 여기서 확인할 수 있다.

// main.js

const mainWindow = new BrowserWindow({
  webPreferences: {
    preload: path.join(__dirname, 'preload.js')
  }
});

const menu = Menu.buildFromTemplate([
  {
    label: app.name,
    submenu: [
      {
        click: () => mainWindow.webContents.send('update-couter', 1),
        label: 'Increment',
      },
      {
        click: () => mainWindow.webContents.send('update-counter', -1),
        label: 'Decrement',
      }
    ]
  }
]);

Menu.setApplicationMenu(menu);
// ...
// preload.js

const { contextBridge, ipcRenderer } = require('electron/renderer');

contextBridge.exposeInMainWorld('electronAPI', {
  onUpdateCounter: (callback) => ipcRenderer.on('update-counter', (_event, value) => callback(value)),
});
// renderer.js

window.electronAPI.onUpdateCounter((value) => {
  setCounter((prev) => prev + value);
});

렌더러 간의 메시지 전송

렌더러에서 렌더러로 메시지를 전송해야 하는 경우가 있을 수 있다. 하지만 직접적으로 전송할 수 있는 방식은 없으며, 메인 프로세스를 렌더러 간의 메시지 브로커로 사용하거나, 메인 프로세스에서 두 렌더러로 MessagePort를 전달하여 두 렌더러가 통신하도록 할 수 있다. Electron은 Web API의 MessagePort 를 확장하여 앱에서 사용할 수 있도록 한다. 해당 예시가 궁금한 경우 Electron의 MessagePort 예시 를 참고하자.

참고자료

profile
growth

0개의 댓글