7편. Redis Sorted Set 실습: 랭킹과 우선순위 처리
📚 목차
1. 인기 게시글 랭킹 만들기
2. 인기 검색어 순위 만들기
3. 사용자 포인트 랭킹 만들기
4. 우선순위 큐 구현하기

📂 [GitHub 코드 보러가기] : https://github.com/cericube/nodejs-practice-lab/tree/main/redis-examples
1. 인기 게시글 랭킹 만들기
게시글 서비스에서는 인기 게시글 기능이 자주 필요합니다.
예를 들어 다음 기준으로 인기글을 만들 수 있습니다.
- 조회수
- 좋아요 수
- 댓글 수
- 공유 수
- 가중치를 합산한 인기 점수
Sorted Set에서는 다음처럼 저장합니다.
key → zset:post-ranking
member → postId
score → 인기 점수
예를 들어 게시글 1번이 10회 조회되고, 게시글 2번이 3회 조회되면 Redis에는 개념적으로 다음처럼 저장됩니다.
zset:post-ranking
member: "1", score: 10
member: "2", score: 3
score가 높은 게시글을 조회하면 인기 게시글 TOP N을 만들 수 있습니다.
✔️ 사용하는 Redis Key
RedisKey.zset.postRanking()
zset:post-ranking
✔️ 인기 점수 증가
// src/services/post-zset.service.ts
/**
* 게시글 인기 점수 증가
*
* 1. 인기 게시글 랭킹 Sorted Set key를 가져옵니다.
* 2. postId를 member로 사용합니다.
* 3. ZINCRBY로 score를 증가시킵니다.
*
* 실습 포인트:
* Sorted Set은 같은 member를 중복 저장하지 않습니다.
* 같은 postId에 대해 ZINCRBY를 여러 번 호출하면 member가 여러 개 생기는 것이 아니라 score만 증가합니다.
*/
async increasePostRankingScore(postId: number, score = 1): Promise<number> {
const key = RedisKey.zset.postRanking();
const member = String(postId);
// Redis 명령: ZINCRBY key score member
// member가 없으면 추가하고, 있으면 기존 score에 값을 더한 뒤 최종 score를 반환합니다.
const newScore = await redis.zIncrBy(key, score, member);
return newScore;
}
인기 점수 증가는 ZINCRBY를 사용합니다.
여기서 key, member, score는 다음 의미입니다.
key → zset:post-ranking
member → 게시글 ID
score → 인기 점수
예를 들어 다음 코드가 실행되면,
await service.increasePostRankingScore(1);
await service.increasePostRankingScore(1);
await service.increasePostRankingScore(2);
Redis에는 개념적으로 다음과 같이 저장됩니다.
zset:post-ranking
member: "1", score: 2
member: "2", score: 1
중요한 점은 member가 중복 저장되지 않는다는 것입니다.
postId = 1을 여러 번 증가시켜도 member "1"이 여러 개 생기지 않고 score만 증가합니다.
✔️ TOP N 조회
/**
* 인기 게시글 TOP N 조회
*
* 1. Redis Sorted Set에서 score가 높은 게시글 ID를 가져옵니다.
* 2. Redis에는 postId와 score만 있으므로 게시글 상세 정보는 DB에서 조회합니다.
* 3. Redis 랭킹 순서를 유지해서 결과를 반환합니다.
*
* 실습 포인트:
* Redis Sorted Set은 랭킹 인덱스 역할에 집중시키고,
* 게시글 제목/본문 같은 원본 데이터는 DB에서 가져옵니다.
*/
async getPopularPosts(limit = 10): Promise<PopularPostOutput[]> {
const key = RedisKey.zset.postRanking();
// Redis 명령: ZRANGE key 0 (limit - 1) REV WITHSCORES
// score 내림차순(REV)으로 limit개의 member와 score를 { value, score } 형태로 조회합니다.
const rankingItems = await redis.zRangeWithScores(key, 0, limit - 1, {
REV: true,
});
if (rankingItems.length === 0) {
return [];
}
const postIds = rankingItems.map((item) => Number(item.value));
const posts = await prisma.post.findMany({
where: {
id: {
in: postIds,
},
},
select: PopularPostSelect,
});
const postMap = new Map(posts.map((post) => [post.id, post]));
return rankingItems
.map((item, index) => {
const postId = Number(item.value);
const post = postMap.get(postId);
if (!post) {
return null;
}
return toPopularPostOutput(post, item.score, index + 1);
})
.filter((post): post is PopularPostOutput => post !== null);
}
인기 게시글 조회는 다음 코드가 핵심입니다.
const rankingItems = await redis.zRangeWithScores(key, 0, limit - 1, {
REV: true,
});
REV: true를 사용하면 score가 높은 순서로 조회합니다.
Redis에는 게시글 ID와 score만 저장되어 있으므로, 게시글 상세 정보는 DB에서 다시 조회합니다.
이 구조는 실무에서 자주 사용하는 방식입니다.
Redis → 빠른 랭킹 인덱스
DB → 원본 게시글 데이터
2. 인기 검색어 순위 만들기
인기 검색어 기능도 Sorted Set으로 구현하기 좋습니다.
검색어가 입력될 때마다 해당 검색어의 score를 증가시키면 됩니다.
key → zset:search-ranking
member → 검색어
score → 검색 횟수
예를 들어 사용자가 다음과 같이 검색했다고 가정합니다.
redis
redis
postgresql
redis
nodejs
Redis에는 다음처럼 저장됩니다.
zset:search-ranking
member: "redis", score: 3
member: "postgresql", score: 1
member: "nodejs", score: 1
✔️ 사용하는 Redis Key
RedisKey.zset.searchRanking()
zset:search-ranking
✔️ 키워드 점수 증가
/**
* 검색어를 집계에 사용할 형식으로 정규화합니다.
*
* 1. 검색어 앞뒤의 공백을 제거합니다.
* 2. 모든 문자를 소문자로 변환합니다.
*
* 실습 포인트:
* "Redis", "redis", " redis "처럼 표현만 다른 검색어가 하나의 member로 집계되도록 합니다.
*/
private normalizeKeyword(keyword: string): string {
return keyword.trim().toLowerCase();
}
/**
* 검색된 키워드의 누적 점수를 증가시킵니다.
*
* 1. 검색어를 정규화하고 빈 값인지 확인합니다.
* 2. 정규화한 검색어를 Sorted Set의 member로 사용합니다.
* 3. ZINCRBY로 score를 지정한 값만큼 증가시킵니다.
*
* 실습 포인트:
* ZINCRBY는 member가 없으면 새로 추가하고, 있으면 기존 score에 값을 더합니다.
*/
async increaseSearchKeywordScore(keyword: string, score = 1): Promise<number> {
const normalizedKeyword = this.normalizeKeyword(keyword);
if (!normalizedKeyword) {
throw new Error('검색어가 비어 있습니다.');
}
const key = RedisKey.zset.searchRanking();
// Redis 명령: ZINCRBY key score member
// member가 없으면 추가하고, 있으면 기존 score에 값을 더한 뒤 최종 score를 반환합니다.
return redis.zIncrBy(key, score, normalizedKeyword);
}
검색어 랭킹에서 가장 중요한 부분은 정규화입니다.
private normalizeKeyword(keyword: string): string {
return keyword.trim().toLowerCase();
}
정규화를 하지 않으면 다음 검색어가 모두 다른 member로 저장됩니다.
"Redis"
"redis"
" redis "
"REDIS"
정규화 후에는 모두 "redis"로 저장됩니다.
검색어 점수 증가는 다음 코드로 처리합니다.
return redis.zIncrBy(key, score, normalizedKeyword);
즉 검색어가 들어올 때마다 score가 증가합니다.
검색어: redis
1회 검색 → score 1
2회 검색 → score 2
3회 검색 → score 3
3. 사용자 포인트 랭킹 만들기
커뮤니티, 학습 서비스, 리워드 서비스에서는 사용자 포인트 랭킹이 자주 필요합니다.
예를 들어 다음 행위에 따라 포인트를 줄 수 있습니다.
- 게시글 작성 +10점
- 댓글 작성 +3점
- 좋아요 받음 +1점
- 출석 체크 +5점
사용자 포인트 랭킹은 Sorted Set으로 구현하기 좋습니다.
key → zset:user-point-ranking
member → userId
score → 포인트
다만 포인트는 정확성이 중요한 데이터입니다.
따라서 이번 실습에서는 DB의 User.point를 원본 데이터로 두고, Redis Sorted Set은 랭킹 조회용 인덱스로 사용합니다.
✔️ 사용하는 Redis Key
RedisKey.zset.userPointRanking()
zset:user-point-ranking
✔️ 사용자 포인트 저장
/**
* 사용자의 현재 포인트를 Redis 랭킹 점수로 저장합니다.
*
* 1. userId를 문자열로 변환해 Sorted Set의 member로 사용합니다.
* 2. 현재 point를 member의 score로 저장합니다.
* 3. 같은 member가 이미 있으면 최신 score로 갱신합니다.
*
* 실습 포인트:
* ZADD를 사용하면 신규 사용자의 랭킹 등록과 기존 사용자의 점수 갱신을 같은 흐름으로 처리할 수 있습니다.
*/
async setUserPointRankingScore(userId: number, point: number): Promise<void> {
const key = RedisKey.zset.userPointRanking();
// Redis 명령: ZADD key score member
// member가 없으면 추가하고, 이미 있으면 전달한 값으로 score를 갱신합니다.
await redis.zAdd(key, {
value: String(userId),
score: point,
});
}
/**
* DB의 사용자 포인트를 증가시키고 Redis 랭킹에 반영합니다.
*
* 1. DB의 User.point를 증가시킵니다.
* 2. 증가된 최신 point 값을 Redis Sorted Set에 반영합니다.
*
* 실습 포인트:
* 포인트의 원본은 DB입니다.
* Redis는 빠른 랭킹 조회를 위한 보조 인덱스로 사용합니다.
*/
async increaseUserPoint(userId: number, point: number) {
const user = await prisma.user.update({
where: {
id: userId,
},
data: {
point: {
increment: point,
},
},
select: UserRankingSelect,
});
await this.setUserPointRankingScore(user.id, user.point);
return user;
}
ZADD는 같은 member가 이미 있으면 새로 추가하지 않고 score를 갱신합니다.
초기:
member "1", score 10
ZADD member "1", score 30
결과:
member "1", score 30
이 구조는 다음 역할 분리가 명확합니다.
DB User.point
→ 원본 포인트 데이터
Redis zset:user-point-ranking
→ 빠른 랭킹 조회용 인덱스
✔️ DB 기준 랭킹 복구
Redis는 메모리 기반 저장소이므로 운영 방식에 따라 데이터가 유실될 수 있습니다.
또한 캐시나 랭킹 인덱스는 언제든 DB 기준으로 다시 만들 수 있어야 합니다.
/**
* DB의 현재 포인트를 기준으로 Redis 사용자 랭킹을 재구성합니다.
*
* 1. DB에서 모든 사용자 id/point를 조회합니다.
* 2. 기존 Redis 랭킹을 삭제합니다.
* 3. DB point 기준으로 Redis Sorted Set을 다시 구성합니다.
*
* 실습 포인트:
* Redis 랭킹이 유실되거나 오래되었을 때 DB를 기준으로 복구할 수 있습니다.
*/
async syncUserPointRankingFromDatabase(): Promise<void> {
const key = RedisKey.zset.userPointRanking();
const users = await prisma.user.findMany({
select: {
id: true,
point: true,
},
});
// Redis 명령: DEL key
// 기존 Sorted Set key를 삭제해 오래된 사용자 랭킹 데이터를 모두 제거합니다.
await redis.del(key);
if (users.length === 0) {
return;
}
// Redis 명령: ZADD key score member [score member ...]
// 여러 사용자의 ID와 포인트를 한 번에 추가해 Sorted Set 랭킹을 다시 구성합니다.
await redis.zAdd(
key,
users.map((user) => ({
value: String(user.id),
score: user.point,
})),
);
}
이 메서드는 DB의 사용자 포인트를 기준으로 Redis 랭킹을 다시 구성합니다.
Redis 랭킹 유실
→ DB에서 user point 조회
→ Redis Sorted Set 재생성
→ 랭킹 기능 복구
4. 우선순위 큐 구현하기
Sorted Set은 우선순위 큐로도 사용할 수 있습니다.
작업 ID를 member로 저장하고, 우선순위를 score로 저장합니다.
key → zset:priority-queue
member → jobId
score → priority
예를 들어 score가 낮을수록 먼저 처리한다고 정하면 다음과 같습니다.
member: "job:email:1", score: 10
member: "job:payment:1", score: 1
member: "job:report:1", score: 50
처리 순서는 다음과 같습니다.
1. job:payment:1
2. job:email:1
3. job:report:1
✔️ 사용하는 Redis Key
RedisKey.zset.priorityQueue()
zset:priority-queue
✔️ 작업을 우선순위 큐에 추가
// src/services/priority-queue.service.ts
/**
* 작업을 우선순위 큐에 추가하고 선택적인 payload를 별도로 저장합니다.
*
* 1. jobId를 Sorted Set의 member로 사용합니다.
* 2. priority를 score로 저장하며 낮은 값일수록 먼저 처리합니다.
* 3. payload가 있으면 JSON 문자열로 변환해 1시간 동안 별도 key에 저장합니다.
*
* 실습 포인트:
* Sorted Set은 score 기준 정렬을 제공하므로 우선순위 큐를 간단히 만들 수 있습니다.
*/
async addJob(input: PriorityJobInput): Promise<void> {
const key = RedisKey.zset.priorityQueue();
// Redis 명령: ZADD key score member
// 작업이 없으면 추가하고, 같은 jobId가 있으면 전달한 priority로 score를 갱신합니다.
await redis.zAdd(key, {
value: input.jobId,
score: input.priority,
});
if (input.payload !== undefined) {
// Redis 명령: SET key value EX 3600
// payload를 JSON 문자열로 저장하고 key가 1시간 뒤 자동 만료되도록 설정합니다.
await redis.set(this.getPayloadKey(input.jobId), JSON.stringify(input.payload), {
EX: 60 * 60,
});
}
}
우선순위 큐에 작업을 추가할 때는 ZADD를 사용합니다.
await redis.zAdd(key, {
value: input.jobId,
score: input.priority,
});
여기서는 score가 낮을수록 먼저 처리한다고 정했습니다.
job:payment:1 → priority 1
job:email:1 → priority 10
job:report:1 → priority 50
✔️ payload를 별도 key로 저장하는 이유
Sorted Set의 member에는 보통 단순한 문자열을 저장하는 것이 좋습니다.
예를 들어 다음처럼 작업 ID만 저장합니다.
member → job:email:1
score → 10
작업 payload 전체를 member에 JSON 문자열로 넣을 수도 있지만 추천하지 않습니다.
비추천:
member → {"type":"email","to":"test@example.com","title":"hello"}
score → 10
이렇게 하면 다음 문제가 생깁니다.
1. member가 너무 길어집니다.
2. 같은 작업인지 비교하기 어렵습니다.
3. payload 수정이 어렵습니다.
4. 랭킹/큐 구조와 데이터 본문이 섞입니다.
그래서 이번 실습에서는 Sorted Set에는 jobId만 저장하고, payload는 별도 String key에 저장합니다.
await redis.set(this.getPayloadKey(input.jobId), JSON.stringify(input.payload), {
EX: 60 * 60,
});
저장 구조는 다음과 같습니다.
zset:priority-queue
member: "job:email:1", score: 10
zset:priority-queue:payload:job:email:1
value: {"type":"email","to":"test@example.com"}
✔️다음 작업 조회
/**
* 큐에서 가장 우선순위가 높은 작업을 조회한 뒤 제거합니다.
*
* 1. score가 가장 낮은 작업 1개를 조회합니다.
* 2. 조회한 작업을 Redis Sorted Set에서 제거합니다.
* 3. 제거에 성공한 경우에만 작업을 반환합니다.
*
* 실습 포인트:
* 조회 후 ZREM의 제거 결과를 확인하면 다른 worker가 먼저 가져간 작업을 반환하지 않을 수 있습니다.
*
* 참고:
* 조회와 제거가 별도 명령이므로 완전히 원자적이지 않습니다.
* 동시 처리 환경에서는 ZPOPMIN 같은 원자적 명령을 고려합니다.
*/
async popNextJob(): Promise<PriorityJobOutput | null> {
const key = RedisKey.zset.priorityQueue();
// Redis 명령: ZRANGE key 0 0 WITHSCORES
// score가 가장 낮은 첫 번째 member와 score를 반환하며 큐에서는 제거하지 않습니다.
const items = await redis.zRangeWithScores(key, 0, 0);
if (items.length === 0) {
return null;
}
const job = items[0];
// Redis 명령: ZREM key member
// 작업을 제거하고 제거된 member 수를 반환하며, 다른 worker가 먼저 제거했다면 0을 반환합니다.
const removedCount = await redis.zRem(key, job.value);
if (removedCount === 0) {
return null;
}
return {
jobId: job.value,
priority: job.score,
};
}
다음 작업을 조회할 때는 score가 낮은 순서로 첫 번째 작업을 가져옵니다.
1. 가장 낮은 score의 작업 조회
2. 해당 작업을 Sorted Set에서 제거
3. 제거에 성공하면 작업 반환
📌 주의점
여러 worker가 동시에 작업을 가져가는 실무 환경에서는 더 안전한 방식이 필요할 수 있습니다.
예를 들어 다음 상황이 발생할 수 있습니다.
Worker A가 같은 작업 조회
Worker B가 같은 작업 조회
Worker A가 제거 성공
Worker B는 제거 실패
더 엄격한 원자성이 필요하면 Lua Script 또는 Redis의 pop 계열 명령을 고려할 수 있습니다.
※ 게시된 글 및 이미지 중 일부는 AI 도구의 도움을 받아 생성되거나 다듬어졌습니다.
'1.시스템&인프라 > redis' 카테고리의 다른 글
| 9편. Redis Pub/Sub 실습: 실시간 메시지 전달 (0) | 2026.07.27 |
|---|---|
| 8편. Redis Stream 실습: 이벤트 로그와 비동기 작업 큐 (0) | 2026.07.27 |
| 6편. Redis Set 실습: 중복 제거와 상태 관리 (0) | 2026.07.08 |
| 5편. Redis List 실습: 최근 기록과 간단한 버퍼 처리 (0) | 2026.07.02 |
| 4편. Redis Hash 실습: 프로필, 세션, 재고, 사용자 설정 (0) | 2026.06.26 |