FE개발자의 성장 스토리 11 : Electron, 저도 한번 해보겠습니다.

Front End(이하 FE) 개발이라고 하면, 보통 웹(Web) 사이트/페이지 또는 앱을 개발한다고만 생각할 수 있는데요, 오래전부터 Node.js등을 활용해 서버 측 백엔드, PC 프로그램, VR/AR 개발까지 영역을 확장해가고 있습니다. 
일렉트론(Electron)을 이용해 GUI 구현과 서버 측 연동으로 데스크톱용 앱을 개발하는 이야기를 들려드리고자 합니다.

이 글은 카카오 FE플랫폼팀에서 데스크톱용 애플리케이션을 개발한 경험을 공유하기 위한 목적으로 당시 사용한 일렉트론 버전 11.2.2를 기준으로 작성되었으며, 글 작성에는  FE플랫폼팀 Thomas와 Judy가 함께했습니다.


 

안녕하세요. FE 플랫폼팀의 비즈인프라FE파트에서 FE 개발자로 근무하고 있는 Thomas와 Judy입니다.

Thomas와 Judy

 

저희는 이번 프로젝트에서 처음으로 Electron을 사용하여 데스크톱 애플리케이션을 개발하게 되었습니다. 프로젝트 동안 Electron을 사용하면서 겪었던 것, 배웠던 것 그리고 느꼈던 것들을 많은 사람과 공유해보고자 합니다.

 

왜 일렉트론을 선택했을까?

 

이미 많은 Electron의 사례들

개발자라면 한 번 정도는 써봤을 법한 VSCode, Atom, Slack 등의 검증된 데스크톱 앱들이 이미 Electron으로 개발되었습니다. 이러한 성공적인 개발 사례들은 기술 도입을 고민 중인 개발자들에게 무한한 자신감을 심어줍니다. (저만 그런가요.. ㅎㅎ)

출처: https://www.electronjs.org/

 

적은 러닝 커브와 큰 효율성

출처: https://www.electronjs.org/docs/tutorial/introduction

 

데스크톱 앱에 대한 개발 경험이 없는 FE 개발자에게 Electron은 한 줄기의 빛이라고 생각됩니다. FE 개발자에게 익숙한 HTML, CSS, JavaScript를 사용하여 데스크톱 애플리케이션을 개발할 수 있기 때문입니다. FE 개발자들이 모여 있는 저희 조직에서는 이 점이 가장 매력적으로 다가왔습니다. 그뿐만 아니라 크로스 플랫폼을 지원하기 때문에 Windows, Mac 그리고 Linux 각각에 설치할 수 있는 애플리케이션을 동시에 개발할 수 있는 것도 큰 장점이었습니다.

 

일렉트론은 어떻게 동작할까?

 

일렉트론에는 두 가지의 프로세스가 존재합니다. 메인(Main) 프로세스와 렌더러(Renderer) 프로세스입니다. 일렉트론 앱은 단 하나의 메인 프로세스를 가집니다. 메인 프로세스는 Node.js 기반으로 동작하며 메인 프로세스에서는 이러한 렌더러 프로세스들을 관리하고, 각각의 렌더러 프로세스는 서로 독립적으로 동작합니다.

위에서 언급한 메인 프로세스와 렌더러 프로세스 간에 통신이 이뤄져야 하는데, 일렉트론에서는 이를 ipcMain과 ipcRenderer와 같은 IPC 모듈을 통해 프로세스 간의 통신을 이루고 있습니다.

 

좌충우돌 개발기

 

1. 카카오 인증 붙이기

대부분의 서비스는 인증 과정을 포함하고 있습니다. 이러한 인증 기능의 구현 방법도 매우 다양합니다. 그중에서도 SNS 로그인이라고 불리는 소셜 로그인이 많이 사용되고 있습니다. (카카오에서도 제공하고 있습니다. ㅎㅎ) 그렇다면 일렉트론에서 카카오 로그인을 구현하려면 어떻게 해야 할까요?

 일반적으로 소셜 로그인을 구현하기 위해서는 인증 과정 이후에 다시 돌아올 곳을 나타내는 REDIRECT_URL이 필요합니다. 웹 서비스의 경우에는 보통 https://kakaocorp.com/login/callback 와 같이 인증을 적용하려는 서비스의 특정 페이지 주소를 전달하여 해당 페이지로 돌아오도록 합니다. 그렇지만 데스크톱 앱의 경우에는 로컬 환경에서 동작하는 것이기 때문에 해당 주소가 존재하지 않습니다. 이러한 주소의 부재는 아래의 과정을 통해서 해결할 수 있습니다.

 

렌더러 프로세스로 화면 생성하고 이벤트 감지해 인증하기

일렉트론에서 화면을 그리기 위해서는 렌더러 프로세스를 생성해야 합니다. 그리고 이 렌더러 프로세스를 생성하기 위해서는 대표적으로 BrowserWindow 객체를 사용합니다.

const { BrowserWindow } = require(‘electron’);

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

이렇게 생성된 BrowserWindow 객체는 속성 중 하나로 webContents를 제공합니다. webContents는 EventEmitter를 상속받기 때문에 렌더러 프로세스에서 발생하는 여러 이벤트를 감지할 수 있습니다. 이 이벤트들을 활용하면 다양한 동작을 구현할 수 있는데, 그중에서 위와 같은 문제를 해결하기 위해서는 will-navigate 이벤트를 사용할 수 있습니다.

 

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

const REDIRECT_URL = ‘임의의 주소’;    // 1.

win.webContents.on(‘will-navigate’, (event, nextUrl) => {
// …
if (REDIRECT_URL === nextUrl) {    // 2.
event.preventDefault();    // 3.
    // 인증 과정을 마무리
}
// …
});

이외에도 render-process-gone 이벤트를 활용하면 예상치 못한 상황으로 인해 화면을 그리지 못하는 경우, 해당 이벤트 감지하여 화면을 새로 고침 하는 등의 동작을 구현하여 더 나은 사용자 경험을 제공할 수 있습니다.

 

2. 화면 안에 여러 화면 그리기: BrowserView

일렉트론에서 화면을 그릴 방법이 또 있습니다. 바로 BrowserView 객체를 사용하는 것입니다. BrowserWindow는 하나의 윈도우 창을 의미하며, 렌더러 프로세스에서의 가장 큰 단위라고 보시면 될 것 같습니다. 물론 BrowserWindow 만으로 프로그램을 충분히 구성할 수 있습니다. 

그런데 간혹 하나의 윈도우 안에서 영역을 나눠서 표현해야 하는 상황이 발생할 수 있습니다. 이런 상황이라면 BrowserView를 사용할 수 있습니다.

BrowserWindow를 사용하여 생성하는 경우에는 높이와 넓이만 따로 설정해 주면 되지만 BrowserView는 높이와 넓이 이외에도 BrowserView를 그릴 BrowserWindow의 특정 위치를 지정해 줘야 합니다. 그러므로 하나의 원도우를 여러 영역으로 나눠서 표현하는 것이 가능합니다.

 지금부터는 BrowserWindow는 윈도우, BrowserView는 뷰라고 부르겠습니다. 이번 프로젝트에서는 기획의 의도에 맞게 구현하기 위해서 하나의 윈도우 안에 최대 3개의 뷰로 나누어 표현해야 했습니다. 이런 상황에서 개발을 진행하면서 경험했던 이슈들을 공유하고자 합니다.

 

메인 프로세스를 통한 BrowserView(렌더러 프로세스)들 간의 상태 공유

하나의 윈도우 안에 여러 개의 뷰를 구성한다는 것은 하나의 화면에 여러 개의 브라우저를 띄워놓는 것과 동일하다고 생각하시면 됩니다. 그렇기 때문에 윈도우 안 각각의 뷰가 상황에 맞는 화면을 그리면 되기 때문에 큰 문제가 없다고 생각했습니다.

