1.시스템&인프라/redis

7편. Redis Sorted Set 실습: 랭킹과 우선순위 처리

쿼드큐브 2026. 7. 27. 12:04
반응형
반응형

 

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 도구의 도움을 받아 생성되거나 다듬어졌습니다.

반응형

 

반응형