TIL - 실시간 랭킹 구현 방법

2026. 4. 23. 01:19·내배캠

실시간 랭킹 구현법

실시간 랭킹을 구현하기 위해선 아래 3가지 고민이 필요하다.

  • 랭킹을 어떻게 계산할 것 인가
  • 랭킹을 어디에 저장할 것 인가
  • 계산된 랭킹 목록을 어떻게 프런트로 전달할 것 인가

먼저 랭킹은 조회 빈도가 굉장히 높다. 그리고 랭킹을 갱신할 때마다 직접 정렬 쿼리가 실행되어야 한다면 네트워크 비용이 많이 들 것이다.

따라서 위 두 문제를 모두 해결해 주는 Redis에 Sorted Set으로 저장하기로 했다.

Sorted Set은 skiplist(스킵리스트) + 해시 테이블 구조로 이루어져 있습니다. skiplist 덕분에 데이터 삽입 시 자동 정렬이 이루어지고 빠른 조회가 가능합니다. 그 이유는 skiplist가 데이터를 삽입할 때 값을 비교해 올바른 위치에 끼워 넣기 때문에 항상 정렬 순서가 유지되고, 여러 레벨의 레인을 통해 큰 단위로 건너뛰며 탐색할 수 있어 O(log n)의 빠른 조회가 보장되기 때문이다.

 

참고) skiplist 동작 원리

더보기

먼저 데이터를 레벨 0부터 레벨 N까지 저장한다. 일단 레벨 0에 데이터를 저장한다. 그리고 상위 레벨로 데이터를 넣을지 말지는 동전 던지기와 같이 일정 확률로 정해진다.

여기선 50%라고 가정했습니다 ( 실제 확률은 다를 수 있습니다. )

앞면(50%) → 윗 레벨에도 올린다
뒷면(50%) → 여기서 멈춘다

1, 5, 10, 15, 20, 25, 30을 차례대로 넣는다고 가정

1 삽입

동전 던지기 → 뒷면 → 레벨 0에만 추가

레벨0 │ 1

5 삽입

동전 던지기 → 앞면 → 레벨 1에도 올리자 동전 던지기 → 뒷면 → 여기서 멈춤

레벨1 │ 5
레벨0 │ 1 → 5

10 삽입

 동전 던지기 → 뒷면 → 레벨 0에만 추가

레벨1 │ 5
레벨0 │ 1 → 5 → 10

15 삽입

동전 던지기 → 앞면 → 레벨 1에도 올리자 동전 던지기 → 앞면 → 레벨 2에도 올리자 동전 던지기 → 뒷면 → 여기서 멈춤

레벨2 │ 15
레벨1 │ 5 → 15
레벨0 │ 1 → 5 → 10 → 15

20 삽입

동전 던지기 → 뒷면 → 레벨 0에만 추가

레벨2 │ 15
레벨1 │ 5 → 15
레벨0 │ 1 → 5 → 10 → 15 → 20

25 삽입

동전 던지기 → 앞면 → 레벨 1에도 올리자 동전 던지기 → 뒷면 → 여기서 멈춤

레벨2 │ 15
레벨1 │ 5 → 15 → 25
레벨0 │ 1 → 5 → 10 → 15 → 20 → 25

30 삽입

동전 던지기 → 뒷면 → 레벨 0에만 추가

레벨2 │ 15
레벨1 │ 5 → 15 → 25
레벨0 │ 1 → 5 → 10 → 15 → 20 → 25 → 30

완성된 스킵리스트에서 20을 찾기

레벨2 │ 15 → 끝
레벨1 │ 5 → 15 → 25 → 끝
레벨0 │ 1 → 5 → 10 → 15 → 20 → 25 → 30
  1. 레벨2, 15 → 다음이 끝, 20보다 작으니까 레벨1로 내려감
  2. 레벨1, 15 → 다음 25, 20보다 크니까 레벨0으로 내려감
  3. 레벨0, 15 → 다음 20, 찾았다! ✅

일반 링크드 리스트였으면 1 → 5 → 10 → 15 → 20, 5번 걸렸을 텐데 3번 만에 찾았어요!


하지만 운이 나쁘면 느려지는 거 아닌가요?

맞아요. 최악의 경우엔 느려질 수 있어요. 하지만 데이터가 많아질수록 확률적으로 균등하게 분포되기 때문에 평균적으로는 항상 O(log n)이 나와요.


한 줄 요약

어떤 노드가 상위 레벨에 올라갈지는 랜덤으로 결정되고, 데이터가 많아질수록 확률적으로 균등하게 분포되어 빠른 탐색이 보장됨

참고) Sorted Set 저장 예시

더보기

키가 대회 24일 때, 아래 사용자들의 데이터가 순서대로 들어온다고 가정한다.
삽입 순서 사용자 평가금

1 user:3 1,050만원
2 user:7 320만원
3 user:1 2,300만원
4 user:10 35만원
5 user:5 870만원
ZADD contest:24 10500000 "user:3" 3200000 "user:7" 23000000 "user:1" 350000 "user:10" 8700000 "user:5"

삽입 순서와 관계없이 Sorted Set은 평가금 기준으로 자동 정렬되어 아래와 같이 저장된다.

contest:24
├── member: "user:10"  │ score: 350,000
├── member: "user:7"   │ score: 3,200,000
├── member: "user:5"   │ score: 8,700,000
├── member: "user:3"   │ score: 10,500,000
└── member: "user:1"   │ score: 23,000,000

이후 user:10이 매매를 통해 평가금이 1,500만 원으로 변경되면

ZADD contest:24 15000000 "user:10"

별도의 정렬 쿼리 없이 자동으로 순위가 갱신된다.

contest:24
├── member: "user:7"   │ score: 3,200,000
├── member: "user:5"   │ score: 8,700,000
├── member: "user:3"   │ score: 10,500,000
├── member: "user:10"  │ score: 15,000,000
└── member: "user:1"   │ score: 23,000,000

참고) Sorted Set 조회 예시

더보기

Redis에서 Sorted Set을 조회하는 명령어는 ZREVRANGE예요. (score 높은 순서대로)

ZREVRANGE contest:24 0 -1 WITHSCORES

옵션 설명

 

  • 0 -1 → 처음부터 끝까지 전부
  • 0 9 → 상위 10명만 조회 (굳이 전체를 가져올 필요 없을 때)
  • WITHSCORES → Redis에서 제공하는 예약어로, 붙이면 member(사용자)와 score(평가금)를 함께 반환해줌

WITHSCORES 유무 비교
WITHSCORES 없을 때 → member만 반환

1) "user:1"
2) "user:10"
3) "user:3"
4) "user:5"
5) "user:7"

WITHSCORES 있을 때 → member + score 번갈아 반환

1) "user:1"
2) "23000000"
3) "user:10"
4) "15000000"
5) "user:3"
6) "10500000"
7) "user:5"
8) "8700000"
9) "user:7"
10) "3200000"

랭킹 화면에서 순위 + 평가금을 같이 보여줘야 하므로 WITHSCORES를 붙여서 조회하는 게 일반적이에요.

스프링에서 받는 형태

이걸 스프링에서 받아서 프런트로 넘겨주면 프런트는 그냥 순서대로 UI에 뿌려주기만 하면 돼요. 이미 Redis에서 정렬된 상태로 오기 때문에 스프링이나 프런트에서 별도로 정렬할 필요가 없는 게 핵심이에요.


이로써 랭킹 계산/저장을 해결했고, 랭킹 목록을 대회 참여자가 볼 수 있도록 전달하는 방식을 결정해야 한다.
이때 사용할 수 있는 방법은 3가지 방법이 있다.

  • WebSocket
  • Polling
  • SSE
  • Redis Pub/Sub

WebSocket

먼저 WebSocket은 대회 참여자가 늘어날수록, 그만큼 채널에 가입해야 하기에 트래픽이 몰리게 되어 안 좋다. 그래서 이 프로젝트에선 안 쓰기로 결정했다.

WebSocket                    SSE
────────────────────────────────────────────────
양방향 통신                   단방향 통신 (서버→클라이언트만)
연결 비용 큼                  연결 비용 작음
채팅, 게임 등에 적합           실시간 알림, 랭킹 등에 적합

