대기열 시스템 학습 프로젝트.
티켓 예매나 한정 판매에서 쓰는 줄서기 구조를 직접 만들어봄.
프로젝트.
0028-queue-system
기술 스택.
FastAPI
Redis
WebSocket
Docker Compose
Caddy
구조.
0028-queue-system/
├── backend/
│ ├── app/
│ │ ├── main.py
│ │ ├── background_tasks.py
│ │ ├── models.py
│ │ ├── queue_manager.py
│ │ ├── redis_client.py
│ │ └── websocket_manager.py
│ ├── requirements.txt
│ └── Dockerfile
├── frontend/
│ ├── index.html
│ ├── app.js
│ ├── style.css
│ ├── admin.html
│ ├── admin.js
│ └── admin.css
├── docker-compose.yml
├── deploy.sh
├── test_api.sh
└── test_admin_api.sh
Redis 데이터 구조.
ZADD queue:waiting {user_id: timestamp}
SADD queue:active {user_id}
HSET queue:meta:{user_id} {field: value}
SET queue:heartbeat:{user_id} {timestamp}
순번 조회.
def _get_position(self, user_id: str) -> int:
"""대기 순번 조회 (1부터 시작)"""
rank = self.redis.zrank(self.WAITING_QUEUE, user_id)
return (rank + 1) if rank is not None else 0
WebSocket 연결 관리.
class ConnectionManager:
def __init__(self):
self.active_connections: Dict[str, WebSocket] = {}
async def connect(self, user_id: str, websocket: WebSocket):
await websocket.accept()
self.active_connections[user_id] = websocket
async def send_personal_message(self, user_id: str, message: dict):
if user_id in self.active_connections:
await self.active_connections[user_id].send_json(message)
메시지 예시.
{
"type": "update",
"position": 42,
"ahead": 41,
"estimated_wait_seconds": 120
}
{
"type": "admitted",
"message": "입장이 허용되었습니다!"
}
예상 대기 시간.
def _estimate_wait_time(self, position: int) -> int:
"""예상 대기 시간 계산 (초)
슬라이딩 윈도우: 최근 10분간 실제 세션 시간의 평균 사용
"""
if position <= 0:
return 0
ten_minutes_ago = time.time() - 600
recent_sessions = self.redis.zrangebyscore(
self.SESSION_TIMES,
ten_minutes_ago,
'+inf',
withscores=True
)
if recent_sessions:
durations = [float(d) for d, _ in recent_sessions]
avg_session_time = sum(durations) / len(durations)
else:
avg_session_time = 60
return int(position * avg_session_time)
백그라운드 정리.
@repeat_every(seconds=5)
async def cleanup_task():
result = queue_manager.cleanup_timed_out_users()
if result["waiting_removed"] > 0:
logger.info(f"대기열에서 {result['waiting_removed']}명 제거")
if result["active_removed"] > 0:
logger.info(f"활성에서 {result['active_removed']}명 제거")
if result["promoted"] > 0:
await notify_admitted_users()
Input Validation.
class QueueJoinRequest(BaseModel):
user_id: str = Field(
...,
min_length=1,
max_length=100,
pattern="^[a-zA-Z0-9_-]+$"
)
metadata: Optional[Dict] = None
CORS.
app.add_middleware(
CORSMiddleware,
allow_origins=["https://queue.example.com"],
allow_methods=["GET", "POST", "DELETE"],
allow_headers=["*"],
)
Rate Limit.
limiter = Limiter(key_func=get_remote_address)
@app.post("/api/queue/join")
@limiter.limit("20/minute")
async def join_queue(...):
...
관리 API 보호.
async def verify_admin(
x_api_key: str = Header(...),
x_forwarded_for: Optional[str] = Header(None)
):
real_ip = x_forwarded_for.split(",")[0] if x_forwarded_for else "unknown"
if real_ip not in ADMIN_ALLOWED_IPS:
raise HTTPException(status_code=403, detail="Forbidden")
if x_api_key != ADMIN_API_KEY:
raise HTTPException(status_code=401, detail="Invalid API key")
return True
Docker Compose.
services:
redis:
image: redis:7-alpine
networks:
- queue-network
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
backend:
build:
context: .
dockerfile: backend/Dockerfile
networks:
- queue-network
- waf-internal
environment:
- REDIS_HOST=redis
- MAX_ACTIVE_USERS=10
- QUEUE_TIMEOUT_SECONDS=300
depends_on:
redis:
condition: service_healthy
Caddy.
queue.example.com {
reverse_proxy queue-backend:8000
header /* {
Strict-Transport-Security "max-age=31536000"
X-Frame-Options "DENY"
X-Content-Type-Options "nosniff"
}
}
배포.
docker-compose up -d
로그.
docker-compose logs -f backend
헬스 체크.
curl http://localhost:8000/
데모 페이지.
http://localhost:8000/demo
관리 페이지.
http://localhost:8000/admin
API 테스트.
curl -X POST http://localhost:8000/api/queue/join \
-H "Content-Type: application/json" \
-d '{"user_id": "user-001", "metadata": {"name": "Alice"}}'
curl http://localhost:8000/api/queue/status/user-001
curl -X DELETE http://localhost:8000/api/queue/leave/user-001
관리 API.
curl http://localhost:8000/api/admin/statistics
curl -X POST http://localhost:8000/api/admin/cleanup
curl -X POST http://localhost:8000/api/admin/reset
트러블슈팅.
정적 파일 404.
Before.
app.mount("/static", StaticFiles(directory="frontend"))
HTML.
<link href="style.css">
After.
app.mount("/static", StaticFiles(directory="frontend"))
HTML.
<link href="/static/style.css">
Docker build context 문제.
Before.
build:
context: ./backend
After.
build:
context: .
dockerfile: backend/Dockerfile
Caddy 프록시 IP 문제.
관리 API 403.
X-Forwarded-For로 실제 IP 확인.
real_ip = x_forwarded_for.split(",")[0] if x_forwarded_for else request.client.host
커밋 기록.
2026-03-01 10:01
정적 파일 경로 수정
2026-03-01 10:11
예상 대기 시간을 슬라이딩 윈도우 방식으로 개선
2026-03-01 10:20
Input Validation, CORS, Rate Limiting, 관리 API 보호
2026-03-01 10:29
MAX_ACTIVE_USERS 100 -> 10
2026-03-01 10:31
네트워크 설정 수정
1일짜리 학습 프로젝트.