17 분 소요

간혹 로컬에서 혼자 쓸 데스크톱 앱을 만들고 싶을 때가 있었다. 그래서 예전에는 파이썬 진영의 Tkinter 또는 PySide를 이용해본 적이 있었다. 그러나 개인적으로 느끼기에 생각보다 간단한 기능을 만드려고 해도 상대적으로 많아보이는 코드를 작성해야 했고, 그렇게 겨우 코딩을 해도 UI가 너무 밋밋해서 어딘가 불만족스러웠었다. 그래서 그 당시엔 “웹 페이지는 이쁘게 만들 수 있는데 왜 데스크톱용 앱을 만들 땐 이게 잘 안되는걸까”란 생각도 들었다.

그러다 최근 또 다른 데스크톱 앱 제작 프레임워크를 발견했는데, 그것은 Electron이었다. 기존에 경험했던 데스크톱 앱 프레임워크와 다른 점은, HTML, CSS, JS와 같은 웹 기술을 그대로 사용하여 제작할 수 있다는 점이었다. 웹 분야에는 MUI, Chakra 등 풍부한 UI Component 라이브러리들이 즐비해있어 상대적으로 쉽게 이쁜 웹 페이지들을 만들 수 있는데, 이 기술들을 데스크톱 앱 제작에도 그대로 사용할 수 있다는 것은 큰 장점으로 다가왔다. 또한 HTML, CSS, JS와 같은 웹 기술들을 이미 알고 있다면 별도의 기술들을 학습할 필요없이 바로 앱 제작에 들어갈 수 있다는 점도 큰 매력이었다.

따라서 이 글에서는 데스크톱 앱 제작 프레임워크인 Electron에 대해 알아보고, 이를 이용하여 데스크톱 앱을 만드는 방법에 대해 알아보도록 하겠다.

Electron 개요

Electron 공식 홈페이지

Electron은 HTML, CSS, JS와 같은 웹 기술들을 Node.js와 결합하여 MacOS, Windows, Linux 등의 여러 OS에서도 작동할 수 있는 크로스 플랫폼 데스크톱 애플리케이션 제작 프레임워크이다. 오픈 소스에 MIT 라이센스로, 개인적인 사용과 상업적 사용 모두 허용하고 있다.

개인적으로 눈에 띄었던 것은, 노션, 디스코드 등 수많은 유명한 회사들의 데스크톱 앱도 이 Electorn을 이용하여 만들어졌다는 것이다. 이러한 회사들의 앱들은 각각 웹 버전의 앱과 데스크톱 앱을 별도로 가지고 있는데, 둘 다 UI가 같은 편이다. 이는 앞서 언급한 Electron이 웹 기술을 기반으로 하고 있기 때문이다. 웹 버전과 데스크톱 버전 사이를 손쉽게 마이그레이션할 수 있기에 똑같은 외관을 가질 수 있는 것이다.

사실 데스크톱 앱 분야에선 Electron이 주류 중 하나로 널리 쓰이고 있다고 한다.

