Architecture(2) - 실시간 게임 리더보드 아키텍처(Redis Sorted Set부터 DynamoDB 샤딩까지)
in DEV on Architecture System-design Redis Sorted-set Nosql Dynamodb Scalability Skiplist Write-sharding Serverless 대규모시스템설계 실시간순위표 레디스 아키텍처 노에스큐엘 다이나모디비 스킵리스트 쓰기샤딩 서버리스 시스템아키텍처
- 1. 문제 이해 및 요구사항 정의
- 2. 개략적 시스템 설계 및 데이터 흐름
- 3. 대규모 확장을 위한 상세 설계
- 4. 운영 이슈 및 고도화 방안
- 5. 요약 및 결론
- 참고 사이트 & 함께 보면 좋은 사이트
온라인 게임의 리더보드(순위표)는 토너먼트가 경쟁전에서 선두를 달리는 플레이어가 누구인지 보여주며, 유저에게 승부욕과 지속적인 플레이 동기를 부여한다.
실시간 순위표는 최상위 권 플레이어의 순위뿐 아니라 순위표를 조회하는 현재 유저 자신의 순위와 앞뒤 순위 유저들의 정보까지 실시간으로 정확히 보여줄 수 있어야 한다.
이번 포스트에서는 수백만 DAU 규모를 지탱할 수 있는 대규모 실시간 리더보드 시스템 설계 과정을 요구사항 정의부터 살펴본다.
1. 문제 이해 및 요구사항 정의
- 점수 계산 방식
- Q: 순위표의 점수는 어떻게 계산하는가?
- A: 게임에서 승리하면 포인트를 얻으며, 이 포인트로 점수를 계산한다. 경기에서 이길 때마다 1점의 포인트를 추가로 획득하게 된다.
- 대상 플레이어
- Q: 모든 플레이어가 순위표에 포함되어야 하는가?
- A: 그렇다.
- 유효 기간
- Q: 한 순위표는 얼마 동안이나 유효한가?
- A: 매달 새로운 토너먼트를 시작할 때마다 새로운 순위표를 만든다.
- 표시 범위
- Q: 상위 10명의 사용자만 신경써도 되는가?
- A: 상위 10명의 사용자와 특정 사용자의 순위를 순위표에 표시할 수 있어야 한다. 가능하면 해당 사용자의 4순위 위(상위) 또는 아래(하위)에 있는 사용자들까지 함께 반환한다.
- 사용자 규모
- Q: 토너먼트에 참가하는 사용자는 몇 명인가?
- A: 평균 일간 활성 사용자 수(DAU)는 500만 명, 월간 활성 사용자 수(MAU)는 2,500만 명으로 가정한다.
- 경기 빈도
- Q: 토너먼트 기간 동안 평균 몇 경기가 진행되는가?
- A: 각 선수는 하루 평균 10경기를 치른다.
- 동점자 처리
- Q: 두 플레이어가 점수가 같은 경우 어떻게 순위를 결정하는가?
- A: 이 경우 두 사람은 동점이다. 가능하다면 동점자 사이의 순위를 가르는 방법도 마련하면 좋다.
- 실시간성
- Q: 순위표가 실시간이어야 하는가?
- A: 그렇다 누적된 결과 이력을 주기적으로 보여주는 방식은 바람직하지 않다.
1.1 기능 요구사항
- 순위표에 상위 10명의 플레이어를 표시한다.
- 특정 사용자의 현재 순위를 표시한다.
- 특정 사용자를 기준으로 위로 4명, 아래로 4명의 순위/플레이어 정보를 표시한다.
1.2. 비기능 요구사항
- 실시간성
- 점수 업데이트는 실시간으로 순위표에 반영되어야 한다.
- 확장성
- 트래픽 급증에도 지연 없이 순위를 조회/갱신할 수 있어야 한다.
- 고가용성 및 안정성
- 시스템 일부에 장애가 발생해도 서비스가 중단되지 않아야 한다.
1.3. QPS 및 트래픽 산정
트래픽을 수치화하여 백엔드 인프라 수용량을 추정해본다.
- 기본 조건
- DAU(Daily Active Users): 5,000,000명(500만 명)
- 하루 = 86,400 \(\approx 10^5\text{초}\)
트래픽 산정
게임을 하는 사용자가 24시간 동안 완벽히 고르게 분포한다고 가정하면 평균 초당 사용자 수는 다음과 같다. \[\text{평균 초당 접속 유저} = \frac{5,000,000 \text{ DAU}}{10^5 \text{초}} = 50 \text{ users/sec}\]
하지만 실제로 트래픽은 절대로 균등하게 분산되지 않는다.
특정 시간대에 트래픽이 몰리는 피크 타임이 존재하며, 서로 다른 시간대의 사람들이 동시에 게임을 할 수 있는 북미 지역 기준 저녁 시간이 그 피크 시간대일 확률이 높다.
여기서는 최대 부하(Peak Load)를 평균의 5배라고 가정한다. \[\text{피크 타임 초당 접속 유저} = 50 \times 5 = 250 \text{ users/sec}\]
💡북미 지역 기준 저녁 피크 타임이 갖는 의미는 무엇일까?
북미(미국/캐나다) 대륙은 동부(EST), 중부(CST), 산악(MST), 태평양(PST) 등 4개의 주요 시차(총 3시간 차이)가 존재한다.
한국이나 단일 시차 국가는 저녁 8시~10시 사이에 피크 타임이 찍히고 빠르게 지나가지만, 북미는 동부 저녁 8시(태평양 오후 5시)부터 시작되어 태평양 저녁 9시(동부 자정)까지 약 4~5시간 동안 북미 대륙 전역의 저녁 퇴근/방학 시간대가 연속적으로 겹치는 거대한 ‘합성 피크 타임 구간’이 형성된다.
또한 글로벌 단일 서버/지역 서비스를 운영할 경우, 가장 많은 플레이어 지출과 동시 접속자가 발생하는 북미 저녁 시간대는 인프라의 최대 수용량을 결정짓는 ‘글로벌 최고 피크 타임’의 대명사로 자주 인용된다.
QPS(Query Per Second) 및 TPS 산정
1) 사용자 점수 획득 이벤트 TPS
- 한 사용자가 하루 평균 10개의 게임을 플레이한다.
- 점수 획득 TPS는 다음과 같이 계산할 수 있다.
2) 상위 10명 순위표 조회 QPS
- 각 사용자가 하루에 한 번 게임을 열고, 상위 10명의 순위표는 사용자가 처음 게임을 열 때만 표시한다고 가정한다.
- 따라서 상위 10명 조회 피크 QPS는 다음과 같다.
2. 개략적 시스템 설계 및 데이터 흐름
실시간 게임 순위표 시스템은 크게 게임 진행을 담당하는 게임 서비스와 점수 및 순위를 관리하는 순위표 서비스로 역할이 분리된다.
2.1. API 설계
클라이언트 및 게임 서버가 클라이언트와 통신하기 위한 RESTful API 명세이다.
1) POST /v1/scores
- 역할: 사용자가 게임에서 승리했을 때 순위표 상의 사용자 점수를 갱신한다.
- 보안 법칙: 이 API는 게임 서버 내부에서만 호출할 수 있는 내부 API이다. 클라이언트(앱/웹)가 이 API를 직접 호출하여 점수를 주작하거나 업데이트할 수 없다.
요청 인자(Request Body)
| 필드 | 타입 | 설명 |
|---|---|---|
user_id | String | 게임에서 승리한 사용자 식별자 |
points | Integer | 사용자가 게임 승리로 획득한 포인트 수 |
응답(Response)
| 상태 코드 | 설명 |
|---|---|
200 OK | 사용자 점수를 성공적으로 갱신함 |
400 Bad Request | 잘못된 인자가 전달되어 점수를 갱신하지 못함 |
2) GET /v1/scores
- 역할: 순위표에서 상위 10명의 플레이어 정보를 가져온다.
응답 예시(Response)
{
"data": [
{
"user_id": "aaa",
"user_name": "assu",
"rank": 1,
"score": 999
},
{
"user_id": "bbb",
"user_name": "silby",
"rank": 2,
"score": 996
}
],
...
"total": 10
}
3) GET v1/scores/{:user_id}
- 역할: 특정 사용자의 현재 순위 및 점수 정보를 가져온다.
파라미터(Path Parameter)
| 필드 | 설명 |
|---|---|
| user_id | 순위 정보를 가져올 대상 사용자 ID |
응답 예시(Response)
{
"user_info": {
"user_id": "aaa",
"score": 999,
"rank": 7
}
}2.2. 개략적 아키텍처와 서비스 분리
아래 다이어그램은 게임 서비스와 순위표 서비스의 역할 분담을 나타낸 개략적인 아키텍처이다.
게임 서비스는 사용자가 게임을 플레이할 수 있는 서비스이고, 순위표 서비스는 순위표를 생성하고 표시한다.

