Skip to content

fomula91/realtime-wait

Repository files navigation

realtime-wait

행사·부스의 대기열을 관리하는 포트폴리오용 실시간 대기 시스템입니다.

참가자는 부스 대기열에 등록하고 자신의 대기 상태를 실시간에 가깝게 확인하며, 운영자는 대기열을 보고 호출·체크인·노쇼를 처리합니다.

🔗 라이브 데모: 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)에서 관리하며, 아래 설계 하이라이트에 핵심을 요약했습니다.

현재 구현 vs 운영 확장 설계

이 저장소의 코드는 **데모(현재 구현)**이고, 운영 규모로 갈 때의 방향은 설계로만 정리해 둔 상태입니다. 둘을 의도적으로 분리합니다.

영역 현재 구현 (이 저장소) 운영 확장 설계 (문서·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)에서 관리합니다.


설계 하이라이트

이 프로젝트에서 보여주고 싶은 건 "큐 앱을 만들었다"가 아니라, 문제를 발견하고 트레이드오프를 숫자로 판단하며 일하는 방식입니다.

1. 문제를 코드에서 찾아 설계로 막았습니다

운영자 두 명이 같은 대기자를 동시에 호출하면 상태가 꼬일 수 있는 동시성 문제를 직접 코드에서 발견했습니다. 상태를 바꾸는 쿼리에 "지금 상태가 맞을 때만 바꾼다"는 조건이 빠져 있었던 것입니다. 그래서 상태 가드를 애플리케이션의 사전 검사에서 UPDATE의 조건(WHERE ... AND status IN (...))으로 내려 데이터베이스가 경합을 직렬화하도록 구현했고, 이미 처리된 요청에서 나오는 409 응답을 에러가 아니라 정상 신호로 재정의했습니다. 동시 호출·중복 처리(call/check-in/no-show/cancel) 시나리오를 테스트 12종으로 고정해, 한쪽만 성공하고 나머지는 409가 나는 동작을 회귀로부터 보호합니다.

2. "왜 폴링인가"를 성능과 비용 숫자로 증명했습니다

실시간 전송에 WebSocket 대신 Polling을 택한 핵심 이유는 *"비용을 미리 계산할 수 있다"*는 점입니다. 이 주장을 말이 아니라 데이터로 방어했습니다.

  • 성능: 이론값 사용자 120명 × (1/5초) ≈ 24 req/s가 k6 실측 24~26 req/s와 일치 — 부하가 예측대로 움직임을 확인.
  • 비용: 실제 배포 시 비용을 모델링한 결과, 행사가 없는 날은 비용 0(서버리스). 전용 실시간 서버(SSE)는 트래픽이 없어도 고정비가 들기 때문에, 상시 대규모 트래픽(월 약 2만 명·시간 이상)이 되어야 SSE 전환이 비용상 유리해집니다. → "지금은 폴링, 트래픽이 커지면 SSE"라는 판단을 손익분기 숫자로 뒷받침.

3. 폴링의 약점을 사용자가 못 느끼게 만들었습니다

폴링은 본질적으로 갱신이 느리고 가끔 실패합니다. 프론트엔드가 매번 화면을 깜빡이거나 일시적 실패를 그대로 보여주면 "끊긴 앱"처럼 보입니다. 그래서 데이터를 첫 로딩 / 갱신 중 / 일시 실패 세 상태로 분리해, 첫 화면만 로딩을 보이고 이후에는 조용히 갱신하며 일시적 네트워크 실패에도 마지막 정상 화면을 유지하도록 했습니다. 백엔드의 기술 선택을 사용자 경험에서 완성한 것입니다.

또한 여러 클라이언트가 같은 순간에 겹쳐 폴링하는 동기화 버스트를 k6로 관측하고, 폴링 주기에 위상 지터를 넣어 완화했습니다 — paired 측정에서 status p95 74.5→16.2ms, 실패율 3.24%→0%. 다만 이 버스트는 부하 테스트가 가상 사용자를 동시 출발시킨 부분적 아티팩트라, 실사용자는 접속 시점이 자연 분산됨을 함께 명시했습니다. 측정 → 원인 → 수정 → 재측정 → 한계까지 정직하게 남기는 방식입니다.

같은 방식으로, 폴링 응답의 대부분이 "변화 없음"이라는 낭비를 HTTP 표준(ETag/If-None-Match304) 으로 줄였습니다 — paired 측정에서 수신 트래픽 −57%, 304 비율 83.3%(이론값 5/6과 일치). 커스텀 프로토콜 대신 표준 조건부 요청을 쓴 덕에 웹 클라이언트는 코드 한 줄 바꾸지 않고(브라우저 HTTP 캐시가 재검증을 자동 처리) 절감을 얻었습니다. 한계도 함께 남겼습니다: 304도 요청 1건이고 DB 조회는 여전히 실행되므로, 요청 수와 지연은 그대로 — 절감은 본문 전송에 국한됩니다.

4. 품질을 테스트·CI·관측성으로 자동화했습니다

기능 구현에 그치지 않고 회귀를 자동으로 막는 안전망을 계층적으로 깔았습니다.

  • 테스트 피라미드: 백엔드 단위/라우트(권한·상태 전이·CAS) + 공유 계약(Zod) + 프론트 훅/컴포넌트(vitest) 를 pnpm verify 하나로 묶어 96개 테스트가 한 번에 돕니다. 그 위에 E2E 1개(Playwright)가 등록 → 폴링 → 운영자 호출 → 체크인 을 실제 브라우저로 돌려, 폴링·상태 머신·동시성이 함께 동작함을 한 테스트로 증명합니다.
  • CI 자동 배포: GitHub Actions 가 push/PR 마다 verify 를 돌리고, main 통과 시에만 wrangler deploy 까지 자동 — 검증 누락 배포를 사람 규율이 아니라 파이프라인이 막습니다.
  • 관측성: 서버 에러를 4xx/5xx 로그 수준으로 분리(409 같은 정상 신호는 잡음으로 남기지 않음)하고, 클라이언트 크래시·API 실패는 sendBeacon 으로 수집해 데모의 운영 성숙도를 보였습니다.

5. AI 에이전트를 "도구"가 아니라 "운영 체계"로 다뤘습니다

이 프로젝트는 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_TOKEN Secret 필요).
  • 분리 실행: 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

API 응답 형식

성공:

{ "ok": true, "data": { } }

실패:

{ "ok": false, "error": { "code": "QUEUE_ENTRY_NOT_FOUND", "message": "Queue entry not found" } }

엔드포인트 목록은 내부 설계 위키(JACOB-LLM-WIKI)에서 관리합니다.


MVP 범위

포함: 이벤트/부스 생성, 대기 등록, 상태 조회, 운영자 대기열 조회, 호출/체크인/노쇼, polling, k6 부하 테스트.

제외(의도적): SMS/카카오/이메일 알림, 결제, WebSocket/SSE, 다국어, 실제 개인정보 수집.

알림 기능은 과거 SMS 오발송 리스크가 있어 MVP에서 제외했습니다. 참가자 이름은 표시용이며 실제 개인정보를 수집하지 않습니다.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors