Skip to content

Repository files navigation

오늘폼 — 오늘 얘 폼 어땠어?

LCK 경기가 끝나면 팬이 직접 선수 폼을 매기는 곳. 중국 후푸(虎扑) 평점의 한국판입니다.

경기마다 선수 열 명에게 별 다섯 개로 점수를 주면 10점 만점으로 집계되고, 별점과 함께 남긴 한마디가 그대로 글이 되어 커뮤니티로 흘러갑니다.

🔗 onform.web.app · 모바일 웹 · 설치 없음

오늘폼 화면

스크린샷은 전부 데모 데이터(?demo=1)입니다. 실제 사용자가 남긴 글이 아닙니다.


왜 "폼" 인가

"폼"은 커뮤니티에서 이미 매일 쓰는 단어입니다 — 폼 미쳤다 / 폼 떨어졌다. 설명이 필요 없고, 서비스 이름이 곧 질문이라 홍보 문구를 쓸 때마다 브랜드가 따라붙습니다.

"어제 젠지 대 한화, 오늘폼 몇 점 줬어?"

평점 태그도 같은 원칙입니다 — 폼 미쳤다 / 무난 / 아쉬움 / 던졌다 / 팀운. 설명이 필요한 순간 참여가 끊깁니다.


화면

홈 · 오늘 세트 평점 선수
홈 세트 선수
오늘 경기 · 최고/최저 폼 · 평가가 갈린 선수를 홈에서 바로 별점으로 받습니다 선수 열 명을 한 화면에서. 점수·태그·베스트 한마디가 접힌 채로 보입니다 커리어(우승 기록) · 경기별 추이 · 시즌 기록 · 응원 한줄
경기 요약 선수 상세 커뮤니티 폼 랭킹
경기 선수상세 커뮤니티 랭킹
공유용 후푸 포맷 카드를 브라우저에서 즉석 생성 별점 분포 · 지표 레이더 · 그 선수에게 남긴 한마디 글과 한마디를 섞어서 무한스크롤 표 많은 순 55명

신경 쓴 것

1. 평균 하나만 보여주는 평점은 재미가 없습니다

우리 데이터에 실제로 있는 예시입니다 — Faker [2,3,0,1,2], 평균 5.5점. 그런데 5.5를 준 사람은 아무도 없습니다. 2점 둘, 4점 셋, 8점 하나, 10점 둘.

그래서 평균 숫자 옆에 분포 막대를 그리고, 양극단이 각각 30%를 넘으면 평가가 갈렸어요 를 띄웁니다. 그 갈라짐이 콘텐츠입니다. 같은 뜻을 두 곳에서 다르게 그리지 않으려고 홈·세트·선수 화면이 같은 .dist 컴포넌트를 씁니다.

2. 댓글은 점수에 붙은 주석입니다 — 이걸 나누지 않았습니다

한마디를 쓰려면 그 선수에게 평점을 냈어야 하고(hasVoted), 한마디 문서에 score 가 실리고, 화면이 Faker 2점 · 혼자 게임 터뜨림 으로 붙여 보여줍니다.

op.gg의 선수 응원 댓글이 실제로 방출 요구 게시판이 돼 있는데, 원인이 정확히 그것입니다 — 붙일 점수가 없으니 문턱이 없습니다. 점수 없는 글을 열 때는 무엇으로 잠글지를 먼저 정합니다.

같은 이유로 응원 한줄(60자)커뮤니티 글(2000자) 을 한 칸에 합치지 않았습니다. 긴 글이 들어갈 수 있으면 게시판이 되고, 게시판이 되면 성토장이 됩니다.

3. 빈 방을 만들지 않습니다

커뮤니티 탭을 열었을 때 글이 0개일 수 있습니다. 그래서 글과 한마디를 한 목록에 섞습니다 — 글이 없어도 쌓여 있는 한마디가 메웁니다. MY 탭도 같은 이유로 '내 글'과 '내 한마디'를 안 나눕니다.