- ① 승리 요청
- 사용자가 게임에서 승리하면 클라이언트는 게임 서비스로 승리 결과를 전송한다.
- ② 검증 및 점수 갱신
- 게임 서비스는 해당 승리가 정상적인 플레이인지 검증한 후, 순위표 서비스 내부의 API(
POST /v1/scores)를 호출한다.
- 게임 서비스는 해당 승리가 정상적인 플레이인지 검증한 후, 순위표 서비스 내부의 API(
- ③ 저장소 반영
- 순위표 서비스는 순위표 저장소(Leaderboard Store)에 해당 사용자의 점수를 갱신한다.
- ④ 순위 조회
- 클라이언트는 순위표가 필요할 때 순위표 서비스로 직접 요청을 보내 (a)상위 10명 순위표, (b)자기 자신의 순위 정보를 가져온다.
최종적으로 이 설계안을 택하기 전에 다른 대안도 고려했지만 채택하지 않은 이유에 대해서 살펴본다.
2.2.1. 대안1: 클라이언트가 순위표 서비스와 직접 통신해야 할까?
클라이언트가 순위표 서비스에 직접 점수를 업데이트하는 방식을 사용해서는 안된다.
클라이언트와 순위표 서비스가 직접 통신할 경우, 사용자가 Proxy 툴(예: Fiddler, Charles)을 중간에 설치하여 요청 패킷을 위변조하는 중간자 공격(Man-In-The-Middle-Attack)에 무방비로 노출된다.
따라서 점수 설정 권한은 반드시 서버(게임 서비스)에 두어야 한다.

온라인 포커처럼 서버가 게임 전반을 통솔하는 경우에는 클라이언트가 점수를 설정하기 위해 게임 서버를 명시적으로 호출할 필요가 없을수도 있다.
게임 서버가 모든 게임 로직을 처리하고 게임이 언제 끝나는지 알기 때문에 클라이언트의 개입 없이도 점수를 정할 수 있다.
💡다른 게임들도 게임 서비스가 게임이 언제 끝나는지 아는 것 아닐까?
모든 게임이 게임 서버에서 완벽히 통제되는 것은 아니다.
- 클라이언트 완결형 게임(예: 싱글 퍼블, 캐주얼 오프라인 게임)
- 게임의 플레이 과정과 종료 판정이 유저의 스마트폰(클라이언트) 내부에서 실행된다.
- 게임이 끝난 시점에 클라이언트가 서버로 ‘나 100점 얻었어’라고 결과만 통보하는 구조이다.
- 이 경우 서버는 게임 진행 상황을 알 수 없으므로 클라이언트의 전달에 의존하므로 위변조에 매우 취약하다.
- 서버 통솔형 게임(예: 온라인 포커, 체스)
- 중앙 게임 서버에서 모든 카드 덱, 기물의 이동, 세션 종료 시점을 직접 계산한다.
- 클라이언트는 단순 입력 전달 장치일 뿐이며, 게임이 언제 끝났는지 서버가 100% 실시간으로 파악하고 결과를 직접 반영한다.
2.2.2. 대안 2: 게임 서비스와 순위표 서버 사이에 메시지 큐가 필요한가?
해당 데이터가 순위표 외에 분석, 푸시 알림 등 다른 서비스에서도 동시에 사용되어야 한다면 카프카와 같은 메시지 큐를 사이에 두는 것이 합리적이다.

2.3. 데이터 모델: RDB vs Redis Sorted Set
2.3.1. RDB의 한계와 SQL 쿼리 분석
규모 확장성이 그다지 중요하지 않고 사용자 수가 많지 않다면 RDB를 이용할 수 있다.
💡RDB는 왜 규모 확장성이 좋지 않을까?
RDB는 디스크 기반 B-Tree 인덱스를 사용한다.
실시간으로 수천 명의 점수가 바뀔 때마다 B-Tree 인덱스 재배치(Page Split)와 디스크 I/O가 발생하여 쓰기 성능이 급격히 저하된다.
또한, N명의 전체 데이터를 정렬하고 순위를 계산하는 연산은 \(O(N \log N)\) 또는 \(O(N)\)의 시간 복잡도를 가지므로,
데이터가 수백만 건을 넘어서면 CPU와 메모리가 고갈되어 Scale-out(Sharding)이 극도로 어려워진다.
순위표 DB 테이블: 
사용자가 점수를 획득한 경우: 
특정 사용자 순위 검색: 
사용자 순위를 가져오려면 순위표 테이블을 점수 기준으로 정렬한 후 순위를 매긴다.
SELECT (@rownum := @rownum + 1) AS rank, user_id, score
FROM leaderboard
ORDER BY score DESC;
위 질의의 실행 결과는 아래와 같다.
| rank | user_id | score |
|---|---|---|
| 1 | aaa | 987 |
| 2 | bbb | 902 |
| 3 | ccc | 870 |
| 4 | ddd | 850 |
데이터가 몇천 건 수준일 때는 이 쿼리가 문제없이 작동하지만, 레코드가 수백만 건 이상으로 늘어나면 성능이 심각하게 저하된다.
유저의 순위를 정확히 찾으려면 수백만 개의 행 전체를 디스크에서 읽어와 정렬해야 한다.
지속적으로 점수가 변하는 대량의 데이터를 RDB가 매번 실시간으로 정렬하는 것은 불가능에 가깝다.
수백만 건을 정렬하는데 수십 초 이상 걸리기 때문에 실시간 응답이 불가능하며, 점수가 계속 변하므로 정렬 결과를 캐시에 보관하기로 어렵다.
즉, RDBMS는 여기서 요구하는 다량의 읽기 부하를 처리하기 어렵다. 일괄 작업으로 하면 가능하겠지만, 이는 실시간 순위를 보여주어야 한다는 요구 사항에 적합하지 않다.
LIMIT절을 이용해 상위 10명만 가져오는 최적화를 적용할 수는 있다.
SELECT (@rownum := @rownum + 1) AS rank, user_id, score
FROM leaderboard
ORDER BY score DESC
LIMIT 10;
하지만 이 역시 전체 확장성 문제를 해결해주지 못한다.
상위 10명은 제한할 수 있어도, 순위표 하단에 위치한 특정 일반 유저의 현재 순위를 알아내려면 결국 전체 테이블을 스캔해야 하기 때문이다.
특정 유저의 순위를 구하기 위해 아래와 같이 상관 서브쿼리(Correlated Subquery)를 실행하는 방안도 있다.
SELECT *, (SELECT COUNT(*) FROM leaderboard lb2 WHERE lb2.score >= lb1.score) AS rank
FROM leaderboard lb1
WHERE lb1.user_id = {:user_id};
위 쿼리는 lb1에 존재하는 대상 유저의 점수보다 높거나 같은 점수를 가진 행의 개수를 lb2 테이블 전체에서 매번 COUNT(*)로 세어오는 방식이다.
전체 유저가 100만 명이라면 유저 1명의 순위를 조회하기 위해 100만 번의 비교 스캔 연산이 수행되어 \(O(N^2)\) 수준의 최악의 지연 시간이 발생한다.
Big-O(빅오) 표기법에 대한 내용은 Big-O(빅오) 표기법를 참고하세요.
💡상관 서브쿼리(Correlated Subquery)란?
서브쿼리가 단독으로 실행되지 못하고, 외부 쿼리의 각 행을 바인딩받아 실행되는 의존적인 구조를 가지는(=외부 쿼리와 연관되어 있는) 쿼리를 말한다.
위 쿼리의 경우 내부 서브쿼리가 외부 쿼리의 컬럼 값인 lb1.score를 참조해서 동작하고 있다.
2.3.2. Redis Sorted Set을 활용한 실시간 순위표 최적화
수백만 명의 유저 환경에서도 일관되게 빠른 속도를 보장하고 복잡한 쿼리 없이 정렬을 처리할 수 있는 최고의 솔루션은 Redis이다.
Redis는 모든 데이터를 메모리에 보관하는 In-Memory 저장소로, 실시간 리더보드 구현에 완벽히 부합하는 정렬 집합(Sorted Set) 자료구조를 기본으로 제공한다.
2.3.2.1. 정렬 집합(Sorted Set)과 스킵 리스트(Skip List)의 원리
Sorted Set은 집합(Set)처럼 각 원소(Member)가 중복되지 않는 고유한 값을 가지면서, 각 원소마다 점수(Score)가 연결되어 있어 점수를 기준으로 자동 정렬되는 자료형이다.
Redis Sorted Set은 내부적으로 해시 테이블(Hash Table)과 스킵 리스트(Skip List)라는 두 가지 자료구조를 조합하여 동작한다.
- 해시 테이블
- \(O(1)\) 시간 복잡도로 유저의 현재 점수를 즉시 찾아낸다.
- 스킵 리스트
- \(O(log N)\) 시간 복잡도로 점수순 정렬 상태를 유지하며 특정 순위 위치를 검색한다.
아래는 Sorted Set을 이해하기 위한 그림이다.
Score 및 Member 열이 있는 테이블로 이해하면 되며, 이 테이블은 Score의 내림차순으로 정렬된다.

스킵 리스트는 정렬된 단방향 연결 리스트(Singly-lined List)에 다단계 색인(Multi-level Index)을 얹은 자료 구조이다.
기본 연결 리스트에서 특정 노드를 찾으려면 처음부터 끝까지 순회해야 하므로 시간 복잡도는 \(O(N)\)이 걸린다.

검색 속도를 정렬된 배열의 이진 탐색 수준으로 높이기 위해 중간 노드들을 건너뛰는 1차 색인, 2차 색인을 단계적으로 쌓아올린다.
새로운 색인이 추가될 때마다 이전 레이어 노드를 하나씩 건너뛰어 탐색 속도를 비약적으로 향상시킨다.
아래 그림처럼 5차 색인까지 구축된 스킵 리스트에서는, 기본 리스트만 이용할 경우 62개 노드를 일일이 거쳐야 했던 목적지에 단 11번의 이동만으로 다다를 수 있다.

💡스킵 리스트에서 노드 사이의 거리가 n-1이 될 때 더 이상 색인을 추가하지 않는 이유는?
스킵 리스트에서 1차, 2차.. 계속 위로 색인 레이어를 올리다 보면, 최상단 레벨 색인은 단 2개의 노드(첫 번째와 마지막 노드)만 연결하게 된다.
이 때 최상단 색인 노드가 한 번에 점프하여 건너뛰는 물리적 노드의 간격이 전체 리스트 개수 n에 해당하는 n-1개의 노드 전체가 된다는 뜻이다.
즉, 더 이상 상위 색인 레이어를 쌓을 수 없는 최상단 헤드 색인 층에 도달했다.는 의미이다.
2.3.2.2. Redis Sorted Set 핵심 명령어
실시간 리더 보드에 Redis Sorted Set을 활용하는 예시는 아래를 참고하세요.
Building real-time Leaderboard with Redis
Build a real-time gaming leaderboard with Amazon ElastiCache for Redis
How we created a real-time Leaderboard for a million Users
ZADD- 순위표에 유저를 신규 추가하거나 기존 유저의 점수를 갱신한다. 실행 소요 시간은 \(O(log(N))\)이다.
ZADD key [NX|XX] [GT|LT] [CH] [INCR] score member [score member ...]
ZINCRBY- 유저의 점수를 지정한 값만큼 가산/감산한다. 실행에 소요되는 시간은 O\((log(N))\)이다.
ZINCRBY key increment member
ZRANGE/ZREVRANGE- 오름차순 또는 내림차순 정렬 기준 특정 범위의 유저 목록을 가져온다. \(O(log(N) + M)\), M은 가져올 항목 수, N은 Sorted Set의 크기
ZRANGE key min max [BYSCORE|BYLEX] [REV] [LIMIT offset count] [WITHSCORES]ZREVRANGE key start stop [WITHSCORES]
ZRANK/ZREVRANK- 오름차순 또는 내림차순 정렬 기준 특정 유저의 순위 인덱스(O-indexed)를 가져온다. 실행 시간은 \(O(log(N))\)이다.
ZRANK key memberZREVRANK key member
2.3.2.3. Sorted Set 기반 실시간 순위표 처리 프로세스
1) 유저가 게임에서 승리하여 점수를 획득한 경우
매월 신규 순위표 키(leaderboard_feb_2021)를 생성해 운영한다.
유저가 승리하여 1점을 얻으면 ZINCRBY를 호출한다.

ZINCRBY leaderboard_feb_2021 1 'assu'
2) 상위 10명 순위표를 조회하는 경우
점수가 가장 높은 순서대로 1위부터 10위까지 가져오기 위해 ZREVRANGE 명령을 사용하며, 유저 ID와 점수를 함께 받아오기 위해 WITHSCORES 옵션을 붙인다.

ZREVRANGE leaderboard_feb_2021 0 9 WITHSCORES
반환 예시:
[(aaa, 998), (silby, 996), ...]
3) 유저 본인이 현재 순위를 조회하는 경우
내림차순 정렬 기준 유저의 순위를 알기 위해 ZREVRANK를 호출한다.

ZREVRANK leaderboard_feb_2021 'assu'
반환 예시:
1
4) 유저 본인 기준 인접 순위(위로 4명, 아래로 4명)를 조회하는 경우
Mallow007 유저의 현재 순위 인덱스가 361이라면, 357부터 365까지의 인덱스 범위를 ZREVRANGE로 지정하여 한 번에 받아온다.

ZREVRANGE leaderboard_feb_2021 357 365
2.3.3. 저장소 요구사항 및 용량 산정
시스템 운영에 필요한 메모리 용량을 추정해보자.
- 최악의 상황 가정
- MAU 2,500만 명 전원이 최소 1회 이상 승리하여 월 순위표 집합에 모두 등재되는 경우
- 데이터 단위 용량
- 유저 ID(24바이트 문자열) + 점수(2바이트 = 16비트 정수) = 26바이트
- 전체 순수 데이터 용량
💡점수는 왜 16비트일까?
점수의 최대 한계치가 0~65,535 범위 이내인 게임 규칙을 가정하여 최소 단위 예시(16비트 = 2바이트)로 제시된 값이다.
만약 점수가 수억 점대라면 32비트(4바이트)나 64비트(8바이트) 정수/실수형을 사용하면 되며, 이 경우에도 전체 메모리 증가량은 수백 MB 수준에 그쳐 단일 서버 수용 범위 내에 있다.
스킵 리스트 포인터 오버헤드와 해시 테이블 메타데이터를 감안해 메모리 사용량을 2배(약 1.3GB)로 넉넉히 잡아도, 단일 레디스 서버 1대만으로 2,500만 유저의 전체 순위표를 RAM에 여유 있게 탑재할 수 있다.
CPU 및 I/O 관점에서도 피크 타임 점수 갱신 트래픽이 초당 2,500 TPS 수준이므로, 초당 5만~10만 TPS 이상을 처리하는 단일 Redis 노드로 충분히 감당 가능하다.
영속성에 대한 부분을 살펴보자.
레디스는 데이터를 디스크에 영속적으로 보관하는 옵션도 지원하지만, 디스크에서 데이터를 읽어 대규모 레디스 인스턴스를 재시작하려면 시간이 오래 걸린다.
메모리 스펙과 디스크 I/O 속도에 따라 다르겠지만, 보통 수 GB~수십 GB의 RDB 파일 스냅숏을 디스크에서 메모리로 로딩할 때 초당 100MB~500MB 복원 속도가 나온다.
따라서 대략 수초에서 수분 이상 소요될 수 있으며, 로딩이 완료될 때까지 Redis 인스턴스가 요청을 처리하지 못하므로 서비스 지연이 발생할 수 있다.
그래서 실무에서는 디스크 복구 대신 고가용성을 위해 복제본(Read Replica)을 주 서버로 승격(Failover)시키는 방식을 사용한다.
MySQL과 같은 관계형 데이터베이스를 사용하는 경우에는 2개의 테이블인 사용자 테이블과 점수 테이블이 필요하다.
사용자 테이블에는 사용자 아이디와 사용자의 게임 내 이름을 저장하고, 점수 테이블에는 사용자 아이디, 점수, 승리 시각을 저장한다.
한 가지 적용할 수 있는 성능 최적화 방안은 가장 자주 검색되는 상위 10명의 사용자 정보를 캐시하는 것이다.
💡상위 10명이 계속 바뀔텐데 ‘프로필 캐시’가 효과적일까?
결론은 유저 순위가 계속 바뀌더라도 프로필 캐싱은 여전히 극적인 성능 최적화 효과를 발휘한다.
여기서 캐싱하는 대상은 실시간으로 시시각각 바뀌는 ‘유저의 점수나 순위’가 아니라, 유저의 ‘정적 프로필 정보(유저 닉네임, 클랜 이름 등)’이다.
Redis Sorted Set에는 유저 ID와 점수만 저장해두고 빠르게 상위 10명의 user_id를 가져온 후, 해당 유저들의 프로필 정보는 DB 대신 캐시(Redis)에서 가져옴으로써 DB 조회를 100% 차단하는 최적화 패턴이다.
- 상위권 유저의 ‘집군(Locality) 효과’
- 전체 유저가 2,500만 명에 달하더라도, 리더보드 1~10위 사이를 두고 실시간으로 랭킹 다툼을 벌이는 유저 층은 대개 상위 50~100명 이내의 한정된 헤비 유저군이다.
- 개별 유저 단위 식별자 캐싱(
user:profile:{user_id})- 프로필 캐시는 ‘상위 10명 통짜 묶음’을 캐싱하는 것이 아니라
user:profile:aaa,user:profile:bbb와 같이 유저 개별 키 단위로 레디스에 보관한다. - Redis ZSet에서 상위 10명의
user_id목록을 새로 뽑았을 때, 백엔드 서버는MGET user:profile:aaa user:profile:bbb...명령으로 10명의 프로필을 캐시에서 \(O(1)\)로 일괄 조회한다. - 유저 조합이 어떻게 바뀌든 개별 프로필 캐시는 유효하므로 DB 조회가 발생하지 않는다.
- 프로필 캐시는 ‘상위 10명 통짜 묶음’을 캐싱하는 것이 아니라
- 조회 트래픽(Read QPS)와 순위 변동(Rank Flip)의 압도적 비율 차이
- 상위 10명의 순위 주인이 뒤바뀌는 주기가 아무리 빠르더라도(예: 수 초~수 분에 1번), 그 짧은 순간 사이에도 일반 유저들이 순위표를 조회하는 횟수(Read QPS)는 수천~수만 건에 달한다.
- 단 1초 동안 상위 10명이 유지되는 사이, 피크 타입에 몰려드는 수천 건(약 2,000~2,500 QPS)의 동시 조회 요청이 DB로 가지 않고 프로필 캐시에서 즉시 리턴되므로, DB의 CPU와 I/O 부담을 차단해준다.
3. 대규모 확장을 위한 상세 설계
수백만 명에서 수억 명 규모의 유저를 지탱하기 위해 리더보드 시스템을 클라우드 환경으로 확장하고, Redis 샤딩 및 NoSQL 대안을 적용하는 상세 설계 방안을 살펴본다.
3.1. 클라우드 서비스 활용 및 인프라 구성
리더보드 서비스 배포 아키텍처는 기업의 기존 인프라 환경에 따라 자체 구축(On-Premis) 방안과 서버리스 클라우드(AWS) 방안으로 나눌 수 있다.
3.1.1. 자체 서비스를 이용하는 방안
이 접근 방식에서는 매월 새로운 Redis Sorted Set을 생성하여 해당 기간의 순위표를 저장하고, 이전 달의 데이터는 이력 DB로 이관한다.
유저의 ID와 점수는 Redis에 저장하고, 닉네임 및 프로필 이미지 같은 상세 정보는 MySQL RDB에 보관한다.

