Redis 활용예제 1 - 블로그 조회수 시스템 설계하기

StrayCat·2026년 3월 16일

본 포스팅은 단순히 명령어를 나열하는 것을 넘어, "어떤 타입을 왜 선택했는가" 라는 설계 사고 과정을 함께 담아보려 한다.

실습 문제를 처음 보면 꽤 단순해 보인다. 조회수니까 숫자를 세면 되지 않나? 맞다. 근데 막상 Redis 타입을 고르려고 하면 멈추게 된다. String? Hash? Sorted Set? 각자가 할 수 있는 일이 다르기 때문이다. 지금부터 두 가지 시나리오를 차례로 풀어나가 보자.


📌 실습 구조 한눈에 보기

시나리오요구사항선택 타입
1번전체 조회수 (비로그인 포함, 글 단위)String
2번사용자별 조회수 + 최다 조회 글Hash + Sorted Set

1. 글별 전체 조회수 — String 타입

왜 String인가?

조건을 다시 읽어보면,

  • 로그인 여부와 무관하게 새로고침마다 카운트가 1 증가한다.
  • 관리하고 싶은 단위는 글(article) 하나 = 하나의 숫자이다.

이건 단순히 "숫자 하나를 키 하나에 매핑"하는 문제다. Redis String 타입은 정수를 저장할 때 INCR 이라는 원자적(atomic) 증가 명령을 지원한다. 즉, 동시에 여러 요청이 몰려도 카운트가 꼬이지 않는다.

원자적(atomic) 연산 : 도중에 다른 연산이 끼어들 수 없는 불가분의 연산. DB의 트랜잭션처럼, "중간 상태"가 존재하지 않는다.

Hash를 쓰는 선택지도 있지만, 글 단위로만 조회수를 집계할 때는 String이 더 직관적이고 효율적이다.

키 설계

articles:{id}:views

예시: /articles/42 접속 → articles:42:views

키 이름에 : 을 구분자로 쓰는 것은 Redis 생태계에서 사실상 관례(convention)로 자리잡은 방식이다. 네임스페이스를 계층적으로 표현할 수 있어서 가독성과 관리 면에서 유리하다.

사용자가 임의의 페이지에 접속할 때 실행되는 명령

# 사용자가 /articles/42 에 접속 → 해당 글의 조회수를 1 증가
# INCR : key가 없으면 0으로 초기화한 뒤 1 증가시킨다. 즉, 최초 접속 시 자동으로 키 생성
INCR articles:42:views

# 현재 조회수 확인
GET articles:42:views
  • INCR은 키가 없어도 자동으로 0 → 1로 만들어주므로, 별도의 초기화 코드가 필요 없다.
  • 응답 값으로 증가 후의 숫자가 바로 반환된다.

2. 로그인 사용자별 조회수 + 최다 조회 글 — Hash + Sorted Set

문제를 분해해보자

이번엔 요구사항이 두 가지로 나뉜다.

  1. 로그인한 사용자가 어떤 글을 몇 번 봤는지 기록한다.
  2. 가장 많은 조회수를 기록한 글이 무엇인지 빠르게 알 수 있어야 한다.

두 요구사항을 하나의 타입으로 해결하려다 보면 막히게 된다. 둘을 분리해서 적합한 타입을 각각 쓰는 것이 맞다.

2-1. 사용자별 글 조회수 — Hash

HashMap<String, Map<String, String>>의 구조다. 사용자 계정을 외부 키로, 각 글의 ID를 필드로, 조회 횟수를 값으로 두면 딱 맞아 떨어진다.

user:views:{username}
  → field: articles:{id}
  → value: 조회 횟수

예시: 사용자 alex가 42번 글을 보면 → user:views:alex Hash의 articles:42 필드를 1 증가

# alex가 /articles/42 에 접속 → alex의 Hash에서 articles:42 필드를 1 증가
# HINCRBY : Hash 필드에 저장된 정수를 지정한 값만큼 증가시킨다
HINCRBY user:views:alex articles:42 1