표가 0개인 날도 마찬가지입니다. 예전엔 안내문 한 줄이었는데, 표 0개인 날이 곧 사람이 제일 필요한 날이라 홈에서 최다 킬 선수를 바로 세워 별점을 받습니다.

4. 읽기 비용을 실측으로 깎았습니다

Firestore는 결과가 0건인 질의도 1건으로 과금합니다. 그걸 모르고 짠 코드가 이렇게 새고 있었습니다.

세트 화면 (선수 10명) 왕복 30번 11번
로그인 시 내 평점 복구 1,000건 보고 있는 세트 10건
커뮤니티 목록 한 번 최대 700건 한 묶음 40건
  • 선수마다 세 번씩 물어보고 있었습니다(평점 · 베스트 한마디 · 상위 한마디 3개). 평점 10개는 문서 ID 범위 질의 한 번으로 묶고, limit(1) 결과는 limit(3) 의 부분집합이라 통째로 지웠습니다.
  • 댓글 수·미리보기는 collectionGroup 으로 매번 300건 훑는 대신 서버가 부모 문서에 적습니다(nr·rb). 생성(bumpReplies)과 삭제(dropReply)를 짝으로 둡니다 — 삭제 쪽을 빼먹으면 지운 댓글이 목록에 남습니다.
  • 경기 실시간 구독이 경기 데이터를 한 글자도 안 쓰는 화면(커뮤니티·MY·랭킹)까지 다시 그리던 것도 막았습니다.

5. 색인은 규칙과 무관하게 프로젝트 전체에 열립니다

이게 여러 설계를 밀어붙인 제약입니다. 복합 색인을 하나 만들면 규칙이 막든 말든 그 모양의 질의가 프로젝트 전체에 가능해집니다.

  • myPosts()orderBy('at') 을 안 붙입니다 → (uid, at) 색인이 열리면 글은 이미 공개 읽기라 "이 사람이 쓴 글 전부"가 한 번의 질의가 되고, 익명 글에도 uid 가 실려서 익명을 벗기기 좋은 도구가 됩니다. 정렬은 클라이언트가 합니다.
  • 응원 한줄의 문서 ID 를 <선수이름>__<uid> 로 잡았습니다 → 선수당 하나가 구조적으로 강제되고, 문서 ID 범위로 자르니 새 색인이 필요 없습니다.
  • 알림함을 notifications/{uid}/items 로 둡니다 → 경로만으로 주인이 정해집니다.

색인은 추측으로 늘리지 않고 실측으로 정했습니다. 안 쓰는 색인도 쓰기마다 비용이 붙습니다.

6. 익명의 범위를 문서로 못박고, 규칙으로 지킵니다

약속은 "다른 사람에게 닉네임이 안 보인다"까지입니다. 그 기준은 화면이 아니라 규칙이 지킵니다 — anon 이 붙으면 name"익명" 이어야 저장됩니다. 안 그러면 화면만 익명이고 문서에는 실명이 남는데, 한마디는 allow read: if true 라 그 순간 익명이 거짓말이 됩니다.

그 이상은 가지 않습니다. 익명 글에도 uid 는 실립니다 — 빼면 본인이 자기 글을 못 지우고 신고 처리도 막힙니다. 알고 감수하는 쪽이고, 그렇게 적어뒀습니다.

7. 규칙이 조용히 망가지지 않게, 에뮬레이터로 실제 검증합니다

Firestore 규칙은 정의되지 않은 이름을 써도 문법 오류가 아니라 '평가 오류' 를 내고, 평가 오류는 조용히 거부로 처리됩니다. 즉 규칙이 통째로 망가져도 겉보기엔 "잘 막히는 것처럼" 보입니다. 실제로 그래서 도배 제한이 한동안 "한 선수당 1개 영구 잠금"으로 돌아간 적이 있습니다.

./security/run.sh --rules

정적 스캔 + CSP 해시 확인 + 규칙 테스트 149개를 에뮬레이터로 돌립니다. 현재 기준선은 정적 스캔 5건(전부 콘솔 설정 또는 기능 개발), 규칙 테스트 149개 통과 · 어긋남 0.