Electron의 주요 특징들을 살펴보면 다음과 같다.

  • Microsoft, Notion 등 여러 댜양한 회사들에서 이 Electron을 관리하고 있어 안정성이 있다. 어떤 기술이 한 명의 사람 또는 하나의 회사에 종속되어 관리될 경우, 기술에 결함이 없음을 보장하기 힘들고, 이를 빠른 시간에 해결한다는 보장도 없다. 또한 한 사람 또는 하나의 회사가 해당 기술을 앞으로 발전시키는 데 있어 그 방향을 독단적으로 결정하여 사용자들이 원치 않는 방향으로 흘러갈 수도 있다. 그러나 Electron처럼 여러 회사들에서 관리한다면 잘못된 방향으로 가는 것을 견제할 수 있고, 어떤 문제가 발생해도 상대적으로 빨리 이를 고칠 가능성이 높다. 필자도 이에 대해 개인적으로 겪은 일이 있다. 특정 기술을 사용하여 라이브러리를 온라인으로 지금도 배포하고 있는데, 관련 Github를 보면 요 몇 달 새 해당 기술에 대해 아무런 업데이트나 변경 사항이 없었으며, 버그, 오류도 누군가가 보고했지만 몇 달 동안 방치되어 있다. 알고보니 해당 기술은 한 개인이 만든 것이라고 한다. 그래서인지 버그, 오류 대응에 굉장히 느려 일부 기능을 못쓰거나 해서 불편했던 경험이 있다.
  • Electron에서는 Chromium을 사용하는데, 이 Chromium에서 major한 업데이트가 있을 때마다 거의 같은 날에 그에 맞게 Electron을 업데이트한다고 한다. 즉, 업데이트 주기가 빠르다.
  • Chromium은 구글에서 개발하는 오픈 소스 웹 브라우저 프로젝트이다. 즉, 웹 브라우저인데, Chrome 브라우저의 기반 기술이다(둘은 매우 비슷해보이지만 여러 기능들에서 차이점이 있다고 한다). Electron에서는 이 Chromium도 같이 번들링하여 앱을 구성하게끔 하고 있다. OS들에서는 각자 고유한 웹 브라우저 혹은 웹 뷰(Web view)를 제공하지만, 이는 OS에 종속적이라서 혹시라도 버그가 있을 경우 OS 자체를 업그레이드해야 한다. 즉, 내가 만든 데스크톱 앱을 사용자가 원활하게 사용하기 위해서 사용자가 자신의 OS를 업그레이드해야하는 것이다. 간혹 어떠한 이유로 OS 업그레이드를 못할 경우, 사용자는 새로운 컴퓨터를 사야하든가 하는 상황이 발생하는 것이다. 이러한 사용자의 불편함을 피하기 위해 Electron에서는 아예 Chromium이라는 웹 브라우저를 제공하여 번들링에 추가해주는 것이다. 그러면 Chromium 자체에 버그가 생겨도 이것만 업데이트하면 되기에 사용자 입장에서 신경쓸 것이 사라지게 된다. 즉, 사용자 OS와는 독립적으로 앱 실행이 가능하도록 하기 위해 Chromium을 Electron에서 자체적으로 제공하는 것이다. 이러한 이유로 Electron으로 제작한 데스크톱 앱이 OS에 상관없이 작동할 수 있는 크로스 플랫폼적인 성격을 띄는 것이다.
  • Electron에서는 Chromium과 Node.js를 제공하여 번들링되도록 하고 있다. 따라서 Node.js의 기능들을 사용하여 개발할 수 있다. 예를 들어, 로컬 디바이스의 파일 시스템에 접근하여 사용자가 지정한 파일 혹은 폴더를 앱에서 불러오는 등의 기능들을 Node.js를 통해 가능하다. Electron에서는 HTML, CSS, JS 및 UI 프레임워크인 React 등의 기술들을 이용하여 프론트엔드 개발이 가능하면서 동시에 Node.js 등을 통해 사용자 눈에는 안보이는 로직들을 수행하도록 하는 백엔드 개발도 가능한 셈이다.
  • Electron으로 제작된 데스크톱 앱은 보통 100mb 정도라고 한다. 따라서 이만큼의 저장용량을 지원하지 않는 기기에서는 다른 기술을 사용하는 것이 좋다. IoT, 스마트워치용 앱 등이 그 예가 되겠다.
  • 높은 메모리 사용량을 차지하여 성능이 어느 정도 떨어질 수 있다. 그래서 고성능을 필요로 하는 게임 혹은 3D 그래픽 관련은 다른 도구를 사용하는 것이 좋다고 한다. Electron 특유의 높은 메모리 사용량으로 인해 사용자 입장에서는 컴퓨터 사양이 어느 정도는 받쳐줘야한다는 단점이 있긴 하다.

Electron으로 간단한 앱 만들어보기

여기서 소개할 내용은 Electron 공식 홈페이지를 참고하였다.

프로젝트 초기화 및 생성

일단 원하는 위치에 프로젝트 폴더를 생성한다. 필자는 다음과 같이 first-study 라는 프로젝트 폴더를 마련였다.

사진 1-1. 첫 electron 프로젝트 생성

사진 1-1. 첫 electron 프로젝트 생성

npm init 후에는 무언가를 물어보는 프롬프트가 계속 뜰텐데, 그냥 엔터키만 계속 눌러 스킵해도 된다. 이 질문은 결국 만들어질 package.json 파일 내용을 어떻게 구성할 것이냐에 따른 것이며, 해당 파일을 만들고나서 수정해도 된다.

npm init 후 프로젝트에 생성된 package.json 파일 내용을 조금 수정하여 필자는 다음과 같이 구성해보았다.

{
  "name": "first-study",
  "version": "1.0.0",
  "description": "hi!",
  "main": "main.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "JeroCaller",
  "license": "ISC"
}

예제 1-1. package.json

Electron 공식 홈페이지에 따르면 위 파일에 대해 다음의 두 가지를 따르는 게 좋다고 한다.

  • 앱의 진입점은 main.js 로 한다. 이를 위해, 위 package.json 파일에서는 "main": "main.js" 로 바꾼다. 즉, package.json 에서의 "main" 속성이 바로 앱의 진입점이 될 파일명을 설정하는 역할이다.
  • author, license, description은 원하는 값으로 줄 수 있으나, 후에 패키징할 때에는 필요하다고 한다.

