feat: 실시간 대기 번호표 웹앱 초기 커밋

PIN 기반 세션으로 관리자·발급기·표시판 3개 역할을 하나의 앱에서 운영.
Socket.IO 실시간 동기화, Podman Quadlet(10.89.0.25) 배포 구성 포함.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
gm
2026-06-26 10:56:15 +09:00
commit e45383f19f
8 changed files with 1832 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
node_modules/

12
Containerfile Normal file
View File

@@ -0,0 +1,12 @@
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY server.js ./
COPY public/ ./public/
EXPOSE 3000
CMD ["node", "server.js"]

168
README.md Normal file
View File

@@ -0,0 +1,168 @@
# 대기 번호표 시스템
실시간 대기 번호표 발급·관리·표시 웹앱입니다.
하나의 URL로 세 가지 역할(관리자 / 발급기 / 표시판)을 모두 운영합니다.
---
## 구조
```
관리자 디바이스 → 번호 호출 / 초기화
발급기 디바이스 → 고객이 번호표 수령
표시판 디바이스 → 호출 번호 대형 화면 표시
```
모든 디바이스는 **PIN**으로 같은 세션에 묶입니다.
같은 PIN이면 관리자 2대 + 표시판 2대처럼 여러 대를 동시에 운영할 수 있습니다.
---
## 시작하기
### 1. 첫 접속 (PIN 생성)
앱 URL에 접속하면 PIN 입력 화면이 나옵니다.
- 원하는 PIN을 자유롭게 입력합니다 (숫자 권장, 최소 4자리).
- 처음 입력하는 PIN은 자동으로 새 세션을 생성합니다.
- **접속** 버튼을 누르거나 Enter를 치면 역할 선택 화면으로 이동합니다.
> 같은 PIN을 다른 디바이스에서 입력하면 동일한 세션에 합류합니다.
---
### 2. 역할 선택
| 역할 | 설명 |
|------|------|
| ⚙️ **관리자** | 번호 호출·초기화를 담당하는 직원용 화면 |
| 🎟️ **번호표 발급기** | 고객이 직접 번호를 뽑는 키오스크 화면 |
| 📺 **번호 표시판** | 대기실에 띄워두는 대형 번호 화면 |
---
## 화면별 사용법
### ⚙️ 관리자 화면
![관리자 화면]
| 항목 | 설명 |
|------|------|
| 현재 호출 번호 | 지금 표시판에 표시 중인 번호 |
| 대기 중 | 아직 호출되지 않은 인원 수 |
| 발급 총계 | 지금까지 발급된 번호표 총 수 |
**다음 호출 ▶** 버튼을 누르면
- 다음 번호가 호출됩니다.
- 표시판 화면이 즉시 업데이트됩니다.
- 대기 인원이 1 감소합니다.
- 대기자가 없으면 버튼이 비활성화됩니다.
**번호 초기화** 버튼을 누르면
- 확인 팝업이 뜨고 확인 시 모든 번호가 0으로 초기화됩니다.
- 발급기·표시판도 동시에 초기화됩니다.
**역할 변경** 버튼으로 역할 선택 화면으로 돌아갈 수 있습니다.
---
### 🎟️ 번호표 발급기 화면
고객이 직접 터치하는 키오스크 화면입니다.
- 화면 상단에 **현재 대기 인원**과 **현재 호출 번호**가 실시간으로 표시됩니다.
- **번호표 뽑기** 버튼을 누르면 번호가 발급되고 초록색 박스로 표시됩니다.
- 발급 후에는 **새 번호표 뽑기** 버튼으로 다음 고객의 번호를 발급할 수 있습니다.
- 관리자가 번호를 호출하면 "앞에 N명 대기 중" 문구가 자동으로 업데이트됩니다.
---
### 📺 번호 표시판 화면
대기실 모니터에 띄워두는 화면입니다.
- 호출된 번호가 전체 화면 크기로 크게 표시됩니다.
- 새 번호가 호출될 때마다 **노란색 플래시 애니메이션**이 재생됩니다.
- 아직 호출된 번호가 없으면 `-`로 표시됩니다.
- 우측 하단의 **역할 변경** 버튼으로 돌아갈 수 있습니다.
---
## 다중 디바이스 운영
같은 PIN으로 접속하면 역할에 관계없이 몇 대든 연결 가능합니다.
**예시: 2창구 운영**
```
관리자 A (창구 1) ─┐
관리자 B (창구 2) ─┤ PIN: 1234 ─── 표시판 A (대기실 TV 1)
발급기 ─┤ ── 표시판 B (대기실 TV 2)
```
- 관리자 A, B 중 누가 "다음 호출"을 눌러도 표시판 A, B 모두 즉시 업데이트됩니다.
- 두 관리자가 동시에 누르지 않도록 운영 상 협의가 필요합니다.
---
## 배포 구성
| 항목 | 값 |
|------|-----|
| 컨테이너 IP | `10.89.0.25` |
| 내부 포트 | `3000` |
| 이미지 | `localhost/queue-app:latest` |
| Quadlet | `/etc/containers/systemd/queue.container` |
| 네트워크 | `proxy.network` |
### 빌드
```bash
cd /root/queue-app
npm install # package-lock.json 생성
podman build -t queue-app:latest .
```
### 서비스 관리
```bash
# 시작
systemctl start queue.service
# 중지
systemctl stop queue.service
# 상태 확인
systemctl status queue.service
# 로그 확인
journalctl -u queue.service -f
```
### 업데이트 배포
```bash
cd /root/queue-app
podman build -t queue-app:latest .
systemctl restart queue.service
```
### NPM(Nginx Proxy Manager) 설정
Proxy Host 추가 시 **Websockets Support를 반드시 활성화**해야 합니다.
Socket.IO 실시간 통신이 WebSocket 업그레이드를 사용하기 때문입니다.
- Forward Hostname: `10.89.0.25`
- Forward Port: `3000`
- ☑ Websockets Support
---
## 기술 스택
- **런타임**: Node.js 20 (Alpine)
- **프레임워크**: Express 4
- **실시간 통신**: Socket.IO 4
- **상태 저장**: 서버 메모리 (재시작 시 초기화)
- **컨테이너**: Podman Quadlet (systemd 관리)

1087
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

12
package.json Normal file
View File

@@ -0,0 +1,12 @@
{
"name": "queue-app",
"version": "1.0.0",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "^4.18.2",
"socket.io": "^4.7.2"
}
}

456
public/index.html Normal file
View File

@@ -0,0 +1,456 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>대기 번호표</title>
<style>
:root {
--radius: 16px;
--shadow: 0 4px 24px rgba(0,0,0,.12);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Segoe UI', 'Noto Sans KR', Arial, sans-serif;
background: #f0f2f5;
min-height: 100dvh;
display: flex;
align-items: center;
justify-content: center;
}
/* ── SCREENS ── */
.screen {
display: none;
width: 100%;
min-height: 100dvh;
padding: 24px 16px;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 20px;
}
.screen.active { display: flex; }
/* ── CARD ── */
.card {
background: #fff;
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 32px 28px;
width: 100%;
max-width: 420px;
text-align: center;
}
.card h1 { font-size: 1.5rem; font-weight: 700; color: #1a1a2e; margin-bottom: 8px; }
.card p { color: #666; font-size: .95rem; margin-bottom: 20px; line-height: 1.5; }
/* ── INPUTS ── */
input[type="text"], input[type="password"] {
width: 100%;
padding: 14px 16px;
font-size: 1.1rem;
border: 2px solid #e0e0e0;
border-radius: 10px;
outline: none;
transition: border .2s;
text-align: center;
letter-spacing: 4px;
}
input:focus { border-color: #4f46e5; }
.error-msg { color: #e53e3e; font-size: .85rem; margin-top: 8px; min-height: 1.2em; }
/* ── BUTTONS ── */
.btn {
display: block;
width: 100%;
padding: 16px;
font-size: 1.05rem;
font-weight: 600;
border: none;
border-radius: 12px;
cursor: pointer;
transition: transform .1s, opacity .2s;
margin-top: 12px;
}
.btn:active { transform: scale(.97); }
.btn:disabled { opacity: .4; cursor: not-allowed; transform: none; }
.btn-primary { background: #4f46e5; color: #fff; }
.btn-success { background: #22c55e; color: #fff; }
.btn-danger { background: #ef4444; color: #fff; }
.btn-secondary { background: #f1f5f9; color: #333; }
/* ── ROLE CARDS ── */
.role-grid {
display: grid;
grid-template-columns: 1fr;
gap: 14px;
width: 100%;
max-width: 420px;
}
.role-card {
background: #fff;
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 24px 20px;
cursor: pointer;
display: flex;
align-items: center;
gap: 18px;
border: 2px solid transparent;
transition: border .15s, transform .1s;
}
.role-card:hover { border-color: #4f46e5; }
.role-card:active { transform: scale(.98); }
.role-icon { font-size: 2.4rem; flex-shrink: 0; }
.role-text h3 { font-size: 1.1rem; font-weight: 700; color: #1a1a2e; }
.role-text p { font-size: .85rem; color: #777; margin-top: 2px; }
/* ── STAT BADGE ── */
.stat-row {
display: flex;
gap: 12px;
justify-content: center;
flex-wrap: wrap;
margin-bottom: 4px;
}
.stat {
background: #f8fafc;
border-radius: 10px;
padding: 12px 18px;
text-align: center;
flex: 1;
min-width: 90px;
}
.stat-val { font-size: 1.8rem; font-weight: 800; color: #4f46e5; }
.stat-lbl { font-size: .75rem; color: #888; margin-top: 2px; }
/* ── ADMIN SCREEN ── */
#screen-admin { background: #eef2ff; }
#screen-admin .card h1 { color: #4f46e5; }
.admin-current {
font-size: 4rem;
font-weight: 900;
color: #1a1a2e;
margin: 12px 0;
line-height: 1;
}
/* ── ISSUE SCREEN ── */
#screen-issue { background: #f0fdf4; }
.my-ticket-box {
background: linear-gradient(135deg, #22c55e, #16a34a);
border-radius: var(--radius);
padding: 24px;
color: #fff;
text-align: center;
margin: 12px 0;
}
.my-ticket-box .tnum { font-size: 4.5rem; font-weight: 900; line-height: 1; }
.my-ticket-box .tlbl { font-size: .9rem; opacity: .85; margin-top: 4px; }
/* ── DISPLAY SCREEN ── */
#screen-display {
background: #0f172a;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 8px;
padding: 0;
}
.display-label {
color: #94a3b8;
font-size: 1.1rem;
letter-spacing: 4px;
text-transform: uppercase;
font-weight: 600;
}
.display-num {
color: #f8fafc;
font-size: clamp(6rem, 25vw, 16rem);
font-weight: 900;
line-height: 1;
font-variant-numeric: tabular-nums;
transition: color .2s;
}
.display-num.flash {
animation: flash-anim .6s ease-out;
}
@keyframes flash-anim {
0% { color: #facc15; transform: scale(1.05); }
60% { color: #facc15; }
100% { color: #f8fafc; transform: scale(1); }
}
.display-sub {
color: #475569;
font-size: 1rem;
margin-top: 4px;
}
.pin-badge {
position: fixed;
top: 16px; right: 16px;
background: rgba(255,255,255,.08);
color: #94a3b8;
font-size: .75rem;
padding: 4px 10px;
border-radius: 20px;
letter-spacing: 2px;
}
#screen-display .btn {
position: fixed;
bottom: 20px;
width: auto;
padding: 10px 20px;
font-size: .85rem;
background: rgba(255,255,255,.08);
color: #94a3b8;
border-radius: 20px;
}
/* ── HEADER PIN ── */
.session-header {
font-size: .8rem;
color: #94a3b8;
letter-spacing: 2px;
margin-bottom: -8px;
}
.divider { border: none; border-top: 1px solid #e5e7eb; margin: 8px 0; }
</style>
</head>
<body>
<!-- ══════════════════ PIN 입력 ══════════════════ -->
<div id="screen-pin" class="screen active">
<div class="card">
<div style="font-size:2.5rem;margin-bottom:12px">🎫</div>
<h1>대기 번호표</h1>
<p>세션 PIN을 입력하세요.<br>없으면 새 PIN을 만들어 접속하세요.</p>
<input type="password" id="pin-input" placeholder="PIN 입력 (4자리 이상)"
maxlength="12" inputmode="numeric" autocomplete="off">
<div class="error-msg" id="pin-error"></div>
<button class="btn btn-primary" id="pin-btn">접속</button>
</div>
</div>
<!-- ══════════════════ 역할 선택 ══════════════════ -->
<div id="screen-role" class="screen">
<div class="session-header" id="role-pin-label">PIN: ----</div>
<div class="role-grid">
<div class="role-card" id="role-admin">
<div class="role-icon">⚙️</div>
<div class="role-text">
<h3>관리자</h3>
<p>번호 호출 및 초기화 관리</p>
</div>
</div>
<div class="role-card" id="role-issue">
<div class="role-icon">🎟️</div>
<div class="role-text">
<h3>번호표 발급기</h3>
<p>대기 번호표 발급 키오스크</p>
</div>
</div>
<div class="role-card" id="role-display">
<div class="role-icon">📺</div>
<div class="role-text">
<h3>번호 표시판</h3>
<p>현재 호출 번호 대형 화면 표시</p>
</div>
</div>
</div>
</div>
<!-- ══════════════════ 관리자 ══════════════════ -->
<div id="screen-admin" class="screen">
<div class="card" style="max-width:460px">
<p class="session-header" id="admin-pin-label">PIN: ----</p>
<h1>⚙️ 관리자</h1>
<p>현재 호출 번호</p>
<div class="admin-current" id="admin-current">-</div>
<hr class="divider">
<div class="stat-row">
<div class="stat">
<div class="stat-val" id="admin-waiting">0</div>
<div class="stat-lbl">대기 중</div>
</div>
<div class="stat">
<div class="stat-val" id="admin-issued">0</div>
<div class="stat-lbl">발급 총계</div>
</div>
</div>
<button class="btn btn-primary" id="admin-next-btn">다음 호출 ▶</button>
<button class="btn btn-danger" id="admin-reset-btn" style="margin-top:8px">번호 초기화</button>
<button class="btn btn-secondary" id="admin-back-btn" style="margin-top:8px;font-size:.85rem">역할 변경</button>
</div>
</div>
<!-- ══════════════════ 발급기 ══════════════════ -->
<div id="screen-issue" class="screen">
<div class="card">
<p class="session-header" id="issue-pin-label">PIN: ----</p>
<h1>🎟️ 번호표 발급</h1>
<div class="stat-row" style="margin-bottom:8px">
<div class="stat">
<div class="stat-val" id="issue-waiting">0</div>
<div class="stat-lbl">현재 대기 인원</div>
</div>
<div class="stat">
<div class="stat-val" id="issue-called">-</div>
<div class="stat-lbl">현재 호출 번호</div>
</div>
</div>
<div id="issue-ticket-box" style="display:none" class="my-ticket-box">
<div class="tlbl">내 번호표</div>
<div class="tnum" id="issue-my-num">-</div>
<div class="tlbl" id="issue-my-wait"></div>
</div>
<button class="btn btn-success" id="issue-btn">번호표 뽑기</button>
<button class="btn btn-secondary" id="issue-back-btn" style="margin-top:8px;font-size:.85rem">역할 변경</button>
</div>
</div>
<!-- ══════════════════ 표시판 ══════════════════ -->
<div id="screen-display" class="screen">
<div class="pin-badge" id="display-pin-label">----</div>
<div class="display-label">현재 호출 번호</div>
<div class="display-num" id="display-num">-</div>
<div class="display-sub" id="display-sub">대기 중 0명</div>
<button class="btn" id="display-back-btn">역할 변경</button>
</div>
<script src="/socket.io/socket.io.js"></script>
<script>
const socket = io();
let currentPin = '';
let currentRole = '';
let myTicketNumber = null;
// ── 유틸 ──────────────────────────────────────
function showScreen(id) {
document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));
document.getElementById(id).classList.add('active');
}
function fmtNum(n) { return n > 0 ? String(n) : '-'; }
// ── PIN 화면 ──────────────────────────────────
const pinInput = document.getElementById('pin-input');
const pinError = document.getElementById('pin-error');
document.getElementById('pin-btn').addEventListener('click', tryJoin);
pinInput.addEventListener('keydown', e => { if (e.key === 'Enter') tryJoin(); });
function tryJoin() {
const v = pinInput.value.trim();
if (v.length < 4) { pinError.textContent = 'PIN은 4자리 이상이어야 합니다.'; return; }
pinError.textContent = '';
socket.emit('join', { pin: v });
}
socket.on('join_error', msg => { pinError.textContent = msg; });
socket.on('join_ok', state => {
currentPin = pinInput.value.trim();
document.getElementById('role-pin-label').textContent = `PIN: ${currentPin}`;
document.getElementById('admin-pin-label').textContent = `PIN: ${currentPin}`;
document.getElementById('issue-pin-label').textContent = `PIN: ${currentPin}`;
document.getElementById('display-pin-label').textContent = `PIN: ${currentPin}`;
applyState(state);
showScreen('screen-role');
});
// ── 역할 선택 ──────────────────────────────────
document.getElementById('role-admin').addEventListener('click', () => {
currentRole = 'admin'; showScreen('screen-admin');
});
document.getElementById('role-issue').addEventListener('click', () => {
currentRole = 'issue'; myTicketNumber = null;
document.getElementById('issue-ticket-box').style.display = 'none';
document.getElementById('issue-btn').disabled = false;
document.getElementById('issue-btn').textContent = '번호표 뽑기';
showScreen('screen-issue');
});
document.getElementById('role-display').addEventListener('click', () => {
currentRole = 'display'; showScreen('screen-display');
});
// 역할 변경
document.getElementById('admin-back-btn').addEventListener('click', () => showScreen('screen-role'));
document.getElementById('issue-back-btn').addEventListener('click', () => showScreen('screen-role'));
document.getElementById('display-back-btn').addEventListener('click', () => showScreen('screen-role'));
// ── 관리자 버튼 ──────────────────────────────────
document.getElementById('admin-next-btn').addEventListener('click', () => {
socket.emit('call_next');
});
document.getElementById('admin-reset-btn').addEventListener('click', () => {
if (confirm('번호를 초기화하시겠습니까?\n발급된 번호가 모두 0으로 돌아갑니다.')) {
socket.emit('reset');
}
});
// ── 발급 버튼 ──────────────────────────────────
document.getElementById('issue-btn').addEventListener('click', () => {
socket.emit('issue_ticket');
});
// ── 상태 갱신 ──────────────────────────────────
function applyState(s) {
// 관리자
document.getElementById('admin-current').textContent = fmtNum(s.calledCount);
document.getElementById('admin-waiting').textContent = s.waitingCount;
document.getElementById('admin-issued').textContent = s.issuedCount;
document.getElementById('admin-next-btn').disabled = s.waitingCount === 0;
// 발급기
document.getElementById('issue-waiting').textContent = s.waitingCount;
document.getElementById('issue-called').textContent = fmtNum(s.calledCount);
if (myTicketNumber !== null) {
const ahead = myTicketNumber - s.calledCount;
document.getElementById('issue-my-wait').textContent =
ahead > 0 ? `앞에 ${ahead}명 대기 중` : '곧 호출됩니다!';
}
// 표시판
const displayEl = document.getElementById('display-num');
const newVal = fmtNum(s.calledCount);
if (displayEl.textContent !== newVal) {
displayEl.classList.remove('flash');
void displayEl.offsetWidth; // reflow
displayEl.textContent = newVal;
if (s.calledCount > 0) displayEl.classList.add('flash');
}
document.getElementById('display-sub').textContent = `대기 중 ${s.waitingCount}`;
}
socket.on('state_update', applyState);
socket.on('ticket_issued', ({ ticketNumber, waitingCount }) => {
myTicketNumber = ticketNumber;
document.getElementById('issue-my-num').textContent = ticketNumber;
document.getElementById('issue-my-wait').textContent =
waitingCount > 0 ? `앞에 ${waitingCount - 1}명 대기 중` : '곧 호출됩니다!';
document.getElementById('issue-ticket-box').style.display = 'block';
document.getElementById('issue-btn').textContent = '새 번호표 뽑기';
});
socket.on('reset_done', () => {
myTicketNumber = null;
document.getElementById('issue-ticket-box').style.display = 'none';
document.getElementById('issue-btn').disabled = false;
document.getElementById('issue-btn').textContent = '번호표 뽑기';
});
// ── 재연결 시 상태 복구 ──────────────────────────
socket.on('connect', () => {
if (currentPin) socket.emit('join', { pin: currentPin });
});
</script>
</body>
</html>

25
quadlet/queue.container Normal file
View File

@@ -0,0 +1,25 @@
[Unit]
Description=Queue Ticket App
After=podman.service network-online.target proxy-network.service
Wants=network-online.target
Requires=podman.service proxy-network.service
[Container]
ContainerName=queue
Image=localhost/queue-app:latest
Network=proxy.network
IP=10.89.0.25
Environment=TZ=Asia/Seoul
Environment=NODE_ENV=production
Environment=PORT=3000
Volume=/etc/localtime:/etc/localtime:ro
[Service]
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target

71
server.js Normal file
View File

@@ -0,0 +1,71 @@
const express = require('express');
const http = require('http');
const { Server } = require('socket.io');
const path = require('path');
const app = express();
const server = http.createServer(app);
const io = new Server(server, {
pingTimeout: 60000,
pingInterval: 25000,
});
app.use(express.static(path.join(__dirname, 'public')));
// pin -> { issuedCount, calledCount }
const sessions = {};
function sessionState(pin) {
const s = sessions[pin] || { issuedCount: 0, calledCount: 0 };
return {
issuedCount: s.issuedCount,
calledCount: s.calledCount,
waitingCount: s.issuedCount - s.calledCount,
};
}
io.on('connection', (socket) => {
let pin = null;
socket.on('join', ({ pin: p }) => {
if (!p || typeof p !== 'string' || p.length < 4) {
socket.emit('join_error', 'PIN은 최소 4자리 이상이어야 합니다.');
return;
}
pin = p.trim();
if (!sessions[pin]) {
sessions[pin] = { issuedCount: 0, calledCount: 0 };
}
socket.join(pin);
socket.emit('join_ok', sessionState(pin));
});
socket.on('issue_ticket', () => {
if (!pin || !sessions[pin]) return;
sessions[pin].issuedCount += 1;
const ticketNumber = sessions[pin].issuedCount;
const state = sessionState(pin);
socket.emit('ticket_issued', { ticketNumber, waitingCount: state.waitingCount });
io.to(pin).emit('state_update', state);
});
socket.on('call_next', () => {
if (!pin || !sessions[pin]) return;
const s = sessions[pin];
if (s.calledCount >= s.issuedCount) return;
s.calledCount += 1;
io.to(pin).emit('state_update', sessionState(pin));
});
socket.on('reset', () => {
if (!pin || !sessions[pin]) return;
sessions[pin] = { issuedCount: 0, calledCount: 0 };
io.to(pin).emit('state_update', sessionState(pin));
io.to(pin).emit('reset_done');
});
});
const PORT = process.env.PORT || 3000;
server.listen(PORT, '0.0.0.0', () => {
console.log(`Queue server running on port ${PORT}`);
});