❌ 랭킹은 서버→클라이언트      ✅ SSE로 충분!
방향만 필요해서 
WebSocket은 과함

Polling

다음으로 Polling이다. 사용자(브라우저)가 주기적으로 랭킹 목록을 요청해서 동기식 받아오기 때문에
완전한 실시간을 구현할 수 없어 사용하지 않기로 결정했다.

Polling
요청을 프론트가 주기적으로 서버로 요청하면
백엔드(스프링)서버가 프론트한테 응답해서 랭킹 목록을 보내준다.
→ 프론트는 받아서 UI에 출력
⇒ 따라서 완전한 실시간을 보여주진 못하기에 우리 프로젝트에선 사용❌

SSE

이제 남은 것은 SSE랑 Redis Pub/Sub이다.

먼저 SSE 방식은 웹소캣에 비해 단 방향 통신으로 이루어진다.
그만큼 트래픽 부하도 적어서 이 방식을 사용하기로 결정했다.

SSE
스프링에서 프론트로 랭킹 목록을 보내주면, 프론트는 바로 받아서 출력한다.

이때 WebHook을 사용하는데, 
스프링 서버 -> 프론트에서 설정한 엔드포인트로 데이터를 단방향으로 전달한다.

WebHook이란?
프론트 서버의 주소가 <https://www.antcamp.com라면>
<https://api.antcamp.com/webhook를> 엔드포인트로 설정한다.
(외부(스프링)에서 프론트 서버로 데이터를 쏴줄 수 있는 창구)

백엔드 서버에서는 이 엔드포인트로 요청을 날려서 데이터를 전달하게 됩니다.

SSE의 특징
✅ HTTP 기반이라 별도 프로토콜 불필요
✅ 서버 → 클라이언트 단방향만 필요한 경우 WebSocket보다 가벼움
✅ 연결 끊기면 브라우저가 자동 재연결 시도
❌ 클라이언트 → 서버 방향은 별도 REST API로 처리

따라서 대회의 참여자가 많아질수록 그만큼 연결을 해줘야 하는 건 웹소캣방식과 동일하다.
하지만 단방향 통신이기때문에 트래픽이 줄어드는 장점이 있다.

SSE를 사용한 매매 및 랭킹 조회 시퀀스 다이어그램

  1. 사용자 매매 버튼 클릭 → 매매 서비스에서 매매 이벤트 발행 → 랭킹서비스 → Redis에 랭킹 갱신
  2. 다시 Redis → 랭킹 서비스 → (SSE 사용) → 프런트 → 사용자에서 화면을 통해 랭킹 목록 확인

핵심 포인트

1. 랭킹 페이지 접속 시 각 브라우저가 SSE 연결 1개씩 맺음
2. 사용자A가 매매하면
3. 랭킹 보드를 보고있는 모든 사용자(A, B, ...)에게 동시에 Push 

정리하면 이렇게 Kafka + SSE 방식으로 구현할 수 있습니다.

하지만!! 이 매매 이벤트를 수신하는 랭킹 서버가 여러 대가 된다면? 모든 사용자가 동일한 랭킹 목록을 수신하기 위해선 Redis Pub/Sub이 필요해집니다.

Kafka + SSE + Redis Pub/Sub

문제 상황 : 스프링 서버가 여러 대일 때

사용자A 브라우저 ──SSE 연결──▶ 스프링 서버1
사용자B 브라우저 ──SSE 연결──▶ 스프링 서버2

SSE 연결은 특정 서버에 물려있어요. 사용자 A는 서버 1에, 사용자 B는 서버 2에 연결된 상태.


Kafka 메시지가 오면?

Kafka ──▶ 스프링 서버1 (메시지 수신)
          서버1에 연결된 사용자A에게만 SSE Push ✅

          스프링 서버2는 모름 ❌
          서버2에 연결된 사용자B는 못 받음 ❌

Kafka 메시지는 서버 1 한 대만 받아요!


Redis Pub/Sub을 추가하면?

Kafka ──▶ 스프링 서버1 (메시지 수신)
              ↓
        Redis Pub/Sub 채널에 Publish
              ↓
    ┌─────────────────────┐
    ↓                     ↓
