Redis sorted set: 리더보드, 우선순위 큐와 sliding window
1. 개요
Sorted set(ZSET)은 Redis의 가장 유연한 데이터 구조입니다. 각 요소(member)에 점수(score)를 붙이고, Redis가 점수 순서대로 정렬해 유지합니다. 이 글에서는 ZSET으로 세 가지 흔한 문제를 해결합니다: 리더보드, 우선순위 큐/지연 큐, 그리고 슬라이딩 윈도우 방식의 rate limiter입니다. 모두 Java 21과 Spring Boot 3.x로 작성했습니다. 이 글을 읽으면 각 문제에 맞는 score 설계법을 알게 되고, 자주 나타나는 race condition을 피할 수 있습니다.
2. Sorted set의 작동 원리
ZSET은 다음 특성을 갖습니다:
- Member는 중복되지 않습니다. 기존 member에
ZADD하면 score만 업데이트됩니다. - Score는 실수(
double)입니다. 같은 score의 member들은 사전식(lexicographic) 순서로 정렬됩니다. - 추가, 제거, 업데이트, 순위 조회는 모두
O(log N)복잡도입니다. 범위 조회는O(log N + M)이며, M은 반환되는 요소 개수입니다.
redis-cli로 빠르게 시험해볼 수 있습니다:
ZADD scores 120 alice 95 bob 150 carol
ZREVRANGE scores 0 1 WITHSCORES
ZREVRANK scores bob
1) "carol"
2) "150"
3) "alice"
4) "120"
(integer) 2
ZREVRANGE는 score 내림차순으로 요소를 반환합니다. ZREVRANK는 이 순서에서 bob의 위치(0부터 시작)를 보여줍니다.
2.1. 프로젝트 설정
Spring Data Redis dependency를 추가합니다. 두 개의 Testcontainers dependency는 6번 섹션의 테스트용입니다:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
연결을 설정합니다:
spring.data.redis.host=localhost
spring.data.redis.port=6379
Spring Boot가 자동으로 StringRedisTemplate bean을 생성합니다. 모든 ZSET 작업은 opsForZSet()으로 수행됩니다.
3. 리더보드
문제: 게임에서 플레이어에게 점수를 더하고, 상위 N명과 특정 플레이어의 순위를 조회해야 합니다. 여기서 score는 점수이고, member는 플레이어 ID입니다.
public record PlayerScore(String player, double score) {}
@Service
public class LeaderboardService {
private static final String KEY = "leaderboard:game:2048";
private final ZSetOperations<String, String> zSet;
public LeaderboardService(StringRedisTemplate redis) {
this.zSet = redis.opsForZSet();
}
public double addPoints(String player, double points) {
return zSet.incrementScore(KEY, player, points);
}
public List<PlayerScore> top(int n) {
Set<ZSetOperations.TypedTuple<String>> tuples =
zSet.reverseRangeWithScores(KEY, 0, n - 1);
if (tuples == null) {
return List.of();
}
return tuples.stream()
.map(t -> new PlayerScore(t.getValue(), t.getScore()))
.toList();
}
public Optional<Long> rankOf(String player) {
Long rank = zSet.reverseRank(KEY, player);
return Optional.ofNullable(rank).map(r -> r + 1);
}
}
각 부분 설명:
incrementScore는ZINCRBY명령에 해당합니다. 이 명령은 atomic입니다. 만약 member가 없으면 Redis는 초기 score를 0으로 취급합니다. 이전 점수를 읽고 더한 후 다시 쓰지 않으므로, 여러 요청이 동시에 같은 플레이어의 점수를 더할 때 race condition이 발생하지 않습니다.reverseRangeWithScores(KEY, 0, n - 1)는ZREVRANGE ... WITHSCORES에 해당합니다. 끝 인덱스는 포함(inclusive)이므로, 상위 10명은0..9입니다.reverseRank는 플레이어가 리더보드에 없으면null을 반환합니다. 사용자 친화적으로 순위가 1부터 시작하도록 1을 더합니다.
시도해봅시다:
leaderboard.addPoints("alice", 120);
leaderboard.addPoints("bob", 95);
leaderboard.addPoints("carol", 150);
leaderboard.addPoints("bob", 60);
System.out.println(leaderboard.top(3));
System.out.println(leaderboard.rankOf("alice"));
[PlayerScore[player=bob, score=155.0], PlayerScore[player=carol, score=150.0], PlayerScore[player=alice, score=120.0]]
Optional[3]
3.1. 같은 점수 처리
두 플레이어의 점수가 같으면, Redis는 member 이름의 사전식 순서로 정렬합니다. ZREVRANGE에서는 사전식 순서가 반대가 되므로, zoe가 anna보다 먼저 나타납니다. 이 결과는 보통 원하는 비즈니스 로직과 맞지 않습니다. 누가 먼저 점수에 도달했으면 그 사람이 더 높은 순위로 나타나게 하려면, 점수의 소수 부분에 시간을 인코딩할 수 있습니다:
public double addPointsWithTieBreak(String player, long points) {
long now = System.currentTimeMillis();
double tieBreak = 1.0 - (now / 1e13);
double score = points + tieBreak;
zSet.add(KEY, player, score);
return score;
}
먼저 점수에 도달한 플레이어가 더 작은 now를 가지므로, 더 큰 tieBreak을 가져 더 높은 순위에 오릅니다. 표시할 때는 Math.floor(score)로 실제 점수를 얻습니다.
주의: score는
double이며, 2^53(약 9×10^15)까지의 정수만 정확하게 표현합니다. 여러 값을 하나의 score에 합치려면 미리 크기를 계산해서 정밀도를 잃지 않도록 해야 합니다. 위 방법으로는points가 몇 백만 이하여야 합니다. 또한,incrementScore와는 함께 사용할 수 없습니다. 호출할 때마다 소수 부분도 계속 더해지기 때문입니다.
4. 우선순위 큐와 지연 큐
4.1. 우선순위 큐
이번에는 score가 우선순위입니다: 숫자가 작을수록 높은 우선순위입니다. 같은 우선순위의 작업도 FIFO 순서대로 나오도록, score에 타임스탐프를 더합니다:
@Service
public class JobQueue {
private static final String KEY = "queue:email-jobs";
private final ZSetOperations<String, String> zSet;
public JobQueue(StringRedisTemplate redis) {
this.zSet = redis.opsForZSet();
}
public void enqueue(String jobId, int priority) {
if (priority < 0 || priority > 9) {
throw new IllegalArgumentException("priority must be 0..9");
}
double score = priority * 1e13 + System.currentTimeMillis();
zSet.add(KEY, jobId, score);
}
public Optional<String> poll() {
var tuple = zSet.popMin(KEY);
return Optional.ofNullable(tuple).map(ZSetOperations.TypedTuple::getValue);
}
public Optional<String> take(Duration timeout) {
var tuple = zSet.popMin(KEY, timeout.toSeconds(), TimeUnit.SECONDS);
return Optional.ofNullable(tuple).map(ZSetOperations.TypedTuple::getValue);
}
}
주요 포인트:
priority * 1e13 + millis의 값은 최대 약 9.2×10^13으로, 2^53보다 훨씬 작습니다. 우선순위 0인 작업은 우선순위 1인 작업보다 먼저 추가되더라도 항상 먼저 나옵니다.popMin은ZPOPMIN에 해당합니다. 이 명령은 요소를 원자적으로 가져오고 제거합니다. 두 워커가 같은 작업을 받지 않습니다.popMin(key, timeout, unit)은BZPOPMIN에 해당합니다. 이 명령은 작업이 나타나거나 타임아웃될 때까지 연결을 대기시키므로, CPU를 낭비하는 폴링을 피할 수 있습니다.
주의:
BZPOPMIN은 대기 중에 연결을 유지합니다. Lettuce에서 기본으로 공유 연결을 사용하면, blocking 명령이 다른 명령들을 차단합니다. 연결 풀을 활성화하거나(commons-pool2추가 및spring.data.redis.lettuce.pool.*설정), 워커용 별도의 연결 factory를 사용해야 합니다.
4.2. 지연 큐
30분 후에 결제 상기 이메일을 보내고 싶으신가요? Score를 실행 시점(epoch millis)으로 설정하세요. 워커는 정기적으로 현재 시점 이하의 score를 가진 작업들을 가져옵니다.
다음 코드는 잘못되었습니다: rangeByScore로 조회한 후 remove합니다. 두 워커가 제거하기 전에 같은 작업을 조회할 수 있습니다. 이 두 단계를 Lua script에서 atomic하게 실행해야 합니다:
@Service
public class DelayedJobQueue {
private static final String KEY = "queue:delayed";
private static final RedisScript<String> POP_DUE = new DefaultRedisScript<>("""
local jobs = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', ARGV[1], 'LIMIT', 0, 1)
if #jobs == 0 then return false end
redis.call('ZREM', KEYS[1], jobs[1])
return jobs[1]
""", String.class);
private final StringRedisTemplate redis;
public DelayedJobQueue(StringRedisTemplate redis) {
this.redis = redis;
}
public void schedule(String jobId, Instant runAt) {
redis.opsForZSet().add(KEY, jobId, runAt.toEpochMilli());
}
public Optional<String> pollDue(Instant now) {
String job = redis.execute(POP_DUE, List.of(KEY), String.valueOf(now.toEpochMilli()));
return Optional.ofNullable(job);
}
}
Script는 조건을 만족하는 작업을 최대 하나 가져온 후 제거합니다. Lua에서 return false는 Java에서 nil로 변환되므로 null을 받습니다. 워커는 @Scheduled(fixedDelay = 500) 메서드에서 pollDue를 반복 호출해서 비워질 때까지 처리할 수 있습니다.
주의: Pop 후 작업은 Redis에서 사라집니다. 워커가 중간에 실패하면 작업이 손실됩니다. 재시도 보장이 필요하면, 작업을 “processing” ZSET으로 옮긴 후(score는 deadline), 처리 완료 시 삭제하세요. 요구사항이 더 복잡하면 Redis Streams이나 실제 message broker를 고려하세요.
5. Sliding window 방식 Rate limiter
Fixed window(매 분마다 요청 수를 센다)의 약점은 경계에 있습니다. 예를 들어, 00:59에 100개 요청, 01:00에 100개 요청이 모두 통과하면, 2초 동안 200개 요청이 처리됩니다. Sliding window는 각 요청의 타임스탐프를 ZSET에 저장해서 이를 해결합니다:
now - window보다 오래된 요청을 제거합니다(ZREMRANGEBYSCORE).- 남은 요청 개수를 세어봅니다(
ZCARD). - 아직 제한을 넘지 않으면, 현재 요청을 score
now로 추가합니다.
세 단계는 atomic하게 실행되어야 하므로, Lua를 사용합니다:
@Service
public class SlidingWindowRateLimiter {
private static final RedisScript<Long> SCRIPT = new DefaultRedisScript<>("""
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
if redis.call('ZCARD', key) < limit then
redis.call('ZADD', key, now, ARGV[4])
redis.call('PEXPIRE', key, window)
return 1
end
return 0
""", Long.class);
private final StringRedisTemplate redis;
private final Clock clock;
public SlidingWindowRateLimiter(StringRedisTemplate redis, Clock clock) {
this.redis = redis;
this.clock = clock;
}
public boolean tryAcquire(String customerId, int limit, Duration window) {
Long allowed = redis.execute(SCRIPT,
List.of("rate:" + customerId),
String.valueOf(clock.millis()),
String.valueOf(window.toMillis()),
String.valueOf(limit),
UUID.randomUUID().toString());
return Long.valueOf(1L).equals(allowed);
}
}
설명:
- Member는 무작위 UUID입니다. 타임스탐프를 member로 사용하면, 같은 밀리초 안의 두 요청이 서로 덮어쓰고 하나로 세어집니다.
PEXPIRE는 Redis가 비활동 고객의 key를 자동으로 정리하도록 합니다.Clockbean을 선언해야 합니다(예:Clock.systemUTC()).Clock을 주입하면 테스트가 쉬워집니다. 하지만 여러 애플리케이션 인스턴스가 있으면 시계를 동기화하거나, script 내에서redis.call('TIME')으로 시간을 가져와야 합니다.
주의: 각 요청마다 ZSET에 요소가 하나 추가됩니다. 제한이 크면, 예를 들어 시간당 100,000 요청/고객이면 메모리가 빠르게 증가합니다. 그 경우 sliding window counter(두 fixed window에서 추정) 또는 token bucket을 사용하세요.
Cluster mode에서는 script가 같은 hash slot의 key들만 접근할 수 있습니다. 이 글의 script들은 각각 한 개의 key만 사용하므로 문제가 없습니다.
6. Testcontainers로 테스트하기
다음 테스트는 Docker의 실제 Redis에서 실행됩니다. @ServiceConnection은 Spring을 위해 host와 port를 자동으로 설정합니다.
@SpringBootTest
@Testcontainers
class SortedSetFeaturesTest {
@Container
@ServiceConnection(name = "redis")
static GenericContainer<?> redis =
new GenericContainer<>("redis:7-alpine").withExposedPorts(6379);
@Autowired LeaderboardService leaderboard;
@Autowired JobQueue jobQueue;
@Autowired SlidingWindowRateLimiter limiter;
@Autowired StringRedisTemplate template;
@BeforeEach
void flush() {
template.getConnectionFactory().getConnection().serverCommands().flushAll();
}
@Test
void leaderboardOrdersByScoreDescending() {
leaderboard.addPoints("alice", 120);
leaderboard.addPoints("bob", 95);
leaderboard.addPoints("bob", 60);
assertThat(leaderboard.top(2))
.extracting(PlayerScore::player)
.containsExactly("bob", "alice");
assertThat(leaderboard.rankOf("alice")).contains(2L);
assertThat(leaderboard.rankOf("nobody")).isEmpty();
}
@Test
void higherPriorityJobComesFirst() {
jobQueue.enqueue("newsletter", 5);
jobQueue.enqueue("password-reset", 0);
assertThat(jobQueue.poll()).contains("password-reset");
assertThat(jobQueue.poll()).contains("newsletter");
assertThat(jobQueue.poll()).isEmpty();
}
@Test
void rateLimiterRejectsAfterLimit() {
Duration window = Duration.ofSeconds(10);
assertThat(limiter.tryAcquire("c-42", 3, window)).isTrue();
assertThat(limiter.tryAcquire("c-42", 3, window)).isTrue();
assertThat(limiter.tryAcquire("c-42", 3, window)).isTrue();
assertThat(limiter.tryAcquire("c-42", 3, window)).isFalse();
assertThat(limiter.tryAcquire("c-99", 3, window)).isTrue();
}
}
마지막 테스트는 두 가지를 확인합니다: c-42의 네 번째 요청이 거부되고, 각 고객마다 별도의 제한이 적용됩니다. 윈도우가 “밀려나가는” 것을 테스트하려면, 조정 가능한 Clock을 주입하고(예: 커스텀 MutableClock), 시간을 10초 앞으로 진행한 후 다시 tryAcquire를 호출하세요.
7. Sorted set을 사용하면 안 되는 경우
- Message queue처럼 견고함이 필요한 경우: ZSET에는 acknowledgement, consumer group, 재시도가 없습니다. Redis Streams, RabbitMQ 또는 Kafka가 더 적합합니다.
- 하나의 ZSET이 너무 클 때: 수천만 요소를 가진 key는 “big key”가 되어
DEL, slot 이동, replication이 모두 느려집니다. 시즌, 지역, 날짜 등으로 key를 분리하세요(leaderboard:2026-10). - Score의 정밀도가 절대적으로 중요할 때: 예를 들어, 많은 소수점을 가진 통화입니다.
double에는 오차가 있으므로 최소 단위(동, 센트)로 정수로 저장하세요. - 대략적인 집계면 충분할 때: “대략 얼마나 많은 고유 사용자인가” 정도면 HyperLogLog가 훨씬 메모리를 절약합니다.
8. 결론
Sorted set은 score를 적절하게 선택하는 것만으로도 많은 문제를 해결할 수 있습니다. 리더보드에서는 score가 점수이고 ZINCRBY, ZREVRANGE, ZREVRANK를 사용합니다. 우선순위 큐에서는 score가 우선순위와 타임스탐프를 합친 것이고 ZPOPMIN/BZPOPMIN을 사용합니다. 지연 큐에서는 score가 실행 시점입니다. Rate limiter에서는 score가 각 요청의 시점입니다.
기억할 두 가지가 있습니다. 첫째, “읽고 쓰는” 일련의 작업은 모두 atomic 명령이나 Lua script로 감싸야 합니다. 둘째, score에 여러 값을 합칠 때 항상 double의 정밀도 한계를 확인하세요. 이 두 가지를 명심하면, 자신 있게 ZSET을 프로덕션 시스템에서 사용할 수 있습니다.