일렉트론: 자바스크립트가 네이티브 데스크탑 환경으로

JavaScript가 한동안 브라우저 환경에서만 돌아가는 HTML의 곁다리(?) 같이 여겨지던 시절이 있었습니다.

하지만 Node.js가 등장한 후 JavaScript는 브라우저에서 뿐만 아니라 백엔드에서도 많이 사용하고 있습니다.

요즘에는 Java + Spring MVC보다 Node + Express가 사용 빈도수가 더 많다는 이야기도 들립니다.

그런 JavaScript가 이제 네이티브 데스크탑 개발 영역에도 진출했습니다.

바로 오늘 소개할 일렉트론이 그것입니다.

일렉트론은 GitHub에서 개발과 유지보수하고 있고, 일렉트론을 사용한 유명한 제품으로는 VSCode가 대표적입니다. Atom 의문의 1패

오늘은 일렉트론 개발 환경을 구성하는 방법을 알아보고, 이때 발생할 수 있는 시간 엄청 잡아먹는 삽질 시행착오를 피해가는 유용한 꿀팁을 안내해드리겠습니다.

일렉트론의 원리

일렉트론의 실행 환경에는 Chromium이라는 브라우저가 들어갑니다. 크로뮴 하니까 크롬이 생각나시죠? 네 맞습니다. 구글에서 개발한 크롬 브라우저의 코어 부분이 크로뮴입니다.

브라우저가 만약 Node.js처럼 동작한다면 어떨까요?

예를 들면 브라우저 환경이 아니라 서버 사이드용(sequelize, mysql2, pg-postgre 등) 패키지를 이용할 수 있다거나 로컬 파일 시스템(fs)에 접근할 수 있는 것입니다.

일렉트론은 이것을 실현하여 브라우저 환경의 제약에서 벗어나 웹을 사용하면서도 로컬 시스템 자원에 자유롭게 접근할 수 있게 만들었습니다.

사실 이것은 애초에 HTML5가 목표했던 바이기도 했는데요. 현실은 지금 보시는 대로… HTML5 나오면 안드로이드, iOS 망한다고 누가 그랬냐

일렉트론으로 데스크탑 애플리케이션을 개발할 때에는 Java Swing이나 C# WinForm처럼 네이티브 코드로 만드는 것이 아니라 기존에 웹 애플리케이션을 만들 듯이 HTML, CSS, JavaScript를 활용합니다.

그러니까 여러분이 평소에 잘 쓰시는 Vue.js, React 등을 사용해서 개발할 수 있다는 소리입니다.

준비물

  • Node 12.6.0
  • Yarn 1.17.3
  • TypeScript 3.5.3
  • Parcel (일렉트론 개발할 때 Webpack 보다 편합니다.)

프로젝트 디렉터리 구조

+- dist/                ... Electron 빌드 최종 결과물 (네이티브 바이너리 패키지 artifact 등)
+- build/               ... Parcel 빌드 결과물 (번들링 된 웹 리소스)
+- app/                 ... React 애플리케이션 디렉터리
+   +- components/
+   +- App.tsx
+   +- index.tsx
+
+- electron.js          ... 컴파일된 일렉트론 진입점
+- electron.ts          ... 일렉트론 진입점 소스 코드
+- icon.png             ... 데스크탑에 노출된 패키지 아이콘 (256x256 이상)
+- index.html           ... React 애플리케이션 index.html
+- package.json
+- tsconfig.json        ... 타입스크립트 설정

제가 사용하는 모양은 이렇게 생겼지만, 여러분도 각자 사용하는 모양이 있을 것입니다.

그래서 남의 블로그 내용을 무작정 따라하다간 트러블이 생길 수도 있습니다.

저도 시간을 많이 허비했기 때문에 여러분이라도 시행 착오를 조금 덜 겪기를 바라면서 어떤 설정 부분을 민감하게 살펴야 하는지 알려드리겠습니다.

필수 패키지

여러분의 기존 웹 애플리케이션에, 일렉트론 개발을 위한 패키지를 더 추가합니다.

// dependencies
$ yarn add electron-is-dev

// devDependencies
$ yarn add concurrently cross-env electron electron-builder wait-on --dev

사용법은 뒤에 설명합니다.

electron.ts 작성

파일 이름이 꼭 electron.ts일 필요는 없습니다. 그냥 순순히 따라한다면 에러 따위는 일어나지 않을 것입니다

앞에서 일렉트론이 기존 웹 기술을 그대로 활용한다고 말씀드렸습니다.

여러분이 만든 리액트 애플리케이션을 일렉트론으로 패키징해서 데스크탑에서 실행하는 것도 가능합니다.

다만 여기에 일렉트론이 웹 애플리케이션을 구동하도록 진입점 코드를 작성하는 약간의 수고가 필요합니다.

그런데 사실 여러분이 작성할 일렉트론과 관련된 코드는 이게 전부입니다.

나머지는 다 여러분이 이미 개발하던 웹 애플리케이션이니까요.

아래는 예제 코드입니다.

import { app, BrowserWindow } from 'electron'