웹 서버는 Redis에서 순위 목록(user_id, score)를 읽어온 뒤, 화면에 표시할 유저의 닉네임과 프로필 이미지를 MySQL 또는 레디스 프로필 캐시에서 조회하여 조합한다.
3.1.2. 클라우드 서비스를 이용하는 방안(서버리스 아키텍처)
AWS의 API Gateway와 AWS Lambda, ElastiCache for Redis를 조합하면 인프라 관리 부담 없이 유저 증가에 따라 자동으로 Auto-scaling되는 완전 관리형 순위표 시스템을 구축할 수 있다.
구글 클라우드는 Cloud Function을,
MS는 Azure Function 이라는 서버리스를 제공한다.
| API | 람다 함수 |
|---|---|
GET /v1/scores | LeaderboardFetchTop10 |
GET /v1/scores/{:user_Id} | LeaderboardFetchPlayerRank |
POST /v1/scores | LeaderboardUpdateScore |
1) 점수 갱신 및 저장 흐름
점수 획득: 
순위표 서비스가 API Gateway로 요청을 보내면 LeaderboardUpdateScore 람다 함수가 실행되어 Redis ElastiCache에 ZINCRBY 명령을 수행함과 동시에, 백업용 MySQL 테이블에 점수 기록을 저장한다.
2) 순위표 조회 흐름
순위 검색: 
클라이언트가 API Gateway를 통해 순위를 조회하면 람다 함수가 Redis에서 상위 10명/특정 유저 순위를 읽어온 뒤, 프로필 정보를 결합하여 응답한다.
💡API Gateway에 Lambda 말고 EC2나 k8s pod도 연결할 수 있을까?
당연히 가능하다.
AWS API Gateway는 Lambda 함수 호출 뿐만 아니라, HTTP/HTTPS 프록시 integration을 지원한다.
따라서 로드 밸런서(ALB/NLB) 뒷단에 위치한 Amazon EC2 인스턴스 파밍 단지나 Amazon EKS(Kubernetes) Cluster 내의 Pod 서비스 엔드포인트로 API 요청을 직접 라우팅할 수 있다.
3.2. Redis 규모 확장
단일 Redis 인스턴스는 메모리 약 1.3GB, 피크 타임 2,500 TPS 수준인 500만 DAU 환경을 1대의 노드로 충분히 처리할 수 있다.
500만 DAU의 전체 메모리 필요양은 약 1.3GB로 최근 서버 스펙에 비하며 매우 적은 용량이다.
또한 피크 타임 쓰기 부하인 2,500 TPS 역시 단일 쓰레드로 동작하는 Redis 노드 1대가 초당 처리할 수 있는 성능(보통 5만~10만 TPS 이상)의 5%에 불과하므로, 1대로 충분히 감당 가능하다.
그러나 트래픽이 100배 증가하여 5억 DAU가 되면, 용량은 65GB, 피크 QPS는 250,000 QPS까지 솟구치므로 단일 노드로는 한계에 다다른다.
이때부터는 여러 Redis 노드로 데이터를 분산하는 샤딩이 필수적이다.
3.2.1. Redis 데이터 샤딩 방안
Redis 분산 샤딩 기법으로는 고정 파티션과 해시 파티션 방식이 있다.
3.2.2. 고정 파티션(Sticky Partition)
고정 파티션은 획득 가능한 점수 범위(Range)에 따라 샤드를 나누는 방식이다.
예를 들어 1~1,000점 범위를 100점 단위로 나누어 10개의 독립된 Redis 샤드(Sorted Set)로 분할 관리하는 방식이다.
고정 파티션 구조에서는 각 점수 범위를 담당하는 Redis 샤드 노드(또는 해당 노드 내의 Key) 자체가 독립된 하나하나의 Sorted Set 인스턴스로 동작한다.