# alex의 전체 조회 기록 확인
HGETALL user:views:alex

# alex의 42번 글 조회수만 확인
HGET user:views:alex articles:42

2-2. 가장 많은 조회수를 기록한 글 — Sorted Set

Sorted Set은 각 멤버에 score라는 실수 값을 부여하고, 이를 기준으로 정렬된 상태를 유지한다. "조회수 순위"가 필요한 경우에 정확히 들어맞는 자료구조다.

articles:views:ranking
  → member: articles:{id}
  → score: 누적 조회수
# alex가 /articles/42 에 접속 → 랭킹 Sorted Set에서 articles:42의 score를 1 증가
# ZINCRBY : Sorted Set 멤버의 score를 증가시킨다. 멤버가 없으면 자동 추가
ZINCRBY articles:views:ranking 1 articles:42

# 가장 조회수가 많은 글 1개 확인 (내림차순 정렬 후 첫 번째)
# ZREVRANGE : score 기준 내림차순으로 멤버를 반환
ZREVRANGE articles:views:ranking 0 0

# score(조회수)도 함께 확인하고 싶다면 WITHSCORES 옵션 추가
ZREVRANGE articles:views:ranking 0 0 WITHSCORES

# 상위 N개를 보고 싶다면 stop 인덱스 조정 (top 3이면 0 2)
ZREVRANGE articles:views:ranking 0 2 WITHSCORES

2-3. 상황에 따라 달라지는 명령 흐름

로그인 여부에 따라 실행 분기가 생긴다.

# === 사용자가 /articles/42 에 접속했을 때 ===

# [공통] Sorted Set 랭킹 업데이트 → 로그인 여부와 무관하게 항상 실행
ZINCRBY articles:views:ranking 1 articles:42

# [분기 1] 로그인한 경우 → Hash에 사용자별 조회 기록 추가
# 계정명이 영문으로만 이뤄져 있으므로, username은 영문 문자열
HINCRBY user:views:alex articles:42 1

# [분기 2] 로그인하지 않은 경우 → Hash 기록 없음, Sorted Set만 갱신됨
# (아무 명령도 추가로 실행하지 않는다)

전체 흐름 정리

사용자가 /articles/{id} 접속
        │
        ▼
[항상 실행] ZINCRBY articles:views:ranking 1 articles:{id}
        │
        ├─ 로그인 상태?
        │       YES → HINCRBY user:views:{username} articles:{id} 1
        │       NO  → (끝)
        ▼
완료

구현 순서 — Spring Boot에서 실제로 적용하고 싶다면

지금까지는 Redis CLI 명령어 수준에서 설계를 완성했다. 이것을 실제 개발 환경에 가져오려면 아래 순서로 진행하면 된다.

  1. Redis 설치 및 연결 확인 (Redis CLI로 PING → PONG 확인)
  2. spring-boot-starter-data-redis 의존성 추가 + application.yml에 host/port 설정
  3. RedisTemplate 또는 StringRedisTemplate Bean 설정
  4. 컨트롤러 또는 서비스 레이어에 조회수 로직 구현
    • StringRedisTemplate.opsForValue().increment(key) → String INCR
    • StringRedisTemplate.opsForHash().increment(key, field, 1) → Hash HINCRBY
    • StringRedisTemplate.opsForZSet().incrementScore(key, member, 1) → Sorted Set ZINCRBY
  5. 동작 확인: Redis CLI에서 GET, HGETALL, ZREVRANGE 로 값이 쌓이는지 직접 확인

마무리 — 타입 선택의 기준

이번 실습에서 핵심은 "무엇을 저장하고 싶은가"에 답하는 것이었다.

  • 단순 숫자 하나 → String + INCR
  • 구조화된 필드별 데이터 (사용자 → 글 → 조회수) → Hash
  • 순위/정렬이 필요한 집계 → Sorted Set