function createWindow () {
  // Create the browser window.
  let win = new BrowserWindow({
    width: 800,
    height: 600,
    webPreferences: {
      nodeIntegration: true
    }
  })

  // and load the index.html of the app.
  win.loadFile('index.html')
}

app.on('ready', createWindow)

브라우저 윈도우를 열고 거기에다가 index.html를 불러오는 게 전부입니다.

끝 (…)

네, 그런데 이렇게만 해놓으면 결과적으로는 맞는데 개발하는 동안에는 불편할 수가 있습니다.

보통 코드에 변경이 있으면 자동으로 빌드하고 브라우저도 refresh (혹은 hot-reload) 해주는 도구를 사용해서 개발을 하고 계실 겁니다.

제가 사용하는 코드는 이렇습니다.

import { app, BrowserWindow } from 'electron'
import * as isDev from 'electron-is-dev'
import * as path from 'path'

// 1. GC가 일어나지 않도록 밖에 빼줌
let main_window: BrowserWindow

function create_window() {
  main_window = new BrowserWindow({
    // 이것들은 제가 사용하는 설정이니 각자 알아서 설정 하십시오.
    alwaysOnTop: true,
    center: true,
    fullscreen: true,
    kiosk: !isDev,
    resizable: false,
    webPreferences: {
      // 2.
      // 웹 애플리케이션을 데스크탑으로 모양만 바꾸려면 안 해도 되지만,
      // Node 환경처럼 사용하려면 (Node에서 제공되는 빌트인 패키지 사용 포함)
      // true 해야 합니다.
      nodeIntegration: true,
    },
  })

  // 3. and load the index.html of the app.
  if (isDev) {
    // 개발 중에는 개발 도구에서 호스팅하는 주소에서 로드
    main_window.loadURL('http://localhost:3000')
    main_window.webContents.openDevTools()
  } else {
    // 프로덕션 환경에서는 패키지 내부 리소스에 접근
    main_window.loadFile(path.join(__dirname, './build/index.html'))
  }

  // Emitted when the window is closed.
  main_window.on('closed', () => {
    main_window = undefined!
  })
}

// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
app.on('ready', create_window)

// Quit when all windows are closed.
app.on('window-all-closed', () => {
  app.quit()
})

위에서 중요한 부분은 개발 중에 편리하도록 로딩 부분을 분기하는 것입니다.

이때 주의할 점이 있습니다. 바로 이 부분입니다.

path.join(__dirname, './build/index.html')

블로그에 적혀있다고 그대로 베껴오면 실행이 안 되는 이유가 주로 이런 부분 때문인데요.

  • build/index.html
  • /build/index.html
  • ./build/index.html
  • ../build/index.html

위의 네 가지의 의미가 모두 다릅니다.

내 프로젝트의 디렉터리 구조를 보고, 점을 몇 개 찍어야 하는지, 슬래시를 쳐야하는지 잘 살펴보시기 바랍니다.

create-react-app (혹은 react-scripts, react-scripts-ts)에서 yarn build (혹은 npm build)를 하면 build/ 경로에 결과물이 생성됩니다.

parcel build index.html 사용한다면 기본 dist/ 경로에 생성이 되는데, 나중에 dist/는 일렉트론 패키징을 할 때 사용할 것이므로 대신 parcel build index.html --out-dir build를 해서 build/에 번들이 생성되도록 합니다.

프로젝트의 루트 디렉터리에서 봤을 때, electron.ts의 입장에서 build/index.html의 상대경로가 어떻게 되는지를 잘 보고 작성해야 합니다.

electron.ts 파일은 따로 컴파일해서 바로 같은 디렉터리에 electron.js를 생성합니다.

저같은 경우 electron.js는 루트에 있으므로 build/index.html를 참조하려면 ./build/index.html 가 됩니다.

혹시 electron.js가 다른 디렉터리에 있으면 그에 맞게 바꿔줘야 합니다. 그냥 저를 따라하세요

electron.ts는 거의 수정하지 않으므로 electron.js로 따로 컴파일 해두고 그것을 계속 사용합니다.

package.json 작성

main 수정

{
  ...,
  "main": "electron.js"
}

일렉트론의 진입점입니다.

script 수정

이 프로젝트에서는 create-react-app, react-scripts, webpack 대신 parcel을 사용하였습니다.

{
  ...,
  "scripts": {
    "build": "parcel build index.html --no-cache --no-source-maps --out-dir build --public-url ./ --target electron",
    "build:electron": "tsc electron.ts",
    "clean": "rm -rf .cache build dist",
    "dist": "electron-builder --linux",
    "debug": "concurrently 'cross-env BROWSER=none yarn start' 'wait-on http://localhost:3000 && electron .'",
    "start": "parcel index.html --port 3000 --out-dir build"
  }
}

1. electron.ts 수정할 때 한 번

$ yarn build:electron

electron.ts => electron.js

2. 개발 중

$ yarn debug

보통 yarn start로 리액트 애플리케이션 개발할 때 처럼 하되, 보통 사용하는 브라우저를 띄우지 않습니다. (BROSWER=none)

