| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | |||
| 5 | 6 | 7 | 8 | 9 | 10 | 11 |
| 12 | 13 | 14 | 15 | 16 | 17 | 18 |
| 19 | 20 | 21 | 22 | 23 | 24 | 25 |
| 26 | 27 | 28 | 29 | 30 | 31 |
- REST API
- contextBridge
- 소셜로그인
- JWT
- typescript
- 배포
- Vite
- FastAPI
- 크롤링
- next.js
- playwright
- 온디바이스AI
- 메일자동화
- github actions
- React
- javascript
- 도메인설계
- Android
- electron
- 아키텍처
- python
- 상태관리
- sqlite
- 인증
- php
- Kotlin
- 보안
- 프론트엔드
- 백엔드
- 마이그레이션
- Today
- Total
HM소프트
Electron 하드웨어 연동 개발기 (3) — 스텁 드라이버와 IPC 상태 통합 본문

프린터·커팅기·결제 단말이 없는 상태에서 스텁 드라이버로 전체 흐름을 완성한 기록. 추상 인터페이스 대신 함수 시그니처를 계약으로 고정하고, 3초 지연까지 흉내 내 진행·타임아웃·재시도 UX를 검증하며, 장비 상태를 IPC 한 채널로 통합해 화면이 구현을 모르게 만든 설계를 다룬다.
장비가 책상 위에 없을 때 개발을 시작하는 법
현장에 설치되는 무인 키오스크는 화면만으로 끝나지 않는다. 결과물을 실제로 찍어 내는 프린터, 스티커 외곽선을 따는 커팅기, 카드를 받는 결제 단말기, 그리고 사용자가 이미지를 들고 오는 USB 저장장치까지 — 물리 장비 네 종이 소프트웨어와 맞물려 돌아간다. 그런데 개발하는 사람 입장에서 이 장비들이 늘 책상 위에 놓여 있는 건 아니다. 결제 단말기는 가맹 계약과 실물 단말이 와야 붙고, 커팅기는 현장에 가야 있고, 프린터도 모델이 확정돼야 SDK가 정해진다.
그래서 이번 편의 출발점은 분명했다. 장비가 하나도 없는 상태에서 주문부터 결제, 인쇄, 완료까지 전체 흐름을 끝까지 만들 수 있어야 한다. 장비가 와야만 개발이 진행된다면, 장비 도착 전까지 화면 절반은 손도 못 댄 채 멈춰 있게 된다. 그건 일정 측면에서 받아들일 수 없었다.
이 글은 그 제약을 풀어낸 방법에 대한 기록이다. 핵심은 두 가지다. 하나는 하드웨어를 시뮬레이션 드라이버(스텁)로 먼저 구현해 두고 나중에 실장비 코드로 갈아 끼우는 패턴, 다른 하나는 여러 장비의 상태를 Electron IPC 한 채널로 통합해 화면이 장비별 세부 구현을 전혀 모르게 만든 설계다. 1편에서 그어 둔 메인-렌더러 프로세스 경계가 여기서 다시 한번 일을 한다.
왜 인터페이스가 아니라 "함수 시그니처"를 먼저 고정했나
스텁 패턴이라고 하면 보통 추상 인터페이스(interface Device)를 먼저 긋고 그 아래에 가짜 구현과 진짜 구현을 갈아 끼우는 그림을 떠올린다. 처음에 나도 그렇게 가려고 했다. 그런데 키오스크의 장비들은 공통점이 생각보다 적었다. 프린터는 이미지 데이터를 받아 결과를 돌려주고, 커팅기는 경로 데이터를 받고, 결제기는 금액을 받아 승인번호를 돌려준다. 입력도 출력도 제각각이라, 억지로 하나의 Device 인터페이스로 묶으면 공통 분모가 status() 하나뿐인 빈껍데기가 된다.
그래서 거창한 추상 클래스 계층 대신, 장비마다 "이 함수만 있으면 화면이 동작한다"는 함수 시그니처를 먼저 확정하는 쪽을 택했다. 모듈 하나가 곧 장비 하나고, 그 모듈이 노출하는 함수 시그니처가 곧 계약이다. 구현이 가짜든 진짜든, 시그니처만 같으면 호출하는 쪽은 차이를 모른다.
프린터 드라이버의 첫 버전은 이렇게 생겼다. 인쇄에 걸리는 시간을 setTimeout으로 흉내 낼 뿐, 실제로는 아무것도 출력하지 않는다.

여기서 의도적으로 챙긴 게 두 가지 있다. 첫째, printImage는 3초의 지연을 실제로 준다. 이게 사소해 보여도 핵심이다. 실장비 프린터는 인쇄에 수 초가 걸리는데, 스텁이 즉시 success를 반환해 버리면 진행 화면(스피너, 단계 표시)을 만들 일이 없어진다. 그러면 정작 실장비를 붙였을 때 처음으로 "느린 작업의 UX"를 마주하게 된다. 그래서 스텁이 느린 척을 하게 만들어, 진행 화면을 장비 없이도 진짜처럼 개발할 수 있게 했다.
둘째, getPrinterStatus가 받는 printerName이 빈 문자열이면 connected: false, not_configured를 돌려준다. 이 빈 문자열 한 가지가 나중에 "장비가 아직 연결/설정되지 않음" 상태를 표현하는 신호로 쓰인다. 설정값이 곧 장비의 존재 여부가 되는 셈이다.
커팅기와 결제기도 같은 결로 짰다. 시그니처만 정해 두고, 내부는 지연 후 성공을 돌려준다.

결제 스텁이 돌려주는 transactionId와 approvalNo는 형식만 진짜 단말과 같게 맞췄다(TX..., AP######). 이 값들은 그대로 주문 레코드에 저장되고 영수증 흐름까지 흘러가기 때문에, 형식이 진짜와 같아야 결제 이후의 모든 코드를 실제 데이터로 검증할 수 있다. 가짜 단말이 "그럴듯한 가짜 데이터"를 만들어 내는 게 중요하다.
스텁과 실장비를 가르는 단 하나의 경계: IPC 핸들러
드라이버 함수를 직접 화면에서 부르면, 렌더러 프로세스가 메인 프로세스의 Node 영역(파일·DB·자식 프로세스)에 직접 닿게 된다. Electron에서 이건 금물이다. 그래서 모든 장비 호출은 메인 프로세스의 IPC 핸들러를 한 번 거치게 했다. 화면은 IPC 채널 이름만 알고, 그 뒤에서 어떤 드라이버가 실행되는지는 모른다.

IPC 핸들러는 단순한 통과 지점이 아니다. 여기서 장비 설정을 SQLite에서 읽어 드라이버에 주입하는 일이 일어난다. 프린터 이름이나 커팅기 포트는 관리자 화면에서 설정해 DB에 저장돼 있고, 핸들러가 그 값을 꺼내 드라이버에 넘긴다.

이 구조의 이점은 스텁을 실장비로 바꿀 때 손댈 곳이 정확히 한 군데라는 점이다. printImage의 내부를 실제 프린터 SDK 호출로 바꾸기만 하면, IPC 핸들러도, preload도, 화면도 한 줄도 바뀌지 않는다. 함수 시그니처(imageDataUrl, printerName을 받아 PrintResult를 돌려줌)가 계약으로 고정돼 있기 때문이다. 가짜에서 진짜로의 전환이 "경계 안쪽 일"이 되도록 경계를 IPC 핸들러 바로 아래에 그어 둔 셈이다.
커팅기 핸들러도 같은 패턴이다. 다른 점은 결과를 push하지 않고 응답으로만 돌려준다는 것 정도다 — 커팅은 화면이 진행 상태를 따로 구독할 필요가 없어서다.
export function registerCutterIpc(): void {
ipcMain.handle('cutter:cut', async (_event, designData: string) => {
const db = getDb();
const row = db
.prepare('SELECT value FROM settings WHERE key = ?')
.get('cutter_port') as { value: string } | undefined;
return cutDesign(designData, row?.value || '');
});
ipcMain.handle('cutter:status', () => {
const db = getDb();
const row = db
.prepare('SELECT value FROM settings WHERE key = ?')
.get('cutter_port') as { value: string } | undefined;
return getCutterStatus(row?.value || '');
});
}
preload — 화면이 닿는 "안전한 표면"을 한 장으로 정의하다
렌더러가 IPC를 직접 만지게 두면, 화면 어디서든 임의의 채널로 메인 프로세스를 호출할 수 있게 된다. 그건 보안 경계가 무너지는 길이다. 그래서 contextIsolation을 켠 상태에서, preload가 화면에 노출할 함수만 골라 화이트리스트로 묶었다. 화면은 window.electronAPI에 있는 함수만 부를 수 있고, 그 외의 채널은 존재조차 모른다.

이 한 장이 곧 "화면이 할 수 있는 모든 것"의 목록이다. 새 장비 기능을 더하려면 여기에 한 줄을 추가해야 하므로, 무엇이 IPC를 통과하는지가 한눈에 통제된다. 그리고 이 표면을 타입으로도 한 번 더 못 박았다. 화면 코드가 오타나 잘못된 인자로 장비를 부르면 컴파일 단계에서 걸린다.
export interface ElectronAPI {
platform: string;
printImage: (imageDataUrl: string) =>
Promise<{ success: boolean; error?: string }>;
requestPayment: (amount: number) =>
Promise<{ success: boolean; transactionId?: string;
approvalNo?: string; error?: string }>;
cutDesign: (designData: string) =>
Promise<{ success: boolean; error?: string }>;
getHardwareStatus: () =>
Promise<{ printer: boolean; payment: boolean; cutter: boolean }>;
onPaymentResult: (cb: (e: unknown, d: unknown) => void) => void;
onPrinterStatus: (cb: (e: unknown, d: unknown) => void) => void;
removeAllListeners: (channel: string) => void;
}
declare global {
interface Window { electronAPI: ElectronAPI; }
}
화면에서 IPC를 쓰는 코드는 이 타입을 거쳐 window.electronAPI를 한 줄로 꺼내 쓴다. 훅 하나로 감싸 둔 덕분에, 나중에 접근 방식을 바꾸더라도 화면 코드는 useIpc()만 부르면 된다.
export function useIpc() {
const api = window.electronAPI;
return api;
}
느린 작업을 화면에 보여 주기 — 인쇄 진행 상태 머신
스텁이 일부러 3초를 끄는 이유가 여기서 드러난다. 인쇄 화면은 단순히 "인쇄 완료"만 띄우는 게 아니라, 준비 → 인쇄 중 → 완료(혹은 오류) 라는 단계를 사용자에게 보여 줘야 한다. 사용자가 결과물을 인쇄대에 올려야 하는 준비 단계가 있고, 그다음 실제 인쇄가 진행되며, 끝나면 완료 화면으로 넘어간다.
진행률을 정확히 알 방법은 없다(스텁이든 실장비든 프린터가 "지금 47%"를 알려주진 않는다). 그래서 진행률을 가짜로 90%까지 천천히 채우다가, 드라이버가 성공을 돌려준 순간 100%로 점프시키는 방식을 썼다. 사용자에게는 "뭔가 진행되고 있다"는 감각이 중요하지, 정확한 퍼센트가 중요한 게 아니다.

여기서 cancelled 플래그가 작은 안전장치다. 인쇄가 진행되는 도중 사용자가 화면을 벗어나면(언마운트), 이미 떠난 화면에 setState를 시도해 경고를 띄우거나 엉뚱한 내비게이션이 일어날 수 있다. 그래서 클린업에서 cancelled = true로 막고, await 이후마다 if (cancelled) return으로 빠져나간다. 비동기 장비 작업과 화면 생명주기가 어긋나는 흔한 함정을 여기서 닫았다.
또 하나 의식적으로 한 건, 성공이든 실패든 결과를 반드시 DB에 남긴다는 점이다. 인쇄가 완료되면 주문 상태를 completed로 바꾸고 로그를 쌓고, 실패하면 failed로 바꾸고 실패 사유를 로그에 남긴다. 무인 키오스크는 옆에 사람이 없으니, "어제 3시에 왜 인쇄가 안 됐나"를 나중에 추적할 단서가 데이터로 남아 있어야 한다.
결제 화면 — 타임아웃과 재시도라는 현실
결제는 인쇄보다 한 단계 더 까다롭다. 무인 환경에서 결제가 멈추면, 돈이 빠졌는지 안 빠졌는지 모르는 사용자가 키오스크 앞에서 발이 묶인다. 그래서 결제 화면에는 스텁 단계에서부터 타임아웃과 재시도 한도를 넣어 뒀다.
const processPayment = async () => {
setPhase('processing');
const result = await api.requestPayment(amount);
if (result.success) {
setPhase('success');
const dbResult = await api.dbExecute(
`INSERT INTO orders (product_type, design_data, amount,
payment_status, transaction_id, approval_no)
VALUES (?, ?, ?, 'completed', ?, ?)`,
[productType, designDataJson, amount,
result.transactionId, result.approvalNo]
);
setOrder({ orderId: dbResult.lastInsertRowid, /* ... */ });
setTimeout(() => navigate('/printing'), 1500);
} else {
setPhase('failed');
setErrorMsg(result.error || '결제에 실패했습니다.');
setRetryCount((c) => c + 1);
}
};
// 60초 안에 결과가 없으면 강제 실패 처리
useEffect(() => {
if (phase !== 'waiting' && phase !== 'processing') return;
const t = setTimeout(() => {
setPhase('failed');
setErrorMsg('결제 시간이 초과되었습니다.');
}, 60000);
return () => clearTimeout(t);
}, [phase]);
재시도는 3회로 제한했다(retryCount >= 3이면 처음 화면으로 돌려보낸다). 무인 키오스크에서 무한 재시도를 허용하면, 오류 상태에 빠진 단말 앞에서 사용자가 같은 버튼만 계속 누르는 상황이 생긴다. 한도를 두고 그 이상이면 "처음으로 돌아가 관리자 호출"로 유도하는 게 안전하다.
스텁 단계에서 이 타임아웃·재시도 로직을 다 만들어 둔 게 결정적이었다. 결제 흐름의 진짜 어려움은 "성공"이 아니라 "성공도 실패도 아닌 애매한 상태"를 다루는 데 있다. 실장비가 오기 전에 이 분기들을 화면 상태 머신(waiting / processing / success / failed)으로 전부 그려 두니, 나중에 실제 단말을 붙였을 때 새로 설계할 게 거의 없었다.
핵심: 장비 상태를 IPC 한 채널로 통합
장비가 네 종이라, 화면이 장비마다 따로 상태를 묻게 두면 코드가 금세 지저분해진다. 관리자 대시보드는 "프린터 연결됨? 커팅기 연결됨? 결제기 살아 있음?"을 한눈에 보고 싶어 한다. 그래서 메인 프로세스에 모든 장비 상태를 한 번에 모아 돌려주는 통합 핸들러를 하나 뒀다.


이 핸들러가 1편의 집계 엔드포인트와 같은 발상이다. 의존의 방향이 한쪽으로만 흐른다 — 통합 핸들러는 세 장비를 알지만, 세 장비는 서로를 모르고 화면도 장비를 모른다. 화면은 { printer, payment, cutter }라는 납작한 boolean 묶음만 받는다. 관리자 화면 코드가 이렇게 단순해진다.
export default function AdminHardware() {
const [status, setStatus] = useState({
printer: false, payment: false, cutter: false,
});
const api = useIpc();
const refresh = async () => setStatus(await api.getHardwareStatus());
useEffect(() => { refresh(); }, []);
const devices = [
{ key: 'printer', label: 'USB 프린터', ok: status.printer },
{ key: 'payment', label: '카드 결제기', ok: status.payment },
{ key: 'cutter', label: '커팅기', ok: status.cutter },
];
return (
<div>
<button onClick={refresh}>새로고침</button>
{devices.map((d) => (
<div key={d.key} style={{ borderColor: d.ok ? '#00b894' : '#e94560' }}>
<span>{d.label}</span>
<span>{d.ok ? '연결됨' : '연결 안됨'}</span>
</div>
))}
</div>
);
}
화면은 장비가 스텁인지 실장비인지, 프린터 SDK가 무엇인지, 커팅기가 어느 포트에 물렸는지 전혀 모른다. boolean 세 개만 그릴 뿐이다. 나중에 스텁을 실드라이버로 바꿔도 hardware:status 채널로 같은 모양의 객체가 흐르므로 이 화면 코드는 그대로 둔다. 장비가 한 종 더 늘어도 핸들러에 한 줄, 타입에 한 줄, 화면 배열에 한 줄을 더하면 끝이다.
설계 단계에서 한 가지 솔직하게 짚어 둘 부분이 있다. 지금은 화면이 getHardwareStatus()를 요청할 때만 상태를 받는 풀(pull) 방식이다. 메인이 일정 주기로 상태를 모아 렌더러로 밀어 주는 푸시(push) 방식으로 바꾸면, 장비가 빠지는 순간 대시보드가 즉시 빨갛게 변하게 만들 수 있다. push를 받을 채널(onPrinterStatus)은 이미 preload에 열어 뒀으니, 통합 상태도 같은 결로 확장하는 건 나중의 작은 작업으로 남겨 뒀다.
두 번째 스텁 — 같은 코드를 브라우저에서 데모하기
장비를 스텁으로 추상화해 둔 보상은 예상치 못한 곳에서 한 번 더 돌아왔다. 영업·시연용으로 이 키오스크를 브라우저에서 그대로 돌리고 싶다는 요구가 생겼다. 그런데 브라우저에는 Electron의 window.electronAPI가 아예 없다. 화면 코드는 전부 useIpc()로 그 API를 부르고 있으니, 그대로는 브라우저에서 첫 화면부터 깨진다.
해결책은 자연스러웠다. 이미 장비를 IPC 표면(ElectronAPI 타입) 뒤로 숨겨 뒀으니, 그 표면 전체를 브라우저용 가짜로 한 번 더 구현하면 된다. 메인 프로세스 없이 메모리 안에서 DB·설정·장비를 흉내 내는 목(mock) 객체다.
export const mockElectronAPI: ElectronAPI = {
platform: 'browser',
printImage: async (_imageDataUrl: string) => {
await new Promise((r) => setTimeout(r, 3000)); // 실드라이버와 같은 지연
return { success: true };
},
requestPayment: async (_amount: number) => {
await new Promise((r) => setTimeout(r, 3000));
return {
success: true,
transactionId: `TX${Date.now()}`,
approvalNo: `AP${Math.floor(Math.random() * 900000 + 100000)}`,
};
},
getHardwareStatus: async () => ({
printer: true, payment: true, cutter: false,
}),
// DB도 메모리 배열로 흉내 — SQL 문자열을 파싱해 분기
dbExecute: async (sql, params) => { /* parseSql(sql, params) */ },
// ...
};
이 목 객체가 메인 프로세스의 IPC 드라이버 + SQLite를 통째로 대신한다. 핵심은 타입(ElectronAPI)이 두 구현을 강제로 같은 모양으로 묶는다는 점이다. 진짜 preload 구현과 브라우저 목 구현이 같은 인터페이스를 만족해야 하므로, 한쪽에만 함수를 빠뜨리면 컴파일이 막힌다. 시그니처를 계약으로 고정해 둔 첫 결정이 여기서 세 번째 배당을 준 셈이다. 같은 화면 코드가 (1) 실장비 Electron, (2) 스텁 Electron, (3) 브라우저 목 — 세 환경에서 한 줄도 바뀌지 않고 돈다.
정리하며
- 장비가 책상 위에 없을 때 개발을 멈추지 않으려면, 하드웨어를 시뮬레이션 드라이버(스텁)로 먼저 구현하고 나중에 실장비 코드로 갈아 끼우는 길을 택해야 한다. 추상 인터페이스 계층 대신 장비별 함수 시그니처를 계약으로 고정한 게 이 프로젝트의 선택이었다.
- 스텁은 단순히 성공을 즉시 돌려주는 게 아니라, 실장비의 지연(3초)까지 흉내 내야 한다. 그래야 "느린 작업의 UX"(진행 화면, 타임아웃, 재시도)를 장비 없이도 진짜처럼 만들 수 있다.
- 스텁과 실장비를 가르는 경계를 IPC 핸들러 바로 아래에 그었다. 드라이버 내부만 SDK 호출로 바꾸면 IPC·preload·화면은 한 줄도 안 바뀐다. preload의 화이트리스트와 타입 계약이 화면이 닿는 표면을 한 장으로 통제한다.
- 장비가 여러 종이어도 화면이 복잡해지지 않게, 모든 장비 상태를 IPC 한 채널(
hardware:status)로 통합했다. 의존은 단방향 — 통합 핸들러는 장비를 알지만 장비도 화면도 서로를 모른다. - 장비를 API 표면 뒤로 숨겨 둔 덕에, 같은 코드를 브라우저 목 구현으로 한 번 더 돌릴 수 있었다. 타입이 실구현과 목 구현을 같은 모양으로 강제하므로, 한 코드베이스가 세 실행 환경에서 그대로 동작한다.
"인터페이스를 먼저 긋고 구현을 나중에 채운다"는 1편의 프로세스 경계와 똑같은 철학이다. 경계를 명확히 해 두면, 그 뒤의 변화(가짜→진짜, 장비 추가, 실행 환경 교체)가 전부 경계 안쪽 일이 된다.
다음 편
마지막 4편에서는 키오스크 모드(전체화면·창 닫기 차단·부팅 시 자동 시작)와, 방금 만든 브라우저 목 위에서 같은 코드베이스를 웹 데모로 배포한 방법(반응형 스케일링, 정적 호스팅)을 다룬다. 1편에서 그어 둔 경계가 여기서 마지막으로 한 번 더 빛난다.
'외주 개발일지' 카테고리의 다른 글
| 이력서·자기소개서 엔티티 설계와 파일 업로드 — 채용 플랫폼 (2) (0) | 2026.06.12 |
|---|---|
| 채용 플랫폼 백엔드 인증 구현 — 기본 인증 + SNS 소셜 로그인 (1) (0) | 2026.06.12 |
| Electron 키오스크 모드와 웹 데모 배포 — 현장 키오스크 개발기 (4) (0) | 2026.06.11 |
| Konva 캔버스 디자인 편집기 — USB 이미지 연동까지, 현장 키오스크 개발기 (2) (0) | 2026.06.11 |
| Electron 키오스크 아키텍처 — IPC 경계와 contextBridge로 하드웨어를 격리하기 (1) (0) | 2026.06.11 |