Stage 기반 큰 화면 흐름 + 모바일 QR 투표. 어드민에서 stage 컨트롤 + 주제 4 카테고리 × 10 편집. 기존 voter/admin/ceremony는 그대로. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
13 KiB
해커톤 진행 앱 — 전체 흐름 (intro → topics → vote → ceremony)
날짜: 2026-04-27
대상 코드베이스: /Users/user/projects/hackathon-vote
변경 종류: 기능 확장 (기존 투표 앱 → 행사 진행 앱)
배경
기존 앱은 투표·시상 전용. 이번 변경으로 해커톤 시작부터 끝까지 한 앱이 진행한다.
추가되는 단계:
- Stage 1 — 팀 편성 + 안내: 큰 화면에 7팀 + 해커톤 순서 + 시상 부문.
- Stage 2 — 주제 예시: 4 카테고리 × 10 주제 (영감용).
- Stage 3 — 투표 (모바일 진입): 큰 화면에 QR + 진행률. 모바일이 QR scan 시 기존 voter UI.
- 시상: 기존
/?mode=ceremony&token=...그대로.
스코프
포함
- 큰 화면 default
/라우트가current_stage따라 dispatch (intro / topics / vote). - 어드민 콘솔에 stage 컨트롤 + topics 편집 추가.
hackathon.json에topics+settings.current_stage키 추가.- QR 코드 생성 (
qrcode[pil]) + auto-refresh (streamlit-autorefresh). - 사용자 제공 4 카테고리 × 10 주제 시드.
제외
- ceremony 변경 (그대로).
- 모바일 voter UI 재디자인 (기존 그대로 사용).
- 인증/세션/권한 변경 (기존 token 그대로).
- 다국어 / 테마 변경.
라우트
| URL | 화면 | 권한 |
|---|---|---|
/ |
큰 화면 stage 진행 (current_stage dispatcher) |
open |
/?mode=vote |
모바일 투표 화면 (기존 voter UI) | open (단, stage gate 적용) |
/?mode=admin&token=… |
어드민 콘솔 (stage 컨트롤 + 주제 편집 + 기존) | token |
/?mode=ceremony&token=… |
시상 reveal (기존 그대로) | token |
/?mode=raw&token=… |
JSON 원본 조회/다운로드 (기존) | token |
기존 mode=admin/ceremony/raw는 동작 변경 없음.
Stage 모델
hackathon.json.settings.current_stage ∈ {"intro", "topics", "vote"} (default "intro").
전환 책임은 어드민. 이전/다음 자유롭게 이동 가능 (intro↔topics↔vote 양방향). vote로 set 시 voting_open=True 자동 같이 set. vote에서 떠나도 voting_open은 그대로 유지 — 어드민의 별도 "투표 마감" 버튼이 명시적으로 False 세팅. 단, voter URL은 current_stage == "vote" 조건도 같이 보므로 stage가 vote 아니면 자동 차단됨 (gate 함수 참조).
ceremony는 stage가 아님. 별도 URL로 진행자가 띄움. vote 마감 후 진입하는 게 자연스럽지만 강제 없음.
데이터 스키마 — hackathon.json
{
"settings": {
"voting_open": true,
"current_stage": "intro"
},
"topics": {
"categories": [
{
"id": "T1",
"title": "내 인생 시간 도둑 처단기",
"tagline": "매일 짜증나는 반복 작업 하나를 2시간 안에 박살내기.",
"tone": "실용 + 살짝 치트키",
"items": [
"Slack 멘션 자동 분류/요약기 — '진짜 날 부른 거' vs 'FYI' 분리",
"Jira 티켓 1줄 자동 요약 + 다음 액션 제안기",
"회의 캘린더 → 하루 시작 브리핑 (\"오늘 3개 있고 2개는 안 가도 됨\")",
"PR 리뷰 우선순위 큐 (크기/긴급도/차단여부 기반)",
"반복 쿼리/kubectl 명령 매크로 CLI — 자주 치는 10개를 1글자로",
"온콜 노이즈 필터 — 진짜 볼 알람 vs 무시해도 되는 알람",
"\"이번 주 내 활동 자동 요약\" — PR/티켓/리뷰 통합 리포트",
"Grafana 자주 보는 패널 즐겨찾기 통합 뷰",
"Slack 스레드 장문 요약기 — 놓친 채널 따라잡기용",
"\"이 회의 들어가야 함?\" 분류기 — 캘린더 제목·참석자 기반 추천"
]
},
{
"id": "T2",
"title": "벼르던 사이드 프로젝트",
"tagline": "평소 \"저거 하나 만들고 싶은데\" 하던 개인 토이를 2시간 안에 작은 완성품으로.",
"tone": "몰입 + 작은 완결성",
"items": [
"내 PR/커밋 패턴 분석 개인 대시보드 (시간대/요일/사이즈 분포)",
"팀 Wiki/Notion을 터미널에서 fzf 스타일로 검색하는 CLI",
"사내 모델/데이터셋 메타데이터 검색 프로토타입",
"git 히스토리 인터랙티브 시각화 뷰어",
"\"오늘 내가 한 일\" 자동 일기 생성기 (커밋/PR/티켓 통합)",
"PR 코멘트 감정/톤 분석으로 팀 리뷰 문화 리포트",
"로컬 Kubernetes 리소스 관계 그래프 실시간 시각화",
"사내 논문/테크 문서 RAG 검색 도구",
"터미널에서 차트 포함된 마크다운 뷰어",
"북마크/링크 자동 분류·태깅 개인 도구"
]
},
{
"id": "T3",
"title": "오버엔지니어링 선수권",
"tagline": "평소 안 써본 무거운 기술 패턴을 일부러 작은 문제에 적용해 배우기.",
"tone": "학습형 오버엔지니어링",
"items": [
"Todo 앱에 이벤트 소싱 + CQRS 제대로 적용",
"간단한 계산기 서비스에 OpenTelemetry 풀 트레이싱 구축",
"문서 검색 기능에 벡터 DB + 하이브리드 검색 (BM25 + 임베딩)",
"파일 업로드에 S3 presigned URL + 체크섬 검증 + 재시도 로직 정식 설계",
"팀 투표 기능을 Raft 합의 알고리즘으로 구현",
"회의실 예약을 Kafka 이벤트 스트리밍 기반으로",
"로컬 개발 환경을 완전한 K8s 매니페스트 (Deployment/Service/Ingress/HPA)로 재현",
"LLM + RAG 기반 PR 자동 리뷰 봇 아키텍처 설계·구현",
"멀티 에이전트 협업(프롬프트 2~3단계)으로 간단한 의사결정 시스템",
"사이드카 패턴으로 로깅/메트릭/인증 분리 데모"
]
},
{
"id": "T4",
"title": "팀에게 주는 작은 선물",
"tagline": "동료를 돕는 도구/봇/사이트. 특정인을 놀리는 게 아니라 팀 전체를 위한 것.",
"tone": "실질적 도움 + 가벼운 온기",
"items": [
"배포 상태 집계·알림 봇 (성공/실패/롤백 요약)",
"신입 한 주 서바이벌 가이드 자동 생성기 (온보딩 링크/문서 수집)",
"팀 내부 용어/약어 사전 봇 — 신입/리서처 친화",
"아침 브리핑 봇 — 오늘 회의/배포/만료 알람 한방",
"점심 투표 1분 컷 봇 — 선택지 자동 생성 후 이모지 투표",
"팀 반복 질문 FAQ 봇 — 같은 질문 반복되는 채널용",
"온콜 교대 시 인수인계 자동 요약 생성기",
"회의실 스마트 추천 — 인원/시간대/위치 기반",
"사내 서비스 변경사항 요약 구독 봇",
"\"이번 주 팀 지표 한 장\" 리포트 — 머지 PR, 해결 티켓, 배포 수"
]
}
]
},
"people": [...],
"votes": [...],
"titles": {...},
"tie_breaks": {...}
}
핫리로드: 매 요청 load_data() 그대로. 변경 즉시 반영.
컴포넌트
app.py 추가 함수
| 함수 | 역할 |
|---|---|
render_show() |
/ 진입 시 dispatcher. current_stage 따라 stage 함수 호출. |
render_stage_intro(data) |
팀 편성 4×2 그리드 + 순서 + 시상 부문. 큰 글씨. |
render_stage_topics(data) |
4 카테고리 2×2 그리드 + 10 주제 list. 큰 글씨. |
render_stage_vote(data) |
QR + n/total 진행률. autorefresh 3초. |
set_stage(stage) |
current_stage + voting_open 갱신 (vote이면 True). atomic write. |
get_stage() |
현재 stage 리턴. |
update_topics(categories) |
topics 통째 교체. atomic write. |
seed_topics(data) |
topics 비어있으면 default 4 카테고리 박제. |
make_qr_png(url) |
qrcode + PIL → PNG bytes. |
compute_vote_url(data) |
public_base_url 우선순위로 vote URL 결정. |
어드민 콘솔 신규 section
기존 render_admin()에 추가:
🎬 Stage 진행
현재: [intro | topics | vote] (radio 또는 시각 표시)
[← 이전] [다음 →]
🗒 주제 편집
Tab 1 — Form (4 expander, 카테고리별 title/tagline/tone/items 10개 input)
Tab 2 — JSON 직접 편집 (textarea + 검증 + 저장)
추가로:
- "현재 QR이 가리키는 URL" 표시.
- "QR target URL 수동 override" 입력란 →
settings.public_base_url저장.
Vote URL 결정 로직
우선순위:
data["settings"].get("public_base_url")(어드민 입력).- 환경변수
PUBLIC_BASE_URL. - 자동 감지 — 호스트 LAN IP (Python
socket.gethostbyname(socket.gethostname())또는/proc/net/route기반 첫 default-route NIC). - fallback
http://localhost:8501.
→ 결과에 ?mode=vote append.
Stage gate (모바일 voter)
신규 helper:
def can_accept_votes(data) -> bool:
s = data.get("settings", {})
return s.get("current_stage") == "vote" and s.get("voting_open", False)
render_voter() 진입 시 can_accept_votes(load_data()) 체크:
- False → "지금은 투표 시간이 아닙니다 ⏳" 메시지 표시 후 return.
- True → 기존 흐름 (이름 select → 사번 → 3 picks → 제출).
insert_vote()는 현행 그대로 (lock + duplicate check만). 진입 단계에서 가드.
큰 화면 CSS
render_show()에서 한 번 inject. Stage별 컴포넌트가 사용:
.stage-title { font-size: 70px; }.stage-section-title { font-size: 40px; }.team-card { font-size: 24px; padding: 20px; }.topic-cat-T1 { background: linear-gradient(...주황...) }(T1~T4 색상).topic-item { font-size: 18px; }.qr-caption { font-size: 32px; }.vote-counter { font-size: 64px; font-weight: bold; }
신규 의존성
requirements.txt에 2줄 추가:
qrcode[pil]
streamlit-autorefresh
시드 (entrypoint)
기존 entrypoint.sh가 assign_teams.py 호출. 그 안 마지막에 ensure_topics_seeded() 호출 추가:
# assign_teams.py 끝 부분
def ensure_topics_seeded(data):
if not data.get("topics", {}).get("categories"):
data["topics"] = DEFAULT_TOPICS_SEED
DEFAULT_TOPICS_SEED는 assign_teams.py에 모듈 상수로 박제 (위 4 카테고리 × 10 주제).
테스트 (tests/e2e.py)
기존 12 + 신규 4 → 총 16 시나리오:
- stage 시드 — fresh
hackathon.json에current_stage="intro"+ topics 4 카테고리 각 10 items. - stage 전환 + voting_open 자동 —
set_stage("vote")후voting_open == True.set_stage("intro")후voting_open변경 없음 (이전 상태 유지). - stage gate —
can_accept_votes(data)가 stage/voting_open 조합 4가지에서 정확한 bool 리턴. (intro+open=False, vote+open=True, vote+closed=False, topics+open=False) - topics atomic update —
update_topics(new_categories)후 reload 시 동일.
추가 회귀 보호:
- 기존 12개 그대로 통과해야 함.
- 라우트 5개 (
/,/?mode=vote,/?mode=admin&token=…,/?mode=ceremony&token=…,/?mode=raw&token=…) HTTP 200.
실패/엣지 케이스
| 상황 | 동작 |
|---|---|
topics 키 없음 (구버전 JSON) |
_empty_state 패턴으로 빈 dict 채움 + entrypoint에서 seed. |
current_stage 값 이상 (예: "foobar") |
dispatcher fallback intro. |
| QR target URL 결정 실패 | fallback http://localhost:8501/?mode=vote. 어드민에 경고 표시. |
| topics JSON 직접 편집에서 invalid JSON | "JSON 검증" 단계에서 reject + 에러 표시, 저장 안 함. |
| autorefresh 패키지 미설치 | requirements 누락 → import 단계 실패. fallback 없음. |
| 동시 stage 변경 (admin 두 명) | _lock + atomic write로 직렬화. 마지막 write 우선. |
| 모바일에서 stage가 vote 아닌데 직접 URL 입력 | gate 메시지 표시. |
운영 흐름 (예상)
docker compose up -d --build(기존 그대로).- 진행자:
?mode=admin&token=mlops2026진입. Stageintro확인. - 큰 화면:
/띄움 (stageintro— 팀 편성). - 본 행사 시작 → 진행자가 어드민 "다음 →" 클릭 → 큰 화면
topics. - 주제 둘러보고 팀별 해킹 진행 (앱 외부).
- 발표 끝나고 진행자 "다음 →" 클릭 → 큰 화면
vote(QR + 진행률,voting_open=True자동). 참가자 휴대폰 QR scan → 모바일 투표. - 모두 투표 → 어드민 "🛑 투표 마감".
- 동률 있으면 어드민에서 추첨/선택. 팀별 제목 입력.
- ceremony URL 띄움 → 시상.
비스코프 / 차후
- 주제 카테고리 수 가변 (4 고정).
- 모바일 vote UI 재디자인.
- WebSocket 푸시 (현재는 polling으로 충분).
- 다국어 / 테마.
- 시드 다국어 / 행사별 템플릿.
변경 파일 목록 요약
app.py— 신규 함수 + admin section 확장.assign_teams.py—DEFAULT_TOPICS_SEED+ensure_topics_seeded()+ 호출.requirements.txt—qrcode[pil],streamlit-autorefresh추가.tests/e2e.py— 신규 4 시나리오.show-urls.sh— show / vote URL 추가.README.md— 새 흐름 설명.- (
hackathon.json자체는 entrypoint가 시드)