블로그로 돌아가기
MMR리더보드알고리즘순위 시스템

킬 수로만 순위 매기면 불공정하다 - MMR 시스템 설계

12월 5일 티어 시스템 도입 → 같은 날 115명 동접 달성. 671명 유저의 공정한 순위를 위한 MMR 설계

Softopia
2024년 12월 5일
8 min read

12월 5일, 새벽 4시

sa._.2394 — 2024-12-05 오전 4:12
저도 작업하면서 놀란 부분이 저희 서버에 총 유입된 유저 수가 671명이더라고요
저희가 이번 시즌에 적용되어 유용하게 사용할 수 있도록 리더보드를 리메이크하여, 
통계 시스템을 배포합니다.

여러분들의 실력이 어느 수준인지 궁금하지 않습니까? 
죽고 죽이는 이 러스트라는 게임에서 저희 서버는 티어를 도입했습니다.

그리고 그날 저녁:

sa._.2394 — 2024-12-05 오후 8:54
그디어 100명 🎉

티어 시스템 도입한 날, 115명 동접을 달성했다.


발단: "내가 왜 쟤보다 순위가 낮아?"

리더보드 첫 버전은 단순했다. 킬 수 기준 내림차순.

const leaderboard = players.sort((a, b) => b.kills - a.kills);

바로 불만이 터졌다.

"나 100킬인데 5위야. 1위는 300킬인데 하루 종일 접속해 있잖아" "킬만 많고 데스도 많은 애가 왜 높아?" "헤드샷 비율 50%인데 10%인 애보다 낮네?"

킬 수만으로 순위를 매기는 건 불공정하다.


티어 분포 설계

최종적으로 적용한 티어 분포:

🏆 Challenger   상위 0.3%   (671명 중 ~2명)
   Master       상위 1.5%   (~10명)
   Diamond      상위 5%     (~34명)
   Platinum     상위 12%    (~80명)
   Gold         상위 30%    (~201명)
   Silver       상위 55%    (~369명)
   Bronze       하위 45%    (~302명)

이 분포는 리그오브레전드 티어 분포를 참고했다. 상위 티어일수록 좁게.

const TIER_DISTRIBUTION = {
  challenger: 0.003,  // 상위 0.3%
  master: 0.015,      // 상위 1.5%
  diamond: 0.05,      // 상위 5%
  platinum: 0.12,     // 상위 12%
  gold: 0.30,         // 상위 30%
  silver: 0.55,       // 상위 55%
  bronze: 1.0,        // 나머지
};

MMR 계산: 7축 평가 시스템

한 가지 지표로 실력을 평가하면 편향이 생긴다. 여러 축으로 나눠서 종합 점수를 내기로.

interface MMRBreakdown {
  combatEfficiency: number;  // 전투 효율
  precision: number;         // 정밀도
  consistency: number;       // 꾸준함
  impact: number;            // 영향력
  skill: number;             // 스킬
  teamwork: number;          // 팀워크
  experience: number;        // 경험
}

각 축 0~100점, 가중치 곱해서 총합.

1. 전투 효율 (Combat Efficiency)

K/D 비율 기반. 근데 단순 K/D는 문제가 있다.

K/D 10.0: 100킬 10데스 (좋음)
K/D 10.0: 10킬 1데스 (애매함 - 샘플 부족)

로그 스케일 적용. 극단적인 K/D의 영향 완화.

function calculateCombatEfficiency(kills: number, deaths: number): number {
  const kd = deaths === 0 ? kills : kills / deaths;
  // K/D 5 이상이면 수렴
  const score = Math.log2(kd + 1) * 50;
  return Math.min(100, score);
}

K/D 1.0 → 50점, K/D 3.0 → 100점 (캡)

2. 정밀도 (Precision)

헤드샷 킬 비율. 조준 능력 지표.

function calculatePrecision(kills: number, headshotKills: number): number {
  if (kills === 0) return 0;
  const ratio = headshotKills / kills;
  // 30%가 100점
  return Math.min(100, ratio * 333);
}

왜 30%가 기준인가? 러스트 평균 헤드샷 비율이 15~20%. 30%면 상위권.

3. 꾸준함 (Consistency)

시간당 킬 (KPH). 접속 시간 보정.

function calculateConsistency(kills: number, playTimeHours: number): number {
  if (playTimeHours === 0) return 0;
  const kph = kills / playTimeHours;
  // KPH 3이 100점
  return Math.min(100, kph * 33.3);
}

100시간 플레이해도 KPH 1이면 점수 낮음. 효율적인 플레이 보상.

4. 영향력 (Impact)

총 데미지량. 킬 못 따도 기여한 경우 반영.

