Redis 정렬 세트를 사용한 게임 실시간 순위표 설계
1. 개요
순위표(리더보드)는 거의 모든 온라인 게임에 존재합니다. 문제는 간단해 보입니다: 플레이어를 점수로 정렬하세요. 수백만 명의 플레이어와 매초 수천 경기가 종료될 때, 관계형 데이터베이스의 ORDER BY score DESC는 더 이상 요구 사항을 충족할 수 없습니다.
본 글은 전체 설계를 다룹니다: 요구 사항, Redis 정렬 세트의 데이터 모델, API, 처리 흐름, 그리고 점수 동점, 중복 업데이트, 데이터 일관성, 트래픽 급증과 같은 어려운 부분. 이 글을 읽고 나면 Spring Boot 3과 Redis로 작동하는 순위표를 구축할 수 있으며, 동시에 이의 한계를 알게 됩니다.
2. 요구사항
2.1. 기능 요구사항
- 각 시즌마다 별도의 순위표가 있습니다. 플레이어의 점수는 한 경기에서 달성한 최고 점수입니다.
- 상위 N명 보기(보통 100명).
- 자신의 순위 보기. 3,456,789등이어도 가능.
- “나 주변”: 위로 5명, 아래로 5명.
- 두 플레이어의 점수가 같으면, 그 점수를 먼저 달성한 사람이 위에 랭크됨.
2.2. 규모 및 제약
- 매 시즌 1천만 명의 플레이어.
- 평균 초당 2,000경기 결과. 피크 시간이나 이벤트 중에는 최대 초당 20,000경기.
- 읽기가 쓰기보다 훨씬 많음: 상위 100 화면이 계속 호출됨.
- 허용 가능한 표시 지연: 경기 종료 후 1–2초 이내.
- 한 경기 결과는 재시도로 인해 여러 번 전송될 수 있습니다. 중복된 결과는 순위표를 손상시켜서는 안 됩니다.
3. 왜 Redis 정렬 세트인가
Sorted set (ZSET)는 member → score 쌍을 저장하고 항상 점수로 순서를 유지합니다. 내부적으로 skip list와 hash table을 결합하므로 다음과 같은 특성이 있습니다:
| 작업 | 명령 | 시간 복잡도 |
|---|---|---|
| 점수 업데이트 | ZADD |
O(log N) |
| 순위 가져오기 | ZREVRANK |
O(log N) |
| 상위 N / 범위 가져오기 | ZREVRANGE |
O(log N + M) |
| 한 사람의 점수 가져오기 | ZSCORE |
O(1) |
N = 1천만인 경우, log N은 약 23 단계입니다. SQL과 비교하면: 한 사람의 순위를 알려면 그보다 높은 점수를 가진 행의 수를 세어야 합니다. 인덱스가 있어도 이 쿼리는 대규모에서 비용이 큽니다.
메모리 측면에서 각 member는 약 80–120바이트가 소요되며, ID 길이에 따라 다릅니다. 1천만 명의 플레이어는 약 1GB로, 한 Redis 노드에 충분히 들어갑니다.
4. 전체 아키텍처
Game Server ──► Kafka (topic: match-results) ──► Leaderboard Consumer ──► Redis ZSET
│ ▲
└──► Persist Consumer ──► PostgreSQL (원본 데이터) │
│
Client ──► Leaderboard API (Spring Boot) ── RAM에 상위 100 캐시 ────────────┘
세 가지 주요 결정:
- PostgreSQL이 원본 데이터입니다. Redis는 빠른 읽기를 위한 인덱스일 뿐입니다. Redis가 손실되면 데이터베이스에서 재구축할 수 있습니다.
- Kafka가 중간에 위치하여 트래픽 급증을 흡수합니다. 게임 서버는 Redis가 바쁠 때 느려지지 않습니다.
- API 읽기는 쓰기와 분리됩니다. 상위 100은 각 인스턴스의 RAM에 단기 캐시됩니다.
PostgreSQL에서 경기 결과를 저장하는 테이블:
CREATE TABLE match_results (
match_id UUID NOT NULL,
player_id BIGINT NOT NULL,
season_id INT NOT NULL,
points INT NOT NULL CHECK (points >= 0),
achieved_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (match_id, player_id)
);
CREATE INDEX idx_season_player ON match_results (season_id, player_id);
기본 키 (match_id, player_id)는 다시 쓰기를 무해하게 만듭니다: 단순히 INSERT ... ON CONFLICT DO NOTHING을 사용하면 됩니다.
5. Redis의 데이터 모델
5.1. 키와 member
lb:{s12} → ZSET, member = playerId, score = 인코딩된 점수
lb:{s12}:dedup:<matchId>:<playerId> → STRING, 처리된 결과 표시 (TTL 1일)
{s12} 부분은 hash tag입니다. Redis Cluster에서는 같은 hash tag를 가진 모든 키가 같은 slot에 위치합니다. Lua script에서 함께 사용하려면 이것이 필수입니다.
5.2. 복합 점수로 동점 처리
Redis는 같은 점수를 가진 member를 member의 사전 순서로 정렬합니다. 이 정렬 방식은 게임에서 의미가 없습니다. “먼저 달성한 사람이 위에”이어야 하므로 시간을 점수에 포함해야 합니다.
Redis 점수는 double이며 2^53까지 모든 정수를 정확히 표현합니다. 53비트를 다음과 같이 나눕니다:
- 상위 21비트: 점수(최대 약 200만).
- 하위 32비트:
MAX_TIME - 시즌 시작 이후 초. 일찍 달성한 사람이 더 큰 값을 가지므로, 내림차순 정렬에서 위에 오게 됩니다.
public final class ScoreCodec {
private static final int TIME_BITS = 32;
private static final long MAX_TIME = (1L << TIME_BITS) - 1;
public static final long MAX_POINTS = (1L << 21) - 1;
private ScoreCodec() {}
public static long encode(long points, long secondsSinceSeasonStart) {
if (points < 0 || points > MAX_POINTS) {
throw new IllegalArgumentException("points out of range: " + points);
}
if (secondsSinceSeasonStart < 0 || secondsSinceSeasonStart > MAX_TIME) {
throw new IllegalArgumentException("time out of range");
}
return (points << TIME_BITS) | (MAX_TIME - secondsSinceSeasonStart);
}
public static long points(double encoded) {
return ((long) encoded) >>> TIME_BITS;
}
}
encode 함수는 long을 반환합니다. double 형식 오류를 피하기 위해 이 값을 정수 문자열로 Redis로 전송합니다. 읽을 때는 points()가 32비트를 오른쪽으로 시프트하여 원래 점수를 복구합니다. (long) encoded 변환은 값이 2^53를 초과하지 않으므로 정확합니다.
참고: 게임의 점수가 200만을 초과하면 시간 해상도를 줄이세요. 예를 들어 초 대신 분 단위로 계산하여 점수를 위한 비트를 더 확보합니다. 절대로 53비트를 초과하지 마세요. 초과하면
double이 반올림하고, 다른 점수를 가진 두 사람이 같은 점수를 받을 수 있습니다.
인코딩 부분에 대한 테스트인데, 여기가 가장 실수하기 쉬운 부분입니다:
class ScoreCodecTest {
@Test
void higherPointsAlwaysRankAbove() {
long late = ScoreCodec.encode(1001, 3_000_000);
long early = ScoreCodec.encode(1000, 0);
assertThat(late).isGreaterThan(early);
}
@Test
void samePointsEarlierAchieverRanksAbove() {
long alice = ScoreCodec.encode(500, 100);
long bob = ScoreCodec.encode(500, 200);
assertThat(alice).isGreaterThan(bob);
}
@Test
void decodeSurvivesDoubleRoundTrip() {
long encoded = ScoreCodec.encode(ScoreCodec.MAX_POINTS, 12_345);
double fromRedis = (double) encoded;
assertThat(ScoreCodec.points(fromRedis)).isEqualTo(ScoreCodec.MAX_POINTS);
}
}
세 번째 테스트는 Redis가 점수를 double로 반환하는 상황을 시뮬레이션합니다. 이 테스트는 최대값에서도 올바르게 디코딩되는지 확인합니다.
6. 점수 업데이트: 원자적이고 멱등적
필요한 의존성:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
점수 업데이트는 동시에 두 가지 요구사항을 충족해야 합니다:
- 새 점수가 현재 점수보다 높을 때만 쓰기.
- 이미 처리한 경기 결과 무시하기.
ZSCORE 다음 ZADD를 사용하는 두 개의 별도 명령을 사용하면, 두 consumer가 그 사이에 개입할 수 있습니다. 그러면 낮은 점수가 높은 점수를 덮어쓸 수 있습니다. 모든 것을 하나의 Lua script로 병합합니다. Redis는 script를 단일 스레드로 원자적으로 실행합니다.
@Service
public class LeaderboardWriter {
private static final RedisScript<Long> SUBMIT = RedisScript.of("""
if not redis.call('SET', KEYS[2], '1', 'NX', 'EX', ARGV[3]) then
return -1
end
local cur = redis.call('ZSCORE', KEYS[1], ARGV[2])
if cur and tonumber(cur) >= tonumber(ARGV[1]) then
return 0
end
redis.call('ZADD', KEYS[1], ARGV[1], ARGV[2])
return 1
""", Long.class);
private final StringRedisTemplate redis;
public LeaderboardWriter(StringRedisTemplate redis) {
this.redis = redis;
}
public long submit(MatchResult r) {
String board = "lb:{s" + r.seasonId() + "}";
String dedup = board + ":dedup:" + r.matchId() + ":" + r.playerId();
long score = ScoreCodec.encode(r.points(), r.secondsSinceSeasonStart());
return redis.execute(SUBMIT, List.of(board, dedup),
Long.toString(score), Long.toString(r.playerId()), "86400");
}
}
public record MatchResult(UUID matchId, long playerId, int seasonId,
int points, long secondsSinceSeasonStart) {}
Script는 세 값을 반환합니다:
-1: 중복. 이 경기 결과는 이미 처리되었으므로SET NX가 실패함.0: 새 기록이 아님, 순위표는 변경되지 않음.1: 기록이 업데이트됨.
이 반환 코드를 사용하여 메트릭을 세거나 “방금 순위를 올렸습니다!“라는 알림을 보낼 수 있습니다.
참고: Redis 6.2+는
ZADD key GT score member를 지원하여 점수가 더 클 때만 쓰기합니다. dedup이 필요 없으면 script 대신 이 명령을 사용할 수 있습니다. 여기서는 중복 확인 단계를 같은 원자적 작업에 병합해야 하므로 script가 필요합니다.
게임 규칙이 점수를 누적하는 경우(ZINCRBY) 최고 점수를 취하는 대신, 멱등성이 필수가 됩니다. 이유: 두 번 더하기는 잘못됨. 그 경우 복합 점수의 시간 부분도 매번 추가할 때마다 업데이트해야 하므로 script가 더 복잡해집니다.
7. 읽기 API
@RestController
@RequestMapping("/seasons/{seasonId}/leaderboard")
public class LeaderboardController {
private final StringRedisTemplate redis;
public LeaderboardController(StringRedisTemplate redis) {
this.redis = redis;
}
public record Entry(long rank, long playerId, long points) {}
@GetMapping("/players/{playerId}/around")
public List<Entry> around(@PathVariable int seasonId, @PathVariable long playerId,
@RequestParam(defaultValue = "5") int radius) {
String key = "lb:{s" + seasonId + "}";
Long rank = redis.opsForZSet().reverseRank(key, Long.toString(playerId));
if (rank == null) {
return List.of();
}
long start = Math.max(0, rank - radius);
return range(key, start, rank + radius);
}
List<Entry> range(String key, long start, long end) {
var tuples = redis.opsForZSet().reverseRangeWithScores(key, start, end);
List<Entry> result = new ArrayList<>();
long rank = start + 1;
for (var t : tuples) {
result.add(new Entry(rank++, Long.parseLong(t.getValue()),
ScoreCodec.points(t.getScore())));
}
return result;
}
}
reverseRank는 내림차순으로 0부터 계산한 순위를 반환하므로, 표시 순위는 rank + 1입니다. 플레이어가 시즌에 점수가 없으면 함수는 null을 반환하고 API는 빈 목록을 반환합니다. /seasons/12/leaderboard/players/42/around를 호출하면 다음과 같은 결과를 얻습니다:
[{"rank":3456784,"playerId":901,"points":1520},
...
{"rank":3456789,"playerId":42,"points":1518},
...
{"rank":3456794,"playerId":77,"points":1515}]
reverseRank와 reverseRangeWithScores 두 명령은 원자적이지 않습니다. 두 명령 사이에 순위표가 약간 변할 수 있습니다. 순위표 화면에서는 수백 밀리초의 오차는 허용됩니다. 완벽한 정확성이 필요하면 Lua script로 병합하세요.
8. 어려운 부분과 해결 방법
8.1. 상위 100의 핫 키
모든 클라이언트가 상위 100을 호출하고, 모두 한 키로 한 노드 Redis로 들어갑니다. replica를 추가하면 도움이 됩니다. 그러나 가장 효과적인 방법은 각 API 인스턴스의 RAM에 캐시하고 매초 새로고침하는 것입니다:
@Component
public class TopCache {
private final LeaderboardController reader;
private volatile List<LeaderboardController.Entry> top100 = List.of();
public TopCache(LeaderboardController reader) {
this.reader = reader;
}
@Scheduled(fixedRate = 1000)
void refresh() {
top100 = reader.range("lb:{s12}", 0, 99);
}
public List<LeaderboardController.Entry> get() {
return top100;
}
}
@EnableScheduling을 활성화하는 것을 잊지 마세요. 50개의 API 인스턴스를 사용하면 Redis는 매초 50개의 상위 100 요청만 수신하며, 수백만 명의 클라이언트가 있을 수 있습니다. 예제에서는 시즌이 간단하게 하드코딩되어 있습니다. 실제로는 설정에서 현재 시즌을 가져와야 합니다.
8.2. 쓰기 트래픽 급증
이벤트가 종료되면 수만 개의 경기가 동시에 종료될 수 있습니다. Kafka는 버퍼 역할을 합니다. Consumer는 배치로 처리하고 pipeline을 통해 명령을 전송하여 네트워크 왕복 횟수를 줄입니다:
@KafkaListener(topics = "match-results", batch = "true", groupId = "leaderboard")
public void onBatch(List<MatchResult> results) {
results.forEach(writer::submit);
}
더 빠르려면 execute를 redis.executePipelined(...)로 배치 처리할 수 있습니다. 또한 topic을 playerId로 partition합니다. 덕분에 한 플레이어의 모든 결과가 같은 partition에서 순차적으로 처리되어 경합을 줄입니다. Lua script는 경합이 있더라도 정확성을 유지합니다.
Redis가 느리면 consumer lag가 증가하지만, 게임 서버는 영향을 받지 않습니다. 플레이어는 순위표 업데이트가 수 초 지연되는 것만 봅니다.
8.3. Redis와 database 간 일관성
Redis는 fsync 전에 노드가 죽으면 데이터를 잃을 수 있습니다. PostgreSQL이 원본 데이터이므로 순위표를 재구축하는 job이 필요합니다:
SELECT player_id,
MAX(points) AS best,
MIN(achieved_at) FILTER (WHERE points = best_points) AS first_at
FROM (
SELECT player_id, points, achieved_at,
MAX(points) OVER (PARTITION BY player_id) AS best_points
FROM match_results
WHERE season_id = 12
) t
GROUP BY player_id;
Job은 결과를 lb:{s12}:rebuild 같은 임시 키에 쓴 다음 RENAME을 사용하여 실제 키를 원자적으로 교체합니다. 사용자는 중간에 순위표가 비어 있는 것을 절대 보지 않습니다.
이 job은 정기적으로 실행되어 대조합니다. Redis의 member 수가 database와 다르면 이를 감지할 수 있습니다.
8.4. 일일, 주간 순위표 및 정리
일일 순위표는 lb:{s12}:d:2026-10-12 키를 사용하며 EXPIRE는 3일입니다. 주간 순위표는 ZUNIONSTORE ... AGGREGATE MAX를 사용하여 일일 순위표에서 집계할 수 있습니다. 시즌 종료 시 상위 1000을 database에 스냅샷하여 보상을 지급한 다음 DEL 대신 UNLINK를 사용하여 큰 키를 삭제하여 Redis 차단을 피합니다.
8.5. 친구 순위표
약 200명의 친구 목록을 가지면, ZMSCORE lb:{s12} id1 id2 ...를 사용하여 모두의 점수를 한 번에 가져온 다음 Java에서 정렬합니다. 각 사람마다 ZSET을 만들 필요가 없습니다.
9. 이 설계를 사용하면 안 되는 경우
- 한 노드를 초과하는 규모. 하나의 ZSET은 Cluster의 여러 노드에 분할될 수 없습니다. 같은 순위표에 수억 명의 플레이어가 있으면 점수 범위나 지역별로 분할해야 하며, 전역 순위는 근사치에 불과합니다.
- 복잡한 다중 차원 순위 기준. 예: 점수, 그 다음 승률, 그 다음 경기 수. 이 경우 53비트는 모든 것을 인코딩하기에 충분하지 않습니다.
- 재정적 목적으로 정확한 순위가 필요한 경우. 실제 돈으로 보상할 때는 마감 시점에 database에서 다시 계산합니다. Redis에서 직접 가져오지 마세요.
- 플레이어 수가 적은 경우. 몇만 명의 행이면
(season_id, points DESC)index가 있는 PostgreSQL로 충분하며, 운영할 시스템 하나를 줄일 수 있습니다.
10. 결론
Redis 정렬 세트는 업데이트, 순위 가져오기, 상위 N 가져오기를 O(log N) 비용으로 수행할 수 있습니다. 이는 실시간 순위표의 자연스러운 기반입니다.
어려운 부분은 주변 세부사항에 있습니다:
- 점수 동점을 53비트를 초과하지 않고 점수에 인코딩.
- Lua script를 사용하여 기록만 쓰고 멱등성 유지.
- 트래픽 급증을 흡수하려면 Kafka를 앞에 배치.
- hot key를 피하려면 상위 100을 RAM에 캐시.
- PostgreSQL을 원본 데이터로 유지하여 언제든지 Redis를 재구축할 수 있도록 함.
적용할 때는 먼저 ScoreCodec에 대한 테스트를 작성하세요. 여기서의 비트 오버플로우 오류는 경고 없이 전체 순위표를 손상시킵니다.