코딩/React

ElectronJS 일렉트론 강좌 2편 SQLite3 sql.js

드리프트 2021. 9. 18. 16:33
728x170

 

안녕하세요?

 

지난 시간에 이어 2편 진행토록 하겠습니다.

 

1편: https://cpro95.tistory.com/500

 

ElectronJS 일렉트론 강좌 1편 SQLite3 sql.js

안녕하세요? 오늘은 Electron 강좌를 시작해 볼까 합니다. ElectronJS는 웹상의 기술인 HTML, CSS, Javascript로 데스크톱 애플리케이션을 만들 수 있는 NodeJS 패키지인데요. HTML, CSS, Javascript로 작동되다..

cpro95.tistory.com

 

2편에서는 Electron 개발에 본격적으로 들어가 보도록 하겠습니다.

 

그러면 ElectronJS와 ReactJS Template가 필요한데요.

 

제가 예전에 올린 아래 글 보시면 최신 버전으로 ElectronJS와 ReactJS 설치하는 법이 나옵니다.

 

https://cpro95.tistory.com/185

 

최신버전으로 React + Typescript + Electron 개발하기

안녕하세요? 예전에는 React를 그냥 Javascipt로 개발했지만 최근에는 점점 더 Typescript가 대세가 되고 있습니다. 그래서 개인적으로 Electron 앱을 취미로 개발하고 있는데 React와 Typescript를 적용해서

cpro95.tistory.com

 

 

 

1. 앱 설치

 

일단 다음과 같이 실행해 볼까요?

 

npx create-react-app electron-sqlite3-demo

 

그리고 electron 관련 파일을 설치하겠습니다.

 

npm install electron electron-builder concurrently wait-on cross-env electron-is-dev

npm install @chakra-ui/react @chakra-ui/icons @emotion/react @emotion/styled framer-motion

npm install sql.js xml2json-light

 

첫 번째 설치하는 패키지 각각의 설명은 본인의 "최신 버전으로 React + Typescript + Electron 개발하기" 글을 보시면 잘 설명되어 있습니다.

 

두 번째 설치한 거는 바로 Chakra-UI 관련입니다. 제가 요즘 빠져있는 ReactJS UI Library입니다.

 

세 번째 설치한 거는 백엔드에서 쓸 sql.js 와 XML 파일을 파싱 할 xml2 json-light 패키지입니다.

 

그럼 이제 electron.js 파일을 만들어 볼까요?

 

/public/electron.js

 

const { app, BrowserWindow } = require("electron");
const path = require("path");
const isDev = require("electron-is-dev");

let mainWindow;

function createWindow() {
  mainWindow = new BrowserWindow({
    width: 900,
    height: 680,
    minWidth: 370,
    minHeight: 470,
    webPreferences: {
      nodeIntegration: true,
      enableRemoteModule: true,
      devTools: isDev,
      nativeWindowOpen: true,
    },
  });

  mainWindow.loadURL(
    isDev
      ? "http://localhost:3000"
      : `file://${path.join(__dirname, "../build/index.html")}`
  );

  if (isDev) {
    mainWindow.webContents.openDevTools({ mode: "detach" });
  }

  mainWindow.setResizable(true);
  mainWindow.on("closed", () => (mainWindow = null));
  mainWindow.focus();
}

app.on("ready", createWindow);

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

app.on("activate", () => {
  if (mainWindow === null) {
    createWindow();
  }
});

 

/package.json

 