8. 인라인 스크립트는 CSP 해시로 허용합니다

스크립트를 한 글자라도 고치면 해시가 어긋나고, 어긋난 채로 배포하면 사이트가 하얗게 뜹니다.

python3 security/csp.py            # 확인
python3 security/csp.py --write    # 갱신
python3 security/csp.py --live https://onform.web.app/   # 배포 후

로컬 검사는 "index.html 과 firebase.json 이 서로 맞는가"만 봅니다. 둘 다 맞아도 배포가 반쪽만 나가면 하얗게 뜹니다. 그래서 배포 뒤 살아 있는 주소에서 한 번 더 봅니다. (HTTP/2라 헤더 이름은 소문자로 옵니다 — 이 오타로 검사가 한동안 거짓 경보를 냈습니다.)

9. 배포는 서로 물려 있습니다 — ./deploy.sh 하나로만

hosting · firestore:rules · functions 중 한 쪽만 올리면 서비스가 깨집니다.

하나만 올리면 결과
--only hosting 클라이언트가 새 카운터를 쓰는데 옛 규칙이 거부 → 한마디 작성 전부 실패
--only firestore:rules 라이브 클라이언트가 카운터를 안 씀 → 한마디 작성 전부 실패
--only functions 새 함수는 state 를 요구 → 옛 클라이언트면 카카오 로그인 실패

storagefirebase deploy기본 대상이 아닙니다. 실제로 놓쳤습니다 — 이미지 업로드를 붙이면서 storage.rules 를 새로 썼는데 저장소만 규칙 없는 상태로 남았습니다. firestore:indexesfirestore:rules 에 안 딸려 갑니다(색인은 만들어지는 데 시간이 걸려 맨 앞에 올려야 합니다).

지금은 deploy.sh 가 마지막 배포 태그와 비교해서 무엇을 같이 올려야 하는지 스스로 판단합니다. 직접 firebase deploy 를 부르면 이 검사를 전부 건너뜁니다 — 그렇게 세 번 사고가 났습니다.

10. 경기 동기화를 노트북에서 뗐습니다

2026-08-23 19:50, 맥 뚜껑이 닫히면서 launchd 가 멈췄습니다. 라이브 Firestore 는 40시간 동안 "BFX 0-0 NS · 진행 중"을 보여줬습니다 (실제 결과 1-2).

지금은 functions-sync/ (파이썬 스케줄 함수)가 5분마다 라이엇을 봅니다. 맥이 꺼져 있어도 돕니다.

  • 요일 목록을 두지 않습니다. 예전 GAME_DAYS = {수~일} 이 화요일 플레이오프를 통째로 놓치게 돼 있었습니다. 매일 깨어나 라이엇에게 물어봅니다 — 경기가 없으면 호출 한 번 쓰고 1초 만에 끝납니다. 판단 근거가 실제 일정이라 낡을 수가 없습니다.
  • 창은 16:00 ~ 다음날 01:59 KST. 17시 시작 Bo5가 자정을 넘겨 끝납니다 — 옛 창(~23:00)은 4·5세트를 상습적으로 놓쳤습니다.
  • 스스로 고칩니다. "완료인데 스코어 합보다 세트가 적은 경기"를 찾으면 다시 받습니다.
  • 세트가 줄어드는 덮어쓰기를 막습니다. 라이엇 피드가 흔들리면 3세트짜리가 1세트만 담겨 돌아옵니다 — 그렇게 두 세트를 잃었습니다.
  • 파일이 한 벌입니다. 로컬 CLI 와 스케줄 함수가 sync_matches.py같이 씁니다. 사본을 만들면 어긋난 쪽이 조용히 틀린 기록을 만듭니다.

11. 커리어는 허용 목록이 없으면 "PC방 대회 우승"이 들어갑니다

