System Design Database Index Snapshot Score Bucketing Eventual Consistency
유저가 보는 랭킹 정보는 정말 매 순간 정확해야 할까?
천만 명의 점수를 실시간으로 정렬하지 않고도, 유저에게는 방금 오른 점수와 그에 맞는 순위를 보여줄 수 있다. DB 부하는 줄이면서 유저가 납득할 수 있는 실시간성은 지키는 랭킹 시스템을 만들어보자.
정확한 랭킹이 만드는 비용
포인트가 자주 바뀌는 서비스에서 랭킹을 만든다고 해보자. 가장 먼저 떠오르는 방법은 사용자 테이블의 포인트 컬럼에 인덱스를 만들고, 점수를 내림차순으로 정렬해 순위를 조회하는 것이다.
SELECT user_id, point
FROM users
ORDER BY point DESC
LIMIT 100;조회만 보면 단순하다. 하지만 포인트가 결제, 활동, 이벤트 참여처럼 빈번한 행동마다 바뀐다면 이야기가 달라진다. 포인트를 갱신할 때마다 인덱스도 유지해야 하고, 트래픽이 커질수록 쓰기 비용과 경합이 함께 커진다. 특정 사용자의 정확한 순위를 구하기 위해 자신보다 점수가 높은 사용자 수를 매번 세는 쿼리도 부담이 된다.
인덱스 자체가 항상 나쁜 선택이라는 뜻은 아니다. 사용자 수와 점수 변경 빈도가 작다면 가장 단순하고 정확한 해법일 수 있다. 문제는 수천만 사용자의 점수가 계속 변하는 상황에서도 모든 조회에 실시간 정렬을 요구할 것인가이다.
랭킹 화면에서 정말 필요한 정확성을 다시 생각해볼 필요가 있었다.
전체 랭킹을 스냅샷으로 만들면
DB의 일을 줄이는 가장 쉬운 방법은 일정 주기마다 랭킹을 계산해 별도 스냅샷으로 저장하는 것이다. 랭킹 조회는 이미 정렬된 결과를 읽기만 하면 된다.
문제는 사용자 경험이다. 사용자가 방금 활동해서 포인트를 얻었는데 랭킹 화면의 내 점수가 그대로라면 시스템이 고장 났다고 느낄 수 있다. 스냅샷 생성 주기가 길수록 이 간극은 더 눈에 띈다.
그렇다면 내 점수만 최신 값으로 표시하면 어떨까? 이번에는 점수와 순위가 서로 모순된다.
19위 민수 30,100점
20위 나 31,200점 <- 점수는 올랐지만 순위는 예전 그대로
21위 지수 30,000점
내 점수는 19위보다 높은데 순위는 20위다. 데이터 각각은 틀리지 않았지만 한 화면에 놓으면 사용자가 납득하기 어렵다. 결국 필요한 것은 완벽한 전역 실시간 랭킹이 아니라, 최신 내 점수와 모순되지 않는 주변 랭킹이었다.
스냅샷에 현재 사용자를 끼워 넣기
선택한 방식은 전체 사용자의 순위는 스냅샷으로 유지하면서, 조회한 사용자의 최신 점수만 그 스냅샷에 다시 배치하는 것이다.
예를 들어 랭킹 스냅샷이 다음과 같다고 하자.
19위 민수 30,100점
20위 지수 30,000점
21위 현우 29,900점
사용자의 현재 점수가 30,050점이라면 19위와 20위 사이에 끼워 넣는다.
19위 민수 30,100점
20위 나 30,050점
21위 지수 30,000점
이 결과는 전체 사용자의 최신 점수를 반영한 절대적인 실시간 순위는 아니다. 다른 사용자의 점수는 마지막 스냅샷 시점의 값이기 때문이다. 하지만 사용자가 방금 얻은 포인트는 즉시 보이고, 화면에 표시된 점수의 정렬 순서와 순위도 서로 모순되지 않는다.
스냅샷 전체를 읽지 않고 위치 찾기
사용자가 천만 명이라면 매 요청마다 스냅샷 천만 건을 읽어서 들어갈 위치를 찾을 수는 없다.
처음에는 현재 점수의 주변부터 범위를 넓혀가며 탐색하는 방식을 생각했다. 먼저 현재 점수 ±100, 찾지 못하면 ±1,000, 다시 ±10,000 범위를 조회하는 식이다. 점수가 촘촘하게 분포한다면 빠르지만, 사용자가 거의 없는 점수대에서는 여러 번 조회해야 한다. 성능이 데이터 분포에 지나치게 의존한다는 점이 마음에 걸렸다.
대신 스냅샷을 만들 때 점수를 일정한 구간으로 나누고, 각 구간에 순위 경계를 함께 저장하기로 했다. 구간 크기를 10,000점으로 정했다면 개념적으로 다음과 같은 데이터가 생긴다.
{
"scoreFrom": 30000,
"scoreTo": 39999,
"firstRank": 19,
"lastRank": 42,
"users": [
{ "userId": "user-17", "score": 39820, "rank": 19 },
{ "userId": "user-81", "score": 39410, "rank": 20 }
]
}조회 과정은 다음과 같다.
- 사용자의 최신 점수를 조회한다.
- 점수로 해당 구간을 바로 계산한다.
- 구간 안의 스냅샷 사용자와 점수를 비교해 사용자의 임시 순위를 구한다.
- 계산한 순위를 기준으로 앞뒤 몇 명을 스냅샷에서 조회한다.
- 기존 스냅샷에서 본인을 제외하고 최신 점수의 사용자를 삽입해 응답한다.
사용자가 속한 구간에 아무도 없다면 경계 정보가 특히 유용하다. 바로 위의 비어 있지 않은 구간이 끝나는 순위 다음이 사용자의 순위가 된다. 구간마다 이전·다음 구간이나 누적 사용자 수를 저장해두면 빈 구간을 반복해서 탐색할 필요도 없다.
50,000점대: 1위 ~ 7위
40,000점대: 사용자 없음 (이전 구간의 마지막 순위: 7위)
30,000점대: 8위 ~ 19위
현재 점수 45,000점 -> 임시 순위 8위
구간을 찾는 비용은 점수로 직접 계산할 수 있고, 순위를 정하는 비교 범위도 전체 사용자가 아니라 한 구간으로 줄어든다. 화면에서 필요한 앞뒤 몇 명만 추가로 가져오면 되므로 조회 비용도 예측하기 쉬워진다.
스냅샷 생성과 조회의 역할 나누기
이 구조에서는 비싼 계산을 요청 시점이 아니라 스냅샷 생성 시점으로 옮긴다.
[주기적인 배치]
사용자 점수 집계
-> 점수 내림차순 정렬
-> 전체 순위 계산
-> 점수 구간과 순위 경계 저장
[랭킹 조회]
최신 내 점수 조회
-> 점수 구간 조회
-> 스냅샷 안에서 내 위치 계산
-> 주변 사용자와 함께 응답
스냅샷은 DB의 별도 테이블에 둘 수도 있고, 조회량이 많다면 캐시나 정렬된 자료구조에 둘 수도 있다. 저장소보다 중요한 것은 실시간으로 갱신되는 원본 점수와 읽기 전용에 가까운 랭킹 스냅샷의 책임을 분리하는 것이다.
결정해야 할 세부 규칙
큰 구조를 정했다고 끝은 아니다. 실제로 적용하려면 몇 가지 정책을 명확히 해야 한다.
동점자는 어떤 순서로 보여줄 것인가
점수가 같을 때 공동 순위를 사용할지, 먼저 점수를 달성한 사용자를 앞에 둘지 결정해야 한다. 스냅샷 생성과 사용자 삽입 로직이 반드시 같은 정렬 기준을 사용해야 한다. 예를 들어 점수 내림차순, 달성 시각 오름차순, 사용자 ID 오름차순처럼 완전한 정렬 기준을 정해두면 결과가 요청마다 흔들리지 않는다.
구간 크기는 얼마가 적당한가
구간이 너무 크면 한 구간 안에서 비교할 사용자가 많아지고, 너무 작으면 관리할 구간과 메타데이터가 늘어난다. 고정된 10,000점은 출발점일 뿐이다. 실제 점수 분포를 보고 구간마다 비슷한 수의 사용자가 들어가도록 경계를 조정하거나, 상위권만 더 촘촘하게 나누는 방법도 고려할 수 있다.
스냅샷에서 얼마나 벗어날 수 있는가
여러 사용자의 점수가 스냅샷 이후 크게 변했다면 각 사용자가 보는 순위는 서로 다를 수 있다. 이 방식은 하나의 절대적인 실시간 순위를 제공하는 것이 아니라, 스냅샷을 기준으로 개인에게 일관된 결과를 제공한다. 경쟁 보상이나 당첨자 선정처럼 정확성이 필요한 계산에는 이 임시 순위를 사용하면 안 된다. 마감 시점의 원본 데이터를 다시 집계해 확정 순위를 만들어야 한다.
스냅샷은 언제 갱신할 것인가
허용할 수 있는 오차와 생성 비용을 기준으로 주기를 정해야 한다. 이벤트 종료 직전처럼 변화가 몰리는 시간에는 생성 주기를 줄이고, 평소에는 늘리는 방식도 가능하다. 화면에 “5분마다 갱신” 같은 기준 시점을 알려주는 것 역시 사용자의 기대를 맞추는 데 도움이 된다.
이 설계가 포기한 것
이 설계는 정확성을 없앤 것이 아니라 정확성이 필요한 지점을 나눴다.
- 현재 사용자의 점수는 원본에서 읽어 정확하게 보여준다.
- 주변 목록은 한 화면 안에서 점수와 순위가 모순되지 않게 만든다.
- 전체 사용자의 절대 순위는 스냅샷 시점까지만 정확하다.
- 보상처럼 결과가 중요한 작업은 별도의 확정 집계로 처리한다.
따라서 “실시간 랭킹”이라는 이름만 보고 적용할 수 있는 범용 해법은 아니다. 모든 사용자가 동시에 동일한 순위를 봐야 하거나, 순위 변화 자체가 거래와 보상에 즉시 영향을 준다면 더 강한 일관성을 가진 별도 시스템이 필요하다.
정리
랭킹 시스템을 설계하면서 처음에는 어떻게 하면 실시간 계산 비용을 줄일지만 고민했다. 하지만 실제 문제는 정확성과 성능 중 하나를 고르는 것이 아니었다. 사용자가 어떤 데이터를 보고 무엇을 납득해야 하는지 정하는 일이 먼저였다.
전체 랭킹을 매번 다시 계산할 필요는 없었다. 전체는 스냅샷으로 만들고, 현재 사용자의 최신 점수만 적절한 위치에 끼워 넣으면 된다. 여기에 점수 구간과 순위 경계를 함께 저장하면 전체 스냅샷을 훑지 않고도 주변 랭킹을 만들 수 있다.
완벽한 실시간성은 비싸다. 더 중요한 것은 모든 데이터를 똑같이 최신으로 만드는 일이 아니라, 사용자가 보는 한 화면 안에서 데이터가 서로 모순되지 않게 만드는 일이었다.

Discussion
댓글
글에 관한 의견이나 질문을 남겨 주세요.
댓글을 불러오는 중입니다.
아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.