이 방식이 실제 운영 환경에서 원활하게 작동하려면 아래와 같은 전제 조건과 운영 로직이 갖춰줘야 한다.
- 점수 분포의 균등성과 동적 구간 재조정
- 고정 파티션이 효율적으로 작동하려면 전체 유저의 점수가 각 샤드에 비교적 고르게 분포되어야 한다.
- 만약 특정 점수대(예: 100~200점 구간)에 유저 90%가 몰려 있다면, 해당 구간을 담당하는 샤드만 과부하가 걸려 샤딩을 한 의미가 사라진다.(핫 파티션 발생)
- 따라서 유저들이 점수 분포 데이터를 지속적으로 모니터링하면서 유저가 밀집된 구간은 점수 범위를 좁히고(예: 100~120점), 유저가 드문 구간은 범위를 넓히는 방식으로 샤드별 점수 커버리지를 동적으로 재조정해야 한다.
- 애플리케이션 주도 샤딩 및 2차 캐시(Secondary Cache) 도입
- 고정 파티션 구조에서는 애플리케이션 서버가 직접 샤딩의 주체가 되어 유저의 요청을 적절한 샤드로 라우팅해야 한다.
- 유저의 점수를 등록하거나 갱신하려면, 먼저 해당 유저가 현재 어느 샤드(점수 구간)에 존재하는지 파악해야 한다.
- DB 직접 조회 방식
- 유저의 현재 점수를 알기 위해 매번 MySQL과 같은 RDB를 조회하는 것은 DB에 심각한 읽기 병목을 일으킨다.
- 2차 캐시 활용 방안
- 따라서 유저 ID와 현재 점수(또는 속한 샤드 ID)의 매핑 정보를 빠르게 읽을 수 있는 Redis Key-Value 형태의 2차 캐시에 보관하는 것이 성능상 훨씬 유리하다(
user:100:score-> 150)
- 따라서 유저 ID와 현재 점수(또는 속한 샤드 ID)의 매핑 정보를 빠르게 읽을 수 있는 Redis Key-Value 형태의 2차 캐시에 보관하는 것이 성능상 훨씬 유리하다(
- DB 직접 조회 방식
- 샤드 간 유저 데이터 이동(Shard Migration) 처리
- 유저가 게임에서 승리하여 점수가 올라가면 기존 샤드의 점수 구간을 벗어나 상위 점수를 담당하는 새 샤드로 이동해야 한다.
- 이 때 애플리케이션은 Atomic하게 다음 연산을 순차 실행해야 한다.
- 기존 샤드 제거: 이전 점수 대역을 담당하던 Redis 샤드에서 유저를 삭제(
ZREM) - 신규 샤드 추가: 점수가 상승하여 진입한 새로운 상위 Redis 샤드에 유저 추가(
ZADD) - 2차 캐시 갱신: 2차 캐시에 저장된 유저의 현재 점수 및 샤드 매핑 정보 최신화
- 기존 샤드 제거: 이전 점수 대역을 담당하던 Redis 샤드에서 유저를 삭제(
💡샤드 간 유저 이동 시 데이터 일관성 이슈
기존 샤드에서 삭제(ZREM)만 되고 서버 장애로 새 샤드 추가(ZADD)가 실패하면 유저가 순위표에서 증발하는 문제가 발생한다.
이를 방지하기 위해 애플리케이션은
(1) 트랜잭션 보장 로직을 작성하거나
(2) 실패 시 DB 백업 데이터 기반의 Retry 메커니즘을 구비해야 하며
2차 캐시의 점수를 기준으로 샤드 위치를 항상 동기화해주어야 한다.
고정 파티션에서의 조회 동작 원리
- 상위 10명 조회
- 가장 높은 점수 구간을 담당하는 최상위 샤드(예: 901~1,000점 샤드)의 Sorted Set에서 상위 10명을 가져오면 되므로 \(O(\log N)\)으로 매우 빠르게 처리된다.
- 특정 유저의 전체 순위 계산
- 유저가 속한 샤드 내부에서의 상대적 순위에 더해, 나보다 높은 점수를 커버하는 상위 샤드들의 전체 유저 수를 모두 더해야 최종 전체 순위가 계산된다.
- 각 샤드의 전체 유저 수(키 개수)는 Redis 의
INFO keyspace명령을 통해 \(O(1)\) 시간에 즉시 조회할 수 있다.
💡Redis의 INFO keyspace 란?
Redis의 시스템 상태를 점검하는 INFO 명령의 옵션 중 하나이다.
현재 DB에 저장된 전체 키의 개수, 만료 설정된 키 개수 등의 통계 데이터를 \(O(1)\) 시간에 출력해 주는 모니터링 명령어이다.
각 샤드에 몇 명의 유저 키가 존재하는지 즉시 파악할 때 사용한다.
💡Redis의 고정 파티션(범위 샤딩)은 Redis Cluster를 사용하지 않는걸까?
Redis Cluster는 레디스가 알아서 키를 해시 슬롯에 분배해 주는 ‘하드웨어/인프라 주도 샤딩’이고,
고정 파티션은 개발자가 점수 구간에 맞춰 직접 서버를 지정하는 ‘애플리케이션 주도 샤딩’이기 때문에 Redis Cluster를 사용하지 않는다.
그렇다. 고정 파티션은 표준 Redis Cluster 기능을 이용하지 않는다.
대신 애플리케이션이 직접 관리하는 독립된 여러 개의 Redis 인스턴스(Standalone/Sentinel)를 연결해서 사용한다.
이유는 아래와 같다.
- 샤딩 기준의 불일치(Key vs Value)
- Redis Cluster의 샤딩 방식: Key 이름을 가지고 연산한다.
CRC16(key) % 16384즉, 키 이름만 보고 어느 노드로 보낼지 레디스가 자동으로 결정한다. - 고정 파티션의 샤딩 방식: 키 이름이 아니라 데이터 내부의 점수(Score/Value)를 기준으로 샤드를 나눈다.
- 레디스 클러스터는 ‘이 유저의 점수가 150점이니 2번 노드로 보낸다’하는 점수 기반 자동 라우팅 기능이 없다.
- Redis Cluster의 샤딩 방식: Key 이름을 가지고 연산한다.
- 데이터 이동 주체의 차이
- Redis Cluster: 노드가 추가되거나 빠질 때 ‘해시 슬롯’ 단위로 데이터를 자동으로 리샤딩(데이터 이동)한다.
- 고정 파티션: 유저가 게임을 해서 점수가 올랐을 때, 상위 점수 샤드로 유저를 옮기는 것은 레디스가 아니라 애플리케이션이 직접
ZREM(기존 샤드에서 삭제) +ZADD(신규 샤드에 추가)해야 한다.
- 클러스터의 ‘Cross-Slot’ 제약 조건
- 레디스 클러스터는 서로 다른 노드에 있는 키들을 한 번에 조작하거나 묶어서 연산하는 것을 엄격하게 제한한다.
- 고정 파티션처럼 애플리케이션이 샤드 간 데이터를 자유롭게 옮기고 통계를 집계하려면, 레디스 클러스터보다는 독립된 Redis 인스턴스 여럿을 앱 서버가 직접 컨트롤하는 구조가 훨씬 안전하다.
그럼 실무에서는 고정 파티션을 어떻게 구성할까?
Redis Cluster 대신 아래와 같은 형태로 인프라를 구성한다.
[ 애플리케이션 서버 (샤딩 로직 보유) ]
│
├─► [ Redis Shard 1 ] (Standalone / 0 ~ 100점 전용)
├─► [ Redis Shard 2 ] (Standalone / 101 ~ 200점 전용)
└─► [ Redis Shard 3 ] (Standalone / 201 ~ 300점 전용)
- 서버 구성: 각 샤드는 서로의 존재는 모르는 독립된 Redis 서버
- 고가용성 대책: 각 샤드가 다운되는 것에 대비해, 샤드마다 Redis Sentinel이나 Master-Replica 복제 구조를 개별적으로 붙여서 안정성을 확보
3.2.3. 해시 파티션(Hash Partition) 방식과 Scatter-Gather 패턴
고정 파티션 방식은 플레이어의 점수가 특정 대역(예: 하위 점수 구간)에 과도하게 몰려있을 때 특정 샤드로 트래픽이 몰리는 단점이 있다.
반면, 해시 파티션은 Redis Cluster를 도입하여 유저의 점수 분포와 상관없이 여러 노드에 데이터를 균등하게 샤딩하는 접근법이다.
Redis Cluster는 안정 해시(Consistent Hashing) 대신 총 16,384개의 해시 슬롯을 기반으로 샤딩을 수행한다.
Key가 들어올 때마다 CRC16(key) % 16384 연산을 실행하여 해당 키가 속할 해시 슬롯과 노드를 결정한다.

- 노드 분산 예시(3개 노드 클러스터)
- 노드 1: 해시 슬롯 [0, 5500] 담당
- 노드 2: 해시 슬롯 [5551, 11000] 담당
- 노드 3: 해시 슬롯 [11001, 16383] 담당
💡Redis Clster는 왜 하필 16,384(\(2^{14}\))개 슬롯을 사용할까?
Redis Cluster 노드들은 서로 상태를 주기적으로 교환하는 Heartbeat 패킷을 보낸다.
이 때 슬롯 상태를 Bitmap 으로 실어 보내는데, 16,384개 비트는 딱 2KB크기로 매우 적은 네트워크 오버헤드만 발생한다.
슬롯 수가 65,536개 등으로 커지면 패킷 크기가 커져 네트워크 병목이 생긴다.
또한 클러스터 최대 권장 노드 수(1,000여 개)에 가장 이상적인 슬롯 개수가 16,384개이다.
💡CRC16(Cyclic Redundancy Check) 이란?
순환 중복 검사(Cyclic Redundancy Check) 알고리즘으로, 임의의 문자열 키를 입력받아 16비트 정수값을 생성하는 해시 함수이다.
Redis는 키 문자열을 입력받아 0~65,536 사이의 해시값을 고르게 생성하며, 이를 16,384로 나눈 나머지(CRC16(key) % 16,384)로 데이터가 위치할 해시 슬록을 균등하게 할당한다.
여기서 65,536 이라는 숫자가 나온 이유는 CRC16이 16비트 알고리즘이기 때문이다.
- 16비트와 65,536의 관계
- 컴퓨터는 모든 데이터를 0과 1로 처리한다. 자릿수(비트 수)가 늘어날 때마다 표현할 수 있는 가지수는 2배씩 늘어난다.
- 1비트 = \(2^1 = 2\)가지 (0, 1)
- 2비트 = \(2^2 = 4\)가지 (00, 01, 10, 11)
- 8비트 = \(2^8 = 256\)가지
- 16비트 = \(2^{16} = 65,536\)가지
- 컴퓨터는 모든 데이터를 0과 1로 처리한다. 자릿수(비트 수)가 늘어날 때마다 표현할 수 있는 가지수는 2배씩 늘어난다.
컴퓨터는 숫자를 0부터 세기 때문에, 총 65,536개의 숫자를 표현하면 그 범위는 0~65,535까지가 된다.
그럼 왜 Redis는 65,536개를 다 안 쓰고 16,384개만 쓸까?
CRC16이 만들어내는 해시값은 0~65,535까지 65,536개나 되는데, 레디스는 굳이 % 16384 연산을 통해 해시 슬롯을 16,384(\(2^{14}\))개로 줄여서 사용한다.
여기에는 레디스 개발자의 네트워크 최적화 고민이 들어있다.
- Heartbeat 패킷 최적화
- 레디스 클러스터 노드들은 서로 주기적으로 Heartbeat를 주고 받는다. 이 때 자신이 담당하는 슬롯 정보를 Bitmap 형태로 실어 보낸다.
- 65,536개 슬롯을 쓴다면: 비트맵 크기가 \(65,536 \div 8 = 8\text{KB}\)가 된다.
- 16,384개 슬롯을 쓴다면: 비트맵 크기가 \(16,384 \div 8 = 2\text{KB}\)로 4배나 작아진다.
즉, CRC16 알고리즘 특성상 연산 결과는 16비트(\(2^{16} = 65,536\)가지)로 나오지만, 레디스는 네트워크 통신 비용을 줄이기 위해 이를 16,384(\(2^{14}\))의 슬롯으로 압축해서 사용하는 것이다.
1) 점수 갱신 연산
사용자의 점수를 갱신할 때는 사용자 ID를 기반으로 슬롯을 계산(CRC16(user_id) % 16384로 찾을 수 있음)하여, 해당 슬롯을 담당하는 특정 샤드에만 접속한 뒤 ZINCRBY 또는 ZADD 명령을 수행하면 된다.
쓰기 작업은 단일 샤드에서 \(O(\log N)\)으로 매우 신속하게 처리된다.
2) 상위 10명 순위표 검색(Scatter-Gather 패턴)
해시 파티션 환경에서는 유저 데이터가 점수와 상관없이 전 샤드에 무작위 분산되어 있다.
따라서 상위 10명의 플레이어를 검색하는 과정을 다소 복잡해진다.
이 때는 모든 샤드에 각각 10명 데이터를 요청한 뒤, 애플리케이션 서버에서 이 결과를 한데 모아 다시 정렬하는 Scatter-Gather 접근법을 사용해야 한다.

