A+ Match
운영 중동호인 스포츠 단체 운영 — 리그·랭킹·영상까지 웹·iOS·Android 한 코드베이스로
- 기간
- 2026-07 ~ 현재
- 커밋
- 401
- 기록된 경기
- 501
- 운영한 리그
- 49
- 등록 선수
- 27
- DB 마이그레이션
- 102건
기술 스택
- Capacitor
- Cloudflare Containers
- Cloudflare Workers
- Next.js
- OpenNext
- React
- Sentry
- Supabase
- Tailwind CSS
- TypeScript
실제 화면 운영 중인 서비스를 그대로 캡처했습니다
2026-10-06 기준

개요
무엇이고 누구를 위한 것인가배드민턴·탁구·볼링 동호회가 단체 운영 전체를 한 곳에서 하도록 만든 서비스다. 리그 편성과 경기 결과 기록, 종목별 랭킹 집계에서 시작해 공지·투표·회비·캘린더·앨범·경기 영상까지 다룬다.
웹(aplusmatch.com)과 iOS·Android 앱을 같은 코드베이스에서 빌드한다. 여러 단체가 한 서비스를 쓰는 멀티테넌트 구조라, 각 단체의 데이터는 그 단체 멤버에게만 보인다.
문제
무엇이 문제였나동호인 스포츠의 운영은 대개 단체 대화방과 엑셀로 굴러간다. 경기 결과는 대화방 사진으로 흩어지고, 랭킹은 누군가 수작업으로 집계하며, 그 사람이 빠지면 멈춘다.
범용 도구로 대체하기 어려운 이유가 셋 있다.
종목마다 규칙이 다르다. 배드민턴은 A~E조 급수, 탁구는 특1부~6부, 볼링은 애버리지 구간이다. 배드민턴·탁구는 두 팀이 맞붙지만 볼링은 각자 기록을 낸다. 하나의 표로 설명되지 않는다.
과거 기록이 중요한데 바뀐다. 선수 급수와 나이는 시간이 지나며 바뀐다. 작년 리그 점수를 오늘 다시 계산하면 오늘의 급수로 계산돼 결과가 달라진다.
참가자는 거의 전부 모바일로 본다. 웹만 만들면 실제로 쓰이지 않는다.
접근
어떻게 풀었나- 종목 차이를 "얇은 상수 파일 두 개"로 흡수했다. 급수 체계(
SPORT_GRADE_SETS)와 경기 유형(대결h2h/ 개인 기록individual)을 종목명 키로 정의하고, 급수 드롭다운·신규 선수 기본 급수·매칭 실력점수·리그 가산점 기본값을 전부 여기서 파생시킨다. 종목이 늘면 한 줄 추가가 유일한 확장 지점이다. - 과거 점수는 그 시점 기준으로 계산한다. 연령 보너스는 리그 개최 시점의 나이로, 급수 보너스는 리그 참가 당시 급수 스냅샷으로 계산한다. 과거 리그를 다시 집계해도 결과가 변하지 않는다.
- 세 채널을 한 코드베이스로. Next.js로 웹을 만들고 Capacitor로 같은 코드를 iOS·Android 네이티브 앱에 담았다.
- 데이터 접근은 DB 정책(RLS)으로 막는다. 애플리케이션 코드가 아니라 Supabase의 행 수준 보안이 "이 단체 멤버인가"를 판정한다. 코드에서 필터 하나를 빠뜨려도 데이터가 새지 않는다.
주요 기능
사용자가 실제로 쓰는 것- 리그 관리 — 리그 편성, 경기 결과 입력, 전체화면 점수판, 대진표 도구
- 종목별 랭킹 — 연도별·통합 랭킹, 득실차 + 승패 점수 + 가산점(출석·여성·연령대·급수) 합산
- 볼링 프레임 스코어링 — 표준 10핀 규칙(스트라이크·스페어 보너스) 자동 계산
- 선수 관리 — 선수 프로필과 급수 이력
- 단체 운영 — 공지, 투표, 회비·기부, 캘린더, 앨범, 교류전
- 시설 찾기 — 카카오 로컬 검색 + 국민체육진흥공단 공공체육시설 데이터, 지도 표시
- 경기 영상 — 대용량 원본 업로드 → 720p HLS 자동 변환 → 앱에서 재생
- 배드민턴TV — 종목 관련 유튜브 채널 큐레이션
- 신고·모더레이션 — 사진·영상 신고와 관리자 처리, 삭제된 콘텐츠 재업로드 차단
- 단체 가입 — 초대 링크로 가입 신청 → 관리자 승인, 단체별 역할 체계
- 선수 발굴·인앱 쪽지 — 단체에 속하지 않은 개인이 실력 프로필을 공개(opt-in)하면 단체 관리자가 검색해 먼저 연락한다
아키텍처
어떻게 구성돼 있나 Next.js (OpenNext) ── Cloudflare Workers ── aplusmatch.com / sports.azclab.com
│ └ Capacitor ── iOS · Android 앱 (같은 코드)
│
├─ Supabase (PostgreSQL) — 102 migrations, 단체별 RLS
├─ KV — YouTube API 캐시 겸 업로드 rate-limit 카운터
└─ R2 (sports-agent-media) — 사진·영상 원본, HLS 세그먼트
영상 파이프라인 (Worker를 거치지 않는다)
브라우저 ──presigned PUT──▶ R2 raw/{org}/{uuid}.mp4
└─ enqueue ──▶ Queue(video-transcode)
└─▶ Transcoder Container
R2를 FUSE로 마운트 → ffprobe + SHA-256 + ffmpeg(720p HLS)
└─ 콜백 → status = ready기술적 의사결정
무엇을 고르고 무엇을 버렸나- 영상은 Worker를 통과시키지 않았다. 원본이 수백 MB~2GB라 Worker의 요청 크기·실행 시간 제한에 걸린다. 브라우저가 서명된 URL로 R2에 직접 업로드하고, 인코딩은 큐를 받아 도는 별도 컨테이너가 맡는다. 컨테이너는 R2를 파일시스템으로 마운트해 ffmpeg가 원본을 그대로 읽는다.
- 재업로드 차단을 미디어 종류마다 다르게 걸었다. 관리자가 신고된 사진을 지우면 그 파일의 SHA-256을 차단 목록에 올리고, 이후 업로드는 R2에 쓰기 전에 거부한다. 영상은 직접 업로드 구조라 서버가 업로드 시점에 원본 바이트를 볼 수 없다. 그래서 원본을 실제로 만지는 유일한 지점인 트랜스코딩 시점에 해시를 대조해, 일치하면 결과를 만들지 않고 원본을 지운다. 사진보다 약한 보장이라는 걸 문서에 명시했다.
- 완전 일치 해시만 쓴다. 재인코딩·크롭된 변형은 못 잡지만, 지각 해시(perceptual hash)는 필요해질 때 도입한다. 지금 필요한 건 "같은 파일 그대로 다시 올리기"를 막는 것이다.
- 비공개 링크를 URL로 만들었다. 미디어 키가 랜덤 UUID라 URL 자체가 추측 불가능한 비공개 링크 역할을 한다.
- 공개 범위 전환을 조용히 실패하게 했다. 멀티테넌트 전환 전에는 모든 데이터가 비로그인에도 공개였다. 전환 후 비멤버는 에러가 아니라 빈 목록을 받는다 — 다른 단체의 존재 자체를 드러내지 않는다.
- 연락처는 RPC로만 연다. 단체에 속하지 않은 개인이 실력 프로필을 공개하고 단체가 먼저 연락하는 기능을 만들면서, 전화·이메일은 테이블 직접 조회가 아니라 전용 RPC로만 열리게 했다. 검색 결과에는 안전한 컬럼만 나간다.
결과
무엇이 달라졌나aplusmatch.com에서 운영 중이며 웹·iOS·Android 세 채널을 같은 코드에서 빌드한다.
- 랜딩에 공개된 누적 지표(2026-10-06 기준): 기록된 경기 501, 운영한 리그 49, 등록 선수 27.
- 커밋 401개로 이 저장소군에서 두 번째로 활발하다. 화면 34개, DB 마이그레이션 102건.
- 과거 기록 이관: 단체 대화방에 사진으로만 남아 있던 리그 30개 분량의 경기 기록을 OCR(Tesseract.js)로 읽어 들였다. OCR은 이관 스크립트 전용이라 앱 번들에는 포함되지 않는다.
- 종목 3개(배드민턴·탁구·볼링)가 상수 파일 두 개로 분기돼, 대결형과 개인 기록형이 같은 리그 화면에서 동작한다.
- Maestro 모바일 E2E 플로우 30개로 실제 앱 동작을 검증한다.
배운 점
다시 한다면"종목 추가"를 처음부터 범용 설계로 풀려 했다면 무거운 추상화가 됐을 것이다. 실제로 필요했던 건 종목명을 키로 하는 상수 파일 두 개였고, 나머지는 거기서 파생시키면 됐다. 반대로 데이터 접근 통제는 애플리케이션이 아니라 DB에 맡긴 게 옳았다 — 34개 화면에서 필터를 하나도 빠뜨리지 않는 것보다, 빠뜨려도 새지 않는 구조가 훨씬 싸다.