본 포스팅은 단순히 명령어를 나열하는 것을 넘어, "어떤 타입을 왜 선택했는가" 라는 설계 사고 과정을 함께 담아보려 한다.
실습 문제를 처음 보면 꽤 단순해 보인다. 조회수니까 숫자를 세면 되지 않나? 맞다. 근데 막상 Redis 타입을 고르려고 하면 멈추게 된다. String? Hash? Sorted Set? 각자가 할 수 있는 일이 다르기 때문이다. 지금부터 두 가지 시나리오를 차례로 풀어나가 보자.
| 시나리오 | 요구사항 | 선택 타입 |
|---|---|---|
| 1번 | 전체 조회수 (비로그인 포함, 글 단위) | String |
| 2번 | 사용자별 조회수 + 최다 조회 글 | Hash + Sorted Set |
조건을 다시 읽어보면,
글(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로 만들어주므로, 별도의 초기화 코드가 필요 없다.이번엔 요구사항이 두 가지로 나뉜다.
두 요구사항을 하나의 타입으로 해결하려다 보면 막히게 된다. 둘을 분리해서 적합한 타입을 각각 쓰는 것이 맞다.
Hash는 Map<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
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
로그인 여부에 따라 실행 분기가 생긴다.
# === 사용자가 /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 → (끝)
▼
완료
지금까지는 Redis CLI 명령어 수준에서 설계를 완성했다. 이것을 실제 개발 환경에 가져오려면 아래 순서로 진행하면 된다.
PING → PONG 확인)spring-boot-starter-data-redis 의존성 추가 + application.yml에 host/port 설정RedisTemplate 또는 StringRedisTemplate Bean 설정StringRedisTemplate.opsForValue().increment(key) → String INCRStringRedisTemplate.opsForHash().increment(key, field, 1) → Hash HINCRBYStringRedisTemplate.opsForZSet().incrementScore(key, member, 1) → Sorted Set ZINCRBYGET, HGETALL, ZREVRANGE 로 값이 쌓이는지 직접 확인이번 실습에서 핵심은 "무엇을 저장하고 싶은가"에 답하는 것이었다.
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) 처리되었다. 새 문법인 ZRANGE의 REV 옵션을 쓰는 것이 권장된다.
# 구 방식 (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% 수준으로, 정확한 수치보다 빠른 처리가 우선인 대시보드 등에서 유용하다.
참고 자료