킬 수로만 순위 매기면 불공정하다 - MMR 시스템 설계
12월 5일 티어 시스템 도입 → 같은 날 115명 동접 달성. 671명 유저의 공정한 순위를 위한 MMR 설계
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이 최선.
현재 기능
- 실시간 리더보드: 킬/데스/MMR 순위
- 7축 분석: 각 축별 점수 시각화 (레이더 차트)
- 티어 시스템: 브론즈~마스터 6단계
- 시즌제: 2주 단위 리셋
- 개인 통계: 자신의 스탯 상세 분석
사용 통계
| 항목 | 수치 | |------|------| | 시즌 참여자 | ~200명 | | 마스터 달성자 | 2~3명/시즌 | | 평균 MMR | 950 | | 가장 높은 MMR 기록 | 1,847 |
마스터가 2~3명인 건 의도한 대로. 상위 1%가 마스터니까.
배운 것
- 공정성에 정답 없다: 어떤 기준이든 누군가는 불만
- 가중치는 정치다: 어떤 플레이스타일을 얼마나 보상할지는 선택의 문제
- 기존 시스템 그대로 못 쓴다: ELO, 트루스킬 다 러스트에 안 맞음
- 시각화가 중요: 숫자보다 티어, 차트가 유저 만족도 높임
레이더 차트 구현
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