Redis의 Sorted Set (ZSET) 자료구조란?
score(점수)를 기준으로 자동 정렬되는 unique string(중복 없는 문자열)의 Collection

또한 공식 문서에 의하면 중복 score를 가지는 문자열의 경우에는 사전순(lexicographically)으로 정렬된다고 한다.

여기서 사전순이란?
사전에 단어가 나열되는 방식과 같다.
"apple"이 "banana" 보다 앞에 오는 것과 같이 이해하면 편할 것 같다.
따라서 Sorted Set에서 정렬 우선순위를 정리하면
- 1순위: score (숫자 비교)
- 2순위: 멤버 문자열 (사전순 비교)
Sorted Set 기본 명령어
데이터 삽입
ZADD ranking:2026 5 kychan // ZADD [KEY] [SCORE] [VALUE]
ZADD ranking:2026 10 minsu
데이터 조회
ZRANGE ranking:2026 0 1 WITHSCORES // ZRANGE [KEY] [시작 인덱스] [끝 인덱스] [WITHSCORES] (SCORE를 함께 조회)
// 0 ~ 1번째까지 출력 (WITHSCORES 없을 시 점수 출력 X)
1) "kychan"
2) "5"
3) "minsu"
4) "10"
이때 끝 인덱스가 -1 이면 끝까지 조회한다.
데이터 조회 (내림차순)
ZRANGE ranking:2026 0 1 WITHSCORES REV
// 내림차순으로 정렬
1) "minsu"
2) "10"
3) "kychan"
4) "5"
SCORE 증가
ZINCRBY ranking:2026 3 kychan // ZINCRBY [KEY] [증가시킬 SCORE] [VALUE]
ZRANGE ranking:2026 0 1 WITHSCORES
1) "kychan"
2) "8" // 5 -> 8로 증가
3) "minsu"
4) "10"
이때 만약 KEY에 해당하는 Sorted Set이 없으면 새로운 Sorted Set을 입력한 Value와 함께 생성한다.
데이터 개수 조회
ZCARD ranking:2026 // ZCARD [KEY]
데이터 삭제 (Score을 기준)
ZREMRANGE ranking:2026 0 3 // ZREMRANGE [KEY] [MINSCORE] [MAXSCORE]
MINSCORE 이상 MINSCORE이하의 Score값을 가진 데이터를 삭제한다.
만료 시간 설정 (TTL)
EXPIRE ranking:2026 10 // EXPIRE [KEY] [TTL / sec]
Sorted Set를 사용해 인기 검색어 TOP 10 조회하기 (+ k6 부하 테스트)
Sorted Set을 사용하지 않았을 때
@Service
@RequiredArgsConstructor
public class SearchService {
private final SearchRepository searchRepository;
@Transactional
public void search(String keyword) {
// 키워드가 DB에 있으면 가져오고 아니면 새로 생성
SearchKeyword searchKeyword = searchRepository.findByKeyword(keyword)
.orElse(new SearchKeyword(keyword));
// 키워드의 count(검색 횟수)를 1증가
searchKeyword.increaseCount();
searchRepository.save(searchKeyword);
}
@Transactional(readOnly = true)
public List<String> getTop10Keywords() {
return searchRepository.findTop10ByOrderByCountDesc().stream()
.map(SearchKeyword::getKeyword)
.toList();
}
}
위와 같이 RDB를 사용하여 TOP 10 키워드 조회 로직을 작성한다.
그리고 가상유저 100명이 10초동안 다음과 같이 동작하는 스크립트를 작성한다.
1. 랜덤 키워드를 검색하는 API를 호출
2. 인기 검색어 조회하는 API를 호출
이후 부하 테스트 진행.

총 25,218개의 API요청에서 약 2,509 TPS가 발생한 것을 확인 할 수 있다.
Sorted Set을 사용할 때
SortedSet을 사용하기 위해 다음과 같은 코드를 추가한다.
public void searchWithRedis(String keyword) {
// ZINCRBY search_keyword_ranking 1 [keyword]
redisTemplate
.opsForZSet()
.incrementScore("search_keyword_ranking", keyword, 1.0);
}
public List<String> getTop10KeywordsWithRedis() {
// ZRANGE search_keyword_ranking 0 9 REV
Set<String> topKeywords = redisTemplate
.opsForZSet()
.reverseRange("search_keyword_ranking", 0, 9);
return new ArrayList<>(topKeywords);
}
redisTemplate으로 작성한 위 코드는 앞서 살펴본 Sorted set의 ZINCRBY, ZRANGE 명령어와 동일하게 동작한다.
이후 이전 테스트 조건과 동일하게 다시 부하 테스트 진행