{
  "name": "electron-sqlite3-demo",
  "version": "0.1.0",
  "private": true,
  "author": "cpro95",
  "main": "public/electron.js",
  "homepage": "./",
  "build": {
    "productName": "electron-test-app",
    "asar": true,
    "appId": "org.cpro95.electron-test-app"
  },
  "scripts": {
    "react-start": "react-scripts start",
    "react-build": "react-scripts build",
    "react-test": "react-scripts test",
    "react-eject": "react-scripts eject",
    "start": "concurrently \"cross-env NODE_ENV=development BROWSER=none yarn react-start\" \"wait-on http://localhost:3000 && electron .\"",
    "build": "yarn react-build && electron-builder",
    "release": "yarn react-build && electron-builder --publish=always"
  },
  "dependencies": {
    "@chakra-ui/icons": "^1.0.15",
    "@chakra-ui/react": "^1.6.7",
    "@emotion/react": "^11.4.1",
    "@emotion/styled": "^11.3.0",
    "@testing-library/jest-dom": "^5.11.4",
    "@testing-library/react": "^11.1.0",
    "@testing-library/user-event": "^12.1.10",
    "electron-is-dev": "^2.0.0",
    "framer-motion": "^4.1.17",
    "react": "^17.0.2",
    "react-dom": "^17.0.2",
    "react-scripts": "4.0.3",
    "sql.js": "^1.6.1",
    "web-vitals": "^1.0.1",
    "xml2json-light": "^1.0.6"
  },
  "eslintConfig": {
    "extends": [
      "react-app",
      "react-app/jest"
    ]
  },
  "browserslist": {
    "production": [
      ">0.2%",
      "not dead",
      "not op_mini all"
    ],
    "development": [
      "last 1 chrome version",
      "last 1 firefox version",
      "last 1 safari version"
    ]
  },
  "devDependencies": {
    "concurrently": "^6.2.1",
    "cross-env": "^7.0.3",
    "electron": "^14.0.1",
    "electron-builder": "^22.11.7",
    "wait-on": "^6.0.0"
  }
}

package.json 파일을 보시면 npm start로 개발서버를 돌리는 걸 볼 수 있습니다.

 

일단 기본적으로 Create-React-App이 만든 스타일 관련 파일을 다 지워주시고, 시작하겠습니다.

 

/src/index.js

 

import React from "react";
import ReactDOM from "react-dom";
import App from "./App";
import reportWebVitals from "./reportWebVitals";
import { ChakraProvider } from "@chakra-ui/react";
import Layout from "./components/Layout";

ReactDOM.render(
  <React.StrictMode>
    <ChakraProvider resetCSS>
      <Layout>
        <App />
      </Layout>
    </ChakraProvider>
  </React.StrictMode>,
  document.getElementById("root")
);

// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

 

/src/App.js

 

import { Flex, VStack, Heading, Text, Button } from "@chakra-ui/react";

function App() {
  return (
    <Flex w="auto" align="center" justify="center" p={[0, 10, 20]}>
      <VStack>
        <Heading>Welcome</Heading>
        <Text>Electon + React + Sqlite3 + Chakra-UI</Text>
        <Button size="md" colorScheme="whatsapp">
          Welcome
        </Button>
      </VStack>
    </Flex>
  );
}

export default App;

 

/src/components/Layout.js

 

import React from "react";
import { Container } from "@chakra-ui/react";
import Header from "./Header";

export default function LayoutPage(props) {
  return (
    <Container maxW="container.lg">
      <Header />
      {props.children}
    </Container>
  );
}

 

/src/components/Header.js

 

import { Flex, Text, Box, Stack, Link, Heading } from "@chakra-ui/react";
import { useState } from "react";
import { DarkModeSwitch } from "./DarkModeSwitch";
import { CloseIcon, HamburgerIcon } from "@chakra-ui/icons";

const MenuItem = ({ children, onClick, to = "/" }) => {
  return (
    <Text
      ms={{ base: 2, sm: 2, md: 2, xl: 2 }}
      mr={{ base: 2, sm: 2, md: 2, xl: 2 }}
      display="block"
      onClick={onClick}
    >
      <Link colorScheme="facebook" to={to}>{children}</Link>
    </Text>
  );
};

