1편에서 opsForValue()로 단일 값을 다루는 법을 봤다. Redis는 단일 값만 저장하는 저장소가 아니다. Hash, List, Set, Sorted Set까지 5가지 핵심 자료구조를 제공하고, 각각에 특화된 연산이 있다.
왜 여러 자료구조가 필요한가
서랍장 비유
옷장을 생각해보자. 모든 옷을 한 칸에 넣으면 찾기 어렵다. 양말은 작은 칸에, 셔츠는 넓은 칸에, 바지는 걸이에 건다. 옷의 종류에 맞는 수납 방식이 있어야 정리도 쉽고 꺼내기도 빠르다.
양말, 속옷"] C --> D2["넓은 선반
셔츠, 니트"] C --> D3["걸이
바지, 코트"] C --> D4["분류함
계절별 정리"] style C fill:#FFF3E0,stroke:#FF9800,stroke-width:2px,color:#000 style D1 fill:#E8F8E8,stroke:#4CAF50,color:#000 style D2 fill:#E8F4F8,stroke:#2196F3,color:#000 style D3 fill:#F3E5F5,stroke:#9C27B0,color:#000 style D4 fill:#FFE6E6,stroke:#F44336,color:#000
Redis에서도 마찬가지다. 데이터의 형태에 맞는 자료구조를 골라야 효율적으로 저장하고 빠르게 조회할 수 있다.
- 작은 서랍 = String — 단일 값. 카운터, 토큰
- 넓은 선반 = Hash — 필드-값 쌍. 객체 속성
- 걸이 = List — 순서 있는 목록. 최근 활동, 큐
- 분류함 = Set / Sorted Set — 중복 없는 집합. 좋아요 유저, 랭킹
자료구조와 Operations 대응표
| 자료구조 | RedisTemplate 메서드 | Redis 명령 접두사 | 핵심 특성 |
|---|---|---|---|
| String | opsForValue() | SET, GET, INCR | 단일 값, 원자적 증감 |
| Hash | opsForHash() | HSET, HGET, HDEL | 필드-값 쌍, 부분 조회 |
| List | opsForList() | LPUSH, RPUSH, LRANGE | 순서 보장, 양방향 삽입 |
| Set | opsForSet() | SADD, SISMEMBER, SINTER | 중복 제거, 집합 연산 |
| Sorted Set | opsForZSet() | ZADD, ZRANGE, ZRANK | 점수 기반 정렬 |
opsForHash — 필드-값 쌍 저장
언제 쓰는가
하나의 키 안에 여러 필드를 저장해야 할 때 쓴다. 유저 프로필, 상품 정보, 설정값처럼 "하나의 대상에 여러 속성이 있는" 데이터에 적합하다.
String으로도 할 수 있지 않을까? 할 수 있다. JSON으로 직렬화해서 통째로 저장하면 된다. 하지만 속성 하나만 바꾸고 싶을 때 차이가 생긴다.
분홍 영역은 전체를 읽고, Java에서 수정하고, 전체를 다시 쓰는 3단계다. 초록 영역은 1단계로 끝난다. 필드 하나를 바꾸는 데 전체 데이터를 읽고 쓸 필요가 없다.
기본 사용법
HashOperations<String, String, Object> hashOps = redisTemplate.opsForHash();
put — 필드 하나 저장
hashOps.put("user:1", "name", "김철수");
hashOps.put("user:1", "age", 25);
hashOps.put("user:1", "city", "서울");
하나의 키(user:1) 안에 여러 필드(name, age, city)를 저장한다. Redis 명령으로는 HSET user:1 name "김철수"에 대응한다.
왜 set이 아니라 put인가? Java의 Map.put()과 동일한 의미다. Hash 자체가 Map 구조이므로, Map의 메서드 이름을 따른다.
putAll — 여러 필드 한 번에 저장
Map<String, Object> fields = Map.of(
"name", "김철수",
"age", 25,
"city", "서울"
);
hashOps.putAll("user:1", fields);
필드가 여러 개일 때 put을 반복 호출하면 Redis 왕복이 필드 수만큼 발생한다. putAll은 한 번의 명령으로 모든 필드를 저장한다. Redis의 HMSET에 대응한다.
get — 필드 하나 조회
Object name = hashOps.get("user:1", "name"); // "김철수"
키 전체가 아니라 특정 필드만 가져온다. Redis의 HGET user:1 name에 대응한다.
왜 전체가 아니라 필드 단위로 조회하는가? 유저 프로필에 필드가 20개인데 이름만 필요한 상황을 생각해 보자. 전체를 가져와서 역직렬화하는 것보다, 필요한 필드 하나만 가져오는 게 네트워크 비용과 처리 비용 모두 낮다.
multiGet — 여러 필드 한 번에 조회
List<Object> values = hashOps.multiGet("user:1", List.of("name", "age"));
// ["김철수", 25]
HMGET user:1 name age에 대응한다. 필요한 필드만 골라서 한 번에 가져온다.
entries — 전체 필드 조회
Map<String, Object> all = hashOps.entries("user:1");
// {name=김철수, age=25, city=서울}
HGETALL user:1에 대응한다. 모든 필드-값 쌍을 Map으로 반환한다.
필드가 수백 개인 Hash에 entries()를 호출하면 Redis가 모든 필드를 한 번에 반환한다. 필드 수가 예측 불가능하면 multiGet으로 필요한 필드만 가져오거나, HSCAN을 써야 한다.
delete — 필드 삭제
hashOps.delete("user:1", "city");
키 자체가 아니라 특정 필드만 삭제한다. HDEL user:1 city에 대응한다. 키 전체를 삭제하려면 1편에서 본 redisTemplate.delete("user:1")을 사용한다.
increment — 필드 값 원자적 증감
hashOps.increment("user:1", "loginCount", 1);
Hash 안의 특정 숫자 필드를 원자적으로 증가시킨다. HINCRBY user:1 loginCount 1에 대응한다. opsForValue().increment()와 같은 원리지만, 하나의 키 안에서 특정 필드만 증감시킨다는 차이가 있다.
String vs Hash 선택 기준
| 상황 | 추천 | 이유 |
|---|---|---|
| 항상 전체를 읽고 전체를 쓴다 | String (JSON) | Hash로 분리할 이유 없음 |
| 특정 필드만 자주 갱신한다 | Hash | 부분 갱신이 효율적 |
| 필드별로 다른 TTL이 필요하다 | String 여러 개 | Hash는 키 단위 TTL만 지원 |
| 필드 수가 일정하고 적다 (5개 이하) | 어느 쪽이든 | 성능 차이 무의미 |
user:1 키에 TTL 10분을 걸면, 모든 필드가 10분 후 함께 삭제된다. "name은 영구, session은 30분"처럼 필드별 만료가 필요하면, 별도 String 키로 분리해야 한다.
opsForList — 순서 있는 목록
언제 쓰는가
데이터의 삽입 순서가 중요할 때 쓴다. 최근 본 피드 목록, 알림 히스토리, 작업 큐 같은 데이터에 적합하다.
기본 사용법
ListOperations<String, Object> listOps = redisTemplate.opsForList();
leftPush, rightPush — 양방향 삽입
// 왼쪽(앞)에 삽입
listOps.leftPush("recent:user:1", "feed-c");
listOps.leftPush("recent:user:1", "feed-b");
listOps.leftPush("recent:user:1", "feed-a");
// 리스트 상태: [feed-a, feed-b, feed-c]
// 오른쪽(뒤)에 삽입
listOps.rightPush("queue:notifications", "noti-1");
listOps.rightPush("queue:notifications", "noti-2");
// 리스트 상태: [noti-1, noti-2]
왜 양방향인가? 용도에 따라 다르다.
- 최근 활동 :
leftPush로 앞에 넣으면 최신 항목이 항상 앞에 온다 - 큐(FIFO) :
rightPush로 뒤에 넣고leftPop으로 앞에서 꺼내면 선입선출
range — 범위 조회
List<Object> recent = listOps.range("recent:user:1", 0, 9);
// 인덱스 0~9, 즉 최근 10개
LRANGE recent:user:1 0 9에 대응한다. 시작 인덱스와 끝 인덱스를 지정한다. -1은 마지막 원소를 의미하므로, range("key", 0, -1)은 전체 조회다.
왜 findAll이 아니라 범위 조회인가? List는 길이 제한이 없다. 전체 조회는 원소가 수만 개일 때 위험하다. 항상 범위를 지정해서 필요한 만큼만 가져온다.
leftPop, rightPop — 꺼내기
Object first = listOps.leftPop("queue:notifications");
// 첫 번째 원소를 꺼내고 리스트에서 제거
LPOP에 대응한다. 조회와 삭제가 원자적으로 동시에 일어난다. 큐 패턴에서 핵심이다.
trim — 길이 제한
listOps.leftPush("recent:user:1", "feed-new");
listOps.trim("recent:user:1", 0, 99);
// 최근 100개만 유지, 나머지 삭제
leftPush 후 trim을 호출하면 리스트 길이를 일정하게 유지할 수 있다. 최근 N개만 보관하는 패턴에 필수적이다.
왜 필요한가? leftPush만 계속하면 리스트가 무한히 커진다. trim이 오래된 데이터를 자동으로 잘라내서 메모리를 관리한다.
"최근 N개" 목록을 유지하는 표준 패턴이다. 새 항목을 앞에 넣고(leftPush), 길이를 잘라낸다(trim). 이 두 연산을 항상 세트로 호출하는 습관을 들인다.
opsForSet — 중복 없는 집합
언제 쓰는가
중복이 없어야 하고, 순서는 상관없는 데이터에 쓴다. "이 피드에 좋아요를 누른 유저 목록", "이 유저가 팔로우하는 유저 목록", "이 피드에 달린 태그 목록"처럼 포함 여부를 빠르게 확인해야 하는 경우에 적합하다.
기본 사용법
SetOperations<String, Object> setOps = redisTemplate.opsForSet();
add — 원소 추가
setOps.add("feed:likes:abc-123", "user-1");
setOps.add("feed:likes:abc-123", "user-2");
setOps.add("feed:likes:abc-123", "user-1"); // 중복 → 무시됨
SADD feed:likes:abc-123 user-1에 대응한다. 이미 존재하는 원소를 추가하면 아무 일도 일어나지 않는다. 반환값은 실제로 추가된 원소 수다. 세 번째 호출은 0을 반환한다.
왜 중복 체크를 안 해도 되는가? Set 자체가 중복을 허용하지 않는 자료구조다. "이미 좋아요를 눌렀는지" 확인하고 추가하는 2단계가 필요 없다. add 한 번이면 된다.
isMember — 포함 여부 확인
Boolean liked = setOps.isMember("feed:likes:abc-123", "user-1");
// true
SISMEMBER에 대응한다. O(1)로 즉시 확인한다.
왜 빠른가? Set은 내부적으로 해시 테이블로 구현되어 있다. 원소가 100만 개든 1개든, 포함 여부 확인은 항상 O(1)이다. List에서 contains를 하면 O(N)이다.
members — 전체 원소 조회
Set<Object> users = setOps.members("feed:likes:abc-123");
// [user-1, user-2]
SMEMBERS에 대응한다. 모든 원소를 반환한다.
Hash의 entries와 같은 문제다. 원소가 수천 개 이상이면 SSCAN을 사용한다.
remove — 원소 삭제
setOps.remove("feed:likes:abc-123", "user-1");
SREM에 대응한다. 존재하지 않는 원소를 삭제해도 에러가 나지 않는다.
size — 원소 수 조회
Long count = setOps.size("feed:likes:abc-123");
// 2
SCARD에 대응한다. 전체 원소를 가져오지 않고 카운트만 O(1)로 반환한다.
집합 연산
Set의 진짜 강점은 집합 연산이다.
// 교집합 — 공통 팔로워
Set<Object> common = setOps.intersect("followers:user-1", "followers:user-2");
// 합집합 — 두 유저의 팔로워 전체
Set<Object> all = setOps.union("followers:user-1", "followers:user-2");
// 차집합 — user-1은 팔로우하지만 user-2는 안 하는 유저
Set<Object> diff = setOps.difference("followers:user-1", "followers:user-2");
이 연산들을 Java로 직접 구현하면 두 Set을 모두 메모리에 올려서 비교해야 한다. Redis의 집합 연산은 서버 내부에서 처리하므로 네트워크로 데이터를 전송할 필요가 없다.
opsForZSet — 점수 기반 정렬 집합
언제 쓰는가
원소마다 점수가 있고, 점수 기준으로 정렬된 상태를 유지해야 할 때 쓴다. 인기 피드 랭킹, 실시간 리더보드, 점수 기반 추천 같은 데이터에 적합하다.
Set과 뭐가 다른가? Set은 원소의 존재 여부만 관리한다. Sorted Set은 각 원소에 점수가 붙어 있고, 항상 점수 순으로 정렬되어 있다.
도서관 예약 대기열을 떠올려 보자.
일반 대기열은 번호표를 받아서 순서대로 기다린다. 누가 먼저 왔는지만 중요하다. 하지만 우선순위 대기열은 다르다. VIP 회원은 10점, 일반 회원은 1점. 점수가 높은 사람이 먼저 호출된다. 나중에 와도 점수가 높으면 앞으로 간다.
이 우선순위 대기열이 Redis에서는 Sorted Set이다. 점수를 기준으로 자동 정렬되고, 언제든 특정 원소의 순위를 O(log N)으로 조회할 수 있다.
기본 사용법
ZSetOperations<String, Object> zSetOps = redisTemplate.opsForZSet();
add — 원소와 점수 저장
zSetOps.add("ranking:popular-feeds", "feed-a", 150); // 좋아요 150
zSetOps.add("ranking:popular-feeds", "feed-b", 320); // 좋아요 320
zSetOps.add("ranking:popular-feeds", "feed-c", 89); // 좋아요 89
ZADD ranking:popular-feeds 150 feed-a에 대응한다. 이미 존재하는 원소를 다시 add하면 점수만 갱신된다. 새로 삽입하는 게 아니라 업데이트다.
왜 점수가 double인가? Redis의 ZADD가 부동소수점 숫자를 받기 때문이다. 정수도 double로 표현된다. 좋아요 수, 조회수, 타임스탬프 등 다양한 값을 점수로 쓸 수 있다.
reverseRange — 점수 높은 순 조회
Set<Object> top3 = zSetOps.reverseRange("ranking:popular-feeds", 0, 2);
// [feed-b, feed-a, feed-c] (320, 150, 89 순)
ZREVRANGE에 대응한다. 점수가 높은 순서대로 인덱스 범위만큼 반환한다. 0, 2는 상위 3개다.
왜 reverseRange인가? Redis의 Sorted Set은 기본이 오름차순(낮은 점수부터)이다. 랭킹은 보통 높은 점수가 1위이므로, 역순인 reverseRange를 쓴다. range는 오름차순이다.
reverseRangeWithScores — 점수와 함께 조회
Set<ZSetOperations.TypedTuple<Object>> top3 =
zSetOps.reverseRangeWithScores("ranking:popular-feeds", 0, 2);
for (ZSetOperations.TypedTuple<Object> tuple : top3) {
System.out.println(tuple.getValue() + " : " + tuple.getScore());
}
// feed-b : 320.0
// feed-a : 150.0
// feed-c : 89.0
원소와 점수를 함께 가져온다. 랭킹 화면에서 "좋아요 320개"를 표시해야 할 때 필요하다.
incrementScore — 점수 원자적 증감
Double newScore = zSetOps.incrementScore("ranking:popular-feeds", "feed-a", 1);
// 150 → 151
ZINCRBY에 대응한다. 1편에서 본 opsForValue().increment()와 같은 원리다. 좋아요가 눌릴 때마다 이 메서드를 호출하면, 별도의 정렬 작업 없이 순위가 자동으로 갱신된다.
왜 중요한가? Java에서 정렬하려면 전체 데이터를 메모리에 올려서 sort()를 해야 한다. Sorted Set은 삽입·갱신 시점에 내부적으로 정렬을 유지하므로, 조회할 때 정렬 비용이 0이다.
rank, reverseRank — 순위 조회
Long rank = zSetOps.reverseRank("ranking:popular-feeds", "feed-a");
// 1 (0-based, 즉 2위)
ZREVRANK에 대응한다. 특정 원소가 몇 위인지 O(log N)으로 조회한다.
score — 점수 조회
Double score = zSetOps.score("ranking:popular-feeds", "feed-a");
// 151.0
ZSCORE에 대응한다. 특정 원소의 점수를 O(1)로 조회한다.
실전 예시: 인기 피드 랭킹
좋아요가 눌릴 때마다 랭킹을 갱신하고, 상위 10개를 조회하는 흐름이다.
초록 영역에서 좋아요 한 번에 ZINCRBY 한 번이면 랭킹이 자동 갱신된다. 파란 영역에서 ZREVRANGE로 이미 정렬된 상위 10개를 바로 가져온다.
자료구조 선택 가이드
(카운터, 토큰)"| V["opsForValue"] Q1 -->|"필드-값 쌍
(유저 프로필)"| H["opsForHash"] Q1 -->|"순서 있는 목록
(최근 활동, 큐)"| L["opsForList"] Q1 -->|"중복 없는 집합
(좋아요 유저)"| Q2{{"정렬이 필요한가?"}} Q2 -->|"아니오"| S["opsForSet"] Q2 -->|"점수 기반 정렬"| Z["opsForZSet"] style Q1 fill:#FFF3E0,stroke:#FF9800,stroke-width:2px,color:#000 style Q2 fill:#FFF3E0,stroke:#FF9800,stroke-width:2px,color:#000 style V fill:#E8F8E8,stroke:#4CAF50,stroke-width:2px,color:#000 style H fill:#E8F4F8,stroke:#2196F3,stroke-width:2px,color:#000 style L fill:#F3E5F5,stroke:#9C27B0,stroke-width:2px,color:#000 style S fill:#FFE6E6,stroke:#F44336,stroke-width:2px,color:#000 style Z fill:#FFFDE7,stroke:#FFC107,stroke-width:2px,color:#000
자주 하는 실수
Redis의 TTL은 키 단위다. Hash 안의 특정 필드에만 TTL을 걸 수 없다. user:1 키에 TTL 10분을 설정하면 name, age, city 모든 필드가 10분 후 함께 삭제된다. 필드별 만료가 필요하면 별도 String 키로 분리한다.
[!DANGER] List에 trim 없이 leftPush만 반복하는 실수
leftPush만 호출하면 리스트가 무한히 커진다. 최근 N개를 유지하는 용도라면 leftPush 직후에 반드시 trim(0, N-1)을 호출한다. 이 두 연산은 항상 세트다.
[!DANGER] Set과 Sorted Set을 혼동하는 실수
"좋아요 누른 유저 목록"에 Sorted Set을 쓰는 경우가 있다. 정렬이 필요 없으면 Set이 더 가볍다. 반대로 "인기 피드 랭킹"에 Set을 쓰면 매번 전체를 가져와서 Java에서 정렬해야 한다. 정렬이 필요하면 Sorted Set, 필요 없으면 Set이 기준이다.
[!DANGER] opsForZSet의 range가 0-based라는 걸 놓치는 실수
reverseRange("key", 0, 2)는 상위 3개다. 1, 3이 아니다. reverseRank도 0-based이므로, 반환값이 0이면 1위다. 화면에 표시할 때 rank + 1로 변환해야 한다.