Redis는 자료구조 선택 자체가 설계 결정이다. 잘못된 타입을 고르면 원하는 쿼리를 쓸 수 없거나, 성능에서 손해를 보게 된다. 처음에는 낯설지만, 몇 번 써보면 자연스럽게 감이 생긴다.


코드

// BoardController

import com.example.redis.service.BoardService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;

@RestController
@RequiredArgsConstructor
@RequestMapping("/articles")
public class BoardController {

    private final BoardService boardService;

    @GetMapping("/{id}")
    public String getArticles(@PathVariable String id) {

        String result = boardService.getArticles(id);

        return id + "번 글의 현재 조회수 : " + result;
    }

    @GetMapping("/hash/{id}")
    public String getArticlesHashWithRanking(@PathVariable String id, @RequestParam String username) {

        String result = boardService.getArticlesHashWithRanking(id,username);

        return "현재 " + username + "사용자의 " + id + "번 글의 조회수 : " + result;
    }
// BoardService

import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.HashOperations;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.data.redis.core.ZSetOperations;
import org.springframework.stereotype.Service;

@RequiredArgsConstructor
@Service
public class BoardService {

    private final StringRedisTemplate stringRedisTemplate;

    public String getArticles(String id) {
        // 게시글 조회시 조회수 1 증가
        ValueOperations<String, String> ops = stringRedisTemplate.opsForValue();

        // increment()는 증가 후의 값을 Long으로 반환하므로
        // 별도 get() 호출 없이 바로 사용 가능

        Long views = ops.increment("articles:" + id + ":views");
        return String.valueOf(views);
    }

    public String getArticlesHashWithRanking(String id, String username) {

        // 1. Hash: 사용자별 글 조회수 기록
        // key, 게시글id, 조회횟수 (user:views:{username} > field: articles:{id} / value: 조회수
        HashOperations<String, String, String> ops = stringRedisTemplate.opsForHash();
        // 키 / 밸류의키 / 증가값 (1씩)
        ops.increment("user:views:" + username , "articles:" + id, 1);

        // 2. SortedSet : 전체 글 조회수 랭킹 업데이트
        ZSetOperations<String, String> zOps = stringRedisTemplate.opsForZSet();
        zOps.incrementScore("articles:views:ranking", "articles:" + id, 1);

        // 3. 현재 내 조회수 반환
        String views = (String) ops.get("user:views:" + username, "articles:" + id);

        return String.valueOf(views);
    }
}

💡 기타 권장사항

ZREVRANGE 대신 ZRANGE ... REV 사용 권장

Redis 6.2.0부터 ZREVRANGE는 deprecated(더 이상 권장되지 않는 API) 처리되었다. 새 문법인 ZRANGEREV 옵션을 쓰는 것이 권장된다.

# 구 방식 (deprecated)
ZREVRANGE articles:views:ranking 0 2 WITHSCORES

# 신 방식 (Redis 6.2+)
ZRANGE articles:views:ranking 0 2 BYSCORE REV WITHSCORES

# 또는 단순 인덱스 기반 내림차순
ZRANGE articles:views:ranking 0 2 REV WITHSCORES

다만, 레거시 시스템에서는 Redis 버전이 낮은 경우가 많으므로, 팀의 Redis 버전을 먼저 확인하고 명령어를 선택하는 것이 현실적이다.

HyperLogLog — 대규모 트래픽 UV(Unique Visitor) 카운팅

조회수가 아닌 중복 없는 방문자 수(UV, Unique Visitor)를 집계하고 싶다면, Set 대신 HyperLogLog(PFADD, PFCOUNT)를 고려할 수 있다. 수백만 건의 유니크 방문자를 매우 적은 메모리(고정 12KB)로 근사치 계산할 수 있다. 오차율은 약 0.81% 수준으로, 정확한 수치보다 빠른 처리가 우선인 대시보드 등에서 유용하다.


참고 자료

profile
알면 좋은 것보단 잊어버리기 싫은 것들을 기록합니다.

0개의 댓글