const Header = () => {
  const [show, setShow] = useState(false);
  const toggleMenu = () => setShow(!show);

  return (
    <Flex
      p={2}
      as="nav"
      align="center"
      alignItems="center"
      justify="space-between"
      wrap="wrap"
      w="100%"
    >
      <Box w="200px">
        <Heading>
          <Link colorScheme="facebook" to="/">Home</Link>
        </Heading>
      </Box>

      <Box display={{ base: "block", md: "none" }} onClick={toggleMenu}>
        {show ? <CloseIcon /> : <HamburgerIcon />}
      </Box>

      <Box
        display={{ base: show ? "block" : "none", md: "block" }}
        flexBasis={{ base: "100%", md: "auto" }}
      >
        <Stack
          spacing={8}
          align="center"
          justify={["center", "space-between", "flex-end", "flex-end"]}
          direction={["column", "row", "row", "row"]}
          pt={[4, 4, 0, 0]}
        >
          <MenuItem onClick={toggleMenu} to={"/link1"}>
            Link1
          </MenuItem>

          <MenuItem onClick={toggleMenu} to={"/link2"}>
            Link2
          </MenuItem>

          <DarkModeSwitch />
        </Stack>
      </Box>
    </Flex>
  );
};

export default Header;

 

/src/components/DarkModeSwitch.js

 

import { useColorMode, IconButton } from "@chakra-ui/react";
import { MoonIcon, SunIcon } from "@chakra-ui/icons";

export const DarkModeSwitch = () => {
  const { colorMode, toggleColorMode } = useColorMode();
  return (
    <IconButton
      aria-label="Toggle Dark Switch"
      icon={colorMode === "dark" ? <SunIcon /> : <MoonIcon />}
      onClick={toggleColorMode}
      ms={{ base: 2, sm: 2, md: 2, xl: 2 }}
      mr={{ base: 2, sm: 2, md: 2, xl: 2 }}
      display="block"
      w={10}
      h={10}
    />
  );
};

 

Layout이랑 Components 들은 제 React 관련 글 중 아래 글에 잘 나와 있습니다.

 

https://cpro95.tistory.com/493 

 

NextJS와 MongoDB 4편 Chakra-UI 적용하기

안녕하세요? 지난 시간에 이어 NextJS와 MongoDB를 이용한 유저 로그인 세션 구현하기 4편을 계속 이어 나가도록 하겠습니다. 1편 : https://cpro95.tistory.com/463 NextJS와 MongoDB로 유저 로그인 세션 구현하..

cpro95.tistory.com

 

이제 실행 화면을 볼까요?

 

 

우리의 ElectronJS 앱이 잘 실행되고 있네요.

 

오른쪽 상단 Light Mode를 클릭하면

 

 

이렇게 나옵니다. 정말 멋지지 않나요? Chakra-UI의 멋진 기능입니다.

 

이제 본격적으로 ElectronJS의 백엔드 부분에 대해 알아보겠습니다.

 

우리가 지난 시간에 만들었던 db 관련 파일을 db 폴더에 옮기겠습니다.

 

/db/getdata.js

 

const fs = require("fs");
const initSqlJs = require("sql.js");

const dbFileName = require('./dbconfig');

let _rowsFromSqlDataArray = function (object) {
  let data = [];
  let i = 0;
  let j = 0;
  for (let valueArray of object.values) {
    data[i] = {};
    j = 0;
    for (let column of object.columns) {
      Object.assign(data[i], { [column]: valueArray[j] });
      j++;
    }
    i++;
  }
  return data;
};

const sqlFilterXml2Json = (movies) => {
  // movies.c08, c20 is the type of xml
  // movies is array
  // if movies is object, below is error
  const parser = require("xml2json-light");

  movies.forEach((row) => {
    if (row.c08 !== "") {
      var poster = parser.xml2json(row.c08);
      // console.log(poster);
      var poster_temp = [];

      if (Array.isArray(poster.thumb)) {
        poster.thumb.map((i) => {
          if (i.aspect === "poster") {
            poster_temp.push(i);
          } else if (i.aspect === undefined) {
            poster_temp.push(i);
          }
          // console.log(poster_temp);
        });
      } else {
        poster_temp.push(poster.thumb);
      }

      row.c08 = poster_temp[0].preview;
    }

    if (row.c20 !== "") {
      var fanart = parser.xml2json(row.c20);
      // console.log(fanart);
      var fanart_temp = [];

      if (Array.isArray(fanart.fanart.thumb)) {
        fanart.fanart.thumb.map((i) => {
          if (i.aspect === "fanart") {
            fanart_temp.push(i);
          } else if (i.aspect === undefined) {
            fanart_temp.push(i);
          }
        });
      } else {
        fanart_temp.push(fanart.fanart.thumb);
      }

      // console.log(fanart_temp);
      row.c20 = fanart_temp[0].preview;
    }
  });

  if (Object.keys(movies).length === 0) {
    console.log("No data found");
  } else {
    // console.log('return movies');
    return movies;
  }
};

