RV Badminton: 포인트 계산 규칙 개편
리그포인트 세 개가 회원당 MMR 하나로 합쳐지고, 게임포인트에서 시드 배율이 빠졌다. 새로 구현한 건 두 가지다.
- 게임포인트 참여 가중치 — 그날 몇 명과 쳤는지로 점수에 배율을 먹인다. 계수를 0.6에서 1.0으로 올려, 30명 정모에서 최고/최저 격차가 1.9배에서 2.4배로 벌어졌다.
- 출석 보너스 / 페널티 — 하루가 끝날 때 자격 있는 회원 전원에게 ±1을 준다. 이 값이 다음 정모의 시작 MMR이 되기 때문에 표시용 보정이 될 수 없고, 그래서 재계산 단위가 경기 순에서 하루 순으로 바뀌었다.
RV Badminton 시리즈 세 번째 글이다. Chapter 2에서 포인트 계산을 Python/FastAPI 서비스로 떼어낸 이유와 “필요한 만큼만 재연”하는 구조를 다뤘다. 이 글은 그 위에서 규칙 자체가 바뀐 이야기다.
무엇이 바뀌었나
2026년 7월 시스템부터 모임 회장 상우 형의 결정으로 다음과 같은 변화가 있었다.
용어를 하나 짚어두면, Chapter 2에서 MMR은 두 사람의 리그포인트를 하나로 뭉친 팀 값이었다. 이번 개편의 MMR은 회원 한 명이 갖는 값이고, 리그포인트 자체를 대체한다.
리그포인트 → MMR
| 이전 | 이후 | |
|---|---|---|
| 포인트 개수 | 리그별 3개 (남복 · 여복 · 혼복) | 회원당 1개 (MMR, 기본 500) |
| 팀 MMR | (2 × 낮은 LP + 높은 LP) / 3 | 팀 MMR을 내지 않고 강팀/약팀 분류만 |
| 승패 변동폭 | 팀 MMR 차 5구간, 2 × 스코어차 + 0/5/10/15/20 | 분류 3종, 고정분 1 / 5 / 10 + 패자 MMR 비례 전송 |
| 팀 내 보정 | 팀메이트 격차 보정 (상대 평균과의 차이 ÷ 100) | 없음. 승자 배분을 파트너 MMR에 반비례 |
| 경기 합계 | 보장 없음 | 네 명 델타 합 = 항상 0 |
| 시드 (리그 레벨) | 리그포인트 백분위 18.5 / 37 / 55.5 / 74% → 1~5시드 | MMR 내림차순 33 / 33 / 33 → 시드 1 · 2 · 3 |
| 시드 자격 | 리그별 10경기 미만은 미지정 (게임포인트 배율에는 백분위 시드가 그대로 적용) | 올타임 10경기 미만은 랭킹에 아예 오르지 않음 |
| 출석 | 반영 없음 | 하루 끝에 ±1 |
| 무승부 | 사실상 팀2의 승리로 처리 | 정산 제외 |
| 랭킹 | 리그별 3종 | 남성부 · 여성부 + 성별 통합 RV 랭킹 |
게임포인트
| 이전 | 이후 | |
|---|---|---|
| 한 경기 점수 | 팀 점수 × 시드 배율 × 1.6 (승) / × 시드 배율 (패) | 팀 점수 × 2 (승) / × 1 (패) |
| 시드 연동 | 시드가 배율을 결정 | 없음 |
| 참여 가중치 | × (0.5 + 0.6 × 비율) | × (0.5 + 1.0 × 비율) |
| 시즌 처리 | 과거 시즌을 감쇠해 합산 | 현재 시즌만 |
변화의 가장 핵심적인 이유는 누구나 이해하기 쉬운 규칙과 시스템을 만드는 것이었다.
Chapter 2에서 남복 한 경기의 계산을 따라가 봤다. 두 사람의 리그포인트를 낮은 쪽에 가중치를 줘 팀 MMR로 뭉치고, 두 팀의 MMR 차를 다섯 구간으로 나눠 변동폭을 정하고, 팀메이트 보정을 얹고, 그 결과를 백분위로 잘라 1~5시드를 매기고, 그 시드가 다시 게임포인트 배율이 된다. 정모에서 “왜 이겼는데 5점밖에 안 올랐냐”는 질문을 받는 사람은 회장인데, 저 다섯 단계는 코트에서 말로 풀 수가 없다.
제로섬 정산
새 규칙의 뼈대는 한 경기에서 오가는 MMR의 총합이 0이라는 것이다. 누가 5점을 얻으면 누가 5점을 잃는다.
1) 분류. 네 명의 현재 MMR을 보고 강팀/약팀을 찾는다. 최댓값이 한 팀에만 있으면 그 팀이 강팀, 최솟값이 한 팀에만 있으면 약팀. 최대·최소가 양 팀에 걸쳐 있으면 NORMAL이다.
| 결과 | 고정분 | 유효 점수차 |
|---|---|---|
| 강팀 승 | 1 | 점수차 |
| NORMAL | 5 | 점수차 |
| 약팀 승 (이변) | 10 | 점수차 × 2 |
2) 패자에게서 걷는다.
실제고정분 = min(mmr, 고정분) # 가진 것보다 더 잃지 않는다
전송분 = int((mmr - 실제고정분) * 유효점수차 / 500) # 반올림이 아니라 버림
차감 = 실제고정분 + 전송분
비례항이 패자 자신의 MMR에 대한 비율이라, 높은 MMR을 가진 사람이 질수록 더 많이 잃는다.
3) 승자에게 나눈다. 걷은 총액을 파트너 MMR에 반비례해서 나눈다. 파트너가 약할수록 내가 더 받는다.
A(600)·B(500) 대 C(500)·D(400), A·B가 21-15로 이긴 경우.
- 최대 600은 팀1에만, 최소 400은 팀2에만 있으니 팀1이 강팀. 고정분 1, 유효 점수차 6.
- C는
1 + int(499×6/500) = 6, D는1 + int(399×6/500) = 5. 총액 11. - A의 가중치는 파트너 B의 500, B의 가중치는 A의 600.
A = int(11×500/1100) = 5,B = 11 − 5 = 6. - A +5, B +6, C −6, D −5. 합 0.
같은 네 명이 반대로 C·D가 이겼다면 이변이라 고정분 10에 점수차도 두 배다. A는 24, B는 21을 잃어 총액 45. 약한 D가 파트너 C(500) 덕에 25, C가 20을 가져간다.
단계가 다섯에서 셋으로 줄었고, 각 단계가 한 문장이 된다. 예상대로 이기면 조금, 뒤집으면 많이. 많이 가진 사람이 더 많이 잃고, 약한 파트너와 이기면 더 받는다.
비례항은 반올림이 아니라 버림이고, 첫 승자 몫을 버림으로 계산한 뒤 두 번째 승자가 남은 전부를 받는다. 반올림하면 네 명의 합이 ±1씩 새서 총합 0이 깨진다.
출석 보정
새로 생긴 규칙이다. 정모에 나오면 조금 이득, 상위권이 계속 안 나오면 조금 손해.
처음엔 리더보드에 얹는 표시용 보정으로 생각했는데 그렇게 둘 수 없었다. 출석 보정을 적용한 하루 끝 MMR이 다음 정모 첫 경기의 시작 MMR이 되기 때문이다. 정산이 네 명의 MMR로 강팀/약팀을 분류하니, 1점 차이가 분류를 뒤집으면 고정분이 1에서 5나 10으로 바뀐다.
그래서 재계산 단위가 바뀌었다. Chapter 2의 재연은 경기 순이었는데 이제는 하루 순이다. 하루의 모든 경기를 정산한 뒤에야 그날 출석 보정을 계산할 수 있고, 그게 끝나야 다음 날로 넘어간다.
이 패스는 그날 안 온 사람까지 포함해 자격 있는 회원 전원을 훑는다. 결석 페널티는 경기가 하나도 없는 사람에게 주는 값이라 그렇다. 출석 여부는 참석 명단으로 판단한다. 와서 안 친 사람도 출석이다.
±3에서 ±1로
첫 설계는 MMR 구간별 브래킷이었다. 출석하면 100 미만 +3, 300 미만 +2, 500 미만 +1. 결석하면 900 이상 −3, 700 이상 −2. 며칠 만에 평평한 ±1로 깎았다.
경기 한 판의 델타가 대개 한 자릿수다. 출석 보정이 +3이면 나와서 앉아 있는 게 이기는 것보다 남는다. 브래킷 방향도 문제였다. 낮은 MMR에 큰 보너스가 붙으면 일부러 지는 게 이득인 구간이 생기고, 결석 페널티를 낮은 쪽까지 깔면 바닥에서 웅크리는 게 페널티를 피하는 전략이 된다.
지금은 출석 시 MMR 400 미만이면 +1, 결석 시 600 초과면 −1, 그 외 0이다. 최대 진폭이 경기 델타보다 작아서 출석이 실력을 이길 수 없고, 브래킷 경계가 없어서 특정 구간에 머물 이유도 없다. 중간층은 아예 건드리지 않는다. 브래킷 여섯 줄은 설명할 수 없지만 “나오면 1점, 안 나오면 1점”은 한 문장이다.
게임포인트
이전에는 그 경기 시점 시드에 따른 배율이 붙었다. 이기면 팀 점수 × 시드 배율 × 1.6, 지면 팀 점수 × 시드 배율. 1~4시드가 1.60~1.15배, 5시드는 1.0배였다. 21점을 이긴 1시드는 54점, 같은 경기를 이긴 5시드는 34점을 받았다.
게임포인트는 참여를 재는 점수인데 실력 배율이 곱해지면 두 값이 섞인다. 참여 상은 실력이 아직 없는 신입에게 닿아야 의미가 있는데 배율은 반대로 작동했다. 지금은 이기면 팀 점수 × 2, 지면 팀 점수 × 1이고 누구나 같다.
대신 참여 쪽 계수를 0.6에서 1.0으로 올렸다.
정모 가중 점수 = 그날 합계 × (0.5 + 1.0 × ((1 + 함께 친 사람 수) / 그날 참석자 수))
바닥 0.5는 나오기만 해도 절반은 받는다는 뜻이고, 비례항은 그날 코트에 있던 사람 중 몇 명과 섞였는지를 잰다. 30명이 온 정모 기준으로 이렇게 벌어진다.
| 그날 함께 친 사람 | 이전 (계수 0.6) | 이후 (계수 1.0) |
|---|---|---|
| 3명 | 0.60배 | 0.63배 |
| 29명 (사실상 전원) | 1.12배 | 1.50배 |
| 격차 | 1.9배 | 2.4배 |
친한 두세 명하고만 계속 치는 것과 여러 조합으로 돌아가며 치는 것의 차이가 실제로 벌어진다.
같은 김에 “함께 친 사람 수”에 자기 자신이 들어가 있던 것도 고쳤다. 수식이 이미 더하는 1과 겹쳐 본인을 두 번 셌다. 모두가 똑같이 부풀려져서 순위는 그대로였지만, 전원과 친 사람의 비율이 31/30으로 1을 넘어 설계상 상한인 1.5배를 넘길 수 있었다. 지금은 전원과 치면 정확히 1.5로 닫힌다.
시즌 처리도 바꿨다. 이전 점수는 과거 시즌 가중합 × 감쇠 + 현재 시즌 × 계수 − 페널티였고 감쇠율과 계수가 설정값이었다. 지금은 현재 시즌 점수에서 페널티만 뺀다. 시즌마다 리셋된다고 공지하고 지난 시즌이 감쇠된 채 따라붙으면 리셋이 아니다. 남은 설정값은 페널티 계수 하나다.
리더보드를 재구축할 때 점수가 있는 회원만 기록해서, 시즌이 넘어가도 지난 시즌 값에 얼어붙어 보이던 버그도 같이 고쳤다. 0점인 회원까지 전부 기록한다.
구현
룰은 짧아졌는데 구현은 늘었다.
MMR을 저장하지 않는다
회원의 MMR을 컬럼에 들고 있다가 경기마다 갱신하는 대신, 델타만 append-only로 쌓고 현재 값은 합으로 구한다.
MMR(회원, t) = 500 + Σ 경기 델타 + Σ 출석 델타
덮어쓰는 지점이 없으니 낡아질 값이 없고, 재연이 순수 함수가 된다. 그래서 결정적으로 뽑은 다섯 개의 랜덤 경계일에 대해 “경계일 D부터 부분 재연한 원장 == 처음부터 전체 재연한 원장”을 바이트 단위로 검증한다. 이전 구조는 before / after를 체인으로 저장했기 때문에 이런 테스트를 쓸 수 없었다. 비교할 대상이 현재 저장된 값밖에 없었다. 제로섬을 정수 산술에서 오차 없이 닫아둔 것도 여기에 걸려 있다.
재계산 경계를 날짜로
재계산 범위를 “이 경기 이후”에서 “이 날짜 이후”로 바꿨다.
정모 직후 관리자가 경기 스무 개를 연달아 입력하면 이전 큐는 재계산을 스무 번 요청했다. 이제 첫 요청에서 1.5초 창을 열어 뒤따르는 요청을 모으고, 배치에서 가장 이른 날짜 하나로 한 번만 요청한다. 날짜는 min을 취할 수 있지만 경기 ID는 그럴 수 없다.
Chapter 2 말미에 경고로 남겨둔 버그도 없어졌다. 경기를 삭제하면 그 ID로는 아무것도 조회할 수 없어 재계산이 건너뛰어졌는데, 삭제 트랜잭션은 커밋 전에 그 경기의 날짜를 알고 있다.
매일 전체 재연
파생 저장소와 캐시가 어긋나는 건 알아채기 어렵다. 매일 새벽 4시에 랭킹을 스냅샷하고 처음부터 전체 재연을 돌린 뒤 비교한다. 차이가 없으면 INFO, 있으면 바뀐 회원마다 before/after/delta를 남기고 WARN이다. ERROR는 아니다. 어긋남이 발견됐다는 건 이미 고쳐졌다는 뜻이다.
전제는 전체 재연이 매일 돌 만큼 빨라야 한다는 것이었다. 처음엔 1만 2천여 경기에 20분쯤 걸렸다. DuckDB의 Postgres 스캐너가 WHERE 조건을 밀어넣지 못해 경기마다 참가자 테이블 전체를 다시 스캔하는 N+1이었다. 세 번의 스캔으로 통째로 읽어 메모리에서 그룹핑하도록 바꿔 80초 아래로 줄였다.
설명은 다시 계산한다
경기 하나의 정산 내역을 요청하면 저장된 델타를 돌려주지 않는다. 원장에서 그 경기 직전 네 명의 MMR을 복원해 정산 함수를 다시 실행한다. 분류, 고정분, 패자별 차감, 총액, 승자별 배분이 그 자리에서 계산돼 응답에 실리고, 다시 계산한 델타를 원장의 델타와 대조해 일치 여부를 함께 준다. 규칙과 원장이 어긋나면 이 플래그가 먼저 꺼진다.
정산 규칙을 의존성 없는 순수 함수 모듈로 떼어둔 게 여기서 값을 한다. 재연 경로와 설명 경로가 같은 함수를 부르니 두 곳의 수식이 갈라질 수 없다.
곁가지: 미리 계산할 값을 하나
게임포인트 집계는 정모 단위로 미리 계산해 적재한다. 요청마다 원본 경기 행에서 바로 계산하면 어떻게 되는지 재봤다. 같은 데이터(경기-회원 행 76,144개, 정모 309개)에 두 경로를 붙이고, 정모 그룹핑과 참석자 수는 즉석 계산 쪽에 공짜로 넘겨줬다.
| 질의 | 미리 계산 | 즉석 계산 | 배수 |
|---|---|---|---|
| 시즌 게임포인트 리더보드 (145명) | 0.33 ms | 10.21 ms | 31배 |
| 회원 한 명의 시즌 점수 | 0.19 ms | 6.23 ms | 33배 |
즉석 계산은 요청마다 경기 이력을 훑으니 이력이 길어질수록 느려지고 읽는 사람 수만큼 반복된다. 미리 계산하면 집계는 정모당 한 번이고 읽기는 이력 길이와 무관하다. MMR 쪽 하루 스냅샷은 성격이 다르다. 출석 보정은 경로 의존이라 요청 시점에 계산할 수가 없다. 다만 잰 건 읽기 쪽 이득뿐이고, 쓰기 시점 비용은 아직 재지 않았다.
Chapter 2에서 포인트 계산을 별도 서비스로 떼어둔 덕분에 이 교체가 Python 쪽에서만 끝났다. Java 백엔드에서 바뀐 건 큐의 시그니처와 캐시 키 몇 개다.