스프링 서버1            스프링 서버2
(Subscribe 중)         (Subscribe 중)
    ↓                     ↓
사용자A SSE Push ✅   사용자B SSE Push ✅

Redis 채널을 모든 서버가 구독하고 있으니까 어느 서버에 연결된 사용자든 다 받을 수 있어요!


한 줄 요약

SSE 연결은 특정 서버에 종속됨
→ 여러 서버가 같은 메시지를 공유하려면
→ Redis Pub/Sub 채널이 중간 다리 역할을 해줌

각 기술의 역할

Kafka
→ 매매서비스와 랭킹서비스 사이 이벤트 전달
→ 메시지 유실 없이 안전하게 전달

Redis ZSet
→ 랭킹 데이터 저장 및 자동 정렬

Redis Pub/Sub
→ 여러 대의 서버에 랭킹 변경 이벤트 동시 전달

SSE
→ 각 브라우저에 실시간 Push

따라서, 랭킹 서버가 1대일 경우에는 Kafka + SSE로도 가능하지만 랭킹 서버가 여러 개일 경우엔 Redis Pub/Sub을 사용합니다.

각 기술이 담당하는 구간이 명확하게 나뉩니다.


Kakfa + SSE + Redis Pub/Sub 시퀀스 다이어그램

결론

따라서 Kafka + SSE 방식을 사용하고이 후 시스템의 규모가 커져서 랭킹 서비스를 여러 대 사용할 때
Redis Pub/Sub을 추가하기로 결정!

+) 적용기술 변경

이후 의사결정 과정에서 적용 기술을 SSE+Kafka에서 Polling 방식으로 바꾸기로 했다.

배경

초기에는 실시간랭킹을 구현할 수 있는 기술인 WebSocket과 SSE 방식 중 네트워크 비용을 고려해
단방향으로 통신하는 SSE 방식을 선택했었다.

하지만 주식 거래 시스템에서 '매매 타이밍'은 실시간성이 절대적이어야 하는 반면에
'랭킹 대시보드'는 초 단위의 완전한 실시간성이 필수적이지 않다. 또한 대규모 사용자가 동시에 접속해 있을 때, 매매가 발생할 때마다 매번 이벤트를 발행하고 다중 서버의 SSE 세션을 통해 Push 하는 방식은 네트워크 비용과 서버 부하(Connection 유지) 측면에서 비효율적이라 판단했다.

최종 결정

따라서, 클라이언트가 주기적으로 데이터를 요청하는 Pulling 방식으로 선회해
데이터 갱신 주기 타협을 1분마다 갱신하는 것으로 타협했다.

'내배캠' 카테고리의 다른 글

TIL - 공통 모듈의 ErrorCode, 어떻게 관리해야 할까  (0) 2026.04.26
TIL - 생성자에 private과 protected, 언제 어떻게 쓸까  (0) 2026.04.24
TIL - 개발 키워드 정리  (0) 2026.04.15
TIL - 회복성, SPOF, 분산 트랜잭션과 결과적 일관성  (1) 2026.04.13
TIL - 이벤트 스토밍 해보기  (0) 2026.04.11
'내배캠' 카테고리의 다른 글
  • TIL - 공통 모듈의 ErrorCode, 어떻게 관리해야 할까
  • TIL - 생성자에 private과 protected, 언제 어떻게 쓸까
  • TIL - 개발 키워드 정리
  • TIL - 회복성, SPOF, 분산 트랜잭션과 결과적 일관성
MvA
MvA
백엔드 개발자 김재현입니다. 주로 공부하면서 느낀점을 기록합니다.
  • MvA
    Man vs Ai
    MvA
  • 전체
    오늘
    어제
    • 분류 전체보기 (94)
      • Java (6)
      • Python (8)
        • 딥러닝 (1)
        • 머신러닝 (7)
      • JavaScript (2)
      • 내배캠 (60)
      • 개인 프로젝트 (11)
      • 책 후기 (5)
      • 기타 (1)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    배포
    TiL
    내일배움캠프
    딥러닝
    Riot API
    아키텍처
    머신러닝
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.4
MvA
TIL - 실시간 랭킹 구현 방법
상단으로

티스토리툴바