아무튼, 쓰기

실시간 랭킹 MySQL로 버티다가, 결국 Redis ZSET으로 갈아탄 이야기 본문

스프링

실시간 랭킹 MySQL로 버티다가, 결국 Redis ZSET으로 갈아탄 이야기

순원이 2025. 11. 28. 16:08

들어가며

독서 습관 형성 서비스에서 랭킹은 유저 참여도를 높이는 핵심 기능입니다. 유저들은 자신의 순위를 확인하고, 상위권 사용자들과 비교하며, 순위를 높이기 위해 다음 행동을 이어갑니다.

현재 서비스는 100만 개 순위에서 평균 동시 접속자 100명, 피크 타임에는 500명이 접속하는 수준을 가정하고 로직을 작성하였고, RDB만으로  50ms 응답속도를 확보하였고 Range scan이라는 한계상 피크 타임 때 처리량이 한계가 보여서 ZSET을 도입하여, 처리량을 높였습니다. 

초기 설계: 점수별 인원 집계 테이블

설계 의도

처음 랭킹 시스템을 설계할 때, MySQL을 기반으로 구현하였습니다. 랭킹 화면에는 Top 100과 내 순위가 함께 표시되는데, Top 100은 인덱스가 정렬되어 있어 빠르게 조회할 수 있습니다. 하지만 내 순위가 하위권인 경우 문제가 됩니다.

예를 들어, 100만 명 중 100만 등이라면 인덱스를 100만 행 스캔해야 순위를 알 수 있습니다. 많은 유저가 동시에 자신의 순위를 조회하면 Index Range Scan 비용이 누적되어 부하가 발생할 것으로 예상했습니다.

이를 해결하기 위해 별도의 캐시 레이어 없이도 성능을 확보할 수 있는 점수별 인원 집계 테이블을 만들었습니다.

@Entity
@Table(name = "score_counts", 
    uniqueConstraints = {
        @UniqueConstraint(name = "uk_score_shard", 
                         columnNames = { "score", "shardIdx" })
    },
    indexes = {
        @Index(name = "idx_ranking_cover", 
              columnList = "score, userCount")
    })
public class ScoreCounts {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @Column(nullable = false)
    private Long score;
    
    @Column(nullable = false)
    private Integer shardIdx;
    
    @Column(nullable = false)
    private Long userCount;
}

일반적으로 랭킹을 조회할 때 RANK() 윈도우 함수를 사용하면 전체 테이블을 스캔해야 합니다. Top 100 조회는 ranking_year, ranking_week, score로 구성된 복합 인덱스를 타면서 WHERE 조건 필터링과 ORDER BY 정렬을 인덱스 레벨에서 처리하여 빠르게 가져올 수 있지만, 하위권 유저의 순위를 구하려면 수십만~수백만 행을 스캔해야 합니다.

이 문제를 해결하기 위해 점수별로 사용자 수를 집계한 ScoreCounts 테이블을 두고, 해당 사용자의 점수보다 높은 점수의 userCount를 누적 합산하는 방식으로 순위를 계산했습니다.

-- 내 순위 조회 (ScoreCounts 집계 테이블)
-- idx_ranking_cover 커버링 인덱스 사용
SELECT SUM(userCount) 
FROM score_counts 
WHERE score > ?;

 

내 순위 조회는 RankingHistory 테이블에서 수십만 행을 스캔하는 것이 아닌, ScoreCounts에 커버링 인덱스(score, userCount)로 자기 점수(최대 2000점)만큼만 Row를 읽을 수 있도록 최적화했습니다.

샤딩 전략

점수 업데이트 시 특정 점수 레코드에 인원 수를 변경을 위한 락 경합이 발생하는 것을 방지하기 위해, shardIdx를 도입해 같은 점수라도 10개의 레코드로 분산 저장했습니다.

