React 웹 앱을 데스크탑 앱으로 만들어보기

💡 React로 만들었던 프로젝트를 Electron으로 감싸는 과정과 발생한 에러에 대해서 다룹니다.

1. Electron 도입 배경

저번 포스트에서 등장했던 Codocs 프로젝트를 시작할 때쯤이었습니다. 팀원들끼리 각자 하고 싶은 개발 내용에 대해서 공유하는 자리에서 Electron에 대해서 언급했습니다. 마치 Notion이나 Obsidian같은 노트 앱들을 데스크탑에서 사용할 수 있는 것처럼 비슷한 느낌을 구현하고 싶었기 때문입니다.

당시에는 충분한 동의를 얻어내지 못하여 프로젝트가 끝나면 개인적으로 도전해보기로 했습니다. 거의 두 달이 지나고서야 시도하게 되었습니다. 프로젝트를 다시 배포한 이유도 여기에 있었습니다.


2. Electron이 뭐에요?

공식 홈페이지에서는 이렇게 소개하고 있습니다.

Electron is a framework for building desktop applications using JavaScript, HTML, and CSS.

말 그대로 JavaScript로 만든 웹 어플리케이션을 데스크탑 앱으로 만들어주는 프레임워크입니다.

Electron을 사용하면 하나의 JavaScript 코드로 다양한 OS에 대응하는 데스크탑 앱을 개발할 수 있습니다.

웹 개발을 더욱 매력적으로 만들어주는 부분 중 하나라고 생각합니다.

Node.js 환경에 익숙한 개발자들이 데스크탑 앱을 만들 수 있다는 점이 Electron의 장점이라면, Native로 실행하는 것이 아니기 때문에 더 많은 리소스를 사용하기도 한다는 단점이 있습니다. 이는 비지니스 로직과 함께 Electron을 실행하기 위한 환경이 제공되어야 하기 때문입니다.


3. 간단한 Electron 동작 방식

Electron을 실행하기 위한 환경은 무엇일까요?

바로 Node.jsChromium(오픈소스 브라우저)입니다.

Node.js는 파일 시스템처럼 사용자의 OS에 접근하여 관련된 작업을 수행할 수 있고, Chromium은 일반적인 웹 브라우저처럼 사용자 인터페이스를 렌더링합니다.

Electron은 Main ProcessRenderer Process라는 두 개의 Process를 가지고 있습니다. Main Process는 어플리케이션의 생애 주기, Render Process 관리, OS 관련 API 제어 등을 담당합니다.

즉, 웹 앱을 네이티브 앱처럼 구동할 수 있게 하는 역할을 합니다.

한편, Renderer Process는 Main Process에 의해서 생겨나며 어플리케이션의 UI를 그리거나 비지니스 로직을 수행합니다. 이러한 Renderer Process는 여러 개가 생성될 수 있기 때문에 멀티 프로세스 구조라고 할 수 있습니다.