선수 커리어는 Leaguepedia에서 굽습니다(55명 전원, 46명에게 우승 기록). 위키에는 이벤트 매치 · 평가전 · 2부 리그 · 자선 대회가 섞여 있어서 TIERS허용 목록입니다 — 없는 대회는 0점이고, 새 대회가 생기면 조용히 틀리는 대신 조용히 빠집니다.

  • LCK는 시즌 우승만 셉니다. 2025 포맷 개편으로 생긴 'Season Opening' 같은 중간 단계를 안 빼면 페이커의 LCK 우승이 11회에서 17회로 부풉니다.
  • LCK 2024 SpringLCK 2024 Spring Playoffs같은 우승입니다. 연도+시즌으로 접습니다.
  • 선수 이름 → 위키 문서 매핑을 자동 추측하지 않습니다. Leaguepedia에 'Smash'가 둘, 'Clear'가 셋 있습니다. 잘못 고르면 조용히 남의 커리어가 붙습니다. 매핑 파일에 없으면 소리를 냅니다.
  • 결과를 커밋합니다. 배포 때 위키를 부르지 않습니다 — 위키가 바뀌거나 죽어도 화면은 그대로입니다.

12. 이미지는 올리기 전에 캔버스로 다시 그립니다

용량·속도도 있지만 진짜 이유는 EXIF 입니다 — 폰 사진의 GPS 좌표가 공개 파일에 그대로 실립니다. shrinkImage() 를 건너뛰면 그게 바로 새깁니다. 그리고 posts.img우리 버킷 주소만 받습니다(규칙이 강제) — 아무 URL 이나 받으면 우리가 못 지우는 남의 서버 이미지가 우리 페이지에 뜹니다.

13. 랭킹을 "평점순"에서 "표 많은 순"으로 바꿨습니다

평점순은 "이 목록이 선수의 서열이다" 로 읽힙니다. 표가 열몇 개일 때 그건 사실이 아니고, 사실이 아닌 서열은 그 선수 팬에게 시비가 됩니다.

후푸도 선수판·챔피언판·명예의전당판 전부 평가 인원수 내림차순입니다 — The Shy 9.3점(49,168명) 1위, UZI 5.4점(24,528명) 2위. 평점판의 랭킹 축은 "누가 잘하나"가 아니라 "누가 제일 화제인가" 입니다.

커리어순도 안 씁니다. op.gg · gol.gg · Leaguepedia · Liquipedia · FotMob · ESPN · NBA.com 어느 곳도 선수 목록을 우승 횟수로 정렬하지 않고, 우리 55명 중 29명이 커리어 0점이라 하위 절반이 빈칸으로 박제됩니다. 커리어는 선수 상세에만 얹습니다.

14. 뺀 것도 적어둡니다

예측 · 포인트 지갑 · 출석을 한꺼번에 걷어냈습니다. 핵심(그날 선수 평점 + 한줄평)만 남기려고요. MY 탭의 통계 카드 세 개도 지웠습니다 — MY 는 내가 남긴 것을 다시 찾아가는 곳이지 통계를 보는 곳이 아닙니다.

죽은 함수는 전부 지웠고, 왜 뺐는지와 되살릴 지점(태그)CLAUDE.md에 남겼습니다. "세던 값이 화면에서 사라졌는데 배지만 남으면 가리키는 데 없는 숫자가 됩니다."


디자인

한국 앱 문법. 회색 배경 위에 흰 카드, 포인트 컬러 하나. 토스·당근·배민이 전부 이 구조라 설명이 필요 없습니다.

토큰 쓰임
배경 #F2F4F6 페이지
카드 #FFFFFF 섹션 (radius 16)
글자 #191F28 / #4E5866 / #626B79 본문 / 보조 / 라벨
포인트 #1B64DA 작은 글자·버튼 배경 (AA 5.4:1)
포인트(강) #3182F6 24px 넘는 점수 숫자 전용
#FFB020 / #828A95 채운 별 / 빈 별
경고 #D32236 / #F04452 작은 글자 / 큰 숫자
  • 회색은 두 단계입니다. 세 단계로 나눴더니 아래 둘이 대비 3:1 밑으로 떨어져 안 읽혔습니다. 위아래 구분은 색이 아니라 크기·굵기가 합니다.
  • 파랑도 둘입니다. 같은 파랑을 12px 라벨에 쓰면 3.71:1 로 떨어집니다.
  • 색은 장식이 아니라 점수에 씁니다. 8점 이상 파랑, 6~8점 검정, 6점 미만 회색. 스크롤만 해도 누가 잘했는지 보이는 게 평점 서비스의 핵심입니다.
  • 별은 노란색입니다. 한국 앱에서 별점은 거의 예외 없이 노랑/주황이라 다른 색을 쓰면 별점으로 안 읽힙니다.