대신 http://localhost:3000이 준비되면 일렉트론(크로뮴 브라우저)으로 실행합니다.

3. 패키지 배포시

$ yarn clean
$ yarn build
$ yarn dist

먼저 웹 애플리케이션 번들을 build/에 생성하고, electron-builder를 실행해서 패키징하여 최종 결과물을 dist/에 생성합니다.

parcel의 default output-dir도 dist/이고 electron-build의 output-dir도 dist/이기 때문에 parcel out-dir을 build/로 바꿨습니다.

build, dist는 각 명령에 따라 결과물이 생성되는 디렉터리 이름을 따라서 지었습니다.

build 수정

일렉트론으로 다양한 플랫폼과 아키텍처로 바이너리 패키지를 생성할 수 있습니다. 여기에 필요한 정보를 넣습니다.

{
  ...,
  "build": {
    "appId": "net.musma.example",
    "productName": "example",
    "files": [
      "build/**/*",
      "icon.png",
      "electron.js"
    ],
    "linux": {
      "target": {
        "target": "deb",
        "arch": "armv7l"
      },
      "category": "Development"
    },
    "deb": {
      "fpm": [
        "--architecture",
        "armhf"
      ]
    }
  }
}

files 부분에 뭐가 들어가 있는지 잘 살펴보십시오.

  • 웹 애플리케이션 번들 (build/…)
  • 아이콘 파일 (없으면 일렉트론 고유 아이콘으로 대체됨)
  • 일렉트론 진입점 파일 (electron.js)

이 파일들만 잘 넣어주면 보통 문제가 없습니다.

electron.js에서 파일을 참조할 때 build/index.html을 올바르게 가리키는지만 신경쓰면 됩니다.

혹시 라즈베리 파이 3에서 사용할 패키지의 경우 fpm 옵션으로 --architecture armhf를 넣어서 패키징해야 합니다.

이 설정은 electron-builder 도구에서 사용하는 옵션입니다.

자세한 내용은 https://www.electron.build/

Q & A

기본 브라우저 대신 일렉트론(크로뮴)으로 실행한다는 점만 다르고, 평소에 웹 애플리케이션 개발하는 방법과 크게 다르지 않습니다.

시행착오를 피하기 위해 살펴봐야 할 부분을 아래에 정리하였습니다.

(프로덕션 환경에서) 실행시 콘솔에 ‘Not allowed to load local resource: …’ 출력

일렉트론 진입점 파일 electron.js 에서 build/index.html를 가리키는 상대 경로가 잘못된 경우 index.html를 불러오지 못해서 하얀 빈 화면만 출력됩니다.

electron.ts 파일에서 build/index.html 부분을 알맞게 수정하고 다시 컴파일합니다.

(프로덕션 환경에서) index.html은 불러왔으나 js 파일 로드 실패 (404 Not Found)

PUBLIC_URL./로 되어 있어야 합니다.

parcel로 할 때는 parcel build index.html ... --public-url ./ 하면 됩니다.

(프로덕션 환경에서) 콘솔에 Uncaught ReferenceError: process is not defined 출력

electron.ts에서 BrowserWindow를 생성할 때 webPrefrences.nodeIntegration 옵션을 true로 설정합니다.

new BrowserWindow({
  ...,
  webPreferences: {
    nodeIntegration: true,
  }
})

이 옵션을 켜야 Node 빌트인 패키지(process 등)에 접근할 수 있습니다.

후기

무스마에서는 ARMv7을 사용하는 라즈베리 파이3 보드에 터치 패드를 조합한 커스텀 태블릿에 시리얼 통신으로 센서 데이터를 수집하는 애플리케이션을 포팅할 계획이었습니다.

그래서 기존 기술을 활용하면서 쉽게 적용할 수 있는 대안을 알아보는 중에 일렉트론을 살펴보게 되었습니다.

원론적으로 말하자면, 웹 기술을 사용해서 데스크탑 애플리케이션을 만들 수 있기 때문에 잘 쓰면 유용할 것도 같습니다.

그런데 하드웨어 종속적인 네이티브 패키지를 사용할 때는 조금 어려운 문제가 있습니다.

일렉트론은 일단 Windows, Linux, macOS 등의 OS 플랫폼과 x64, ia32, armv7 arm64 아키텍처를 지원합니다.

그런데 의존 패키지가 네이티브 C++ 모듈이 들어간 패키지라면 그것들도 타겟 플랫폼/아키텍처를 지원하는지 여부를 따로 고려해야 합니다.

예를 들면 serialport 패키지는 Windows/Linux ia32/x64 용으로만 제공하고 있어서 armv7로는 바로 사용할 수 없습니다.

그래도 방법이 없는 것은 아니라서, 저희가 직접 타겟 환경에 따라서 다시 빌드를 해도 되지만…

node-gyp, gcc, Makefile… 뭔가 마음이 힘듭니다. 귀찮아요

그래서 다른 대안을 또 찾아보려고 합니다.

그럼 이만…

감사합니다!


References