Electron은 메인 프로세스가 OS와 소통하며 하위 렌더러 프로세스를 관리하는 구조 Electron Architecture [출처: https://livecodestream.dev/post/how-to-build-desktop-applications-using-electron-the-right-way/]


4. Electron 시작하기

이제부터 Codocs 프로젝트를 Electron을 통해 데스크탑 앱으로 만들어보겠습니다. 기존 웹앱과 동일한 UI, 비지니스 로직을 제공하고 별도로 오프라인 상태에 대응하지 않을 것이기 때문에 낮은 복잡도의 앱이 될 것입니다. 간단하게 홈페이지에 나와있는 튜토리얼대로 진행해보았습니다.

electron을 devDependencies로 다운로드합니다.

npm install electron --save-dev

Electron 관련 코드를 별도의 폴더에서 관리하기 위해 electron이라는 이름의 폴더를 만들고 내부에 main.ts를 생성했습니다. 위에서 설명했던 Main Process가 시작되는 지점입니다.

Renderer process를 생성해서 별도의 작업을 할당하기를 원한다면 공식 문서에 있는 것처럼 ipcMain 이나 ipcRenderer 모듈을 활용하면 되지만 특별히 목표로 하는 기능이 없기 때문에 사용하지 않았습니다.

관련한 내용은 이곳에서 확인할 수 있습니다.

const { app, BrowserWindow } = require('electron');
const path = require('path');

const createWindow = () => { // (1)
  const win = new BrowserWindow({
    width: 1024,
    height: 768,
    webPreferences: {
      nodeIntegration: true,
    }
  });
  
  win.loadFile(`${path.join(__dirname, '../build/index.html')}`); // (2)
};

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

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

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

각 코드에 주석으로 달린 번호 순서대로 살펴보면 다음과 같습니다.

(1) 앱을 실행했을 때 열리는 새 창에 대한 값을 설정합니다. 각 옵션에 대한 설명은 여기서 확인할 수 있습니다.

(2) 새 창에서 불러올 html 파일 경로를 적습니다. main.ts를 기준으로 빌드된 index.html 파일 경로로 설정합니다.

(3) macOS일 때 현재 열려 있는 창이 없을 때에만 새로운 창을 여는 콜백 함수를 activate 이벤트에 등록합니다.

(4) window나 linux에서 모든 창이 닫히면 앱을 종료하도록 설정합니다. (여기서 darwin 은 macOS를 지칭합니다)


다음으로 아래와 같이 package.json에 electron 을 실행할 수 있는 명령어를 정의합니다.

"scripts": {
  "electron": "electron ."
}

이어서 터미널에서 React 빌드 후 Electron을 실행시켜보았습니다.

npm run build 
npm run electron

새 창이 하나 떴는데, 이상하게 기다려도 흰 화면에서 벗어나질 않았습니다.


아무것도 렌더링되지 않는 Electron 창 화이트 스크린?


5. 에러 대응

macOS 상단에 있는 메뉴바에서 개발자 도구를 열었습니다.


개발자 도구에 출력된 ERR_FILE_NOT_FOUND 에러 Failed to load resource: net::ERR_FILE_NOT_FOUND

파일을 찾지 못하는 것을 보니 경로와 관련된 문제로 보입니다. Electron으로 인한 문제라기 보다는 CRA로 생성한 프로젝트에서 root path를 찾지 못하는 문제였습니다. package.json 의 homepage 속성을 설정해주었습니다.

{
  "homepage": ".",
  ... (생략)
}


404 페이지가 렌더링된 Electron 이미지 파일도 정상적으로 불러옵니다.

근데 이상하게 계속해서 Error 페이지로 시작하는게 마음에 걸립니다. 분명 랜딩 페이지로 진입했을텐데 말입니다.

원인은 React-router에 있었습니다. Router 컴포넌트에서 사용하고 있는 BrowserRouter는 서버 요청을 기반으로 작동하기 때문에 현재 main.ts에서 loadFile을 통해 index.html을 정적으로 불러오는 방식에 맞지 않습니다. 따라서 파일 경로를 기반으로 작동하는 HashRouter를 대신 사용하기로 했습니다.

import { HashRouter } from 'react-router-dom';

const Router = () => {
  return (
    <HashRouter>
    ... (생략)
    </HashRouter>
  )
}

시도해보지는 않았지만 BrowserRouter를 유지한다면 파일을 불러오는 loadFile 대신에 loadURL 메서드를 사용하는 방법도 있을 것 같습니다.

아무튼 다시 빌드 후, electron을 실행하니 정상적으로 랜딩 페이지로 들어옵니다.


Codocs 메인 화면이 렌더링된 Electron 여기까지 너무 오래 걸렸습니다.


6. 패키징

이제 개발 환경에서 잘 돌아가는 것을 확인했으니 빌드 후 다른 사람들에게 배포하는 단계만 남았습니다.

패키징하는 과정 또한 Electron 공식 문서에 있는 내용을 보고 참고했습니다.

공식 문서에 접속하면 상단에 광고처럼 조그맣게 보이는 Electron-Forge를 사용한 패키징 튜토리얼입니다.

간략하게 장점에 대해서 설명하고 있는데, 요약하면 프로젝트를 패키징하고 배포할 수 있는 파일로 만들어주는 과정을 한 번에 처리하기 때문에 매우 편하다고 합니다.

아래와 같은 명령어를 입력하면 현재 경로에 forge.config.js라는 forge 설정 파일이 생깁니다. 별다른 설정을 추가하지 않고 패키징을 진행했습니다.

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

make 명령어를 실행하면 프로젝트 경로에 out 이라는 폴더가 하나 생기는데, 폴더 내부로 계속 들어가다보면 macOS를 위한 .app 파일과 설치할 수 있는 파일인 .zip 파일이 생긴 것을 확인할 수 있습니다.

npm run make


정상적으로 빌드되어 생성된 app 파일과 zip 파일 정말 간편하네요.

생성된 frontend.app 을 실행하여 터미널 명령어로 실행했을 때와 동일하게 작동하는 것을 확인했습니다. 다른 사람들에게 공유하기 전에 앱 이름과 아이콘을 바꿔주었습니다.

이름을 codocs로 변경하기 위해 package.json에서 name 필드를 변경합니다.

{
  "name": "codocs",
  ... (생략)
}

아이콘을 변경하기 위해서는 위에서 생성된 forge.config.js 파일에서 아이콘 경로를 명시해줍니다. Electron-forge 공식 문서에 나와있듯이 macOS용으로 아이콘을 넣을 때에는 512x512 짜리 icns 형식의 이미지 파일이 필요합니다.

module.exports = {
  packagerConfig: {
    'icon': './src/assets/codocs.icns'
  },
  ... (생략)
}

다시 npm run make로 만들어내면,


Codocs 아이콘이 적용된 app 파일

이렇게 잘 만들어집니다.

설정하는 김에 .zip 파일로 결과물이 나오게 하는 것보다 macOS에 어울리게 .dmg 파일로 만들고 싶습니다. 추가적인 npm 라이브러리로 @electron-forge/maker-dmg 를 설치하고 dmg 형식으로 결과물이 나올 수 있도록 config 파일의 내용도 수정해줍니다.

module.exports = {
  "makers": [
    ... (생략)
    {
      "name": "@electron-forge/maker-dmg",
      "config": {
        "format": "ULFO"
      }
    }
  ]
}

이렇게 빌드를 하고 나면 이제는 dmg 파일이 생긴 것을 확인할 수 있습니다.


빌드된 macOS용 dmg 파일 out/make 경로에 새로 생긴 DMG 파일

바로 해당 파일을 시험 삼아 동료에게 전송하고 실행해보라고 했습니다.

하지만 이내 비보가 들려왔습니다. 아래 사진과 같은 에러가 뜬다는 것입니다.


codocs가 손상되어 열 수 없다는 에러 메세지 손상되어버린 codocs

로컬에서는 잘 실행되는데 다른 사람은 실행할 수 없다니 충격이었습니다. 구글링을 열심히 하면서 다시 튜토리얼들도 많이 찾아보고 동영상도 찾아보았지만 위 사진처럼 손상되었다는 에러 메세지와 함께 설치가 불가능한 경우는 볼 수 없었습니다.

그렇게 한참을 방황하다가 공식 문서를 다시 차근차근 읽어가면서 의심이 되는 구간을 발견하게 되었습니다. 바로 code sign 부분이었습니다. 요약하자면 code sign을 진행해야 데스크탑 어플리케이션을 만든 사람을 인증할 수 있고 다른 사람들이 정상적으로 다운로드할 수 있다는 것입니다. 즉, 보안적인 측면에서 code sign은 필수적이었습니다.

결론적으로는 code sign을 진행할 수 없었는데, 이유는 바로 macOS 앱에 대한 code sign을 위해 애플 개발자로 등록해야 한다는 것이었고 $99나 지불할 여유가 없었습니다.


7. 최종 해결책

정말 다른 방법이 없을까? 하고 다양한 검색어로 브라우징을 많이 했습니다. Electron Forge 외에 Electron-builder 라는 라이브러리를 사용해서 시도해보았지만 결과는 동일했습니다.

그래도 여기까지 해온게 너무 아깝기도 하고 꼭 한번 릴리즈해보고 싶은 마음이 있어서 Electron is damaged and can't be opened 라고 검색했고 비슷한 상황에 놓여 있는 개발자가 질문을 올린 GitHub 이슈가 있었습니다. 과거 Electron-builder 라는 라이브러리를 사용할 때에도 동일하게 발생했던 code sign 문제를 우회할 수 있는 방법을 댓글에서 찾을 수 있었습니다.

프로그램을 설치한 뒤에 다음과 같은 명령어를 입력합니다.

sudo xattr -r -d com.apple.quarantine /Applications/Codocs.app

  • xattr : 파일이나 폴더의 추가적인 속성을 제어합니다.
  • -r : 디렉토리 전체 내용에 대해서 재귀적으로 적용합니다.
  • -d : 속성을 제거합니다.
  • com.apple.quarantine : 앱을 실행하기 전에 확인하고 필요시 실행을 멈추는 속성

즉, 설치한 파일에 대해서 OS 차원에서 검사하는 속성을 제거하여 실행 가능하도록 만드는 것입니다.

정말로 되는지 확인해보기 위해서 내게 쓰기로 dmg 파일을 이메일에 첨부한 뒤 다운로드 받은 후 진행해보았습니다.


Codocs를 설치하고 터미널에 명령어를 입력하니 앱이 실행되는 gif 실행됩니다.

더 이상 파일이 손상되었다고 하지 않습니다.

어플리케이션 구동을 확인할 수 있었지만 사용자가 수동적으로 진행해야 하는 불편한 프로세스가 추가되었다는 점, 보안적인 측면을 해쳤다는 점이 마음에 걸리긴 합니다.

정식으로 어플리케이션을 만든다고 하면 애플 개발자 등록도 하고 공식 홈페이지대로 code sign을 진행하면 될 것 같습니다. 구체적인 방법은 Electron Forge Signing a macOS app에서 찾을 수 있었습니다.

한편, 로컬에서 빌드했던 어플리케이션이 정상적으로 동작했던 것은 로컬에서 빌드한 파일은 내부적으로 안전하다고 판단하기 때문이라는 것을 알 수 있었습니다.


9. 맺으며

Electron 자체를 적용하는 것은 사소한 이슈들이 있었지만 추가적인 기능 없이 기존 프로젝트를 감싸는 정도였기 때문에 어렵지 않은 일이었습니다. 하지만 실제로 다른 사람이 다운로드 받았을 때 쓸 수 있는지를 검증하는 과정에서 꽤 많은 시간을 쏟았습니다. 아쉬운 부분이 있지만 그래도 데스크탑 프로그램을 만들었습니다. 프로젝트에서 활용할 수 있는 기능들이 있는지 더 찾아보면 좋을 것 같습니다.

몇 가지 짧은 소감을 나열해보면,

  • Electron 공식 문서에는 한글이 없다는 것이 의외였습니다.
  • 우회 방법을 찾다가 Facebook 채널에 있는 Electron Korea에도 가보았는데 최신 활동이 없어서 이리저리 많이 헤맸습니다.
  • React-router 종류에 대해서 관심 있게 살펴볼 기회가 없었는데 이번 계기로 추가로 공부하면 좋을 것 같습니다.
  • 애플 개발자 등록비 너무 비쌉니다..

[참고]

[코드]