const findAll = (stmt, callback) => {
  initSqlJs().then((SQL) => {
    SQL.dbOpen = function (databaseFileName) {
      try {
        return new SQL.Database(fs.readFileSync(databaseFileName));
      } catch (error) {
        console.log("Can't open database file.", error.message);
        return null;
      }
    };

    let db = SQL.dbOpen(dbFileName);
    var res = db.exec(stmt);
    // console.log(res.legth);
    if (res.length === 0) callback([{ error: "No Data found!" }]);
    else {
      res = _rowsFromSqlDataArray(res[0]);
      res = sqlFilterXml2Json(res);
      callback(res);
      db.close();
    }
  });
};

const findAll2 = (stmt, callback) => {
  initSqlJs().then((SQL) => {
    SQL.dbOpen = function (databaseFileName) {
      try {
        return new SQL.Database(fs.readFileSync(databaseFileName));
      } catch (error) {
        console.log("Can't open database file.", error.message);
        return null;
      }
    };

    let db = SQL.dbOpen(dbFileName);
    var res = db.exec(stmt);
    // console.log(res.legth);
    if (res.length === 0) callback([{ error: "No Data found!" }]);
    else {
      res = _rowsFromSqlDataArray(res[0]);
      callback(res);
      db.close();
    }
  });
};

const getData = (query) => {
  // var stmt = `select idMovie, c00, c01, c03, c08, c19, c20, premiered, strPath,rating, uniqueid_value from movie_view where c00 like '%${query}%' order by idMovie desc`;
  // console.log(query);
  return new Promise((resolve, reject) => {
    findAll(query, (res, err) => {
      if (err) reject(err);
      else resolve(res);
    });
  });
};

/*
 * getData2 : query without poster, thumbnail (no sqlFilterXml2Json function)
 */
const getData2 = (query) => {
  return new Promise((resolve, reject) => {
    findAll2(query, (res, err) => {
      if (err) reject(err);
      else resolve(res);
    });
  });
};

module.exports = { getData, getData2 };

 

위 코드에서 dbFileName을 새로 정했습니다.

 

/db/dbconfig.js

 

const path = require('path');
const isDev = require('electron-is-dev');
const Store = require('electron-store');

const store = new Store();

var dbFileName;

if (store.has('dbFileName')) {
  // console.log(store.get('dbFileName'));
  // store.get returns array, so (xxx)[]0
  dbFileName = store.get('dbFileName')[0];
  module.exports = dbFileName;
} else {
  if (isDev && process.argv.indexOf('--noDevServer') === -1) {
    dbFileName = path.join(
      path.dirname(__dirname),
      'extraResources',
      'MyVideos116.db'
    );
  } else {
    dbFileName = path.join(
      process.resourcesPath,
      'extraResources',
      'MyVideos116.db'
    );
  }
  module.exports = dbFileName;
}

dbconfig.js 파일은 우리의 DB 파일을 불러오는데요.

 

일단 MyVideos116.db 파일을 electron-store를 통해 저장하고 그 위치를 나중에 읽어오는 로직입니다.

 

우리는 MyVideos116.db 파일을 ElectronJS 앱에 통합하려고 extraResources 폴더에 저장하겠습니다.

 

/extraResources/MyVideos116.db

 

자, 이제 DB 관련 파일도 모두 완성되었습니다.

 

 

2. IPC 이해하기

 

이제 ElectronJS에서 db 관련 함수를 호출해 보겠습니다.

 

일단 App.js 파일을 수정해 보겠습니다.

 

import { Flex, VStack, Heading, Text, Button } from "@chakra-ui/react";
import { getData, getData2 } from "../db/getdata";