근거는 docs/ui-audit-2026-08-08.md.


구조

브라우저 (public/index.html · 단일 파일 SPA, 빌드 없음)
   │
   ├─ Firestore ─── 평점 집계 · 한마디 · 글 · 응원 · 신고
   │     └ firestore.rules (831줄) ← 1인 1표 · 익명 보장 · 도배 제한을 여기서 강제
   │
   ├─ Cloud Functions (Node)      functions/       카카오 로그인 · 신고함 · 알림 · 유입 추적
   ├─ Cloud Functions (Python)    functions-sync/  5분마다 라이엇 → Firestore
   └─ Cloud Storage               커뮤니티 이미지 (글당 한 장)

정적 데이터 (배포에 실려 나감)
   public/matches.json   경기·세트·선수
   public/players.json   누적 평점 · 분포 · 시즌 기록
   public/careers.json   커리어 (Leaguepedia에서 구워서 커밋)

빌드 도구가 없습니다. public/index.html 하나에 다 들어 있습니다(8,443줄). 번들러를 안 쓰니 배포가 파일 복사이고, 대신 CSP 인라인 해시를 손으로 맞춰야 합니다 (자주 부딪히면 app.js 로 빼는 게 영구적인 해법입니다).

폴더

public/          앱 본체 + 정적 데이터
functions/       Cloud Functions (Node) — 로그인 · 신고함 · 알림
functions-sync/  Cloud Functions (Python) — 경기 동기화 (5분 주기)
security/        보안 하네스 — 정적 스캔 · CSP 해시 · 규칙 테스트 149개
tools/           빌드/운영 CLI — players.json · careers.json · 스크린샷 · 소셜
card/            결과 카드 PNG 생성 (1080×1350, 2종)
docs/            기획·역설계·감사 문서 (4,600줄)
deploy.sh        검사 → 배포 → 배포 후 확인 → 되돌릴 지점 기록

저장 구조

컬렉션
ratings/{경기ID__p인덱스} 선수 한 명의 집계. b1b5(별 분포), t1t5(태그)
votes/{세트ID__uid__p번호} 1인 1표 증표. 만들어지면 끝 — 수정·삭제 불가
ratings/{…}/comments/{id} 한마디. uid name score text likes at
posts/{id} · posts/{id}/replies/{id} 커뮤니티 글과 댓글
cheers/{선수이름__uid} 응원 한줄 (선수당 하나)
flags/{id} · notifications/{uid}/items 신고 큐 · 알림함 — 클라이언트 접근 전면 차단

평균과 참여 수는 전부 b1~b5 에서 계산합니다. 따로 저장하지 않습니다.

1인 1표가 서버에서 강제되는 방식 — 증표 문서 ID 가 <세트ID>__<uid>__p<번호> 라서 같은 사람이 같은 선수에게 두 번 만들 수 없고, create 는 문서가 없을 때만 성공합니다. 규칙은 집계 수정을 "이번 커밋에서 증표가 새로 만들어지는 중" 일 때만 허용합니다(getAfter).


직접 돌려보기

전용 Firebase 프로젝트가 필요합니다. Firestore 규칙 파일은 프로젝트당 하나뿐이라 남의 프로젝트에 얹으면 그쪽 규칙을 덮어씁니다.

# 1. 설정 파일
cp public/config.example.js public/config.js     # 값을 채우세요
cp .firebaserc.example .firebaserc               # 프로젝트 ID 기입

