Notice
Recent Posts
Recent Comments
반응형
관리 메뉴

HM소프트

Electron 키오스크 아키텍처 — IPC 경계와 contextBridge로 하드웨어를 격리하기 (1) 본문

외주 개발일지

Electron 키오스크 아키텍처 — IPC 경계와 contextBridge로 하드웨어를 격리하기 (1)

HM소프트 2026. 6. 11. 16:59
반응형

Electron 키오스크 앱의 메인-렌더러 프로세스 경계를 contextBridge와 nodeIntegration:false로 설계한 기록. Preload IPC 브릿지로 표면을 좁히고, 내장 SQLite를 WAL·외래키·CHECK 제약으로 단단히 묶고, 하드웨어를 스텁으로 추상화해 같은 코드를 브라우저 데모로도 굴린 과정을 다룬다.

시리즈를 시작하며

현장에 무인으로 설치되는 터치스크린 키오스크를 한 대 만들었다. 사용자가 화면에서 직접 이미지를 올리고 글자를 얹어 굿즈 표면 디자인을 만들고, 그 자리에서 카드로 결제한 뒤, 옆에 붙은 인쇄·커팅 장비로 결과물을 받아 가는 흐름이다. 즉 이 앱은 순수한 웹 화면이 아니라 실제 하드웨어와 맞물려 돌아가는 데스크톱 앱이어야 했다. 동시에 영업·전시용으로 브라우저에서 그냥 클릭만으로 흐름을 보여줄 수 있는 웹 데모도 필요했다.

한 편으로 요약하기엔 다룬 게 많아서 네 편으로 나눠 기록한다.

  1. (이 글) Electron 아키텍처와 IPC 경계 설계 — 프로세스를 어디서 가르고, 둘 사이의 다리를 어떻게 좁게 놓았는가
  2. 캔버스 디자인 편집기 — 레이어 모델과 합성
  3. 하드웨어 연동: 스텁 패턴과 IPC 이벤트 통합
  4. 키오스크 모드와 브라우저 데모, 그리고 하나의 코드로 두 환경 굴리기

특정 업종·고객사 정보는 전부 빼고 구조와 구현만 다룬다. 1편에서 다루는 건 앱 전체의 뼈대다. 특히 프로세스 경계를 어디에 긋느냐. 결론부터 말하면, 이 경계 하나를 초반에 제대로 그어 둔 게 프로젝트가 끝날 때까지 가장 크게 보답했다. 뒤의 세 편에서 "이게 되네?" 하고 놀랐던 순간들은 거의 다 이 1편의 결정 덕분이었다.

왜 Electron이었나 — 세 갈래 길에서

키오스크 앱을 짤 기술을 고를 때 선택지는 크게 셋이었고, 각각이 정확히 한 가지씩을 포기하라고 요구했다.

  • 순수 웹앱(브라우저): 배포와 업데이트가 제일 쉽다. 하지만 브라우저 샌드박스 안에서는 프린터, 커팅기, USB 메모리, 카드 결제 단말 같은 로컬 하드웨어에 직접 접근할 수 없다. 키오스크의 본질이 "그 자리에서 물건이 나온다"인데, 그 본질을 못 한다. 탈락.
  • 네이티브(C#/WPF 같은): 하드웨어 접근은 완전히 자유롭다. 대신 디자인 편집기처럼 풍부한 터치 UI를 빠르게 만들고 자주 바꾸는 일이 느리고 고통스럽다. UI 생산성을 포기하기 싫었다.
  • Electron: 화면은 브라우저(렌더러)로 React를 써서 짜고, 하드웨어와 OS 권한이 필요한 일은 메인 프로세스(Node.js)에서 한다. 두 세계를 한 앱 안에 같이 들고 간다.

키오스크의 요구가 정확히 "리치한 터치 UI + 로컬 하드웨어 제어" 두 개의 교집합이었기 때문에 Electron이 가장 덜 타협하는 선택이었다. UI는 React와 TypeScript로 짜고, 번들은 Vite로 묶었다. 다만 Electron을 고른 순간부터 진짜 설계가 시작된다. "두 세계를 한 앱에 들고 간다"는 말은 곧 그 둘 사이의 국경선을 직접 그어야 한다는 뜻이기 때문이다.

두 개의 세계 — 메인과 렌더러

Electron 앱은 성격이 전혀 다른 두 프로세스로 나뉜다. 이 구분을 흐릿하게 두면 나중에 반드시 대가를 치른다.

  • 메인 프로세스(Node.js 환경): OS 권한이 필요한 일 전부를 맡는다. 윈도우 생성, SQLite 파일 접근, 파일 시스템 읽기, USB 드라이브 감지, 프린터·커팅기·결제 단말 제어. 여기서는 fschild_process도 마음껏 쓴다.
  • 렌더러 프로세스(브라우저 환경): 사용자가 보는 화면. React로 그려진다. 이 세계는 OS나 하드웨어에 직접 손을 대지 않는다. 손댈 수 없게 막아 둔다.

여기서 이 프로젝트의 가장 중요한 원칙을 세웠다. 렌더러는 하드웨어를 절대 직접 만지지 않는다. 화면은 "이걸 해 달라"고 요청만 하고, 실제 동작은 전부 메인이 한다. 이 단방향 규칙을 코드 한 줄로 강제하는 게 Electron의 webPreferences 설정이다.

// src/main/index.ts — 렌더러를 샌드박스에 가두는 창 설정
mainWindow = new BrowserWindow({
  width: 1920,
  height: 1080,
  webPreferences: {
    preload: path.join(__dirname, '../preload/index.js'),
    contextIsolation: true,   // 렌더러와 preload의 컨텍스트를 분리
    nodeIntegration: false,   // 렌더러에 Node 권한을 통째로 주지 않음
  },
  frame: isDev,
  fullscreen: !isDev,
  kiosk: !isDev,
  autoHideMenuBar: true,
});

nodeIntegration: falsecontextIsolation: true 이 두 줄이 핵심이다. 이걸 켜 두면 렌더러(=화면 자바스크립트)는 require('fs')도, require('better-sqlite3')도 할 수 없다. 화면 코드가 아무리 부주의하게 짜여도 디스크나 DB를 직접 건드리는 일이 구조적으로 불가능해진다. 보안 모범 사례이기도 하지만, 나에게는 그보다 설계를 강제하는 장치로서 더 중요했다. 규칙을 문서에 적어 두는 것과, 어겼을 때 코드가 컴파일조차 안 되게 만드는 것은 차원이 다르다.

그러면 화면은 어떻게 DB를 읽고 결제를 요청하나? 메인과 렌더러를 잇는 유일한 다리를 통해서다. 그 다리가 Preload 스크립트다.

이 그림의 핵심은 화살표가 딱 한 군데로만 지난다는 점이다. 화면에서 하드웨어로 가는 모든 경로는 반드시 window.electronAPI → Preload → IPC 채널을 거친다. 옆으로 새는 길이 없다. 이 좁은 통로 하나가 뒤에 나올 모든 유연성의 근원이 된다.

Preload IPC 브릿지 — 표면을 좁게 깎기

Electron에서 렌더러에 Node 권한을 통째로 열어 주는 건 위험하다. 대신 Preload 스크립트에서 딱 필요한 함수만 contextBridge.exposeInMainWorld로 노출한다. 노출되지 않은 건 화면 입장에서 존재하지 않는다.

그래서 Preload는 사실상 이 앱의 공개 API 명세서 역할을 한다. 화면이 메인에게 부탁할 수 있는 일의 전체 목록이 이 파일 하나에 다 적혀 있다.

이 파일을 짤 때 의식적으로 지킨 두 가지가 있다.

첫째, ipcRenderer를 직접 노출하지 않는다. 흔한 실수가 exposeInMainWorld('ipc', ipcRenderer)처럼 통째로 넘기는 것이다. 그러면 화면이 임의의 채널로 임의의 메시지를 보낼 수 있게 되어, 애써 만든 경계가 도로 무너진다. 그래서 채널 이름은 전부 이 파일 안에 가두고, 밖으로는 printImage(), requestPayment() 같은 의미 있는 함수만 내보냈다. 화면은 'printer:print'라는 채널 문자열의 존재조차 모른다.

둘째, 요청/응답형(invoke)과 이벤트형(on)을 분리했다. DB 조회나 인쇄 요청처럼 "보내고 답을 기다리는" 일은 ipcRenderer.invoke로, 결제 단말이 비동기로 결과를 통보하는 것처럼 "메인이 먼저 말을 거는" 일은 ipcRenderer.on으로 받는다. 이 둘을 섞지 않고 명확히 갈라 둔 덕에, 어떤 상호작용이 동기적 부탁이고 어떤 게 비동기 통보인지가 코드만 봐도 드러난다.

메인 쪽에서는 이 채널들을 ipcMain.handle로 받는다. 그런데 핸들러를 한 파일에 몰아넣지 않고, 도메인별로 등록 함수를 쪼개 두었다.

// src/main/index.ts — 도메인별 IPC 핸들러를 한곳에서 등록
function createWindow() {
  const db = getDb();
  runMigrations(db);     // 스키마 보장
  seedDefaults(db);      // 초기 데이터 주입

  registerDbIpc();        // db:query, db:execute
  registerSettingsIpc();  // settings:get / set / get-all
  registerUsbIpc();       // usb:list-images, usb:read-image
  registerPrinterIpc();   // printer:print, printer:status
  registerPaymentIpc();   // payment:request, payment:cancel
  registerCutterIpc();    // cutter:cut
  // ... 이후 BrowserWindow 생성
}

registerDbIpc, registerPrinterIpc 처럼 도메인마다 등록 함수를 따로 두니, "결제 채널이 어디서 처리되지?"의 답이 항상 src/main/ipc/payment.ts 한 파일로 수렴한다. 채널 이름(payment:*), 파일 이름(ipc/payment.ts), 등록 함수 이름(registerPaymentIpc)이 전부 같은 단어로 정렬되어 있어서, 새 하드웨어가 붙어도 어디에 무엇을 추가할지 고민할 필요가 없다.

채널 하나의 일생 — DB 조회를 따라가 보기

추상적인 그림보다, 실제 호출 하나가 경계를 어떻게 통과하는지 끝까지 따라가 보는 게 빠르다. 화면에서 상품 목록을 읽는 코드는 이렇게 끝난다.

// 렌더러 — 화면 코드에는 SQLite도 IPC도 보이지 않는다
// useIpc()는 그저 window.electronAPI를 돌려주는 한 줄짜리 훅
const api = useIpc();
const rows = await api.dbQuery(
  'SELECT * FROM products WHERE active = ?', [1]
);

이 한 줄 뒤에서 벌어지는 일을 순서대로 풀면 이렇다.

메인 쪽 핸들러는 놀랄 만큼 단순하다.

여기서 한 가지 솔직하게 짚고 넘어갈 설계 트레이드오프가 있다. 나는 getProducts, saveOrder 같은 도메인 메서드를 채널로 두지 않고, 그냥 dbQuery(sql, params)라는 범용 채널 두 개로 SQL을 통째로 메인에 넘기는 방식을 택했다. 즉 SQL 문장을 화면 쪽 코드가 들고 있다.

장점은 명백하다. 새 화면, 새 쿼리가 생길 때마다 메인에 핸들러를 추가할 필요가 없다. IPC 보일러플레이트가 사실상 0이 된다. 1인 개발에 빠르게 화면을 늘려 가는 단계에서는 이 속도가 컸다. 단점도 분명하다. SQL이 렌더러에 흩어지고, 임의 SQL을 실행할 수 있는 통로가 열린다. 다만 이 앱은 외부 입력을 받지 않는 무인 키오스크이고 DB도 로컬 파일 하나라, 일반 웹 서비스에서 걱정하는 SQL 인젝션의 위협 모델이 사실상 없다. stmt.run(...params)로 파라미터는 항상 바인딩해 문자열 연결은 피했고, "지금 단계에 맞는 만큼만 엄격하게" 가는 쪽을 골랐다. 만약 이 앱이 외부 네트워크를 받게 되는 날이 온다면, 이 두 범용 채널을 도메인 메서드로 좁히는 게 가장 먼저 할 일이 될 것이다.

데이터는 로컬 우선 — 내장 SQLite

키오스크는 네트워크가 불안정하거나 아예 없는 현장에 놓일 수 있다. 그래서 핵심 동작은 로컬만으로 완결되도록 SQLite를 앱에 내장했다(better-sqlite3). 상품·가격·주문·결제 내역·인쇄 로그가 전부 로컬 DB 파일 하나에 들어간다. DB 파일은 OS가 정해 주는 사용자 데이터 폴더에 둔다.

// src/main/database/connection.ts — 단일 연결과 WAL 모드
let db: Database.Database | null = null;

export function getDb(): Database.Database {
  if (!db) {
    const dbPath = path.join(app.getPath('userData'), 'kiosk.db');
    db = new Database(dbPath);
    db.pragma('journal_mode = WAL');   // 읽기와 쓰기가 서로 막지 않게
    db.pragma('foreign_keys = ON');    // 외래 키 제약 실제로 강제
  }
  return db;
}

두 가지 결정이 들어 있다. journal_mode = WAL 은 인쇄가 한참 진행되며 주문 상태를 쓰는 와중에도 다른 조회가 막히지 않게 한다. 키오스크는 단일 사용자지만 인쇄·결제 같은 느린 작업이 백그라운드로 도는 경우가 많아 체감이 다르다. foreign_keys = ON 은 SQLite가 기본으로 외래 키를 강제하지 않는다는 함정을 막는다. 인쇄 로그가 존재하지 않는 주문을 가리키는 식의 깨진 데이터를 DB 레벨에서 거부하게 했다.

연결은 모듈 안에 db 한 개로 캐시해 두고 getDb()로만 꺼내 쓴다. better-sqlite3는 동기 API라 커넥션 풀이 필요 없고, 앱 종료 시 closeDb()로 한 번 닫으면 끝이다. 스키마는 앱이 켜질 때 runMigrationsCREATE TABLE IF NOT EXISTS로 보장한다.

CHECK 제약을 일부러 넉넉히 걸었다. 결제 상태나 인쇄 상태가 정해진 값 집합을 벗어나면 DB가 INSERT 자체를 거부한다. 상태 머신의 불변식을 애플리케이션 코드가 아니라 데이터 계층이 직접 지키게 한 것이다. 키오스크는 사람이 옆에 붙어 감시하지 않는 무인 환경이라, "잘못된 상태가 애초에 저장될 수 없게" 막는 한 겹이 운영 사고를 크게 줄여 준다. 결제·인쇄의 상태 전이를 실제로 어떻게 다루는지는 3편에서 깊게 파고든다.

하드웨어는 전부 스텁으로 시작했다

이 프로젝트의 또 다른 큰 결정은 하드웨어 연동을 처음부터 스텁(stub)으로 짠 것이다. 실제 프린터·커팅기·결제 단말 드라이버가 손에 들어오기 전에, 같은 함수 시그니처를 가진 가짜 구현을 먼저 만들었다. 메인 프로세스의 하드웨어 모듈은 "약속된 모양의 결과를, 약속된 시간 뒤에" 돌려주는 가짜다.

스텁의 핵심은 타입과 비동기 타이밍을 진짜처럼 흉내내는 것이다. Promise로 3초쯤 지연을 주는 이유가 여기 있다. 인쇄가 즉시 끝나는 가짜였다면, "인쇄 중…" 화면이나 진행 표시, 타임아웃 처리 같은 실제로 까다로운 부분을 검증할 수 없다. 지연을 흉내내니 실장비 없이도 화면 흐름 전체를 끝까지 돌려볼 수 있었다. 결제 단말 스텁도 마찬가지로 3초 뒤에 가짜 거래번호와 승인번호를 만들어 돌려준다.

이게 가능했던 이유가 바로 1편 내내 강조한 그 경계다. 화면은 window.electronAPI.printImage()만 알 뿐, 그 뒤가 스텁인지 실장비인지 모른다. 그래서 나중에 스텁을 실제 드라이버 호출로 교체할 때, 메인 프로세스의 이 파일 하나만 바꾸면 되고 화면 코드는 한 줄도 손대지 않는다. IPC 채널이라는 계약(contract)만 지키면, 그 계약 뒤의 구현은 통째로 갈아 끼워도 된다. 스텁에서 실장비로 가는 이 교체 작업이 3편의 주제다.

USB만은 실제 OS를 건드린다

대부분의 하드웨어는 스텁이었지만, USB 메모리에서 이미지를 읽어 오는 부분만큼은 처음부터 진짜로 만들었다. 사용자가 자기 USB를 꽂아 사진을 디자인에 넣는 흐름이라, 이건 가짜로는 검증이 안 됐다. 이 모듈은 메인 프로세스의 특권을 가장 노골적으로 쓰는 곳이다.

// src/main/hardware/usb-monitor.ts — 이동식 드라이브 탐색
const IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.bmp', '.gif'];

export async function getRemovableDrives(): Promise<string[]> {
  try {
    // 이동식(drivetype=2) 디스크만 골라낸다
    const { stdout } = await execPromise(
      'wmic logicaldisk where "drivetype=2" get deviceid'
    );
    return stdout.split('\n').map(l => l.trim())
      .filter(l => l && l !== 'DeviceID');
  } catch (error) {
    console.error('이동식 드라이브 조회 실패:', error);
    return [];   // 실패해도 빈 목록 → 화면은 "USB 없음"으로 흐른다
  }
}

여기서 child_process.exec로 OS 명령을 돌리고 fs.readdirSync로 디렉터리를 재귀 탐색한다. 정확히 렌더러에서는 절대 못 하게 막아 둔 일들이다. 이 코드가 메인 프로세스에만 존재하고 IPC 채널 뒤에 숨어 있기 때문에, 화면은 그저 listUsbImages()를 부르면 { path, name, size } 배열을 받을 뿐이다. 드라이브를 어떻게 찾고 폴더를 어떻게 훑는지는 화면이 알 바가 아니다.

탐색에는 작은 방어들을 박아 뒀다. 재귀 깊이를 3단계로 제한해 거대한 드라이브에서 무한정 헤매지 않게 했고, 읽을 수 없는 폴더나 stat이 실패하는 파일은 조용히 건너뛴다. 무인 키오스크에서는 하나의 깨진 파일이 전체 흐름을 멈추게 두면 안 된다. "최대한 읽되, 못 읽는 건 무시하고 계속"이 무인 환경의 기본 자세다.

하나의 코드로 두 환경 — 브라우저 데모의 비밀

마지막으로, 이 경계 설계가 가장 멋지게 보답한 지점. 영업·전시용 웹 데모를 별도 코드베이스 없이 같은 렌더러 코드로 굴린 방법이다.

렌더러는 하드웨어를 직접 안 만지고 오직 window.electronAPI 한 겹만 통한다고 했다. 그렇다면 브라우저에서 돌릴 때는 그 한 겹을 가짜로 바꿔치기하면 된다. Electron 안에서 실행되면 Preload가 진짜 electronAPI를 심어 주고, 그냥 브라우저에서 열면 그게 없으니 목(mock)을 끼워 넣는다.

목 구현은 ElectronAPI 타입을 그대로 만족시키는 객체다. DB 호출은 받은 SQL 문자열을 대충 파싱해 인메모리 배열로 흉내내고, 결제·인쇄는 스텁과 똑같이 몇 초 지연 뒤 성공을 돌려준다. 타입이 같으니 화면 코드는 자기가 진짜 위에서 도는지 가짜 위에서 도는지 끝내 모른다.

이게 되는 건 전적으로 1편의 경계 덕이다. 만약 화면 곳곳에서 SQLite나 결제 SDK를 직접 불렀다면, 브라우저 데모를 만들려고 그 호출들을 일일이 찾아 분기 처리해야 했을 것이다. 하지만 모든 하드웨어 접근이 window.electronAPI 하나의 좁은 통로로 모여 있으니, 그 통로 끝만 목으로 갈아 끼우면 화면 코드 변경 0으로 같은 앱이 브라우저에서 돈다. Vite 빌드 설정에서 base 경로만 정적 호스팅에 맞춰 주면 배포 끝이다. 이 웹 데모 빌드와 배포는 4편에서 자세히 다룬다.

정리하며 — 경계 하나가 만든 것들

1편을 관통하는 결정은 사실상 하나다. "렌더러는 하드웨어를 직접 만지지 않는다. 모든 건 window.electronAPI 한 겹을 지난다." 이 규칙 하나를 nodeIntegration: false + contextIsolation: true로 코드가 강제하게 만들어 두니, 뒤따르는 거의 모든 게 쉬워졌다.

  • 보안: 화면 코드가 디스크·DB·OS를 직접 못 건드린다. 무인 키오스크에서 위협 표면이 좁아진다.
  • 교체 가능성: 하드웨어를 스텁 → 실장비로 바꿔도 IPC 채널이라는 계약만 지키면 화면은 무변경. (3편)
  • 이식성: 같은 렌더러 코드를 Electron과 브라우저 양쪽에서 굴린다. 경계 끝만 목으로 갈면 된다. (4편)
  • 추적 가능성: "이 데이터 어디서 오지?", "이 결제 누가 처리하지?"의 답이 항상 도메인별 IPC 파일 하나로 수렴한다.
  • 불변식 강제: 상태 머신의 규칙을 CHECK 제약으로 DB가 직접 지킨다.

경계를 늦게 그으면 하드웨어 호출이 화면 곳곳에 스며들고, 어느 순간 떼어낼 수 없게 엉겨 버린다. 반대로 초반에 좁은 통로 하나로 못 박아 두면, 그 통로 양 끝의 구현을 두고두고 자유롭게 바꿀 수 있다. 작은 규칙 하나가 프로젝트 전체의 유연성을 결정했다는 말은 과장이 아니다.

다음 편

2편에서는 이 앱에서 가장 공을 들인 화면, 캔버스 기반 디자인 편집기를 다룬다. 레이어 모델을 어떻게 설계했는지, 텍스트·이미지·캐릭터 요소를 한 캔버스 위에서 어떻게 합성했는지, 그리고 앞서 만든 USB 채널로 읽어 온 이미지를 편집기에 어떻게 얹었는지까지 이어서 기록한다.

반응형