하지만 문제는 얼마 지나지 않아서 발견되었습니다. 바로 윈도우 전체에 알림 창을 나타내야 했기 때문입니다. 보통 알림 창을 띄울 때, 사용자의 다른 행동을 제약하기 위해서 백그라운드에는 불투명하게 비활성화시키는 처리를 합니다. 이런 동작은 하나의 뷰인 상황에선 간단합니다. 하지만 뷰가 나뉘어 있다면, 이는 조금 복잡한 상황이 됩니다. 왜냐하면 알림 창이 켜져 있는지에 대한 상태를 모든 뷰가 동시에 알고 있어야 하기 때문입니다.

하지만 앞에서 언급했던 것처럼 각각의 뷰는 독립적으로 존재합니다. 즉, A 뷰의 상태 값은 A 뷰에서만 존재하기 때문에 B 뷰에서는 A 뷰의 상태 값을 알 수 없습니다. 이런 경우에는 특정 뷰의 상태 값 혹은 상태 값의 변화를 나머지 뷰에 전달하는 동작을 이용하여 뷰 간의 상태 값을 공유합니다.

 단, 위에서 설명한 것과 같이 일렉트론에서 각각의 렌더러 프로세스는 독립적으로 동작하기 때문에, 윈도우와 뷰 같은 렌더러 프로세스 간의 직접적인 통신이 불가능합니다.

 

이런 일렉트론의 특징으로 인해서 뷰 간의 상태 값을 공유하기 위해서는 메인 프로세스를 사용해야 합니다. 여기서 메인 프로세스는 뷰의 상태 값 혹은 상태 값의 변화가 발생했을 때, 그것을 특정 뷰 혹은 나머지 뷰 등에게 그대로 전달하는 역할을 담당합니다.

이렇게 메인 프로세스를 사용하면 뷰 간의 상태를 공유할 수 있습니다. 하지만 만일 뷰가 하나였다면, 메인 프로세스를 이용한 뷰 간의 상태 공유 자체가 필요하지 않았을 것입니다. 그러므로 하나의 윈도우에 뷰를 나누는 것은 단순히 화면을 나눈다는 것 이상의 의미가 있다는 것을 경험했습니다. (굉장히 신중히 결정해야 한다는 것을 배웠습니다.)

 

초기에 많은 프로세스 생성으로 인한 렌더링 실패

이번 프로젝트에서는 위에서 언급한 하나의 윈도우에 최대 3개의 뷰를 그리는 것 이외에도 2개의 윈도우를 추가로 생성했습니다. 그렇기 때문에 최대 5개의 렌더러 프로세스가 생성될 수 있었습니다. 이로 인해서 문제가 발생했습니다. 앱 실행 시에 5개의 렌더러 프로세스를 생성하고 있었기 때문에 저사양의 데스크톱에서는 간헐적으로 일부 렌더러 프로세스가 렌더링에 실패하는 현상이 발생했습니다.

이런 상황에서 가장 근본적인 해결은 렌더러 프로세스의 숫자를 줄이는 것입니다. 그러나 모든 윈도우와 뷰가 필요한 상황이었고 저희는 두 가지 방법을 이용하였습니다.

첫 번째로 불필요한 뷰의 제거입니다. 기획 의도에 따르기 위해서 뷰를 나눴지만, 특정 뷰는 특정한 상황에서만 쓰이고 그 이후엔 사용되지 않았습니다. 그래서 해당 뷰가 필요한 상황에 생성하고  필요하지 않은 상황이 되면 해당 뷰를 제거하는 방식으로 렌더러 프로세스의 수를 최소화하기 위해 노력했습니다.