# 2. Firebase 콘솔에서 Authentication → Sign-in method 로 Google 을 켭니다
#    (안 켜면 페이지가 '로그인 설정 필요' 를 띄우고 제출을 막습니다 — 조용히 실패하진 않습니다)

# 3. 검사 → 배포
./deploy.sh --check     # 검사만
./deploy.sh             # 검사 → 배포 → 배포 후 확인 → 태그

public/config.js저장소에 없습니다(.gitignore). 실제 키가 들어가는 자리라 public/config.example.js 를 복사해서 만드세요. 서버 시크릿(카카오 REST 키 등)은 파일이 아니라 firebase functions:secrets:set 으로 넣습니다.

자주 쓰는 명령
./security/run.sh --rules                    # 정적 스캔 + CSP + 규칙 테스트 149개
python3 security/csp.py --write              # 인라인 스크립트 고쳤을 때 해시 갱신
python3 tools/build_players.py               # 누적 평점 → players.json
python3 tools/build_careers.py               # Leaguepedia → careers.json
python3 tools/shots.py --label after         # 아홉 화면 스크린샷 대조표
python3 card/make.py 260823-hlet1 --style both   # 결과 카드 PNG

문서

이 저장소는 왜 그렇게 만들었는지를 코드만큼 적어둡니다.

문서 내용
CLAUDE.md 작업 전에 알아야 할 것. 사고 기록과 "이건 하지 마세요" 가 전부 여기
docs/product-spec.md 유저 플로우 · 화면 · 다음 순서 · 하면 안 되는 것
docs/hupu-spec.md 후푸 역설계. 서버 응답을 직접 뜯은 문서라 추측이 아닙니다
docs/ui-audit-2026-08-08.md UI 감사. 어디가 안 읽히는지, 왜 그런지, 어떤 순서로 고칠지
docs/cold-start-spec.md 표 0 문제. 경기 기록으로 예상 폼을 내되 가짜 표는 안 넣는 이유
security/README.md · security/REPORT.md 보안 하네스 · 진단 이력

아직 안 된 것

  • App Check 강제 적용(Enforce)이 꺼져 있습니다. reCAPTCHA v3 키는 등록을 마쳤고, 콘솔의 "확인된 요청" 비율이 100% 근처로 오른 뒤에 켭니다.
  • 사칭을 막지 못합니다. 표시 이름이 자유 입력이라 남의 닉네임을 쓸 수 있습니다.
  • collectionGroup 질의가 열려 있습니다. 한마디 모아보기가 그걸 씁니다.
  • 저작권 방어선이 신고함 하나뿐입니다. 이미지 업로드 화면의 사전 경고 문구를 뺐습니다 — 올리기 전에 막는 장치가 없으니 신고가 들어오면 더 빨리 봐야 합니다.
  • onReportCreated 배포 전에 들어온 신고는 큐에 없습니다. 트리거는 소급 적용이 안 됩니다.

고지

  • 비공식 팬 프로젝트입니다. Riot Games 및 LCK 와 아무런 제휴 관계가 없습니다. League of Legends 는 Riot Games, Inc. 의 상표입니다.
  • 선수 초상은 라이엇 공식 e스포츠 API 한 곳에서만 가져옵니다. 나무위키·구글 이미지·구단 SNS 에서 긁어오지 않습니다 — 대부분 언론 보도사진 재업로드라 권리가 불분명합니다.
  • 후푸 화면을 픽셀 단위로 베끼지 않았습니다. 라인 아이콘은 라이엇 공식 아이콘 대신 같은 역할의 기하 도형으로 그렸고, 배경의 소환사컵 이미지도 쓰지 않았습니다.
  • 커리어 데이터 출처는 Leaguepedia (lol.fandom.com), CC BY-SA 3.0 입니다. careers.json 안과 화면 양쪽에 표기가 들어 있습니다 — 이 파일에만 해당하고 앱 전체가 그 라이선스가 되는 것은 아닙니다.

About

오늘폼 — LCK 경기가 끝나면 팬이 직접 선수 폼을 매기는 곳. 한국판 후푸(虎扑) 평점.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages