행사·부스의 대기열을 관리하는 포트폴리오용 실시간 대기 시스템입니다.
참가자는 부스 대기열에 등록하고 자신의 대기 상태를 실시간에 가깝게 확인하며, 운영자는 대기열을 보고 호출·체크인·노쇼를 처리합니다.
🔗 라이브 데모: https://realtime-wait.fomula91.workers.dev — Cloudflare Workers + D1 단일 오리진 배포. 운영자 데모 키 demo-admin-key, 데모 행사 evt_demo.
realtime-wait는 행사 대기열을 관리하는 실시간 대기 시스템입니다.
초기에는 SSE/WebSocket 기반 구조를 고려했지만, 포트폴리오용 무료 데모에서는 장기 연결보다 요청량 예측 가능성이 더 중요하다고 판단했습니다.
따라서 Cloudflare Workers + D1 + polling 구조를 선택했습니다.
이 구조는 사용자 수와 polling 주기로 서버 부하를 계산할 수 있어 무료 한도 안에서 안정적으로 데모를 운영하기 쉽습니다.
실제 운영 환경에서는 SSE 서버와 Redis Pub/Sub 기반 구조로 확장할 수 있도록 ADR(아키텍처 결정 기록)에 확장 방향을 정리했습니다. (상세 설계·ADR 문서는 내부 설계 위키에서 관리합니다.)
| 결정 | 선택 | 이유 |
|---|---|---|
| 실시간 전송 | Polling (SSE/WS 대신) | 3~5초 지연을 감수하는 대신 사용자 수 × polling 주기로 요청량을 예측 가능. 무료 데모에 적합 |
| 인프라 | Cloudflare Workers (직접 서버 대신) | 무료 데모 유지가 쉽고 요청 수 기반 부하 계산 가능 |
| 저장소 | Cloudflare D1 | Workers 와 자연스럽게 통합, 무료 한도 내 데모 운영 |
| 구조 | pnpm 모노레포 | web/worker/shared/load-test 를 한 저장소에서 관리 |
상세 아키텍처와 ADR은 내부 설계 위키(JACOB-LLM-WIKI)에서 관리하며, 아래 설계 하이라이트에 핵심을 요약했습니다.
이 저장소의 코드는 **데모(현재 구현)**이고, 운영 규모로 갈 때의 방향은 설계로만 정리해 둔 상태입니다. 둘을 의도적으로 분리합니다.
| 영역 | 현재 구현 (이 저장소) | 운영 확장 설계 (문서·ADR) |
|---|---|---|
| 실시간 전송 | Polling (참가자 5s / 운영자 3s, 위상 지터 + ETag/304 무변화 재검증) | SSE + Redis Pub/Sub (ADR-0005) |
| 동시성 제어 | 조건부 UPDATE(CAS) + 409 정상신호 + 상태 전이 테스트 12종 | 다중 리전 시 트랜잭션 격리·낙관적 락 재점검 |
| 인프라 | Cloudflare Workers (서버리스) | 전용 realtime 서버(Go/NestJS) |
| 저장소 | Cloudflare D1 (SQLite) | Postgres/MySQL + Redis |
| 대기 인원 계산 | countAhead COUNT 쿼리 |
증분 카운터 캐싱 (O(N²)→O(1)) — 미구현, 확장 과제 |
| 권한 | 데모 키 + 범위 토큰 (3단 역할) | 계정+세션/JWT, QR은 일회성 코드 교환 |
"현재 구현"은 실제 동작·테스트되는 코드만, "운영 확장 설계"는 트래픽 근거가 생겼을 때의 방향입니다. 확장 항목의 손익분기 수치와 근거는 내부 설계 위키(ADR·scaling-strategy)에서 관리합니다.
이 프로젝트에서 보여주고 싶은 건 "큐 앱을 만들었다"가 아니라, 문제를 발견하고 트레이드오프를 숫자로 판단하며 일하는 방식입니다.
운영자 두 명이 같은 대기자를 동시에 호출하면 상태가 꼬일 수 있는 동시성 문제를 직접 코드에서 발견했습니다. 상태를 바꾸는 쿼리에 "지금 상태가 맞을 때만 바꾼다"는 조건이 빠져 있었던 것입니다. 그래서 상태 가드를 애플리케이션의 사전 검사에서 UPDATE의 조건(WHERE ... AND status IN (...))으로 내려 데이터베이스가 경합을 직렬화하도록 구현했고, 이미 처리된 요청에서 나오는 409 응답을 에러가 아니라 정상 신호로 재정의했습니다. 동시 호출·중복 처리(call/check-in/no-show/cancel) 시나리오를 테스트 12종으로 고정해, 한쪽만 성공하고 나머지는 409가 나는 동작을 회귀로부터 보호합니다.
실시간 전송에 WebSocket 대신 Polling을 택한 핵심 이유는 *"비용을 미리 계산할 수 있다"*는 점입니다. 이 주장을 말이 아니라 데이터로 방어했습니다.
- 성능: 이론값
사용자 120명 × (1/5초) ≈ 24 req/s가 k6 실측 24~26 req/s와 일치 — 부하가 예측대로 움직임을 확인. - 비용: 실제 배포 시 비용을 모델링한 결과, 행사가 없는 날은 비용 0(서버리스). 전용 실시간 서버(SSE)는 트래픽이 없어도 고정비가 들기 때문에, 상시 대규모 트래픽(월 약 2만 명·시간 이상)이 되어야 SSE 전환이 비용상 유리해집니다. → "지금은 폴링, 트래픽이 커지면 SSE"라는 판단을 손익분기 숫자로 뒷받침.
폴링은 본질적으로 갱신이 느리고 가끔 실패합니다. 프론트엔드가 매번 화면을 깜빡이거나 일시적 실패를 그대로 보여주면 "끊긴 앱"처럼 보입니다. 그래서 데이터를 첫 로딩 / 갱신 중 / 일시 실패 세 상태로 분리해, 첫 화면만 로딩을 보이고 이후에는 조용히 갱신하며 일시적 네트워크 실패에도 마지막 정상 화면을 유지하도록 했습니다. 백엔드의 기술 선택을 사용자 경험에서 완성한 것입니다.
또한 여러 클라이언트가 같은 순간에 겹쳐 폴링하는 동기화 버스트를 k6로 관측하고, 폴링 주기에 위상 지터를 넣어 완화했습니다 — paired 측정에서 status p95 74.5→16.2ms, 실패율 3.24%→0%. 다만 이 버스트는 부하 테스트가 가상 사용자를 동시 출발시킨 부분적 아티팩트라, 실사용자는 접속 시점이 자연 분산됨을 함께 명시했습니다. 측정 → 원인 → 수정 → 재측정 → 한계까지 정직하게 남기는 방식입니다.
같은 방식으로, 폴링 응답의 대부분이 "변화 없음"이라는 낭비를 HTTP 표준(ETag/If-None-Match → 304) 으로 줄였습니다 — paired 측정에서 수신 트래픽 −57%, 304 비율 83.3%(이론값 5/6과 일치). 커스텀 프로토콜 대신 표준 조건부 요청을 쓴 덕에 웹 클라이언트는 코드 한 줄 바꾸지 않고(브라우저 HTTP 캐시가 재검증을 자동 처리) 절감을 얻었습니다. 한계도 함께 남겼습니다: 304도 요청 1건이고 DB 조회는 여전히 실행되므로, 요청 수와 지연은 그대로 — 절감은 본문 전송에 국한됩니다.
기능 구현에 그치지 않고 회귀를 자동으로 막는 안전망을 계층적으로 깔았습니다.
- 테스트 피라미드: 백엔드 단위/라우트(권한·상태 전이·CAS) + 공유 계약(Zod) + 프론트 훅/컴포넌트(vitest) 를
pnpm verify하나로 묶어 96개 테스트가 한 번에 돕니다. 그 위에 E2E 1개(Playwright)가 등록 → 폴링 → 운영자 호출 → 체크인 을 실제 브라우저로 돌려, 폴링·상태 머신·동시성이 함께 동작함을 한 테스트로 증명합니다. - CI 자동 배포: GitHub Actions 가 push/PR 마다
verify를 돌리고, main 통과 시에만wrangler deploy까지 자동 — 검증 누락 배포를 사람 규율이 아니라 파이프라인이 막습니다. - 관측성: 서버 에러를 4xx/5xx 로그 수준으로 분리(409 같은 정상 신호는 잡음으로 남기지 않음)하고, 클라이언트 크래시·API 실패는
sendBeacon으로 수집해 데모의 운영 성숙도를 보였습니다.
이 프로젝트는 AI 에이전트(Claude Code 등)와 함께 진행했는데, 단순히 코드를 받아쓰는 방식이 아니라 에이전트가 신뢰성 있게 일하도록 운영 체계를 설계했습니다 — 설계 지식을 별도 지식 베이스에 정본화해 에이전트의 장기 기억으로 쓰고, 세션마다 그 맥락을 자동으로 불러오게 하고, 작업 이력을 자동 기록했습니다. 엔지니어링 판단(기술 선택·비용 분석·문제 발견)은 제가 소유하고, 에이전트는 실행과 문서화를 맡는 구조입니다.
- Monorepo: pnpm workspace
- Frontend: React + Vite + TypeScript
- Backend: Cloudflare Workers (Hono)
- Database: Cloudflare D1 (SQLite)
- Validation: Zod (shared 패키지)
- Realtime(데모): Polling (참가자 5초 / 운영자 3초, 위상 지터 + ETag/304 조건부 응답)
- Test: vitest + @testing-library/react + jsdom (단위/훅/컴포넌트), Playwright (E2E)
- Load Test: k6
- CI/CD: GitHub Actions (verify → main push 시 wrangler 자동 배포)
- Observability: Workers Logs 구조화 로깅 + 클라이언트 에러 비콘
realtime-wait/
apps/
web/ React + Vite 프론트엔드 (참가자/운영자 화면) · test/ vitest
worker/ Cloudflare Workers API (Hono + D1) · test/ vitest
packages/
shared/ 공유 타입 / 상수 / Zod 스키마 · test/ vitest
db/ D1 schema.sql / seed.sql
load-test/
k6/ k6 부하 테스트 스크립트
e2e/ Playwright E2E (핵심 흐름 1개)
.github/
workflows/ CI (verify + main push 자동 배포)
사전 요구사항: Node.js 22+ (테스트 셰임 D1 이 node:sqlite 를 사용), pnpm 9+ (corepack 권장)
# 1) 의존성 설치
pnpm install
# 2) 로컬 D1 초기화 + seed
pnpm db:init
pnpm db:seed
# (한 번에) pnpm --filter @realtime-wait/worker db:reset
# 3) worker 실행 (http://localhost:8787)
pnpm dev:worker
# 4) 새 터미널에서 web 실행 (http://localhost:5173, /api 는 worker 로 프록시됨)
pnpm dev:web브라우저에서 http://localhost:5173 접속:
- 참가자: 홈 → 행사 ID(
evt_demo) → 부스 선택 → 대기 등록 → 상태 화면(5초 자동 갱신) - 운영자: 상단 "관리자" → 데모 키
demo-admin-key입력 → 이벤트/부스/대기열 관리(3초 자동 갱신)
# 전체 검증: 환경 점검 → frozen install → typecheck → 단위·라우트·프론트·shared 테스트 (96개)
pnpm verify
# E2E (Playwright): worker+web dev 서버 자동 기동 + 로컬 D1 시드. 최초 1회 브라우저 설치
npx playwright install chromium
pnpm e2e
# 배포: web 빌드 → wrangler deploy
pnpm deploy- CI:
.github/workflows/ci.yml가 push/PR 마다verify를, main push 는 verify 통과 시wrangler deploy를 자동 실행합니다(배포 job 은CLOUDFLARE_API_TOKENSecret 필요). - 분리 실행: E2E(Playwright)와 부하 테스트(k6)는 실행 중인 서버가 필요해
verify와 분리합니다.
polling 구조의 핵심 장점은 부하를 단순 계산할 수 있다는 점입니다.
참가자 100명, 5초 polling = 초당 약 20 requests
참가자 500명, 5초 polling = 초당 약 100 requests
10분 데모, 500명 = 500 × 120 ≈ 60,000 requests
k6 로 이 계산을 실제로 검증합니다. (부하 테스트 계획·결과는 내부 설계 위키에서 관리합니다.)
# 예) 참가자 폴링 부하 (동기화)
k6 run load-test/k6/participant-polling.js
# 예) 폴링 위상 지터 on (동기화 버스트 완화 before/after 비교용)
k6 run -e JITTER_S=2.5 load-test/k6/participant-polling.js
# 예) ETag/304 조건부 폴링 (무변화 본문 절감 before/after 비교용)
k6 run -e JITTER_S=2.5 -e ETAG=1 load-test/k6/participant-polling.js
# 예) 혼합 시나리오 (참가자 폴링 + 운영자 호출)
k6 run load-test/k6/mixed-scenario.js성공:
{ "ok": true, "data": { } }실패:
{ "ok": false, "error": { "code": "QUEUE_ENTRY_NOT_FOUND", "message": "Queue entry not found" } }엔드포인트 목록은 내부 설계 위키(JACOB-LLM-WIKI)에서 관리합니다.
포함: 이벤트/부스 생성, 대기 등록, 상태 조회, 운영자 대기열 조회, 호출/체크인/노쇼, polling, k6 부하 테스트.
제외(의도적): SMS/카카오/이메일 알림, 결제, WebSocket/SSE, 다국어, 실제 개인정보 수집.
알림 기능은 과거 SMS 오발송 리스크가 있어 MVP에서 제외했습니다. 참가자 이름은 표시용이며 실제 개인정보를 수집하지 않습니다.