두 번째로는 지연 생성입니다. 기존에는 모든 윈도우와 뷰를 앱 실행 초기에 생성해놓고 이를 보여줄지 여부를 결정했습니다. 그렇기 때문에 초기에 대부분의 프로세스 생성 작업이 몰려 있었습니다. 이런 부담을 최소화하기 위해서 윈도우 혹은 뷰가 필요한 시점에 생성하도록 변경하였습니다.

위의 두 가지 방법으로 초기 실행 시 발생했던 렌더링 실패 현상은 확연하게 감소하였습니다. 그러나 낮은 빈도이지만 여전히 발생했기 때문에 렌더링을 실패한 경우에 대한 대비가 필요했습니다. 이때, 위에서 잠시 언급 드렸던 webContents의 render-process-gone 이벤트를 활용하였습니다.

const view = new BrowserView({ … });

// …

view.webContents.on(‘render-process-gone’, (event, {reason}) => {
    // 화면을 다시 그리는 동작 수행
});

렌더링을 실패한 경우에 render-process-gone 이벤트 리스너를 통해 해당 이벤트가 들어오기 때문에 이벤트가 감지된 상황에 그리려고 했던 화면을 다시 그리도록 하여서 사용자가 렌더링 실패로 인한 흰 화면을 볼 수 없도록 하였습니다.

 

3. 버전 업데이트

electron-builder를 사용해서 빌드 하는 경우에는 electron-updater를 통해서 업데이트를 구현할 수 있습니다.

가장 간편하게 업데이트를 사용하는 방법으로 Github의 Releases를 활용할 수 있습니다. Public Repository의 경우에는 별도의 추가적인 코드 없이 동작합니다. 이와 달리 Private Repository는 해당 리포지토리에 대한 Github Personal access tokens을 발급받아서 사용할 수 있습니다. 이외에 Amazon S3에 대해서도 업데이트를 지원하고 있습니다.

저희 프로젝트에서는 자체적으로 릴리즈 서버를 구축하여 업데이트를 진행했습니다. 아래는 electron-updater를 사용하여 업데이트를 하기 위한 기본적인 설정을 나열했습니다. 

# Github Releases Example
publish:
    provider: github
    # Private Repository 인 경우
    tokeh: {accessToken}

# S3 Example
publish:
    provider: s3
    bucket: {bucketName}
    region: {regionName}

# Custom Release Server Example
publsh:
    provider: generic
    url: {releaseServerUrl}

어떤 업데이트 방식을 선택하는지에 따라 설정은 조금씩 다르지만 메인 프로세스에서 업데이트를 구현하는 방식은 업데이트 방식과 상관없이 동일합니다. 그리고 업데이트를 진행하는 방식에는 두 가지가 있습니다. 사용자가 직접 업데이트를 진행해야 하는 방식과 사용자의 의사와는 관계없이 자동으로 업데이트를 진행하는 방식으로 나눠집니다.

 

const {app} = require(‘electron’);
// …
app.on(‘ready’, () => {    // 1. 앱 실행 시 호출됨
    // …

    autoUpdater.checkForUpdates();  // 2. 업데이트 여부 확인 및 자동 업데이트 진행
});

위의 예제와 같이 autoUpdater.checkForUpdates를 사용하는 경우에는 사용자에게 별도의 알림 없이 업데이트를 진행하게 됩니다. 이와 달리 autoUpdater.checkForUpdatesAndNotify를 사용하게 되면 업데이트 가능한 상황에는 알림 창을 띄워서 업데이트 진행 여부를 묻게 됩니다.

업데이트 방식은 서비스의 특성에 맞게 선택하여 사용하면 됩니다.(https://www.electron.build/auto-update)

 

4. 윈도우 알림

일렉트론에서는 사용자에게 알림을 보내야 할 때, 푸시 알림을 주는 모바일과 유사하게 데스크톱 알림을 줄 수 있습니다. 단, 렌더러 프로세스와 메인 프로세스에서 알림을 주는 방법이 다릅니다. 렌더러 프로세스에서는 HTML5 Notification API를 사용해 알림을 보낼 수 있습니다. 메인 프로세스에서는 일렉트론에서 제공하는 Notification API를 사용해 알림을 보낼 수 있지만, OS 별 제약이 심하다는 단점이 있습니다.

저희 프로젝트에서는 알림 소리를 원하는 소리로 지정할 수 있고, 사용성을 위해 하단에 명시적인 닫기 버튼을 가진 알림이 필요했습니다. 

렌더러 프로세스에서 알림을 생성할 경우, 즉 HTML5 Notification API를 사용할 경우 serviceWorker를 통해서 생성한 알림에서만 버튼 커스텀을 할 수 있었는데, 당시 사용하던 일렉트론 버전에서는 serviceWorker.showNotification이 동작하지 않는 이슈가 있었습니다.

메인 프로세스에서 알림을 생성할 경우, 커스텀 버튼을 만들 수 있는 action 또는 closeButtonText는 macOS에서만 동작하는 문제가 있었습니다. 기본적으로 제공되는 기능들 외에 node-notifier npm을 사용해 메인 프로세스에서 알림을 생성해보려 했지만, window 버전별로 다른 UI를 보여주어서 사용하기 어려웠습니다.

결국 별도의 윈도우 창으로 알림을 구현했고, 원하는 형태의 알림을 사용할 수 있었습니다.  window OS에서 커스텀이 들어간다면 알림은 별도의 윈도우 창으로 직접 구현해서 사용하는 것이 좋을 것 같습니다.

 

5. 상단바 없는 윈도우 창 이동하기

OS에 따라 동작이 다른 이슈는 또 있습니다. 상단 바 없는 (frameless) 윈도우 창을 생성하면, 상단 바가 없기 때문에 드래그가 되지 않습니다. 해당 윈도우 창을 움직이고 싶다면 두 가지 방법이 있습니다.

첫 번째는 클릭해서 드래그할 영역에  —webkit-app-region: drag을 적용하는 것입니다. 예를 들어 윈도우창의 기본 상단 바를 제거한 창에 커스텀 한 상단 바를 만들고, 커스텀 상단 바에 —webkit-app-region: drag를 적용한다면 커스텀 상단 바를 잡고 드래그할 수 있게 됩니다. 

하지만 이 경우 mac OS에서는 별다른 이슈가 없지만, window OS에서는 드래그가 가능해지는 대신 클릭 등의 그 외의 마우스 이벤트가 발생하지 않는 이슈가 발생합니다.

드래그도 가능하고 클릭도 가능하게 만들고 싶으면 어떻게 하면 될까요? 두 번째 방법을 사용하면 됩니다. 두 번째 방법은 마우스에 mouseDown, mouseMove 이벤트를 붙여서 마우스를 클릭했을 때, 그리고 마우스가 이동할 때의 위치를 계산해서 마우스가 이동할 때마다 setWindowBounds 통해 새롭게 윈도우 창을 계속 그려내는 방법입니다. (단순하게 setPosition을 사용할 경우, OS에 따라 윈도우 창 크기가 변경되는 이슈가 있어서 setWindowBounds를 사용해 크기를 고정했습니다.)

 

// renderer Process

const onMouseMove = event => {
  window.ipcRenderer.send('windowMouseMoving', {mouseX: event.screenX, mouseY: event.screenY});
};

const onMouseUp = () => {
  document.removeEventListener('mousemove', onMouseMove);
  document.removeEventListener('mouseup', onMouseUp);
};

const onMouseDown = event => {
window.ipcRenderer.send('windowMouseDown', {startMouseX: event.screenX, startMouseY: event.screenY});
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
};
// main Process
ipcMain.on('windowMouseMoving', (_event, {mouseX, mouseY}) => {
  const newX = mouseX - this._mouseDiffX;
  const newY = mouseY - this._mouseDiffY;
  this.setWindowBounds(newX, newY); // 마우스를 이동할때마다 위치를 계산해 새로 그림
});

ipcMain.on('windowMouseDown', (_event, {startMouseX, startMouseY}) => {
  const bounds = this.getBounds();
  this._mouseDiffX = startMouseX - bounds.x; // 처음 마우스가 클릭된 위치
  this._mouseDiffY = startMouseY - bounds.y;
});

 

6. 프린터 연결하기

일렉트론은 데스크톱 애플리케이션인 만큼 데스크톱에 연결되는 다양한 기기들을 가지고 와서 사용할 수 있는데요, 대표적으로 프린터가 있습니다. 일렉트론에서 제공하는 win.webContents.print 속성을 사용하거나 라이브러리를 사용해 데스크톱에 연결된 포트 정보들을 가지고 와서 프린터에 연결해 사용할 수 있습니다.

프린트를 하기 위해서는 프린터가 연결되어 있는 포트에, 프린터에 맞는 연결 속도로 출력 요청을 보내야 합니다. 잘못된 연결 속도로 요청을 보내는 경우 프린터가 출력을 하지 않지만, 일렉트론 측에서는 해당 사실을 확인하기 어렵습니다. 따라서 자주 사용되는 연결 속도를 목록으로 만들어 사용자에게 선택 값으로 제공하거나, 연결 속도를 확인하는 방법을 안내하는 것이 좋습니다.

프린터가 잘 되는 것을 확인해서 개발을 완료한 다음, 빌드를 할 때도 주의해야 할 점이 하나 있는데요, 프린터와 같은 데스크톱 연결 기기를 가지고 와서 사용을 할 때 serialport를 electron-rebuild를 해서 가지고 와야 한다는 것입니다. 그렇지 않을 경우, 데스크톱 연결 기기들 정보를 가지고 오지 못해 추후 프린터를 사용할 때 에러가 발생할 수 있습니다. 빌드 시 아래의 명령어를 추가해 serialport를 rebuild 할 수 있습니다.

electonr-rebuild -f -w serialport

 

마치며

 

이번 프로젝트에서 Electron을 처음 사용해봤는데요, 모바일 웹 위주로 서비스를 개발하다가 PC 애플리케이션을 일정 내에 완성도 있게 만들려다 보니 기술적인 난이도가 꽤 있었습니다. 하지만 새로운 시도를 통해 기술적 역량 향상과 다음에 일렉트론을 사용할 팀원분들에게 좋은 경험 기와 자료를 남길 수 있었습니다.

사실 위에서 언급했듯이 electron은 뷰가 여러 개 합쳐지면 성능 이슈가 발생한다거나, 버전이 올라갈 때마다 사용자가 업데이트를 해야 하는 문제가 있기 때문에 electron에 익숙하지 않다면 electron으로는 기본적인 데스크톱 애플리케이션 frame만 만들어놓고, 내부는 웹앱을 불러와서 사용하는 것도 좋은 방법일 것 같다는 생각이 듭니다.? 

데스크톱 앱을 개발해야 할 때, 기존의 FE 개발 지식으로 쉽게 접근할 수 있는 Electron을 사용해보시면 어떨까요? 이 글을 통해 electron 서비스를 개발하실 때 조금이나마 도움이 되었으면 좋겠습니다. 감사합니다.

카카오톡 공유 보내기 버튼

Latest Posts

제5회 Kakao Tech Meet에 초대합니다!

Kakao Tech Meet #5 트렌드와 경험 및 노하우를 자주, 지속적으로 공유하며 개발자 여러분과 함께 성장을 도모하고 긴밀한 네트워크를 형성하고자 합니다.  다섯 번째

테크밋 다시 달릴 준비!

(TMI: 이 글의 썸네일 이미지는 ChatGPT와 DALL・E로 제작했습니다. 🙂) 안녕하세요, Kakao Tech Meet(이하 테크밋)을 함께 만들어가는 슈크림입니다. 작년 5월에 테크밋을 처음 시작하고,