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와의 호환성이 뛰어남
프로젝트 설정하기
- Yarn 설치: 프로젝트 매니저로 Yarn을 사용. Yarn을 설치한 후, 설치가 제대로 되었는지 확인
- Create React App 생성: 아래 명령어를 사용하여 TypeScript가 활성화된 Create React App 프로젝트를 생성
yarn create react-app my-app --template typescript
- 서버 실행 확인: 프로젝트 루트에서 아래 명령어를 실행하면,
localhost:3000에서 React 앱이 실행됨
cd my-app yarn start
Electron 및 Electron Builder 설치
- 필수 라이브러리 설치
yarn add electron electron-builder --dev
- package.json 설정: Electron의 메인 엔트리 파일과 React 앱의 빌드 폴더를 설정
package.json의main필드와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);
프로그램 실행
- 터미널 열기: 프로젝트 루트에 두 개의 터미널.
- 첫 번째 터미널에서 React 앱 실행
- 두 번째 터미널에서 Electron 앱 실행
yarn react-start
yarn electron-start
- 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
사이드바와 라우팅 구현
왼쪽에 있는 네비게이션을 클릭할때마다 오른쪽에 있는 화면의 라우트가 변경되도록

라우트 정의: 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의 기능을 테스트하기 위해 버튼을 추가하고, 브라우저 열기, 클립보드에 복사, 파일 선택 등의 기능을 구현