@Transactional
public void updateScore(Long memberId, boolean isCorrect, LocalDate date) {
    RankingYearWeek currentYearWeek = RankingYearWeek.of(date.getYear(), week);
    
    RankingHistory ranking = rankingHistoryRepository
        .findByMemberIdAndRankingYearWeek(memberId, currentYearWeek)
        .orElseGet(() -> RankingHistory.create(memberId, currentYearWeek));
    
    Long oldScore = ranking.getScore();
    ranking.updateScore(isCorrect, date);
    Long newScore = ranking.getScore();
    
    // 점수가 변경되었을 때만 ScoreCounts 업데이트
    if (!oldScore.equals(newScore)) {
        // 랜덤 샤드 선택으로 분산
        int oldShard = ThreadLocalRandom.current().nextInt(1, 11);
        scoreCountsRepository.decrementCount(oldScore, oldShard);
        
        int newShard = ThreadLocalRandom.current().nextInt(1, 11);
        scoreCountsRepository.incrementCount(newScore, newShard);
    }
    
    rankingHistoryRepository.save(ranking);
}

점수가 업데이트될 때마다 랜덤하게 1~10 사이의 샤드를 선택합니다. 이렇게 하면 내 순위 조회 시 읽어야 할 ROW가 샤딩 개수 만큼 배로 늘어나긴 하지만, 동일한 점수를 가진 여러 유저가 동시에 업데이트하더라도, Row Lock이 10개로 분산되어 경합이 줄어듭니다. 

성능 측정

100만개 순위에서 동시 접속자 100명까지는 평균 응답 속도가 11ms 정도로 준수했습니다. 하지만 500명으로 부하 테스트를 돌리자, p99 응답 속도가 720ms까지 증가했습니다.

동시 사용자 100, 내 순위 조회
DB 동시 사용자 500, 내 순위 조회

ScoreCounts 테이블은 샤딩(10) × 점수 범위(최대 2000점)로 구성되어, 최악의 경우 약 2만 row를 스캔합니다. 커버링 인덱스 덕분에 쿼리 자체는 매우 빠르지만, 문제는 데이터베이스 커넥션 풀에 있었습니다.

(전제: 컨텍스트 스위칭 비용을 고려하여 DB가 처리할 수 있는 최적의 커넥션 풀 개수에 맞추어 커넥션 풀을 150개로 설정했었습니다)

동시 접속자 500명이 랭킹을 조회하면, 커넥션 대기 시간이 누적되면서 전체 응답 속도가 느려졌습니다. 쿼리는 빠른데, 커넥션을 얻기까지 기다리는 시간이 병목이었던 것입니다.

Redis 도입 결정

Master-Slave 구조 검토

동시 접속자가 늘어날 것을 대비해, 근본적인 해결책을 찾아야 했습니다. 처음에는 읽기 처리량을 늘리기 위해 MySQL Master-Slave 구조를 고려했습니다.

검토한 구성: Master(1) + Slave(2)

  • Master는 쓰기 전용
  • Slave 2대로 읽기 부하 분산
  • 이론적으로 읽기 처리량 2배 증가

하지만 다음과 같은 이유로 보류했습니다.

  • Replication Lag: Master와 Slave 간 데이터 동기화 지연 발생 가능. 랭킹처럼 실시간성이 중요한 경우 문제가 될 수 있음
  • Failover 처리: Slave 장애 시 자동 전환 로직 필요
  • 인프라 복잡도: DB 인스턴스 3대 관리 및 모니터링 비용 증가

Redis 선택

저는 이미 캐싱 용도로 Redis를 사용하고 있었습니다. 추가 인프라 없이 기존 Redis를 활용하는 것이 관리 비용 측면에서 더 유리하다고 판단했습니다.

Redis는 싱글 스레드 기반이지만 메모리 연산이므로 처리량이 훨씬 높습니다. 또한 Sorted Set(ZSET)은 내부적으로 Skip List와 Hash Table을 함께 사용해, 이미 정렬된 상태를 유지합니다.  메모리 기반으로 빠른 처리량과 RDB를 사용할 때보다 더 빠른 시간복잡도로 처리할 수 있기 때문에 레디스 Zset 사용이 적합하다고 생각했습니다.

