Frontend
엑셀 파일 하나로 버티던 과학관, Electron 키오스크로 바꾸기까지
전성진··수정됨 2026.07.21

들어가며
여러분이 과학관이나 박물관에 방문하면, 입구에서 발권기를 만나게 됩니다. 화면을 몇 번 터치하면 종이 한 장이 나오고, 그걸로 입장합니다. 단순해 보이죠.
그런데 그 뒤에서는 누군가가 "오늘 유아가 몇 명 왔지?", "이번 달 외국인 방문자 추이가 어떻게 되지?" 같은 질문에 답해야 합니다. 인천학생과학관에서는 그 답을 엑셀 파일에서 찾고 있었습니다.
저는 이 문제를 해결하기 위해 Electron 기반 키오스크 발권 시스템을 만들었습니다. 단순한 카운터 앱에서 시작해, 다중 키오스크 통합 관리 시스템으로 진화하기까지의 이야기를 공유합니다.
1. 문제: 숫자를 세는 일이 왜 이렇게 어려울까
과학관의 방문자 관리는 생각보다 복잡합니다.
방문자를 유아, 초등, 중등, 고등, 성인, 외국인 6개 카테고리로 분류해야 하고, 개인 방문인지 단체 방문인지 일일탐방인지도 구분해야 합니다. 이걸 매일, 매시간 기록하고, 월별·연별 통계를 뽑아야 합니다.
기존에는 이랬습니다.
1. 방문자가 입구에서 종이에 체크
2. 직원이 수기로 엑셀에 입력
3. 월말에 통계 정리
4. 실수 발견 → 다시 정리
수기 입력의 오류율, 실시간 현황 파악 불가, 통계 산출의 반복 노동. 이 세 가지가 핵심 문제였습니다.
2. 설계: 키오스크는 결국 "제약이 많은 웹"이다
키오스크 앱을 설계할 때 가장 먼저 고민한 것은 기술 스택이었습니다.
"그냥 웹으로 만들면 되지 않나?"라고 생각할 수 있습니다. 맞습니다. 그래서 React로 만들되, Electron으로 감쌌습니다.
┌─────────────────────────────────────────┐
│ Electron Shell │
│ │
│ ┌─────────────────────────────────┐ │
│ │ React + Vite (프론트엔드) │ │
│ │ Zustand (상태 관리) │ │
│ │ GSAP (애니메이션) │ │
│ └──────────┬──────────────────────┘ │
│ │ fetch() │
│ ┌──────────┴──────────────────────┐ │
│ │ Express + SQLite (백엔드) │ │
│ │ 20개+ REST API │ │
│ └─────────────────────────────────┘ │
└─────────────────────────────────────────┘
이 구조를 선택한 이유는 명확했습니다.
요구사항 해결 인터넷 없는 환경에서도 동작 Electron + SQLite로 완전한 오프라인 지원 키오스크 전용 모드 Electron의 kiosk 모드 + 단축키 잠금 빠른 개발과 유지보수 React + TypeScript의 생산성 별도 서버 인프라 불필요 Express를 앱 내부에 임베드
핵심은 서버를 앱 안에 넣은 것입니다. 별도의 서버 인프라를 구축하거나 유지할 필요 없이, Electron 앱이 실행되면 Express 서버가 함께 뜹니다. 데이터베이스도 SQLite 파일 하나로 끝납니다.
3. 키오스크 잠금: 예상하지 못한 전쟁
키오스크 개발에서 가장 과소평가하기 쉬운 것이 "사용자가 앱을 탈출하는 것을 막는 일" 입니다.
처음에는 Electron의 kiosk: true 옵션 하나면 될 줄 알았습니다.
// 이것만으로는 부족합니다
mainWindow = new BrowserWindow({
kiosk: true,
fullscreen: true
});
하지만 현실은 달랐습니다. Windows 환경에서 Alt+Tab, Alt+F4, Win 키, Ctrl+Esc 등을 누르면 앱 밖으로 나갈 수 있었습니다. 과학관에 설치된 키오스크에서 방문자가 바탕화면으로 나가는 상황을 상상해 보세요.
결국 17개 이상의 시스템 단축키를 하나하나 차단해야 했습니다.
const shortcutsToBlock = [
'Super', // Win 키
'Alt+Tab', // 창 전환
'Alt+F4', // 강제 종료
'Control+W', // 탭 닫기
'Control+Escape', // 시작 메뉴
'Alt+Escape', // 창 순환
'F11', // 전체화면 토글
// ... 그리고 더
];
shortcutsToBlock.forEach(shortcut => {
globalShortcut.register(shortcut, () => {
// 아무것도 하지 않음 — 이것이 핵심입니다
});
});
여기서 끝이 아닙니다. 창이 포커스를 잃는 경우에 대한 방어도 필요했습니다.
mainWindow.on('blur', () => {
// 포커스를 잃으면 즉시 되찾아옵니다
mainWindow.focus();
mainWindow.moveTop();
});
단순해 보이는 "전체화면 잠금"이 실제로는 OS 레벨의 전쟁이라는 것, 키오스크 개발을 해보지 않으면 알기 어려운 교훈이었습니다.
4. 숨겨진 관리자 모드: 3초의 비밀

