ElectronJS로 데스크톱 앱 개발 템플릿

Category
스터디노트 Electron
Status
Published
Tags
Electron
Description
Published
Slug
React 애플리케이션을 Electron을 사용하여 데스크톱 앱으로 실행하는 과정
React, ElectronJS, TypeScript, Material UI, React Router를 사용하여 크로스 플랫폼 데스크톱 애플리케이션을 제작하는 방법
 

사용 라이브러리 및 프레임워크

  • React: 사용자 인터페이스를 구축하기 위한 JavaScript 라이브러리로, 현재 SPA(Single Page Application) 시장에서 가장 인기 있는 프레임워크
  • Electron: 웹 애플리케이션을 데스크톱 애플리케이션으로 변환해주는 프레임워크로, Slack, Discord와 같은 대규모 애플리케이션이 사용함
  • TypeScript: JavaScript의 상위 집합으로, 정적 타입을 지원하여 코드 품질과 협업을 향상시킴
  • React Router: React 애플리케이션의 라우팅을 관리하는 라이브러리입니다. Electron 앱에서는 HashRouter를 사용하여 파일 시스템 라우팅을 지원
  • Material UI: Google의 Material Design을 기반으로 한 React UI 프레임워크로, TypeScript와의 호환성이 뛰어남
 

프로젝트 설정하기

  1. Yarn 설치: 프로젝트 매니저로 Yarn을 사용. Yarn을 설치한 후, 설치가 제대로 되었는지 확인
  1. Create React App 생성: 아래 명령어를 사용하여 TypeScript가 활성화된 Create React App 프로젝트를 생성
    1. yarn create react-app my-app --template typescript
  1. 서버 실행 확인: 프로젝트 루트에서 아래 명령어를 실행하면, localhost:3000에서 React 앱이 실행됨
    1. cd my-app yarn start

Electron 및 Electron Builder 설치

  • 필수 라이브러리 설치
    • yarn add electron electron-builder --dev
  • package.json 설정: Electron의 메인 엔트리 파일과 React 앱의 빌드 폴더를 설정
  • package.jsonmain 필드와 homepage 필드를 추가
    • { "main": "public/Main.js", "homepage": "./" }

스크립트 추가

  • package.json에 스크립트 추가
    • "scripts": { "react-start": "react-scripts start", "electron-start": "electron .", "electron-pack": "yarn build && electron-builder" }
    • react-start: React 앱을 실행하지만 브라우저를 띄우지 않음.
    • electron-start: Electron 앱을 실행.
    • electron-pack: React 앱을 빌드하고 Electron 앱으로 패키징.