뭔가 이상하죠?

 

네. 맞습니다.

 

ReactJS 앱은 src 폴더를 벗어날 수 없습니다.

 

그럼 우리의 db 폴더를 src 폴더 안으로 넣을까요?

 

아닙니다. 그럴 필요 없습니다. 원래부터 안 되는 거니까요?

 

우리의 sql.js 부분은 NodeJS 로직입니다.

 

ElectronJS가 불러온 ReactJS앱 상에서는 작동하지 않습니다.

 

그럼 왜 그럴까요?

 

ElectronJS의 구조에 대해 먼저 이해하는 게 필요합니다.

 

ElectronJS는 두 개의 Process로 실행되는데요.

 

바로 main process와 renderer process입니다.

 

우리가 public 폴더에 만든 electron.js 파일이 바로 main process에 해당됩니다.

 

main process에서 mainWindow.loadURL 함수를 통해 renderer process를 불어왔는데요.

 

이 renderer process에서 ReactJS 앱을 로딩시키고 있습니다.

 

main process는 그냥 NodeJS 앱입니다.

 

그래서 import 대신 require 방식으로 모듈을 불러와야 되고, 모든 NodeJS API를 사용할 수 있습니다.

 

그래서 우리가 만든 db 모듈인 getData, getData2는 바로 이 main process에서 사용해야만 합니다.

 

그러면 main process에서 돌린 함수의 결과값을 어떻게 renderer process에 전달할까요?

 

바로 ElectronJS에서 제공하는 IPC를 통해서입니다.

 

IPC는 inter process communication의 약자인데 초창기 버전의 ElectronJS에는 IPC 한 개의 모듈만 있었는데 최근에는 main process 용 ipcMain과 renderer process 용 ipcRenderer 모듈이 있습니다.

 

그럼 이 두 개의 모듈을 이용해서 ElectronJS가 main process와 renderer process 간 커뮤니케이션하는 방법에 대해 알아보겠습니다.

 

// In main process.
const { ipcMain } = require('electron')
ipcMain.on('asynchronous-message', (event, arg) => {
  console.log(arg) // prints "ping"
  event.reply('asynchronous-reply', 'pong')
})

ipcMain.on('synchronous-message', (event, arg) => {
  console.log(arg) // prints "ping"
  event.returnValue = 'pong'
})

 

// In renderer process (web page).
// NB. Electron APIs are only accessible from preload, unless contextIsolation is disabled.
// See https://www.electronjs.org/docs/tutorial/process-model#preload-scripts for more details.
const { ipcRenderer } = require('electron')
console.log(ipcRenderer.sendSync('synchronous-message', 'ping')) // prints "pong"

ipcRenderer.on('asynchronous-reply', (event, arg) => {
  console.log(arg) // prints "pong"
})
ipcRenderer.send('asynchronous-message', 'ping')

 

ElectronJS 홈페이지에 있는 예제입니다.

 

커뮤니케이션은 Renderer process에서 main process로 이동합니다.

 

보시면 비동기 방식(Asynchronous)과 동기 방식(Synchronous)이 있습니다.

 

먼저, 동기 방식입니다.

 

Renderer Process에서 ipcRenderer.sendSync('synchronous-message', 'ping');라고 보내면 바로 main process에서 ipcMain.on 함수를 통해 그 결괏값을 리턴합니다.

 

ipcMain.on 함수는 event와 arg 인수를 갖는데 event의 returnValue 값을 통해서 다시 Renderer process로 값을 전달할 수 있습니다.

 

당연히 arg 인수는 renderer Process에서 보낸 신호가 되겠죠.

 

동기방식은 sendSync 하면 바로 리턴될 때까지 프로세스가 멈춰있습니다.

 

그래서 동기 방식은 특별한 경우가 아니면 잘 쓰이지 않고요.

 

바로 비동기 방식이 가장 많이 쓰입니다. 비동기 방식이야 말로 웹 방식에 가장 적합하기 때문입니다.

 

비동기 방식에 대해 알아보겠습니다.

 