MySQL: Index Scan
Redis: 메모리 기반 O(log N) 순위 조회

 

캐싱 전략: Write-Through

데이터 영속성 vs 성능

Redis를 도입하고 캐싱 전략을 정하는 과정에서 고민이 있었습니다.

Write-Back (비동기 쓰기)

  • Redis에만 먼저 쓰고, 나중에 배치로 DB에 반영
  • 성능은 가장 좋지만, Redis 장애 시 데이터 유실 가능성 발생하고, 실시간 랭킹 보여줄 수 없음 

Write-Through (동기 쓰기)

  • DB 트랜잭션 커밋 후 Redis 업데이트
  • 쓸 때마다 매번 디비에 요청이 가지만, 데이터 확보가 가능하기 때문에, 레디스 장애 시에도 실시간 랭킹 조회 가능

습관 형성 서비스이기 때문에, 유저가 열심히 쌓은 점수인 랭킹데이터가 유실되면 신뢰를 잃고 이탈로 이어질 수 있다고 생각하여 Write-Through를 선택했습니다.

    public void updateRanking(Long memberId, boolean isCorrect, LocalDate date) {
        rankingShardingService.updateScore(memberId, isCorrect, date);

        try {
            rankingRedisRepository.updateRankingAtomic(memberId, isCorrect, date);
        } catch (Exception e) {
            log.error("Redis 업데이트 실패 (DB는 성공): {}", e.getMessage());
        }
    }

DB 커밋이 실패하면 Redis는 갱신되지 않습니다. 또한, 레디스가 장애시에도 랭킹 시스템은 동작해야 하기 때문에, 레디스가 장애나더라도 디비 저장은 가능하도록 만들었습니다.

Fallback 메커니즘

Write-Through를 선택했기 때문에, Redis 장애 시 MySQL로도 실시간 랭킹을 보여주는 Fallback 로직을 구현할 수 있었습니다.

public List<RankingDto> getTopRanking(RankingYearWeek yearWeek) {
    try {
        return redisRankingService.getTop100(yearWeek);
    } catch (RedisConnectionException e) {
        log.warn("Redis unavailable, falling back to MySQL");
        return mysqlRankingService.getTop100(yearWeek);
    }
}

Redis가 다운되더라도 서비스는 계속되며, 유저들은 조금 느린 응답 속도를 경험하지만 서비스 중단은 없습니다.

Redis 구현: Lua Script의 선택

저장 구조

Redis에 랭킹을 구현하기 위해 세 가지 자료구조를 사용합니다.

1. ZSET: ranking:{year}:{week}
   - 점수와 memberId 저장
   - 정렬된 순위 조회에 사용

2. Hash: ranking:{year}:{week}:{memberId}:stats
   - days, solved, correct 저장
   - 점수 계산에 필요한 세부 데이터

3. Set: ranking:{year}:{week}:{memberId}:attendance
   - 출석 날짜 저장 (YYYY-MM-DD)
   - 중복 출석 방지

원자성 보장: Lua Script vs Transaction

점수 업데이트는 다음 과정을 거칩니다.

  1. Hash에서 solved 증가
  2. 정답이면 correct 증가
  3. 오늘 날짜가 Set에 없으면 days 증가
  4. days, solved, correct로 점수 계산
  5. ZSET에 점수 업데이트

이 과정을 원자적으로 처리해야 했습니다. Redis Transaction(MULTI/EXEC)도 고려했지만, 중간 값(오늘 날짜가 set에 있는지)을 읽어서 값 업데이트에 사용해야 하므로 적합하지 않았습니다.

Redis Transaction은 명령어들을 큐잉만 할 뿐, 중간 결과를 읽어서 다음 명령에 사용할 수 없습니다. 반면 Lua Script는 서버 측에서 원자적으로 실행되므로, 중간 계산 결과를 활용할 수 있습니다.