모든 샤드에 유저 목록을 질의하는 절차를 병렬로 수행하면 네트워크 지연 시간을 일부 줄일 수 있다.
3) 해시 파티션 및 Scatter-Gather 패턴의 구조적 문제점
하지만 이 방식은 실시간 게임 순위표 시스템에서 다음과 같은 치명적인 한계를 가진다.
- 상위 K개 결과 반환 시 지연 시간 증가
- 상위 K명의 결과를 얻기 위해 각 샤드에서 K개씩, 총 S(샤드 수) * K개의 데이터를 읽어와 애플리케이션 메모리에서 재정렬해야 하므로 읽기 지연 시간이 대폭 증가한다.
- Tail Latency(가장 느린 파티션 병목)
- 모든 샤드에 병렬로 질의를 던지더라도, 결과 집계는 가장 느리게 응답하는 파티션의 처리가 끝날 때까지 대기해야 한다.
- 시스템 전체 응답 속도가 최악의 성능을 내는 노드에 구속된다.
- 특정 사용자의 전체 순위 산출 불가
- 유저가 속한 샤드 내에서의 순위는 알 수 있지만, 다른 샤드에 나보다 점수가 높은 유저가 몇 명이나 존재하는지 알 방법이 없다.
- 특정 유저의 정확한 글로벌 순위를 산출하려면 결국 모든 샤드의 전체 데이터를 뒤져야 하므로 Sorted Set의 장점이 사라진다.
이러한 문제 때문에 실시간으로 정밀한 순위를 보여주어야 하는 본 설계안에서는 해시 파티션 대신 고정 파티션(점수 범위 샤딩) 방안을 최종 채택한다.
💡실무에서는 고정 파티션과 해시 파티션 중 무엇을 더 많이 사용할까?
일반적인 분산 세션/데이터 캐싱 환경에서는 운영과 노드 증설(Resharding)이 자동화되어 있는 해시 파티션(Redis Cluster)을 90% 이상 사용한다.
하지만 리더보드, 실시간 랭킹 시스템처럼 ‘전체 데이터 간 정렬 및 순위 조회’가 핵심인 특수 목적 시스템에서는 Scatter-Gather 방식의 한계(Tail Latency, 전체 순위 측정 불가) 때문에 애플리케이션 레벨에서 직접 제어하는 고정 파티션(범위 샤딩)을 채택하는 것이 실무 표준이다.
3.2.4. 레디스 노드 크기 조정 및 벤치마킹
Redis 노드를 구성할 때는 스냅숏(RDB) 생성 및 Copy-on-Write 버퍼를 고려하여 쓰기 작업이 많은 환경에서는 메모리를 이론치보다 2배 정도 넉넉하게 할당해야 안전하다.
하드웨어 수용량을 측정하기 위해 Redis가 기본 제공하는 redis-benchmark 도구를 활용한다.
💡redis-benchmark란?
Redis 서버의 가공할 응답 속도와 TPS를 측정하는 공식 성능 테스트 유틸리티이다.
redis-benchmark -h localhost -p 6379 -c 50 -n 100000 처럼 실행하여 50개의 동시 클라이언트가 10만 건의 요청을 보낼 때의 지연 시간 분포와 초당 처리량을 리포트로 확인한다.
3.3. 대안: NoSQL 데이터베이스(DynamoDB)
Redis 대신 관리 부담이 적고 안정적인 Scale-out을 지원하는 NoSQL 데이터베이스(Amazon DynamoDB, Cassadra, MongoDB 등)를 리더보드의 메인 저장소로 활용하는 방안도 훌륭한 대안이 될 수 있다.
실시간 리더보드 구축을 위해 NoSQL을 선택할 때는 다음 두 가지 조건이 필수적이다.
- 높은 쓰기 연산 최적화
- 초당 수천 건 이상의 점수 갱신 요청을 병목 없이 처리할 수 있어야 함
- 파티션 내 자동 정렬 기능
- 동일한 파티션에 저장된 데이터를 점수(Score) 기준으로 효율적으로 정렬하여 읽을 수 있어야 함