function calculateImpact(totalDamage: number, playTimeHours: number): number {
  // 시간당 데미지
  const dph = totalDamage / Math.max(1, playTimeHours);
  // DPH 1000이 100점
  return Math.min(100, dph / 10);
}

5. 스킬 (Skill)

장거리 킬 능력. 러스트에서 장거리 저격은 고급 스킬.

function calculateSkill(longestKill: number, avgKillDistance: number): number {
  // 최장 킬 거리 (미터)
  const longScore = Math.min(50, longestKill / 6);  // 300m가 50점
  // 평균 킬 거리
  const avgScore = Math.min(50, avgKillDistance / 2);  // 100m가 50점
  return longScore + avgScore;
}

6. 팀워크 (Teamwork)

어시스트, 소생 횟수.

function calculateTeamwork(assists: number, revives: number, playTimeHours: number): number {
  const teamActionsPerHour = (assists + revives * 2) / Math.max(1, playTimeHours);
  return Math.min(100, teamActionsPerHour * 20);
}

솔로 서버에서는 이 축 점수가 낮을 수밖에 없음. 그래서 가중치 낮게.

7. 경험 (Experience)

총 플레이타임. 베테랑 보너스.

function calculateExperience(playTimeHours: number): number {
  // 100시간이 100점, 이후 완만하게 상승
  return Math.min(100, Math.sqrt(playTimeHours) * 10);
}

제곱근 적용해서 노가다만으로 점수 못 올리게.


가중치 설정

const WEIGHTS = {
  combatEfficiency: 0.25,  // 전투 효율: 25%
  precision: 0.20,         // 정밀도: 20%
  consistency: 0.15,       // 꾸준함: 15%
  impact: 0.15,            // 영향력: 15%
  skill: 0.10,             // 스킬: 10%
  teamwork: 0.05,          // 팀워크: 5%
  experience: 0.10,        // 경험: 10%
};

function calculateTotalMMR(breakdown: MMRBreakdown): number {
  let total = 0;
  for (const [key, weight] of Object.entries(WEIGHTS)) {
    total += breakdown[key] * weight;
  }
  // 0-100을 0-2000으로 스케일
  return Math.round(total * 20);
}

가중치 설정이 제일 어려웠다. 어떤 비율이 "공정"한 건지 정답이 없음.

가중치 논란

처음엔 팀워크를 15%로 했다. 솔로 유저들 불만 폭발.

"나 혼자 하는데 팀워크 점수가 왜 이렇게 영향 커?"

5%로 내렸다. 이번엔 팀 플레이어들 불만.

"서포트 해주는 건 인정 안 받아?"

정답 없다. 서버 성격에 따라 조정하라고 설정 열어뒀다.


티어 시스템

숫자만 보여주면 감이 안 온다. 티어로 시각화.

type Tier = 'BRONZE' | 'SILVER' | 'GOLD' | 'PLATINUM' | 'DIAMOND' | 'MASTER';

function getTier(mmr: number): Tier {
  if (mmr >= 1800) return 'MASTER';
  if (mmr >= 1500) return 'DIAMOND';
  if (mmr >= 1200) return 'PLATINUM';
  if (mmr >= 900) return 'GOLD';
  if (mmr >= 600) return 'SILVER';
  return 'BRONZE';
}

티어 분포 문제

처음 기준이 너무 빡빡했다. 전체의 80%가 브론즈.

MASTER: 0.5%
DIAMOND: 2%
PLATINUM: 5%
GOLD: 12%
SILVER: 30%
BRONZE: 50.5%

의도한 분포:

MASTER: 1%
DIAMOND: 5%
PLATINUM: 15%
GOLD: 30%
SILVER: 30%
BRONZE: 19%

해결: 상대 평가로 변경.

function assignTiers(players: Player[]): void {
  // MMR 순으로 정렬
  const sorted = [...players].sort((a, b) => b.mmr - a.mmr);
  const total = sorted.length;
  
  sorted.forEach((player, index) => {
    const percentile = (index / total) * 100;
    
    if (percentile <= 1) player.tier = 'MASTER';
    else if (percentile <= 6) player.tier = 'DIAMOND';
    else if (percentile <= 21) player.tier = 'PLATINUM';
    else if (percentile <= 51) player.tier = 'GOLD';
    else if (percentile <= 81) player.tier = 'SILVER';
    else player.tier = 'BRONZE';
  });
}

이제 상위 1%가 마스터, 하위 19%가 브론즈.


실시간 업데이트

킬 발생 → 리더보드 즉시 반영

// WebSocket으로 킬 이벤트 수신
socket.on('kill', (data) => {
  const { killerId, victimId, isHeadshot, distance } = data;
  
  // 킬러 스탯 업데이트
  updatePlayerStats(killerId, {
    kills: '+1',
    headshotKills: isHeadshot ? '+1' : '+0',
    // ...
  });
  
  // 피해자 스탯 업데이트
  updatePlayerStats(victimId, {
    deaths: '+1',
  });
  
  // MMR 재계산
  recalculateMMR([killerId, victimId]);
  
  // 리더보드 브로드캐스트
  broadcastLeaderboardUpdate();
});