local memberId = ARGV[1]
local isCorrect = ARGV[2] == 'true'
local today = ARGV[3]
local scoreKey = KEYS[1]
local statsKey = KEYS[2]
local attendanceKey = KEYS[3]

-- 1. Increment Solved
redis.call('HINCRBY', statsKey, 'solved', 1)

-- 2. Increment Correct
if isCorrect then
    redis.call('HINCRBY', statsKey, 'correct', 1)
end

-- 3. Update Attendance
local added = redis.call('SADD', attendanceKey, today)
if added == 1 then
    redis.call('HINCRBY', statsKey, 'days', 1)
end

-- 4. Calculate Score (중간 값 활용)
local days = tonumber(redis.call('HGET', statsKey, 'days') or 0)
local solved = tonumber(redis.call('HGET', statsKey, 'solved') or 0)
local correct = tonumber(redis.call('HGET', statsKey, 'correct') or 0)

-- Formula: (days * 10) + (solved * 1) + (correct * 5)
local score = (days * 10) + (solved * 1) + (correct * 5)

-- 5. Update ZSET
redis.call('ZADD', scoreKey, score, memberId)

-- 6. Set TTL (2 weeks)
redis.call('EXPIRE', statsKey, 1209600)
redis.call('EXPIRE', attendanceKey, 1209600)
redis.call('EXPIRE', scoreKey, 1209600)

return score

Lua Script 덕분에 Hash 조회, 점수 계산, ZSET 업데이트가 단일 원자 연산으로 처리됩니다.

조회 성능 최적화: Pipelining

네트워크 RTT 문제

Top 100 memberIds를 조회 후 memberId별로 정보를 가져올 때 Redis 명령을 여러 번 호출하고 있었습니다. 각 명령마다 네트워크 왕복이 발생하니, 1ms × 100명 = 100ms가 네트워크 비용만으로 소모될 수 있습니다. 

Redis Pipelining 도입

Redis Pipelining은 여러 명령을 한 번에 전송하고, 응답을 일괄로 받도록 하였습니다.

@SuppressWarnings("unchecked")
public Map<Long, RankingStats> getRankingStats(List<Long> memberIds, LocalDate date) {
    List<Object> results = redisTemplate.executePipelined(
        new SessionCallback<Object>() {
            @Override
            public <K, V> Object execute(RedisOperations<K, V> operations) 
                throws DataAccessException {
                RedisOperations<String, String> ops = 
                    (RedisOperations<String, String>) operations;
                HashOperations<String, String, String> hashOps = ops.opsForHash();
                
                // 모든 조회 명령을 파이프라인에 추가
                for (Long memberId : memberIds) {
                    String key = getStatsKey(memberId, date);
                    hashOps.multiGet(key, Arrays.asList("days", "solved", "correct"));
                }
                return null;
            }
        });
    
    // 결과는 일괄 수신
    Map<Long, RankingStats> statsMap = new HashMap<>();
    for (int i = 0; i < memberIds.size(); i++) {
        Object result = results.get(i);
        if (result instanceof List) {
            statsMap.put(memberIds.get(i), parseStatsFromList((List<String>) result));
        }
    }
    return statsMap;
}

모든 명령을 하나의 네트워크 패킷으로 묶어서 전송했습니다. 100명의 stats를 조회하더라도 네트워크 RTT는 1회로 줄어들었고, 평균 응답 속도는 36ms로 개선됐습니다.

결과

최종적으로 내 순위 조회 API 결과는 다음과 같습니다.

레디스 동시 사용자 100명, 내순위 조회
레디스 동시 사용자 500명, 내순위 조회

동시 사용자 500일 때, 지표 Before (MySQL) After (Redis) 개선율

평균 응답 속도 410ms 32ms 92.2% ↓
p95 응답 속도 600ms 163ms 72.8% ↓
p99 응답 속도 720ms 203ms 71.8% ↓

동시 접속자가 500명 이상으로 늘어나도 200ms 안팍의 응답속도로 반환할 수 있었습니다.