이후, 해당 프로젝트 폴더에 위치한 상태에서 터미널 창에 npm i electron --save-dev 을 입력한다. 그러면 잠시 후, package-lock.json 파일 및 node_modules 폴더가 생성된다.

필요에 따라 .gitignore 파일을 추가하여 불필요한 파일 및 폴더들이 git에 커밋되는 것을 방지한다. Node.js에 맞는 gitignore 템플릿은 “여기”를 참고.

앱 실행하기

프로젝트 초기화가 잘 되었는지, 앱은 잘 실행되는지 확인해보기 위해 다음의 과정을 거쳐보자. 먼저 프로젝트 루트에 main.js 파일을 생성하고 다음과 같이 앱이 잘 실행됨을 확인할 수 있는 간단한 메시지 출력 코드를 넣어본다.

console.log('hi there!');

예제 1-2. main.js

그리고 package.json 파일의 "script" 부분에 "start": "electron ." 이 부분을 추가한다. 다음은 이를 추가한 후의 모습이다.

{
  "name": "first-study",
  "version": "1.0.0",
  "description": "hi!",
  "main": "main.js",
  "scripts": {
    "start": "electron .",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "JeroCaller",
  "license": "ISC",
  "devDependencies": {
    "electron": "^38.4.0"
  }
}

예제 1-3. package.json

그 후 터미널 창에서 npm run start 를 치면 앱이 실행된다.

사진 1-2. 명령어 실행 결과

사진 1-2. 명령어 실행 결과

지금은 콘솔창으로 메시지를 출력하게끔 했기에 아직 시각적인 요소는 없다.

main.js 는 Electron으로 제작한 앱의 진입점이 되며, main process를 통제한다. 이 main process는 Node.js 환경에서 실행되며, 앱의 생명주기(lifecycle), 사용자 인터페이스를 화면에 보이는 작업 등을 통제하는 역할을 맡는다.

브라우저 창 띄우기

콘솔창에 텍스트를 띄우는 게 아닌, 그래픽 요소가 있는 화면을 띄워보도록 하겠다.

Electron에서는 각각의 창(window)은 외부 웹 페이지 주소 또는 로컬에 있는 HTML 파일을 로드해와 그 웹 페이지를 창에 보여주는 방식을 취한다. 여기서는 간단한 HTML 파일을 만들어 창에 띄워보겠다.

현재 프로젝트 루트 폴더에 index.html 파일을 만든 후, 다음과 같은 코드를 작성한다.

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
    <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>
  </body>
</html>

예제 2-1. Electron 공식 튜토리얼에서 발췌. https://www.electronjs.org/docs/latest/tutorial/tutorial-first-app#loading-a-web-page-into-a-browserwindow

그 후, main.js 파일 내용을 다음과 같이 바꾼다.

import { app, BrowserWindow } from 'electron'

const createWindow = () => {
  const win = new BrowserWindow({
    width: 800,
    height: 600
  })

  win.loadFile('index.html')
}

app.whenReady().then(() => {
  createWindow()
})

예제 2-2. main.js

이제 콘솔창에서 npm run start 를 입력하여 실행해보면 다음과 같이 창이 하나 뜰 것이다.

사진 2-1. 첫 Electron 앱의 창 띄우기

사진 2-1. 첫 Electron 앱의 창 띄우기

예제 2-2에서, app 은 애플리케이션의 이벤트 생명 주기를 관리하는 역할을 맡는다. BrowserWindow 는 앱의 창을 생성 및 관리하는 역할을 한다.

createWindow 함수에서는 BrowserWindow 를 이용하여 앱 창에 해당하는 객체를 생성한 후, win.loadFile('index.html') 으로 프로젝트 루트 폴더에 있는 index.html 파일을 로드하도록 하고 있다.

이러한 창 생성 함수는 마지막 부분의 app.whenReady(). 구문에서 호출하여 실제로 생성하도록 하였다.

창에 보여지는 각각의 웹 페이지들은 renderer process(또는 줄여서 renderer)라 불리는 서로 분리된 프로세스에서 실행된다. 이 renderer process들은 같은 자바스크립트 API, 또는 webpack, React와 같은 프론트엔드 기술에서 흔히 쓰이는 도구들에도 공통으로 접근할 수 있다.

사실 여기까지만 하더라도 앱 창 띄우기는 끝이다. 다만 앱 창의 라이프사이클 관리를 위해 다음의 코드 추가를 고려할 수 있다.

Window, Linux에서는 어떤 특정 앱과 연관된 모든 창을 닫으면 앱이 종료된다고 한다. 이를 구현하기 위해, window-all-closed 이벤트를 listening하도록 하여 해당 이벤트 발생 시 app.quit() 을 통해 앱을 종료하도록 할 수 있다. 이는 다음의 코드로 구현할 수 있다.

app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') app.quit()
})