Main.js 파일 생성

  • Main.js 파일: React 앱의 메인 진입점인 Main.js 파일을 public 폴더에 생성하고 아래 코드를 추가
    • const { app, BrowserWindow } = require('electron'); const path = require('path'); function createWindow() { const win = new BrowserWindow({ width: 800, height: 600, webPreferences: { nodeIntegration: true, contextIsolation: false, }, }); win.loadURL('<http://localhost:3000>'); // React 앱을 로드 } app.whenReady().then(createWindow);

프로그램 실행

  1. 터미널 열기: 프로젝트 루트에 두 개의 터미널.
      • 첫 번째 터미널에서 React 앱 실행
        • yarn react-start
      • 두 번째 터미널에서 Electron 앱 실행
        • yarn electron-start
  1. React 앱 확인: 위의 명령어를 실행하면 브라우저에서 React 앱이 실행되고, 두 번째 터미널에서 Electron 앱이 실행됨

실시간 반영 확인

  • App.tsx 수정: src/App.tsx 파일에 들어가서 내용을 수정한 후 저장하면, Electron 앱에서 즉시 변경 사항이 반영됨

데스크톱 앱 패키징

  • 패키지 실행: electron-pack 명령어를 실행하여 실제 데스크톱 앱을 제작
    • yarn electron-pack
  • 결과 확인: 빌드가 완료되면, dist 폴더에 DMG 파일(또는 Windows에서는 EXE 파일)이 생성됨
  • 이 파일을 실행하면 패키징된 React 앱이 데스크톱 앱으로 실행됨
 
 
 

ElectronJS와 라우팅

  • 라우팅의 중요성
  • ReactJS를 웹에서 사용할 때와 ElectronJS에서 사용할 때의 차이점은 라우팅 방식.
  • Electron에서는 파일 시스템을 사용하여 라우팅을 진행하므로, BrowserRouter 대신 HashRouter를 사용해야 함.

3. React Router 설치

  • 설치 명령어: 프로젝트 루트에서 React Router 관련 모듈을 설치
    • yarn add react-router-dom @types/react-router-dom

라우팅 히스토리 생성

  • history.tsx 파일 생성: src/service 폴더에 history.tsx 파일을 생성하고,
  • createHashHistory를 사용하여 라우팅 히스토리를 생성
    • import { createHashHistory } from 'history'; export const history = createHashHistory();

페이지 구성

  • IndexPage.tsx 생성: src/page 디렉터리에 IndexPage.tsx 파일을 생성하고 기본 페이지 컴포넌트를 구현. Material UI의 스타일링 시스템을 사용하여 스타일을 적용.
    • import React from 'react'; import Grid from '@material-ui/core/Grid'; import {makeStyles} from "@material-ui/core"; import Typography from "@material-ui/core/Typography"; const useStyles = makeStyles(theme => ({ root: { height: '100vh', }, })); export const IndexPage: React.FC = () => { const classes = useStyles(); return ( <Grid container className={classes.root}> <Typography variant="h1">메인 페이지입니다!</Typography> </Grid> ) };

Material UI 설정

  • MuiThemeProvider: src/index.tsx 파일에서 Material UI를 사용하기 위해 MuiThemeProvider로 애플리케이션을 감싸줌.
    • import React from 'react'; import ReactDOM from 'react-dom'; import './index.css'; import * as serviceWorker from './serviceWorker'; import {MuiThemeProvider, createMuiTheme} from "@material-ui/core"; import {App} from './App'; const theme = createMuiTheme({}); ReactDOM.render( <MuiThemeProvider theme={theme}> <App/> </MuiThemeProvider>, document.getElementById('root') ); // If you want your app to work offline and load faster, you can change // unregister() to register() below. Note this comes with some pitfalls. // Learn more about service workers: https://bit.ly/CRA-PWA serviceWorker.unregister();

tsconfig.json 설정

  • Absolute Path 설정: tsconfig.json 파일에 src 폴더를 소스 루트로 설정하여 상대 경로 대신 절대 경로를 사용할 수 있도록 함
    • { "baseUrl": "src", }

App.tsx 변경

  • App.tsx 파일 수정: 라우팅을 위해 App.tsx 파일을 업데이트하여 HashRouter를 사용하고, 페이지 컴포넌트를 연결
    • import React from 'react'; import logo from './logo.svg'; import './App.css'; import {IndexPage} from "page/IndexPage"; const App: React.FC = () => { return ( <IndexPage/> ); }; export default App;
 

두 개의 터미널에서 앱 실행

  • 터미널에서 실행: 두 개의 터미널을 열어 각각 React 앱과 Electron 앱을 실행
    • yarn react-start yarn electron-start
 

사이드바와 라우팅 구현

왼쪽에 있는 네비게이션을 클릭할때마다 오른쪽에 있는 화면의 라우트가 변경되도록
notion image
라우트 정의: src/route/index.tsx 파일을 생성하여 라우트를 정의
import React from 'react'; import { IndexPage, ChannelPage, MessagePage, } from 'page'; const channels = [ { title:'FMD 임원', route: '/chiefs', }, { title:'FMD 일반', route:'/general' }, { title:'FMD 브레인스토밍', route:'/brainstorming', }, { title:'FMD 오퍼레이션', route:'/operation', }, { title:'FMD 마케팅', route: '/marketing', }, { title:'FMD 개발', route:'/devs', }, { title:'FMD QA', route:'/qa', }, { title:'FMD 클라이언트', route:'/client' }, { title:'FMD 전체', route:'/all' } ]; const messages = [ { title:'아이린', route:'/irene', }, { title:'슬기', route:'/seulgi', }, { title:'조이', route:'/joy', }, { title:'예리', route:'/yeri' }, { title:'웬디', route:'/wendy', } ]; export const indexRoutes = [ ...channels.map( item => ({ route:item.route, title:item.title, exact:true, type:'channel', component:<ChannelPage title={item.title}/> }) ), ...messages.map( item => ({ route:item.route, title:item.title, exact:true, type:'message', component:<MessagePage title={item.title}/> }) ), { route:'/', title:'메인', exact:true, type:'root', component:<IndexPage/> } ];
Sidebar.tsx 생성: src/component/sidebar/Sidebar.tsx 파일을 생성하고, 정의한 라우트를 사용하여 버튼 형태의 네비게이션을 구현
import React from 'react'; import Grid from '@material-ui/core/Grid'; import {makeStyles} from "@material-ui/core"; import List from '@material-ui/core/List'; import ListItem from '@material-ui/core/ListItem'; import ListItemText from '@material-ui/core/ListItemText'; import MenuItem from '@material-ui/core/MenuItem'; import Menu from '@material-ui/core/Menu'; import BottomArrow from '@material-ui/icons/ExpandMore'; import Typography from "@material-ui/core/Typography"; import Button from '@material-ui/core/Button'; import PlusIcon from '@material-ui/icons/ControlPoint'; import history from 'service/history'; import {indexRoutes} from "route"; const useStyles = makeStyles(theme => ({ root: { height: '100vh', }, drawer: { backgroundColor: '#350C35', height: '100%', }, whiteText: { color: '#FFFFFF' }, whiteIcon: { fill: '#FFFFFF' }, greyText: { color: '#AEAEAE' }, greyIcon: { color: '#AEAEAE' }, categoryHeader: { marginTop: 20, }, pageHeader: { padding: 20 }, bold: { fontWeight: 'bold' } })); export const Sidebar: React.FC = (props) => { const classes = useStyles(); return ( <List component="a" aria-label="nav-header"> <ListItem button onClick={() => { history.push('/'); }} > <ListItemText primary={ <React.Fragment> <Grid container alignItems="center"> <Typography variant="h6" className={classes.whiteText}> FiveMinutesDev </Typography> <BottomArrow className={classes.whiteIcon}/> </Grid> <Typography variant="body2" className={classes.whiteText}> CEO 최지호 </Typography> </React.Fragment> }> </ListItemText> </ListItem> <ListItem button dense onClick={() => { }} > <ListItemText primary={ <Grid container justify="space-between"> <Typography variant="body2" className={classes.greyText}> Channels </Typography> <PlusIcon className={classes.greyIcon}/> </Grid> }/> </ListItem> { indexRoutes.filter(item => item.type === 'channel').map(item => { return ( <ListItem button onClick={() => { history.push(item.route) }} dense > <ListItemText primary={ <Typography variant="body2" className={classes.greyText}> # {item.title} </Typography> }/> </ListItem> ) }) } <ListItem button className={classes.categoryHeader} dense onClick={() => { }} > <ListItemText primary={ <Grid container justify="space-between"> <Typography variant="body2" className={classes.greyText}> Direct Messages </Typography> <PlusIcon className={classes.greyIcon}/> </Grid> }/> </ListItem> { indexRoutes.filter(item => item.type === 'message').map(item => { return ( <ListItem button onClick={() => { history.push(item.route) }} dense > <ListItemText primary={ <Typography variant="body2" className={classes.greyText}> # {item.title} </Typography> }/> </ListItem> ) }) } </List> ) };
 

페이지 추가

MessagePage.tsx와 ChannelPage.tsx 생성: 두 개의 페이지 파일을 생성하고, 각 페이지에 이름과 타이틀 표시
MessagePage.tsx
import React from 'react'; interface MessagePageProps { title:string; } export const MessagePage : React.FC<MessagePageProps> = (props)=>{ return ( <div> Message Page 제목 : {props.title} </div> ) };
 
ChannelPage.tsx
import React from 'react'; interface ChannelPageProps{ title:string; } export const ChannelPage : React.FC<ChannelPageProps> = (props) => { return ( <div> Channel Page 제목 : {props.title} </div> ) };
 
페이지 통합: 모든 페이지를 한꺼번에 export할 수 있도록 src/page/index.tsx 파일을 생성
export * from './IndexPage'; export * from './ChannelPage'; export * from './MessagePage';

라우팅 적용

App.tsx 업데이트: 최종적으로 src/App.tsx 파일을 수정하여 라우팅이 제대로 작동하도록 설정
import React from 'react'; import logo from './logo.svg'; import './App.css'; import Grid from '@material-ui/core/Grid'; import {IndexPage} from 'page'; import history from "service/history"; import {Route, Router, Switch} from "react-router"; import {indexRoutes} from "route"; import {Sidebar} from "component/sidebar/Sidebar"; import {makeStyles} from "@material-ui/core"; const useStyles = makeStyles(theme => ({ root: { height: '100vh', }, drawer: { backgroundColor: '#350C35', height: '100%', }, })); export const App: React.FC = () => { const classes = useStyles(); return ( <Router history={history}> <Switch> {/* 저희가 정의해놓은 라우트를 여기서 실제 라우트 컴포넌트로 전환합니다. 왼쪽으 사이드바는 어떤 라우트던 고정으로 사용되고 item.component 부분만 src/route/index.tsx에서 지정해놓은 컴포넌트로 다이나믹하게 렌더링을 합니다. */} {indexRoutes.map((item, key) => { return ( <Route path={item.route} exact={item.exact}> <Grid container className={classes.root}> <Grid item xs={2} className={classes.drawer}> <Sidebar/> </Grid> <Grid item xs={10}> {item.component} </Grid> </Grid> </Route> ) })} </Switch> </Router> ); };
src/index.tsx를 아래처럼 교체
import React from 'react'; import ReactDOM from 'react-dom'; import './index.css'; // 아래부분을 바꿔주세요 import {App} from './App'; import * as serviceWorker from './serviceWorker'; ReactDOM.render(<App />, document.getElementById('root')); // If you want your app to work offline and load faster, you can change // unregister() to register() below. Note this comes with some pitfalls. // Learn more about service workers: https://bit.ly/CRA-PWA serviceWorker.unregister();

Electron의 고유 기능 사용

  • Electron API 접근: React 컴포넌트에서 Electron API를 사용하려면 글로벌 electron 변수를 가져와야 합니다. 이를 위해 Sidebar.tsx를 수정합니다.
  • Main.js 파일 수정: Electron의 기능을 사용할 수 있도록 public/Main.js 파일을 업데이트합니다.
 

버튼 추가 및 기능 구현

  • 버튼 추가: Electron의 기능을 테스트하기 위해 버튼을 추가하고, 브라우저 열기, 클립보드에 복사, 파일 선택 등의 기능을 구현