문제: 너무 자주 바뀜

킬 하나에 순위가 왔다 갔다. 1등이 계속 바뀌니까 의미가 없음.

해결: 디바운싱 + 스냅샷

// 10초마다 스냅샷 저장
setInterval(() => {
  saveLeaderboardSnapshot();
}, 10000);

// UI는 스냅샷 기준으로 표시, 실시간 변화는 작은 지표로만

시즌제 도입

리더보드가 누적이면 신규 유저가 의미 없음. 올드비가 영원히 1위.

2주 단위 시즌 도입.

interface Season {
  id: string;
  startDate: Date;
  endDate: Date;
  isActive: boolean;
}

// 시즌 종료 시 리셋
function endSeason(seasonId: string) {
  // 최종 순위 기록
  archiveSeasonResults(seasonId);
  
  // 모든 플레이어 스탯 리셋
  resetAllPlayerStats();
  
  // 새 시즌 시작
  startNewSeason();
}

근데 2주마다 완전 리셋하면 기존 유저 이탈.

해결: 소프트 리셋

function softReset(player: Player) {
  // 스탯은 리셋
  player.kills = 0;
  player.deaths = 0;
  // ...
  
  // MMR은 일부만 유지 (시작점 보너스)
  const carryOver = player.mmr * 0.3; // 30% 캐리오버
  player.mmr = 400 + carryOver; // 기본 400 + 캐리오버
}

골드였던 사람은 다음 시즌 실버에서 시작. 완전 바닥은 아님.


실패한 시도들

1. ELO 시스템

체스, 롤에서 쓰는 ELO.

// 킬 시 ELO 변동
const expectedWin = 1 / (1 + 10 ** ((victimElo - killerElo) / 400));
const deltaElo = K * (1 - expectedWin);

문제: 러스트는 1:1 대전이 아님. 팀전, 기습, 장비 차이... 변수가 너무 많음.

강한 장비로 약한 유저 잡아도 ELO 오르면 이상함.

2. 글리코 레이팅

ELO 개선판. 활동 안 하면 불확실성 증가.

문제: 러스트 유저는 비정기적으로 접속. 불확실성 계산이 의미 없음.

3. 트루스킬 (TrueSkill)

마이크로소프트가 만든 팀 기반 레이팅.

문제: 팀 구성이 매번 바뀜. 고정 팀이 아니라 적용 불가.

결론: 기존 레이팅 시스템은 러스트에 안 맞는다. 스탯 기반 커스텀 MMR이 최선.


현재 기능

  1. 실시간 리더보드: 킬/데스/MMR 순위
  2. 7축 분석: 각 축별 점수 시각화 (레이더 차트)
  3. 티어 시스템: 브론즈~마스터 6단계
  4. 시즌제: 2주 단위 리셋
  5. 개인 통계: 자신의 스탯 상세 분석

사용 통계

| 항목 | 수치 | |------|------| | 시즌 참여자 | ~200명 | | 마스터 달성자 | 2~3명/시즌 | | 평균 MMR | 950 | | 가장 높은 MMR 기록 | 1,847 |

마스터가 2~3명인 건 의도한 대로. 상위 1%가 마스터니까.


배운 것

  1. 공정성에 정답 없다: 어떤 기준이든 누군가는 불만
  2. 가중치는 정치다: 어떤 플레이스타일을 얼마나 보상할지는 선택의 문제
  3. 기존 시스템 그대로 못 쓴다: ELO, 트루스킬 다 러스트에 안 맞음
  4. 시각화가 중요: 숫자보다 티어, 차트가 유저 만족도 높임

레이더 차트 구현

7축을 시각화하는 레이더 차트.

function MMRRadarChart({ breakdown }: { breakdown: MMRBreakdown }) {
  const axes = [
    { key: 'combatEfficiency', label: '전투' },
    { key: 'precision', label: '정밀도' },
    { key: 'consistency', label: '꾸준함' },
    { key: 'impact', label: '영향력' },
    { key: 'skill', label: '스킬' },
    { key: 'teamwork', label: '팀워크' },
    { key: 'experience', label: '경험' },
  ];
  
  // SVG로 레이더 차트 그리기
  // 각 축을 360/7도 간격으로 배치
  // ...
}

유저들이 이거 좋아했다. 자기 강점/약점이 한눈에 보이니까.

다음 편에서는 팀 파인더. "솔로인데 같이 할 팀 구해요"를 자동화한 이야기.


질문 있으면 디스코드로: softopia