Amazon DynamoDB는 이러한 조건을 만족하는 대포적인 완전 관리형 NoSQL 데이터베이스이다.
DynamoDB는 PK가 아닌 다른 Attribute로도 데이터를 효율적으로 조회할 수 있도록 GSI(Global Secondary Index)를 제공한다.
GSI는 원본 테이블의 속성들을 활용해 구성되지만, 원본 테이블과는 완전히 별개의 새로운 PK를 정의하여 전혀 다른 관점에서 데이터를 질의할 수 있게 해준다.
3.3.1. 비정규화 테이블의 한계와 핫 파티션(Hot Partition) 문제
체크 게임의 월간 순위표를 설계한다고 가정해보자.
단순하게 순위표와 유저 정보를 비정규화하여 아래와 같이 단일 테이블로 저장할 수 있다.

하지만 데이터가 수백만 건으로 늘어나면 상위 점수를 찾기 위해 전체 테이블을 훑어야 하는 테이블 풀스캔이 발생하여 읽기 성능이 급격히 고갈된다.
이 문제를 피하려면 DynamoDB의 파티션 키와 정렬 키(=클러스터 키) 구조를 활용해야 한다.
game_name#{year-month} 를 파티션 키로, score를 정렬 키로 지정하면 특정 달의 점수를 정렬된 상태로 모아둘 수 있다.

그러나 이 스키마는 핫 파티션이라는 치명적인 문제를 야기한다.
DynamoDB는 내부적으로 파티션 키를 기준으로 물리적 노드에 데이터를 분산하는데, 모든 유저가 이번 달(2026-08) 데이터를 갱신하다 보면 특정 노드 하나에만 전체 트래픽이 몰려 디스크 I/O 병목이 발생하게 된다.
💡GSI(Global Secondary Index)란?
DynamoDB의 기본 테이블은 최초 지정한 PK로만 조회가 가능하다.
하지만 다른 컬럼을 기준으로 정렬 조회하고 싶을 때, 원본 테이블의 데이터를 바탕으로 ‘새로운 파티션 키와 정렬 키를 가지는 보조 인덱스 테이블’을 백그라운드에서 동기화하여 만들어주는 기능이다.
💡GSI는 어떻게 파티션 키와 정렬 키를 사용하는 걸까?
GSI는 쉽게 말해 원본 테이블의 데이터를 바탕으로 DynamoDB가 백그라운드에서 실시간으로 만들어주는 자동 복제(미러링) 인덱스 테이블이다.
GSI 매핑과 작동 과정은 아래와 같다.
- 원본 테이블 데이터 저장(Write)
- 애플리케이션이 원본 테이블에 유저 데이터(예: user_id: 1234, score: 950, shard_id: game#2026-08#p0)를 넣는다.
- GSI 비동기 복제 및 자동 정렬(DynamoDB 내부 동작)
- DynamoDB가 백그라운드에서 원본 데이터 변경을 감지하고, 지정해 둔 GSI로 데이터를 자동으로 복제한다.
- 이 때 GSI에 설정된 규칙에 따라
- 파티션 키(shard_id):
game#2026-08#p0값을 기준으로 특정 물리 노드에 데이터를 모은다. - 정렬 키(score): 같은 파티션(p0) 안에서 점수(950)을 기준으로 디스크상에서 점수 순서대로 자동 정렬하여 보관한다.
- 파티션 키(shard_id):
- GSI를 통한 고속 조회(Read)
- 개발자가 GSI로
game#2026-08#p0 파티션에서 score 내림차순으로 상위 10개만 줘라는 Query 요청을 날린다. - GSI 파티션 내부에는 점수가 이미 순서대로 정렬되어 있으므로, DynamoDB는 전체를 뒤질 필요 없이 맨 위에 10개만 빼서 즉시 반환한다.
- 개발자가 GSI로
3.3.2. 쓰기 샤딩(Write Sharding) 패턴과 파티션 개수 트레이드오프
핫 파티션 문제를 완벽히 해결하기 위해 파티션 키에 무작위 샤드 번호(user_id % 파티션 수)를 조합하여 데이터를 여러 파티션에 강제로 분산시키는 쓰기 샤딩(Write Sharding) 패턴을 적용한다.
- 새로운 파티션 키 규격:
game_name#{year-month}#p{partition_number}

이 때 GSI를 구성하여 game_name#{year-month}#p{partition_number}를 파티션 키로, score를 정렬 키로 매핑하면 샤드별로 점수가 정렬된 N개의 파티션이 만들어진다.
파티션 개수 산정과 트레이드 오프
샤딩을 적용할 때 가장 중요한 질문은 ‘과연 몇 개의 파티션(N)을 두어야 하는가?’이다.
이는 예상되는 쓰기 볼륨(Write TPS)이나 DAU를 기준으로 산정해야 하며, 파티션 부하와 읽기 복잡도 사이의 트레이드오프를 철저히 고려해야 한다.
- 파티션 수를 줄일 경우
- 장점(읽기 단순화)
- 조회할 파티션 수가 적어지므로 파티션이 많을 때보다 Scatter-Gather 읽기 속도가 빨라지고 구현이 단순해진다.
- 단점(쓰기 병목 위험)
- 파티션 하나당 담당하는 트래픽이 커져 특정 파티션에 부하가 쏠리는 핫 파티션(Hot Partition) 위험이 커진다.
- 장점(읽기 단순화)
- 파티션 수를 늘릴 경우
- 장점(쓰기 분산)
- 데이터를 여러 파티션으로 고르게 분산시키므로 개별 노드의 쓰기 부하가 비약적으로 낮아진다.
- 단점(읽기 지연 증가)
- 상위 10명을 읽으려고 할 때 조회해야 할 파티션 수가 늘어나므로(Scatter-Gather) 애플리케이션의 읽기 복잡도와 지연 시간이 증가한다.
- 장점(쓰기 분산)

💡쓰기 샤딩(Write Sharding)이란?
쓰기 샤딩은 특정 파티션에 트래픽이 몰리는 핫 파티션(Hot Partition) 문제를 해결하기 위해, 파티션 키에 무작위 또는 계산된 접미사(샤드 번호)를 붙여 데이터를 여러 파티션으로 강제 분산시키는 기법이다.
- 기존 방식
game_name#2026-08하나의 파티션 키로 모든 유저의 데이터가 몰림
- 쓰기 샤딩 방식
user_id % 파티션 개수(N)을 계산하여game_name#2026-08#p0,game_name#2026-08#p1..game_name#2026-08#pN으로 트래픽을 N개로 찢어서 저장
쓰기 샤딩은 쓰기 성능 향상을 위해 읽기 성능 및 구현 복잡도를 대가로 지불하는 대표적인 트레이드오프 구조이다.
- 장점
- 핫 파티션 완벽 예방
- 쓰기 트래픽이 N개의 파티션으로 균등하게 분산되므로 단일 노드 I/O 병목 및 쓰기 처리량 제한(Throttling)을 방지
- 무제한 쓰기 확장성
- 데이터나 트래픽이 늘어나더라도 샤드 수(N)만 늘려주면 쓰기 성능을 수평적으로 확장할 수 있음
- 핫 파티션 완벽 예방
- 단점
- 읽기 작업의 복잡도 및 비용 증가(Scatter-Gather)
- 전체 순위표(예: 전체 Top 100)를 조회할 때 하나의 파티션만 읽을 수 없다.
- 모든 샤드 N개를 각각 병렬 조회(Scatter)한 후, 애플리케이션 메모리에서 결과를 하나로 합치고 다시 정렬(Gather)해야 한다.
- 파티션 개수(N) 결정의 트레이드 오프
- N이 너무 작은 경우
- 여전히 핫 파티션 위험이 남음
- N이 너무 큰 경우
- 쓰기는 쾌적해지지만, 읽을 때 N번의 조회 요청(Fan-out)이 발생하여 읽기 비용(RCU, Read Capacity Unit)와 응답 지연 시간이 비례해서 증가함
- N이 너무 작은 경우
- 읽기 작업의 복잡도 및 비용 증가(Scatter-Gather)
즉, 쓰기 샤딩은 쓰기 속도를 끌어올리는 대신, 전체 데이터를 읽어올 때 N배의 읽기 수고(Scatter-Gather)를 감수하는 전략이다.
💡벤치마킹을 통해 파티션 수의 최적값을 파악하는 방법은?
부하 테스트 툴(JMeter, Locust 등)을 이용해 파티션 수를 4개, 8개, 16개로 바꿔가며 테스트한다.
쓰기 요청 시 DynamoDB에서 ThrottlingException(부하 초과 에러)이 발생하지 않는 최소한의 파티션 개수를 산출함으로써, 읽기 시 발생하는 Scatter-Gather 병목을 최소화하는 최적 포인트를 찾는다.
3.3.3. NoSQL 환경에서의 상대적 순위 계산: 백분위수(Percentile) 활용
NoSQL 쓰기 샤딩을 적용하면 데이터가 여러 샤드 파티션으로 분산되므로, 특정 유저가 전체 중에서 정확히 몇 위인지 계산하기 위해 모든 샤드의 데이터를 합치는 것은 심각한 읽기 지연을 유발한다.
하지만 대규모 게임 서비스에서는 유저에게 ‘당신은 정확히 123위입니다.’ 라고 알려주는 것보다, ‘당신은 상위 10% 이내의 최상위 플레이어입니다.’ 라고 백분위수로 보여주는 것만으로도 충분히 유저 경험(UX)와 승부욕을 제공할 수 있다.
크론 작업을 통한 점수 분포 캐싱 메커니즘
- 전체 조건(점수 분포의 균등성)
- 유저 규모와 트래픽이 충분히 커서 샤딩이 필요한 시스템이라면, 통계적으로 모든 샤드의 점수 분포는 거의 동일하다라고 가정할 수 있다.
- 주기적 크론 작업
- 주기적으로 실행되는 크론 배치 작업이 전체 샤드 중 1개 샤드의 점수 분포 데이터를 분석하여 각 백분위수 구간별 점수 기준표를 생성하고 이를 캐시에 보관한다.
- 캐시된 백분위수 점수표 예시
- 10번째 백분위수 (상위 90%) = 점수 < 100점
- 20번째 백분위수 (상위 80%) = 점수 < 500점
- …
- 90번째 백분위수 (상위 10%) = 점수 < 6,500점
- 상대적 순위 즉시 산출
- 유저가 자신의 순위를 조회할 때, 복잡한 크로스 샤드 연산 없이 캐시에 올려둔 백분위수 점수표와 유저의 현재 점수만 비교(\(O(1)\))하여, 당신은 상위 10%(90번째 백분위수)입니다.라는 결과를 지연없이 즉시 반환한다.
4. 운영 이슈 및 고도화 방안
실시간 리더보드를 실제로 운영할 때 필요한 조회 속도 최적화, 동점자 순위 처리, 그리고 장애 복구 전략을 정리한다.
4.1. Redis Hash를 활용한 조회 최적화 및 동점자 순위 판정 방안
💡Redis Hash란?
Redis Hash는 하나의 Key 내부에 여러 개의 필드-값 쌍을 객체 형태로 저장하는 자료 구조이다.
예: HSET user:profile:aaa name "assu" image "profile.png" score "999"
일반 Key-Value 구조보다 메모리를 매우 효율적으로 사용하며, 유저의 프로필 객체 데이터를 저장하고 조회하는 데 가장 이상적이다.
Redis Hash는 리더보드 시스템에서 다음 두 가지 핵심 목적으로 활용된다.
1) 유저 프로필 정보 빠른 조회를 위한 캐시
- Redis Sorted Set에는 오직 유저 ID와 점수만 저장되어 있다.
- 순위표 화면에 유저 닉네임, 프로필 이미지, 칭호 등을 함께 보여주기 위해 Redis Hash에 유저 ID와 프로필 객체를 대응시켜 보관한다.
- 이를 통해 DB(MySQL)를 전혀 조회하지 않고도 상위 10명의 상세 프로필을 메모리 상에서 \(O(1)\)로 즉시 합성하여 응답할 수 있다.
2) 동점자 발생 시 선착순(타임스탬프) 기반 순위 판정
- 두 유저의 점수가 같을 경우, ‘누가 더 먼저 해당 점수에 도달했는가?’에 따라 순위를 매긴다.
- Redis Hash에 유저 ID와 마지막 승리 경기 타임스탬프를 대응시켜 기록해둔다.
- 동점자 발생 시 기록된 타임스탬프 값이 더 오래된(먼저 점수를 획득한) 유저에게 더 높은 순위를 부여하는 보완 로직을 적용한다.
4.2. 시스템 장애 복구 전략
Redis는 In-Memory 데이터베이스이므로 노드 다운이나 클러스터 장애 발생 시 데이터 소실 위험이 존재한다.
이를 위해 DB(MySQL)를 활용한 복구 체계를 구축한다.
- 승리 이력의 영구 저장
- 유저가 경기에서 승리할 때마다 MySQL의 점수 테이블에
user_id,points,timestamp를 영구 기록한다.
- 유저가 경기에서 승리할 때마다 MySQL의 점수 테이블에
- 장애 발생 시 자동 복구 스크립트 실행
- Redis 클러스터 장애로 순위표 데이터가 손상되면, MySQL에 저장된 당월 승리 로그 데이터를 순회하는 복구 스크립트를 작동시킨다.
- 유저별 승리 이력을 차례로 읽으며 레코드당 한 번씩 Redis 명령(
ZINCRBY)을 replay함으로써, 손상된 실시간 순위표를 100% 완벽하게 복원해낸다.
5. 요약 및 결론