비동기 방식은 ipcRenderer.send('asynchronous-message', 'ping')처럼 send 함수를 사용합니다.

 

그러면 main process에서는 똑같은 함수인 ipcMain.on 함수를 이용하는데 이때 리턴은 Promise를 리턴해야 하기 때문에 reply 함수를 쓰고 event.reply('asynchronous-reply', 'pong')처럼 메시지 이름을 지정할 수 있습니다.

 

여기서 메시지 이름을 지정한 이유는 바로 이 메시지를 받아서 Renderer Process에서 처리해야 하기 때문이죠.

 

renderer process에서는 바로 ipcRenderer.on('asynchronous-reply', (event, arg) => { console.log(arg) // prints "pong" })처럼 ipcRenderer.on 함수를 씁니다.

 

ipcMain.on 함수와 비슷합니다.

 

그럼, 일렉트론의 IPC에 대해 살펴보았는데 본격적으로 우리가 만든 db 모듈을 적용해 보겠습니다.

 

 

3. IPC 적용하기

 

먼저, public 폴더의 electron.js 파일을 수정하겠습니다.

 

const { app, BrowserWindow, ipcMain } = require("electron");
const path = require("path");
const isDev = require("electron-is-dev");
const { getData, getData2 } = require("../db/getdata");

맨 윗부분입니다.

 

getData , getData2 모듈을 불러왔습니다.

 

 

그리고 electron.js 파일 맨 아래에 아래 코드를 넣어주세요.

 

ipcMain.on("latest-query", (event, arg) => {
  console.log("query from renderer : ", arg);
  getData2(arg)
    .then((res) => event.sender.send("sql-return-latest", res))
    .catch((error) => console.log(error));
});

 

ipcMain.on 함수에서 메시지는 'latest-query'입니다.

 

즉, renderer process에서 ipcRenderer.send('latest-query', arg)를 실행한 게 됩니다.

 

ipcRenderer.send('latest-query', arg)에서 arg 부분에 query를 주게 되면 main Process에서 ipcMain.on에 의해 getData2 함수는 query가 인수가 되어 실행됩니다.

 

그러면 그 결괏값을 다시 event.sender.send 함수로 'sql-return-latest' 메시지로 renderer process에 보내게 됩니다.

 

그럼 renderer process에서는

ipcRenderer.on('sql-return-latest', (event, arg) => {
    setData(arg);
    ipcRenderer.removeAllListeners('sql-return-latest');
  });

위 코드처럼 ReactJS의 useState를 이용해서 setData에 저장하고 그 state를 이용해서 ReactJS에 뿌려주게 됩니다.

 

그럼 한번 만들어 볼까요?

 

/src/App.js

 

const [data, setData] = useState([]);

  useEffect(() => {
    ipcRenderer.send(
      'latest-query',
      'select idMovie, c00, premiered, rating from movie_view order by premiered desc'
    );
  }, []);

  ipcRenderer.on('sql-return-latest', (event, arg) => {
    setData(arg);
    ipcRenderer.removeAllListeners('sql-return-latest');
  });
  
  
  return (
    <div>
      <UnorderedList>
          {data &&
            data.map((i) => <ListItem key={i.idMovie}>{i.c00}</ListItem>)}
        </UnorderedList>
    </div>
  );

 

실행해 보면 에러가 납니다.

 

왜냐하면 ipcRenderer 이 없다고 합니다.

 

그러고 보니 ReactJS 쪽에서 ipcRenderer를 import 안 했네요.

 

import { ipcRenderer } from 'electron';

 

이렇게 하면 될 거 같은데 역시나 안됩니다.

 

ElectronJS 작년 버전까지는 다음과 같이 하면 가능했었습니다.

 

const { ipcRenderer } = window.require('electron');

window라는 글로벌 객체에서 로드하는 방식이죠.

 

그런데 최신 버전 ElectronJS에서는 이 방법도 에러가 납니다.

 

보안에 취약하다는 의미이기 때문입니다. ipc 커뮤니케이션 간에 data가 탈취될 수 있다고 보기 때문입니다.

 

그럼 어떻게 해야 할까요?

 

ElectronJS에서 추천하는 방법은 contextBridge API를 이용하는 방법입니다.

 

contextBridge API는 바로 main process에서 일정 API 만 renderer process에 연결시켜주는 역할을 합니다.

 

먼저 /public/electron.js 코드를 조금 고쳐야 합니다.

 

function createWindow() {
  mainWindow = new BrowserWindow({
    width: 900,
    height: 680,
    minWidth: 370,
    minHeight: 470,
    webPreferences: {
      nodeIntegration: true,
      enableRemoteModule: true,
      devTools: isDev,
      nativeWindowOpen: true,
      preload: path.join(app.getAppPath(), "/public/preload.js"),
    },
  });

 

BrowserWindow에 preload 옵션을 넣어줘야 합니다.

 

 preload: path.join(app.getAppPath(), "/public/preload.js"),

 

이 preload 옵션은 ElectronJS가 브라우저 윈도를 만들 때 미리 로드한다는 뜻입니다.

 

이 preload.js에서 contextBridge API 관련 일을 처리해 줘야 합니다.

 

그럼 /public/preload.js 파일을 만들어 봅시다.

 

 

/public/preload.js

 

const { contextBridge, ipcRenderer } = require("electron");

// Expose protected methods that allow the renderer process to use
// the ipcRenderer without exposing the entire object
contextBridge.exposeInMainWorld("myApi", {
  send: (channel, data) => {
    // whitelist channels
    let validChannels = ["latest-query"];
    if (validChannels.includes(channel)) {
      ipcRenderer.send(channel, data);
    }
  },
  receive: (channel, func) => {
    let validChannels = ["sql-return-latest"];
    if (validChannels.includes(channel)) {
      // Deliberately strip event as it includes `sender`
      ipcRenderer.on(channel, (event, ...args) => func(...args));
    }
  },
  removeListeners: (channel) => {
    let validChannels = ["sql-return-latest"];
    if (validChannels.includes(channel)) {
      // Deliberately strip event as it includes `sender`
      ipcRenderer.removeAllListeners(channel);
    }
  },
});

contextBridge API의 exposeInMainWorld 함수를 이용해 renderer process에 보여줄 자신만의 api를 만드는 겁니다.

 

위 코드에서 보면 자신만의 api 이름은 myApi이고, 이 api는 객체 안에서 함수를 설정하는 방식으로 작성되어 있습니다.

 

즉, myApi.send 함수, myApi.receive 함수, myApi.removeListeners 함수가 설정되어 있는 겁니다.

 

먼저, myApi.send 함수를 볼까요?

 

send: (channel, data) => {
    // whitelist channels
    let validChannels = ["latest-query"];
    if (validChannels.includes(channel)) {
      ipcRenderer.send(channel, data);
    }
  },

channel이랑 data를 인자로 받고 만약 channel이 validChannels에 있으면 ipcRenderer.send() 함수를 사용하라는 뜻입니다.

 

이렇게 validChannels 방식을 쓰면 해킹에 안전할 수 있습니다.

 

validChannels 배열에는 앞으로 우리가 추가할 channel 이름을 추가하면 됩니다.

 

myApi.receive 함수도 마찬가지입니다.

 

그럼 이제 App.js 파일을 알아보겠습니다.

 

 

/src/App.js

 

import React, { useState, useEffect } from "react";

import {
  Flex,
  VStack,
  Heading,
  Text,
  UnorderedList,
  ListItem,
} from "@chakra-ui/react";

function App() {
  const [data, setData] = useState();

  useEffect(() => {
    window.myApi.send(
      "latest-query",
      "select idMovie, c00, premiered, rating from movie_view order by premiered desc limit 2"
    );
  }, []);

  window.myApi.receive("sql-return-latest", (data) => {
    console.log(`Received data from main process`);
    console.table(data);
    setData(data);
    window.myApi.removeListeners("sql-return-latest");
  });

  return (
    <Flex w="auto" align="center" justify="center" p={[0, 10, 20]}>
      <VStack>
        <Heading>Welcome</Heading>
        <Text>Electon + React + Sqlite3 + Chakra-UI</Text>
        <UnorderedList>
          {data &&
            data.map((i) => <ListItem key={i.idMovie}>{i.c00}</ListItem>)}
        </UnorderedList>
      </VStack>
    </Flex>
  );
}

export default App;

 

useEffect 훅을 써서 App.js 가 실행되자마자 main process로 채널을 보냅니다.

 

이때 우리가 preload.js 에서 설정한 contextBridge를 사용하는데요.

 

window.myApi.send() 형식으로 사용하면 됩니다.

 

window.myApi.send(
      "latest-query",
      "select idMovie, c00, premiered, rating from movie_view order by premiered desc limit 2"
    );

"latest-query" 채널로 SQL 쿼리를 보냈습니다.

 

그러면, electron.js 파일의 맨 밑에 있는 코드를 볼까요?

 

ipcMain.on("latest-query", (event, arg) => {
  console.log("query from renderer : ", arg);
  getData2(arg)
    .then((res) => event.sender.send("sql-return-latest", res))
    .catch((error) => console.log(error));
});

"latest-query" 채널로 뭔가가 오면 ipcMain.on() 함수가 작동하게 됩니다.

 

여기서 getData2 함수로 DB 데이터를 event.sener.send()로 보내게 되죠. 어디로 보낼까요? 바로 renderer process입니다.

 

renderer process로 보낼 때 채널을 "sql-return-latest"로 했습니다.

 

그럼 다시, preload.js 파일에서 이 부분을 처리하는 코드를 볼까요?

 

receive: (channel, func) => {
    let validChannels = ["sql-return-latest"];
    if (validChannels.includes(channel)) {
      // Deliberately strip event as it includes `sender`
      ipcRenderer.on(channel, (event, ...args) => func(...args));
    }
  },

myApi.receive 함수에 지정되어 있습니다.

 

이 함수에서 다시 ipcRenderer.on으로 인수를 넘겨주게 됩니다.

 

그러면 다시 App.js 파일로 가보면

 

  window.myApi.receive("sql-return-latest", (data) => {
    console.log(`Received data from main process`);
    console.table(data);
    setData(data);
    window.myApi.removeListeners("sql-return-latest");
  });

이와 같은 코드를 볼 수 있는데, 이 코드의 역할은 바로 main process에서 넘어온 data가 있으면 window.myApi.receive의 contextBridge로 인해서 ipcRenderer.on이 실행되며, 그 리턴 값이 data로 지정되고 또, 우리가 지정한 setData(data)로 인해서 ReactJS 앱의 State인 data에 저장됩니다.

 

그리고 UI 부분에서 이 data를 data.map 함수로 뿌려주게 되는 거죠.

 

그럼 마지막에 있는 window.myApi.removeListerners 는 뭔가요?

 

main Process에서 채널이 한번 왔으면 그 채널을 수신하는 걸 더 이상 하지 말라는 뜻입니다.

 

그래야 data 값이 한 번만 저장되게 되는 거죠.

 

그럼 preload.js에서 removeListeners 부분을 볼까요?

 

removeListeners: (channel) => {
    let validChannels = ["sql-return-latest"];
    if (validChannels.includes(channel)) {
      // Deliberately strip event as it includes `sender`
      ipcRenderer.removeAllListeners(channel);
    }
  },

간단합니다.

 

ipcRenderer.removeAllListeners를 실행하는 겁니다.

 

스크린숏을 살펴보겠습니다.

 

터미널 창에서 마지막 부분이 query from renderer로 출력되는 console.log()가 보입니다.

 

그리고 Renderer Process 부분에서 console.table(data) 한 부분이 보입니다.

 

어떻습니까?

 

정말 복잡하죠?

 

보안 문제가 계속 이슈가 되면서 ElectronJS도 점점 더 IPC 커뮤니케이션이 어려워지고 있습니다.

 

그래도 완벽한 보안을 위해 우리가 좀 더 수고해야 하지 않을까요?

 

그럼. 다음 시간에는 우리의 앱을 완성해 보겠습니다.

 

 

그리드형