예제 2-3.

참고로 여기서 darwin 은 macOS, IOS 등의 여러 운영체제들의 기반이 되는 애플에서 제작한 오픈 소스 유닉스 기반 컴퓨터 운영체제라고 한다.

한 편 macOS에서는 창이 열려있지 않더라도 앱이 계속 실행되는 편이라고 한다. 어떤 창도 열려있지 않은 상태에서 앱을 활성화시키고자 할 때 새 창을 하나 열도록 해줘야 한다. 이를 구현하기 위해서는 activate 이벤트 발생 시 BrowserWindow 가 하나도 열려있지 않다면 새 창을 열도록 해준다. 다음의 코드에서 이를 구현한다.

app.whenReady().then(() => {
  createWindow()

  app.on('activate', () => {
    if (BrowserWindow.getAllWindows().length === 0) createWindow()
  })
})

예제 2-4.

지금까지의 설명들을 종합할 때, 일반적으로 main.js 의 시작 코드는 다음과 같다고 한다.

import { app, BrowserWindow } from 'electron'

const createWindow = () => {
  const win = new BrowserWindow({
    width: 800,
    height: 600
  })

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

예제 2-5. main.js 시작 코드

Preload script와 IPC(Inter-Process Communication)

보안상의 이유로 웹 브라우저에서 사용자의 로컬 기기에 직접 접근할 수는 없도록 되어 있다. 이와 같은 이유로 웹 브라우저 측에서는 사용자 로컬 기기에 설치된 Node.js를 직접 실행시킬 수 없다.

따라서 Electron에서는 웹 페이지를 렌더링하여 화면에 보여주는 renderer process와, Node.js 환경에서 사용자 로컬 기기의 OS, 파일 시스템 등에 접근할 수 있는 main process로 나뉘어 존재한다. renderer 쪽에서는 HTML, CSS, Javascript, React 등의 UI 기술들을 이용하여 웹 페이지를 구성하는 일종의 “프론트엔드” 영역이라 보면 되겠고, main process 쪽은 “백엔드” 영역이라 보면 되겠다.

프로세스는 각각 독립적인 형태로 존재하며, 각자 별도의 메모리 및 그 외 자원들을 할당받아 사용한다. 그래서 원래 프로세스는 다른 프로세스에 직접 접근하여 영향을 끼칠 수 없다.

그러나 현재 Electron의 상황처럼 때로는 프로세스들끼리 서로 상호작용을 해야할 때도 있다. 이를 위한 프로세스 간 통신 방법, 메커니즘을 IPC(Inter-Pocess Communication)라고 한다.

그리고 Electron에서는 main process와 renderer process 이 둘의 통신을 위해 가교 역할을 하는 preload라 불리는 스크립트를 사용한다.

preload script는 HTML DOM에 접근 가능하며, 그와 동시에 Node.js 및 Electron module의 일부 제한된 API에 접근할 수 있다(일부 제한되어 있는 건 역시 보안을 위해서이다).

preload script는 renderer 쪽에서 웹 페이지를 렌더링하기 전에 주입되어 두 프로세스간 통신이 가능한 원리라고 한다.

이를 구현하려면 preload script 역할을 할 preload.js 파일을 프로젝트 루트에 생성한 후, contextBridge라고 하는 API를 이용하여 renderer에서 접근하여 사용할 수 있는 전역(global) 객체를 정의해줘야 한다.

다음은 앱에서 사용하는 Node.js, Chromium, Electron 각각의 버전들을 화면에 띄우기 위한 기능을 구현하는 과정이다. 먼저 preload.js 파일에 다음의 내용을 작성하였다.

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

contextBridge.exposeInMainWorld('versions', {
  node: () => process.versions.node,
  chromium: () => process.versions.chrome,
  electron: () => process.versions.electron
  // 함수 뿐만 아니라 변수도 정의 및 노출 가능.
})

예제 3-1. preload.js

위 코드에서는 versions 라는 이름의 전역 객체를 생성하도록 하고 있다. 따라서 renderer 쪽에서 versions 이름의 전역 객체를 바로 사용할 수 있게 된다.

참고로 필자는 현재 ESM 방식을 사용하고 있는데, 위 예제 3-1에서도 import 방식을 ESM 방식으로 할 경우, renderer.js 에 작성한 HTML 요소들을 동적으로 삽입하는 작업이 제대로 동작하지 않는다. 이로 인해 필자는 어쩔 수 없이 preload.js 에서만큼은 CommonJS 방식을 사용하였다.

main.js 에는 다음과 같이 webPreferences 부분을 추가한다.

import { app, BrowserWindow } from 'electron'
import path from 'path';
import { fileURLToPath } from 'url';

// 현재 실행되는 스크립트 파일의 위치 추출.
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

const createWindow = () => {
  const win = new BrowserWindow({
    width: 800,
    height: 600,
    webPreferences: {
      preload: path.join(__dirname, 'preload.js')
    }
  })

  win.loadFile('index.html')
}

예제 3-2. main.js

그리고 프로젝트 루트 폴더에 renderer.js 파일을 생성하여 다음과 같이 화면에 앞서 만든 각 도구들의 버전을 띄우는 코드를 작성해보았다.

const information = document.getElementById('version');
information.innerHTML = `<p>도구 버전</p>
  <ul>
    <li>
      Node.js : ${versions.node()}
    </li>
    <li>
      Chromium : ${versions.chromium()}
    </li>
    <li>
      Electron : ${versions.electron()}
    </li>
  </ul>`;

예제 3-3. renderer.js

이제 앱을 실행해보면 다음과 같이 각 도구 버전들이 화면에 렌더링된다.

사진 3-1. preload script를 이용한 버전 정보 추가

사진 3-1. preload script를 이용한 버전 정보 추가

여기까지는 두 프로세스를 연결해주는 preload.js 설정법에 관한 것이었고, 두 프로세스 간 통신을 하고자 한다면 Electron에서 제공하는 ipcMainipcRenderer 모듈을 사용해야 한다. 두 프로세스 간 통신을 위해, main process에서는 ipcMain.handle() 을 이용하여 핸들러를 등록한다. 그 후, preload.js 에서는 ipcRenderer.invoke() 를 이용하여 해당 핸들러를 트리거할 수 있도록 하면서 renderer 쪽에서 사용할 수 있도록 함수를 노출시켜야 한다.

여기서는 간단하게 앱 창의 콘솔에 메시지를 출력하는 간단한 기능을 만들어보겠다. 먼저, main.js 에는 다음과 같은 코드를 추가한다.

import { app, BrowserWindow, ipcMain } from 'electron'
// 생략...

app.whenReady().then(() => {
  // ipcMain.handle 코드는 HTML 파일 로딩 전에 실행되도록 해야 한다. 
  // 따라서 createWindow() 호출문 전에 작성한다. 
  ipcMain.handle('my-custom-feature', () => '안녕하세요!');
  createWindow()

  // 생략
})

예제 3-4. main.js

ipcMain.handle(channel, listener) 형태의 API는 첫 인자에 문자열 형태의 이름을 주고, 이 채널이 renderer 쪽에 의해 호출될 경우 실행시킬 콜백 함수를 두 번째 인자에 작성하는 것이다. 여기서는 간단하게 “안녕하세요!”란 인삿말이 출력되도록 하였다. 이러한 핸들러는 HTML 파일이 로드되기 전에 정의되어야 하기 때문에 createWindow() 호출문 이전에 작성하였다. 만약 반대로 createWindow() 호출문 뒤에 작성하면 의도한 대로 작동하지 않는다.

다음으로 preload.js 에는 다음과 같이 코드를 추가한다.

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

contextBridge.exposeInMainWorld('versions', {
  node: () => process.versions.node,
  chromium: () => process.versions.chrome,
  electron: () => process.versions.electron,
  myCustomFeature: () => ipcRenderer.invoke('my-custom-feature')  // 추가된 코드
  // 함수 뿐만 아니라 변수도 정의 및 노출 가능.
})

예제 3-5. preload.js

여기서는 ipcRenderer.invoke() 함수에 앞서 ipcMain.handle() 에서 정의한 채널명을 그대로 인자로 대입한다. 이렇게 하면 renderer 쪽에서 versions.myCustomFeature() 를 호출하면 ipcMain.handle() 의 두 번째 인자로 작성한 콜백 함수가 실행되어 해당 기능을 사용할 수 있는 구조인 것이다.

renderer.js 파일에는 다음의 내용을 추가하였다.

const callMyCustomFeature = async () => {
  const response = await versions.myCustomFeature();
  console.log(response);
}

callMyCustomFeature();

예제 3-6. renderer.js

ipcRenderer.invoke() 의 반환값 형태가 Promise 형태이므로 여기서 async - await를 사용하였다.

이제 앱을 실행해보면 다음의 결과를 얻을 수 있다.

사진 3-2.

사진 3-2.

참고로 위와 같이 개발자 도구를 킬려면 맨 위 메뉴바에서 ViewToggle Developer Tools 를 클릭하면 된다.

지금까지의 내용을 되돌아보면, Electron에서는 Node.js 환경에서 사용자 로컬 기기에 접근할 수 있는 권한을 가진 main process와, 웹 기술을 이용하여 화면을 렌더링하는 renderer process가 존재한다는 것을 배웠다. 그리고 이 두 프로세스 간 통신을 위해 중간 역할을 하는 preload 스크립트를 생성하며, Electron에서 제공하는 ipcMain , ipcRenderer 모듈들을 통해 실제로 두 프로세스가 통신을 하게 되는 구조다. 그래서 Electron을 이용한 데스크톱 앱 제작을 위해서는 프로젝트 폴더에 각각 main.js , preload.js , renderer.js 가 필수로 존재하는 셈이다. 나중에 소개할 Electron 앱 제작을 더 쉽게 해주는 Electron-Vite 도구를 이용하면 실제로는 main , preload, renderer 이 세 개의 폴더로 나눠 작업하게 된다. 실제로는 프로젝트 규모가 커질수록 작성할 모듈들도 많아지기에 분류를 위해 이 세 개의 폴더로 나눠 작업하는 것이다.

앱 패키징

앱을 모두 제작하였고, 개발 환경에서 테스트도 완료했다면 이제 앱을 패키징하여 배포 준비가 되도록 할 차례이다.

사실 Electron 자체에는 앱을 패키징하고 배포 가능한 파일로 제작하는 툴을 제공하고 있지 않다고 한다. 대신 Electron Forge라는 툴이 이를 대신한다. Electron Forge는 Electron으로 제작된 앱을 패키징하고 배포 가능한 파일로 만드는 과정을 처리하는 all-in-one 도구라고 한다. 앱 패키징 시 이미 존재하는 Electron 툴들을 하나의 인터페이스로 패키징하기 때문에 개발자는 패키징 과정에서 필요한 관련 의존성들을 일일히 연결하는 수고로움이 불필요하다고 한다.

Electron Forge를 이용하여 앱을 패키징해보자. 먼저 프로젝트 루트 폴더에서 터미널에서 다음의 명령어들을 차례대로 입력하자.

npm install --save-dev @electron-forge/cli
npx electron-forge import

예제 4-1.

위 명령어들을 모두 실행했다면, 두 가지 변경점들이 생겼을 것이다. 하나는 기존에 존재하던 package.json 파일 내용의 변경, 또 하나는 프로젝트 루트 폴더에 forge.config.js 라는 파일이 새로 생성되었다는 점이다.

먼저 package.json 파일 내용부터 보겠다. 변경된 점만 작성하였다.

"scripts": {
  "start": "electron-forge start",
  "test": "echo \"Error: no test specified\" && exit 1",
  "package": "electron-forge package",
  "make": "electron-forge make"
},
// ...
"devDependencies": {
  "@electron-forge/cli": "^7.10.2",
  "@electron-forge/maker-deb": "^7.10.2",
  "@electron-forge/maker-rpm": "^7.10.2",
  "@electron-forge/maker-squirrel": "^7.10.2",
  "@electron-forge/maker-zip": "^7.10.2",
  "@electron-forge/plugin-auto-unpack-natives": "^7.10.2",
  "@electron-forge/plugin-fuses": "^7.10.2",
  "@electron/fuses": "^1.8.0",
  "electron": "^38.4.0"
},
"dependencies": {
  "electron-squirrel-startup": "^1.0.1"
}

예제 4-2. package.json 내용 일부

일단 "script" 부분의 속성값들이 electron-forge 로 변경된 것을 볼 수 있다. 그 외에도 "devDependencies" 속성값에 @electron-forge/... 로 시작되는 개발용 의존성들이 추가되었고, "dependencies" 에도 "electron-squirrel-startup" 속성이 추가된 것을 볼 수 있다.

그 다음으로는 새로 생성된 forge.config.js 파일이다.

import { FusesPlugin } from '@electron-forge/plugin-fuses';
import { FuseV1Options, FuseVersion } from '@electron/fuses';

export const packagerConfig = {
  asar: true,
};
export const rebuildConfig = {};
export const makers = [
  {
    name: '@electron-forge/maker-squirrel',
    config: {},
  },
  {
    name: '@electron-forge/maker-zip',
    platforms: ['darwin'],
  },
  {
    name: '@electron-forge/maker-deb',
    config: {},
  },
  {
    name: '@electron-forge/maker-rpm',
    config: {},
  },
];
export const plugins = [
  {
    name: '@electron-forge/plugin-auto-unpack-natives',
    config: {},
  },
  // Fuses are used to enable/disable various Electron functionality
  // at package time, before code signing the application
  new FusesPlugin({
    version: FuseVersion.V1,
    [FuseV1Options.RunAsNode]: false,
    [FuseV1Options.EnableCookieEncryption]: true,
    [FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false,
    [FuseV1Options.EnableNodeCliInspectArguments]: false,
    [FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: true,
    [FuseV1Options.OnlyLoadAppFromAsar]: true,
  }),
];

예제 4-3. forge.config.js

처음에는 CommonJS 방식의 require(...) 방식으로 파일을 import 하는 코드가 맨 상단에 작성되어 있을 것이다. 필자는 기본적으로 ESM 모듈 방식을 취하고자 했기에 이미 package.json 파일에 "type": "module" 속성을 추가한 상태여서 위 파일의 import 방식도 import ... from 방식으로 코드를 바꿔 작성하였다. package.json 에 ESM 모듈 사용을 명시한 상태에서 위 파일에서 CommonJS 방식을 그대로 두면 패키징 과정에서 에러가 뜨니 이 점 유념해야한다.

한 편 위 코드에는 “makers”라는 객체가 보이는데, 여기에는 각 플랫폼(운영체제)에 맞는 배포용 앱 생성을 위한 도구들을 등록하는 곳이라 보면 된다.

한 편, 앞서 package.json 파일의 "script" 부분을 다시 살펴보면 "make" , "package" 가 새로 추가된 것을 볼 수 있는데 이 명령어들이 실질적으로 패키징 명령어이다.

이를 이용하여 이제 터미널에서 다음의 명령어를 입력해보자.

npm run make

예제 4-4.

위 명령어를 실행하면 먼저 내부적으로 electron-forge package 명령어를 자동으로 실행한다. 이는 앞서 살펴본 "package" 속성값과 동일하다. 해당 명령어를 실행하면 개발자가 작성한 앱 코드를 이진 파일로 번들링한다. 이 과정을 통해 패키징된 코드는 한 폴더 내에 생성된다. 그 후 이전의 forge.config.js 파일에서 makers 내에 설정한 각각의 maker들이 이 패키징된 앱 폴더를 이용하여 별개의 배포 가능한 앱으로 번들링한다.

위 명령어를 실행하고 나면 프로젝트 폴더에 out 폴더가 생성될 것이다. forge.config.js 를 어떻게 설정하느냐에 따라 out 폴더의 내부 구조가 조금씩 다르겠지만, 필자의 경우 {앱 이름}-win32-x64 폴더와 /make/squirrel.windows/x64 폴더가 각각 생성되었다. 여기서 앱 이름은 package.json 파일의 "name" 속성값을 따른다. 전자의 경우 폴더 안에 {앱 이름}.exe 파일이 포함되어 있어 이를 더블 클릭하면 바로 앱을 실행할 수 있다. 다만 이는 해당 폴더에 다른 의존성 파일들이 존재하기에 실행 가능한 것이다. 사용자가 이 앱을 제대로 사용하려면 /make/squirrel.windows/x64 폴더에 생성된 {앱 이름}-{버전} Setup.exe 를 배포한다. 사용자가 해당 파일을 실행시키면 사용자 로컬 컴퓨터의 AppData/Local 폴더에 앱 이름과 동일한 폴더가 생성되어 그 안에 앱 실행에 필요한 의존성 파일들이 생성된다. 해당 폴더 안에 있는 {앱 이름}.exe 파일을 실행하여 사용하면 된다.

위에서 소개된 코드들의 전체 소스 코드는 “Github repo”를 참고.

그 외 참고 사항들

사실 여기까지 다룬 내용들만 참고해도 Electron을 이용한 데스크톱 앱 제작을 위한 첫 설정부터 앱 패키징까지의 과정을 수행하는 것이 가능할 것이다. 다만 여기서 추가적으로 고려해볼만한 사항들이 있으니 참고바란다.

Code Signing

사용자 입장에서는 개발자가 배포한 실행 가능한 파일(.exe) 형태의 데스크톱 앱이 정말로 신뢰할만한 개발자가 배포한 것인지, 아니면 악성 해커가 만든 것인지 분간하기 힘들다. 또는 어떤 악성 해커가 신뢰할만한 개발자의 앱을 가로채 똑같이 생겼지만 악성 코드를 심어둔 비슷한 앱을 사용자에게 배포할지도 모를 일이다. 이러한 보안적인 이슈를 방지하기 위한 방법 중 하나가 Code Signing이라는 것이 있다. Code Signing은 데스크톱 앱이 신뢰할만한 개발자가 제작했음을 증명해주는 보안 기술이다.

Window, macOS 등의 여러 운영체제들은 각자 OS에 Code signing 시스템을 탑재하고 있다. 그래서 sign되지 않은 앱을 신뢰할 수 없는 앱으로 간주하여 사용자가 해당 앱을 다운로드 및 실행하기 어렵게 만드는 시스템이 있다고 한다. 필자는 Window만 사용해봐서 다른 운영체제는 모르겠지만, Window의 경우 경고창을 띄우지만 그렇다고 해서 아예 다운로드, 설치, 실행이 안되는 것은 아니다.

macOS에서는 Code signing이 앱 패키징 단계에서 실행되고, Window의 경우 배포용 installer(처음 프로그램 다운로드 받으면 이를 컴퓨터에 설치하기 위해 뜨는 프로그램) 단계에서 sign된다고 한다.

Electron app에 Code Signing을 적용하는 구체적인 방법에 대해서는 다음의 사이트들을 참고하면 되겠다.

자동 업데이트 (auto-updating)

웹 개발도 그러하듯, 데스크톱 앱 개발에서도 시간이 지남에 따라 이미 사용자들에게 배포한 뒤에 뒤늦게 오류 또는 버그를 발견하거나 아니면 새로운 기능을 추가해야할 때가 종종 있을 것이다. 이를 위해 추가 개발을 진행한 뒤에 버전업하여 또 배포할 것이다. 웹 개발의 경우에는 사용자가 해당 웹 사이트에 방문하면 자동으로 업데이트된 버전의 웹 사이트를 보게 되겠지만, 데스크톱 앱의 경우엔 사용자가 일일히 개발자의 사이트에 들어가서 새로운 버전으로 업데이트된 사항이 있는지 직접 확인해야할 것이다. 이는 사용자의 입장에서 굉장히 번거로울 것이다.

그래서 사실 Electron에서는 사용자가 일일히 개발자 사이트에 가서 업데이트 사항을 확인하는 번거로운 과정을 거치지 않아도 앱 실행 시 앱이 자동으로 새 릴리즈 사항을 체크하고 새로 업데이트된 버전이 존재한다는 것을 확인한다면 이를 자동으로 다운로드 받아 업데이트하는 기능을 소개하고 있다. 다음의 사이트를 참고.

Electron 자체에서 제공하는 update.electronjs.org 에서 제공하는 서비스를 이용하려면,

  1. macOS 또는 Windows에서 실행될 수 있는 앱인 경우
  2. public Github repo에 오픈 소스로 그 코드를 공개한 경우
  3. Github release를 통해 빌드된 앱이 배포되는 경우
  4. macOS의 경우 code sign이 된 경우

위 4가지 사항들을 만족하면 무료로 자동 업데이트 기능을 사용할 수 있다고 한다. 이를 구현하는 상세한 과정은 해당 사이트를 참고하면 되겠다. 다만 위 조건 중 하나라도 만족을 못할 경우라던가 또는 좀 더 low level에서 스스로 자동 업데이트 배포 파이프라인을 구축하는 걸 더 선호하는 경우에는 개발자가 별도로 자동 업데이트를 위한 서버를 구성하여 운영해야 한다고 한다.

글을 마치며…

이 글에서는 웹 기술을 이용하여 데스크톱 앱을 제작할 수 있는 Electron에 대한 개요 및 간단한 사용 방법에 대해 살펴보았다.

사실 Vite를 알고 있다면 Electron-Vite라는 좀 더 사용하기 쉬운 빌드 툴을 이용하는 것이 더 좋을지도 모른다. 다음에는 기회가 된다면 이 Electron-Vite에 대해 소개하는 글을 올리도록 하겠다.


References

[1] electron 공식 홈페이지

Build cross-platform desktop apps with JavaScript, HTML, and CSS | Electron

[2] electron-vite 공식 홈페이지

electron-vite | Next Generation Electron Build Tooling

[3] electron

Electron(프레임워크)

[4] chromium

크로미엄 (웹 브라우저)

[5] chromium

Chromium

[6] chromium vs chrome

크로미움? 크롬? 무슨차이일까

[7] IPC

프로세스 간 통신

[8] IPC

Inter Process Communication (IPC) - GeeksforGeeks

[9] IPC

IPC(Inter-Process Communication)란?

[10] 내 블로그 글 - 스레드 (프로세스)

[Java] 스레드 (Thread)

This content is licensed under CC BY-NC 4.0

댓글남기기