- RDB의 한계
- 관계형 데이터베이스(MySQL 등)는 B-Tree 인덱스 재배치와 전체 정렬 스캔 병목으로 인해 대규모 실시간 리더보드에 부적합하다.
- Redis Sorted Set 활용
- 스킵 리스트(Skip List) 구조를 기반으로 데이터 추가/조회를 \(O(\log N)\) 시간에 완수하므로 실시간 리더보드 구축의 가장 이상적인 솔루션이다.
- 대규모 확장 기법
- 고정 파티션(점수 범위 샤딩)
- 정밀한 순위 계산이 필요한 대규모 트래픽 환경에 적합하다.
- NoSQL 쓰기 샤딩
- 핫 파티션을 해소하고, 백분위수(Percentile) 방식을 결합하여 무제한 확장을 달성한다.
- 고정 파티션(점수 범위 샤딩)
- 고가용성 및 장애 복구
- Redis Hash를 통한 프로필 캐싱/동점자 처리와 MySQL 승리 로그 기반의 장애 복구 스크립트를 갖춤으로써 시스템 안정성을 극대화한다.
참고 사이트 & 함께 보면 좋은 사이트
본 포스트는 알렉스 쉬, 산 람 저자의 가상 면접 사례로 배우는 대규모 시스템 설계 기초 2를 기반으로 스터디하며 정리한 내용들입니다.
- 가상 면접 사례로 배우는 대규모 시스템 설계 기초 2
- 책에 나온 링크들 모음
- Man-in-the-middle attack - Wikipedia
- Redis Sorted Set (ZSet) 상세 자료구조
- Redis Sorted Set C언어 소스코드 (GitHub)
- Building Real-Time Leaderboard with Redis
- Building a real-time gaming leaderboard with Amazon ElastiCache for Redis
- How we created a real-time leaderboard for a million users
- AWS Lambda 공식 페이지
- Cloud Functions - Google Cloud
- Azure Functions 공식 페이지
- Redis INFO 명령어 공식 문서
- Why Redis Cluster only have 16384 slots - Stack Overflow
- Cyclic redundancy check (CRC) - Wikipedia
- S3-like Object Storage 테이블 규모 확장 (Sharding)
- AWS DynamoDB Global Secondary Index (GSI) 가이드
- NoSQL 데이터 모델링: 파티션 키와 클러스터 키 설계
