[ 롤모임 운영일지 ] - 12. 랭킹 공식을 하드코딩에서 조립식으로 — Glicko-2까지
내전 랭킹 점수는 원래 하드코딩된 공식 하나였다. 평가 점수 평균에 승패 기록으로 가중치를 곱하는 식. 문제는 모임마다 원하는 랭킹 방식이 다를 수 있다는 것 — 어떤 모임은 평가 없이 순수 배치(레이팅) 시스템만 쓰고 싶어할 수도 있다. 그래서 공식 자체를 모임별로 조립할 수 있게 바꿨다.
세 가지 전략
랭킹 점수 계산을 세 가지 독립된 “전략”으로 쪼갰다.
-
eval (평가): 그 시즌 각 평가자가 매긴 최신 점수의 평균. 시즌 2부터는 평가자별 Z-점수 보정(
Z_ADJUST_FROM_SEASON)도 들어간다. 평가가 하나도 없으면 0. -
winrate (승률): 순승수(승-패)에 배율을 곱해 조정한다. 흥미로운 건 이 전략이 이전 단계의 결과값을 받아서 조정할 수도, 독립적으로 계산할 수도 있다는 것 —
usesPrevious: true면이전값 × (1 + 순승수×배율/100)처럼 기존 점수에 %를 얹는 방식이 되고,false면 순승수 자체에 배율만 곱한 독립 점수가 된다. -
placement (배치, Glicko-2): 체스 레이팅에 쓰이는 Glicko-2 알고리즘으로 계산한 히든 MMR을 같은 100점대 스케일로 환산. 배치가 안 끝난 유저(
glickoGames < 5)는 0.
파이프라인 — 최대 3단계, 순서가 있다
모임마다 이 전략들을 최대 3개까지 순서대로 조합할 수 있다. 각 단계는 combine 방식(값을 그대로 대체하는 set, 더하는 add, 곱하는 mul)을 갖고, usesPrevious가 켜진 전략은 앞 단계에서 누적된 점수를 입력으로 받는다. 그러니까 파이프라인은 단순 합산이 아니라, 순서가 결과에 영향을 주는 진짜 “조립식 계산”이다.
별도의 전용 테이블을 만들지도 않았다. 모임마다 이 설정을 그냥 Setting 테이블에 ranking_config_g{groupId}라는 키로 JSON 하나 저장해두는 방식을 썼다 — 새 전략 조합이 필요할 때마다 스키마를 안 건드려도 되는 가벼운 구조다.
기본값은 기존 공식과 완전히 같다
가장 신경 쓴 부분은 여기다. 기본 파이프라인은 eval(set) → winrate(usesPrevious, set) — 정확히 예전 하드코딩 공식과 같은 조합이다. 그냥 “비슷하게 만들었다”가 아니라, 예전 함수(recomputeInternalScoreForUser)에 있던 SQL 쿼리와 계산식을 그대로 각 전략의 compute 함수로 옮겨 붙였다. 새로 짠 게 아니라 그대로 이동시킨 것 — 그래서 기존 모임은 이 전환으로 랭킹 점수가 단 1점도 바뀌지 않는다. 테스트에도 이 사실 자체를 명시적으로 검증하는 케이스가 있다: “기본 공식 재현: eval(set) → winrate(usesPrevious, set) = base × (1+net%)”.
Glicko-2 — 논문의 검증 벡터로 테스트하다
배치 전략에 쓴 Glicko-2는 체스 레이팅 시스템으로 유명한 그 알고리즘이다. 레이팅, 레이팅 편차(RD), 변동성(volatility) 세 값을 매 경기마다 갱신한다. 초기값은 레이팅 1500, RD 350, 변동성 0.06 — Glickman의 원 논문에 있는 표준 기본값 그대로다.
이 구현이 맞는지 어떻게 확인했을까. 직접 사람 눈으로 검증하는 대신, Glickman이 논문에 실은 표준 검증 벡터를 그대로 테스트 케이스로 썼다. 레이팅 1500/RD 200/변동성 0.06인 선수가 세 상대(1400/30, 승 / 1550/100, 패 / 1700/300, 패)와 겨뤘을 때 논문이 제시하는 결과값(레이팅 ≈1464.06, RD ≈151.52, 변동성 ≈0.06)과 비교하는 식이다. 알고리즘을 직접 다시 유도하지 않고도, 원 저자가 검증해둔 숫자와 비교하는 것만으로 구현이 맞다는 확신을 얻을 수 있었다.
5v5 팀전이라는 특성도 반영했다 — 상대팀 개개인을 각각 상대하는 게 아니라, 상대팀의 평균 레이팅/RD를 가상의 “상대 1명”으로 취급해서 한 경기당 한 번만 레이팅을 갱신한다. 양 팀 모두 경기 시작 시점의 레이팅을 스냅샷으로 고정해두고 대칭적으로 평가한다.
뒤늦게 알아챈 것 — 잘못된 모델에 얹었었다
처음 Glicko 필드(glickoRating, Rd, Vol, Games)를 어디에 붙였는지도 짚어볼 만하다. 처음엔 전역 User 모델에 붙였는데, 이건 멀티테넌시 구조에서 틀린 선택이다 — 유저의 레이팅은 모임마다 따로 관리돼야 하는데, User는 모임과 무관한 전역 모델이기 때문이다. 이후 두 차례에 걸쳐 MemberScrim(모임별 유저 데이터)으로 옮겼다. 지금은 User에 이 필드들이 하나도 안 남아있다. 기능부터 만들고 나서, 그 기능이 어느 모델에 있어야 정합적인지를 뒤늦게 바로잡은 사례다.
배포 이후에도 자잘한 회귀가 하나 있었다 — 지난 시즌에 배치를 이미 끝냈지만 이번 시즌 판수가 적은 복귀 유저가 “배치중” 표시도 없이 그냥 빈 티어로 보이던 버그였다. 배치 완료 여부 체크가 glickoGames만 보고 있었던 게 원인이라, 시즌 판수든 배치 판수든 둘 중 하나라도 부족하면 아직 배치 중이라는 조건으로 고쳤다. 이 체크 로직이 코드 세 군데에 중복돼 있던 것도 이번에 하나로 합쳤다.
정리
- “기본값은 기존 동작과 완전히 동일해야 한다”는 원칙을, 코드를 새로 짜지 않고 그대로 옮기는 방식으로 지켰다. 무손실 마이그레이션을 주장만 하지 않고, 옛 로직을 리팩터링 없이 옮겨서 증명한 셈이다.
- 알고리즘 구현을 검증할 때, 원 논문의 레퍼런스 벡터를 테스트로 쓰는 건 꽤 효율적인 방법이다. 직접 손으로 계산을 검산하는 것보다 훨씬 믿을 만하다.
- 기능은 옳아도 어느 모델에 둘지는 틀릴 수 있다. Glicko 필드를 전역 모델에 먼저 얹었다가, 멀티테넌시 원칙에 맞춰 모임별 모델로 옮긴 건 “일단 동작하게 만들고, 구조는 나중에 바로잡은” 사례로 남겨둔다.