키오스크에는 물리적 키보드가 없습니다. 그러면 관리자는 어떻게 설정을 바꾸거나 통계를 확인할까요?
화면 우하단 80×80px 영역을 3초간 롱프레스하면 비밀번호 입력 모달이 나타납니다.
const useLongPress = (callback: () => void, ms: number = 3000) => {
const timerRef = useRef<ReturnType<typeof setTimeout>>();
const start = useCallback(() => {
timerRef.current = setTimeout(callback, ms);
}, [callback, ms]);
const stop = useCallback(() => {
clearTimeout(timerRef.current);
}, []);
return {
onMouseDown: start,
onMouseUp: stop,
onMouseLeave: stop,
onTouchStart: start,
onTouchEnd: stop,
};
};
이 설계에는 의도가 있습니다.
일반 방문자가 실수로 누를 수 없는 위치 (우하단 모서리)
우연히 터치해도 진입할 수 없는 시간 (3초 유지)
비밀번호 이중 잠금 (기본값
1234, 변경 가능)마우스와 터치 이벤트 모두 지원 (개발 환경과 키오스크 환경 호환)
관리자 모드에 들어가면 대시보드, 방문자 목록, 일별·월별 통계, CSV 내보내기까지 6개 탭의 풀 관리 인터페이스를 사용할 수 있습니다.
5. 발권기가 2대가 되면 생기는 일
처음에는 발권기 한 대로 시작했습니다. 잘 동작했습니다.
그런데 과학관 입구에 발권기가 하나 더 생겼습니다.
이 순간 단순한 문제가 분산 시스템 문제로 바뀝니다. 두 대의 발권기가 각각 독립적으로 데이터를 쌓고 있으니, 관리자가 통계를 볼 때 두 대의 데이터를 합쳐서 보여줘야 합니다.
중앙 서버를 두지 않은 이유
일반적으로는 중앙 데이터베이스를 두고 두 키오스크가 모두 그곳에 기록하게 할 것입니다. 하지만 이 환경에는 제약이 있었습니다.
별도 서버 인프라를 관리할 인력이 없음
네트워크 장애 시에도 각 발권기가 독립적으로 동작해야 함
추가 비용 없이 기존 구조를 확장해야 함
그래서 선택한 방식은 프록시 기반 통합 조회입니다.
[발권기 A — 관리자 모드]
│
├── 로컬 SQLite 조회 ──► A의 데이터
│
└── HTTP 프록시 ──────► [발권기 B의 API] ──► B의 데이터
│
5초 타임아웃, 실패 시 무시
각 발권기는 자기 데이터만 책임지고, 관리자가 통계를 볼 때만 상대방 데이터를 가져와 합칩니다. 핵심 코드는 이렇습니다.
// 로컬과 원격 데이터를 동시에 가져옵니다
const [localStats, remoteStats] = await Promise.all([
fetchApi<TodayStats>('/api/stats/today'),
fetchApi<TodayStats>('/api/remote/stats/today')
.catch(() => null) // 원격 실패 시 null — 로컬만 표시
]);
// 같은 날짜의 데이터를 합산합니다
function mergeDailyStats(local: DailyStats[], remote: DailyStats[]) {
const merged = new Map<string, DailyStats>();
for (const stat of [...local, ...remote]) {
const existing = merged.get(stat.date);
if (existing) {
existing.total_visitors += stat.total_visitors;
existing.total_infant += stat.total_infant;
existing.total_elementary += stat.total_elementary;
// ... 모든 카테고리 합산
} else {
merged.set(stat.date, { ...stat });
}
}
return Array.from(merged.values());
}
이 방식의 장점은 각 발권기의 독립성이 완전히 보장된다는 것입니다. 네트워크가 끊겨도, 상대 발권기가 꺼져 있어도, 자기 발권기는 정상 동작합니다. 통계 조회 시에만 "있으면 합치고, 없으면 내 것만 보여주는" graceful degradation을 구현했습니다.
6. 소소하지만 중요했던 디테일들
타임존: 자정이 바뀌는 순간
방문자 통계에서 "오늘"이 언제 시작되는지는 생각보다 중요합니다.
서버의 시스템 시간이 UTC라면, 한국 시간 기준 오전 9시에 등록된 방문자가 "어제" 방문자로 잡힐 수 있습니다. SQLite의 datetime('now') 도 UTC 기준입니다.
// SQLite 기본값 — UTC+9를 명시적으로 적용
created_at DATETIME DEFAULT (datetime('now', '+9 hours'))
// 클라이언트에서도 KST 기준으로 날짜를 계산합니다
export function getKSTDateString(): string {
return new Date().toLocaleString('en-CA', {
timeZone: 'Asia/Seoul',
year: 'numeric',
month: '2-digit',
day: '2-digit'
});
}
단순해 보이지만, 이 처리를 빠뜨리면 자정 전후로 통계가 하루씩 밀리는 유령 같은 버그가 생깁니다.
페이지 전환: 터치 한 번의 무게
키오스크 UI에서 페이지가 바뀔 때 아무런 전환 효과가 없으면, 사용자는 "눌렸나?"하고 다시 누릅니다. 그래서 GSAP을 이용한 5-스트립 와이프 전환을 구현했습니다.
// 화면을 5개 수직 스트립으로 나누고, 순차적으로 와이프합니다
strips.forEach((strip, i) => {
tl.to(strip, {
scaleX: 1,
duration: 0.3,
ease: 'power2.inOut',
}, i * 0.06); // 60ms 간격으로 순차 실행
});
동시에 prefers-reduced-motion 미디어 쿼리를 존중합니다. 모션 감소를 선호하는 사용자에게는 전환 효과를 건너뜁니다. 작은 디테일이지만, 접근성은 선택이 아닙니다.
DB 마이그레이션: 프레임워크 없이 살아남기
ORM이나 마이그레이션 도구를 쓰지 않는 환경에서, 스키마 변경은 이렇게 처리했습니다.
// "이미 있으면 무시" 패턴
try {
db.exec(`ALTER TABLE visitors ADD COLUMN visit_type TEXT DEFAULT 'individual'`);
} catch (e) {
// SQLITE_ERROR: duplicate column name — 이미 마이그레이션 됨
}
우아하지는 않습니다. 하지만 Electron 앱이 업데이트될 때 기존 DB를 깨뜨리지 않으면서 스키마를 확장할 수 있는 실용적인 방법이었습니다. 완벽한 마이그레이션 시스템을 구축하는 것보다, 이 맥락에서는 이 정도의 단순함이 적절한 트레이드오프였습니다.
7. 레거시에서 배운 것
프로젝트 코드에는 지금은 사용하지 않는 레거시 컴포넌트들이 남아 있습니다.
초기 버전은 방문자를 성인/어린이 2개 카테고리로만 분류했습니다. MainMode.tsx, FeedbackMode.tsx, CounterCard.tsx — 이 컴포넌트들이 그 시절의 흔적입니다.
[v1] 성인 / 어린이 → 2개 버튼, 단순 카운터
↓
[v2] 유아 / 초등 / 중등 / 고등 / 성인 / 외국인 → 6개 카테고리, 키오스크 플로우 재설계
↓
[v3] 다중 키오스크 통합, 방문 유형 구분, 통계 고도화
이 진화 과정에서 깨달은 것은, "처음부터 완벽하게 설계하는 것"보다 "현재의 문제를 정확히 풀고, 다음 문제가 왔을 때 확장하는 것"이 현실적이라는 점입니다.
2개 카테고리로 시작한 것이 부끄러운 일이 아닙니다. 그때는 그것이 필요한 전부였으니까요. 6개 카테고리가 필요해졌을 때 확장했고, 발권기가 2대가 되었을 때 통합 조회를 만들었습니다. 각 단계에서 그때 필요한 만큼만 복잡해졌습니다.
8. 최종 아키텍처
세 번의 진화를 거친 현재 시스템의 전체 그림은 이렇습니다.
┌──── 발권기 A (Electron Kiosk) ──────────────────────────────┐
│ │
│ [방문자 터치] [관리자 3초 롱프레스] │
│ │ │ │
│ ▼ ▼ │
│ ┌──────────┐ ┌──────────────┐ │
│ │ 대기 화면 │───터치──►│ 인원 입력 │ │ 비밀번호 입력 │ │
│ │ (Idle) │ │ (Input) │ │ 숫자 키패드 │ │
│ └──────────┘ └────┬─────┘ └──────┬───────┘ │
│ │ │ │
│ 제출 (POST) 인증 성공 │
│ │ │ │
│ ▼ ▼ │
│ ┌──────────────┐ ┌─────────────────┐ │
│ │ 완료 화면 │ │ 관리자 모드 │ │
│ │ (3초 후 복귀) │ │ 대시보드 / 통계 │ │
│ └──────────────┘ │ 목록 / CSV 내보내기│ │
│ └────────┬────────┘ │
│ ┌────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ Express :3000 ─── SQLite ─── 프록시 ──► 발권기 B (원격) │
│ Express :3000 │
│ SQLite │
└──────────────────────────────────────────────────────────────┘
구성 요소 기술 역할 프론트엔드 React 18 + TypeScript 키오스크 UI + 관리자 UI 상태 관리 Zustand (2 stores) 앱 상태 + 키오스크 플로우 애니메이션 GSAP 페이지 전환 (5-스트립 와이프) 백엔드 Express 5 REST API 20개+ 데이터베이스 better-sqlite3 방문자 기록, 설정 데스크톱 Electron 28 키오스크 잠금, 시스템 통합 빌드 Vite 6 + electron-builder 크로스 플랫폼 패키징
마치며
이 프로젝트를 한 문장으로 요약하면 이렇습니다.
"엑셀로 방문자 수를 세던 과학관에, 터치 한 번으로 끝나는 시스템을 만들었다."
거창한 기술이 아닙니다. 대규모 트래픽을 처리하는 것도 아니고, 머신러닝 모델이 들어간 것도 아닙니다. 하지만 현장의 실제 문제를 기술로 풀었다는 점에서, 저에게는 의미 있는 프로젝트였습니다.
키오스크 잠금에서 배운 OS 레벨의 싸움, 다중 기기 통합에서 경험한 분산 시스템의 축소판, 그리고 레거시 코드가 보여주는 점진적 진화의 가치 — 작은 프로젝트 안에서도 꽤 많은 것을 배울 수 있었습니다.
혹시 비슷한 오프라인 키오스크 시스템을 만들 계획이 있다면, 이 글에서 공유한 시행착오들이 조금이나마 도움이 되길 바랍니다.
인천학생과학관 방문자 발권 시스템은 React, Electron, Express, SQLite로 구축되었으며, 현재 과학관 입구에서 실제 운영 중입니다.
읽기 도구
약 16분 읽기
이 글이 도움이 되었나요?