총 30,754개의 API요청에서 약 3,065 TPS가 발생한 것을 확인 할 수 있다.
왜 성능차이가 발생하게 되는 걸까?
그 이유는 다음 두 가지 핵심 요인에 의해서 일어나게 된다.
1. 데이터 저장 위치
Redis는 메모리, RDB는 디스크에 저장되기 때문에 접근 시간에서 차이가 발생한다.
RDB는 데이터 접근 시 디스크 I/O가 발생하기에 훨씬 더 많은 시간이 소요될 수밖에 없다.
2. 정렬 시점의 차이
먼저 RDB의 경우에는 TOP 10 키워드를 가져오기 위해 데이터를 정렬하는 과정에서 다음과 같이 쿼리문을 수행한다.
SELECT keyword FROM keywords ORDER BY count DESC LIMIT 10;
이때 내부적으로는 다음과 같은 순서로 동작하게 된다.
- keywords 테이블에서 데이터 조회
- count 컬럼 기준으로 정렬 수행
- 상위 10개를 잘라서 반환
위 과정에서 count에 인덱스가 없는 경우, 데이터베이스는 Full Table Scan 후 정렬을 수행한다.
이후 LIMIT 10을 가져오기 위해 내부적으로 정렬 알고리즘을 수행하고, O(N log N) 정렬 연산의 시간복잡도가 소요된다.
반면 Redis의 Sorted Set의 경우는 이미 Sorted Set에 자료를 추가하게 되는 시점에 O(log N) 정렬 연산이 수행된다.

우리가 데이터를 조회할 시점에는 이미 정렬된 자료구조 내에서 조회를 하게되기 때문에, 추가적인 Overhead가 발생하지 않는다.
Sorted Set를 사용해 API 요청 횟수 제한하기
구현 방법
Sorted Set을 사용하여 API 요청 횟수를 제한하는 방법은 다음과 같다.

요청이 들어올 때, 사용자에 각 요청을 구분을 구분할 수 있는 고유 값(UUID)를 Sorted Set에 넣고, Score에는 각 사용자가 요청을 보낸 시간을 unixtime으로 넣는다.
그리고 현재 시간(unixtime)에서 score 값을 뺀 값이 10,000(10초)을 초과하는 지 확인하고, 초과한 값들은 모두 삭제한다.
이후 Sorted set에 남아있는 값(10초 이내의 요청들)에서 허용할 API들을 관리해주면 된다.
실제로 테스트를 진행해보기 위해 다음과 같이 코드를 작성해준다.
@Component
public class RateLimiter {
private static final long RATE_LIMIT_TIME_MS = 10_000; // 요청을 카운팅하는 시간 단위
private static final long MAX_REQUESTS = 5; // 10초 동안 최대 5개의 요청만 허용
private final RedisTemplate<String, String> redisTemplate;
public RateLimiter(RedisTemplate<String, String> redisTemplate) {
this.redisTemplate = redisTemplate;
}
public boolean allow(String userId) {
long now = System.currentTimeMillis();
// user 단위로 API 요청 횟수를 제한하기 위해, userId를 기반으로 Key를 생성
// (IP 단위로 API 요청 횟수를 제한하고 싶다면, userId 대신 IP를 파라미터로 받으면 된다.)
String redisKey = "rate_limit:" + userId;
// 서로 다른 요청을 구분하기 위해 UUID로 requestId 값을 생성
String requestId = UUID.randomUUID().toString();
// 1. Sorted Set에서(redisKey)에서 score가 0 이상이고 (now - 10_000) 이하인 모든 member를 삭제
// = 최근 10초 내에 저장한 member만 남겨두고, 나머지 member는 전부 삭제
redisTemplate
.opsForZSet()
.removeRangeByScore(redisKey, 0, now - RATE_LIMIT_TIME_MS);
// 2. 현재 들어온 API 요청을 Sorted Set에 저장.
// member를 UUID 기반으로 생성된 고유의 requestId로 저장하고, score를 현재 시간값(ex. 1735972805123)으로 저장한다.
redisTemplate
.opsForZSet()
.add(redisKey, requestId, now);
// 3. Sorted Set에 저장되어 있는 데이터 개수 조회
// = 최근 10초 내에 요청을 보낸 횟수
Long count = redisTemplate
.opsForZSet()
.size(redisKey);
// 4. 만료 시간(TTL)을 10초로 설정함으로써 Redis 공간을 불필요하게 많이 점유하는 것을 방지
redisTemplate.expire(
redisKey,
10_000,
TimeUnit.MILLISECONDS
);
// 최대 요청 수보다 작거나 같으면 true를 리턴, 최대 요청 수를 초과하면 false를 리턴
return count <= MAX_REQUESTS;
}
}
이후 위 코드에 대한 Filter 코드를 작성한다.
// OncePerRequestFilter : HTTP 요청이 들어올 때마다 단 한 번 실행되는 필터 로직
@Component
public class RateLimitFilter extends OncePerRequestFilter {
private final RateLimiter rateLimiter;
public RateLimitFilter(RateLimiter rateLimiter) {
this.rateLimiter = rateLimiter;
}
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
// 어떤 사용자인지 식별하기 위한 키 (실무에서는 IP 값을 활용해 식별하는 편)
String userId = request.getHeader("USER-ID");
// userId가 null일 땐 RateLimiter가 작동하지 않게 바로 return하기
if (userId == null) {
filterChain.doFilter(request, response);
return;
}
// API 요청 횟수를 초과 시
if (!rateLimiter.allow(userId)) {
response.setStatus(429);
return;
}
filterChain.doFilter(request, response);
}
}
이후 Postman으로 10초에 5번이 넘는 요청을 실제로 보내보게 되면?

다음과 같이 429 Too Many Request 에러가 잘 들어오는 것을 확인 할 수 있다.
