백엔드 데이터 인프라 50편. Redis Hash — 객체 한 개를 통째로 담는 자료구조. HSET·HGET·HGETALL·HMGET·HINCRBY 명령어, JSON String 대 Hash 의 선택 기준, User Profile·Product 같은 도메인의 객체 캐싱 패턴까지 풀어쓴 학습 노트.
이 글은 백엔드 데이터 인프라 시리즈 130편 중 50편이에요. 49편 에서 Redis String 으로 가장 기본 자루를 잡았다면, 이번 50편은 Redis Hash — 객체 한 개를 통째로 담는 자료구조예요. Java HashMap, Python dict, JSON 객체와 가장 가까운 구조라고 보면 됩니다.
Redis Hash가 어렵게 느껴지는 이유
Hash는 이름도 구조도 익숙해서 어렵지는 않은데, 두 가지 자리에서 멈춰요.
첫째, String JSON 으로도 같은 일이 되는데 왜 Hash를 따로 쓰는가가 안 잡힙니다. User 객체를 통째로 캐싱한다면 SET user:42 "{\"name\":\"Alice\",\"age\":30}" 으로도 되고, HSET user:42 name Alice age 30 으로도 돼요. 둘 중 언제 어느 쪽이 유리한지가 보이지 않으면, Hash가 필요한 자리도 안 보입니다.
둘째, Hash는 TTL(Time To Live, 키 만료 시간)이 키 단위로만 걸려요. User 객체 안에 name 필드만 5초, age 필드는 영구로 가져가는 식의 필드별 TTL은 안 됩니다. (Redis 7.4 부터 HEXPIRE 로 필드 TTL 일부 지원, 하지만 운영 환경에서 보편적이지 않음.)
이 글에서는 Hash가 필요한 정확한 자리와 JSON String 과의 선택 기준을 분명히 잡고, 자주 쓰는 명령어와 객체 캐싱 패턴까지 정리할게요.
Hash 기본 명령어 5종
Hash의 핵심은 명령어 5개예요. 이것만 알아도 일상 80%는 해결돼요.
HSET — 필드 한 개 또는 여러 개 저장
> HSET user:42 name "Alice"
(integer) 1
> HSET user:42 age 30 email "alice@example.com"
(integer) 2
복잡도는 추가된 필드 한 개당 O(1)이고, 반환값은 새로 추가된 필드 수예요. 덮어쓴 필드는 카운트에서 빠집니다.
HGET — 필드 한 개 조회
> HGET user:42 name
"Alice"
> HGET user:42 nonexistent
(nil)
존재하지 않는 필드는 nil 이 나와요. Hash 자체가 없는 키도 마찬가지로 nil(에러 아님)이고요.
HGETALL — 모든 필드 한 번에
> HGETALL user:42
1) "name"
2) "Alice"
3) "age"
4) "30"
5) "email"
6) "alice@example.com"
복잡도는 O(N) (N은 필드 수). 필드 수가 수십에서 수백 정도면 괜찮고, 수천 개 이상으로 가면 응답 시간 부담이 생겨요. 그럴 때는 HSCAN 으로 페이징합니다.
HMGET — 여러 필드 한 번에
> HMGET user:42 name email phone
1) "Alice"
2) "alice@example.com"
3) (nil)
필요한 필드만 골라서 조회해요. 객체 전체가 필요하지 않을 때는 HGETALL 보다 효율적이고, 존재하지 않는 필드는 해당 자리에 nil 이 들어갑니다.
HINCRBY — 필드 값 증감 (숫자형 필드만)
> HSET bike:1:stats rides 0 crashes 0
(integer) 2
> HINCRBY bike:1:stats rides 1
(integer) 1
> HINCRBY bike:1:stats rides 1
(integer) 2
> HINCRBY bike:1:stats crashes 1
(integer) 1
한 객체 안에 카운터 여러 개를 묶는 패턴이에요. 사용자별 rides, crashes, owners 같은 통계를 한 키 안에 함께 두는 자연스러운 구조죠. String 으로는 키 3개를 따로 만들어야 할 일을, Hash는 한 키에 필드 3개로 풉니다.
여기서 시험 함정이 하나 있어요 — HSET 의 반환값입니다. 새로 만들어진 필드 수만 카운트해요. 같은 필드를 덮어쓰면 0 이 반환됩니다.
> HSET user:42 name "Alice"
(integer) 1 # 새로 만들어짐
> HSET user:42 name "Bob"
(integer) 0 # 덮어쓰기, 새로 만든 거 없음
성공/실패가 아니라 신규 필드 수이니, 성공 체크 용도로 쓰면 안 돼요.
보조 명령어 — HEXISTS · HDEL · HKEYS · HVALS · HLEN
> HEXISTS user:42 email
(integer) 1
> HDEL user:42 email
(integer) 1
> HKEYS user:42
1) "name"
2) "age"
> HVALS user:42
1) "Alice"
2) "30"
> HLEN user:42
(integer) 2
HEXISTS— 필드 존재 여부 (1 있음, 0 없음)HDEL— 필드 삭제 (여러 필드 동시 가능, 반환값 = 실제 삭제된 수)HKEYS— 모든 필드 이름 (Java Map.keySet())HVALS— 모든 값 (Java Map.values())HLEN— 필드 개수 (Java Map.size())
자주 쓰는 자리는 부분 객체 갱신이나 통계 표시예요. 객체 전체를 가져오는 HGETALL 보다 필요한 것만 골라 가져오는 쪽이 성능에 유리합니다.
JSON String vs Hash — 언제 어느 쪽?
여기가 처음 헷갈리는 가장 큰 자리예요. 같은 데이터를 두 방식으로 표현 가능한데, 선택 기준은 의외로 명확합니다.
두 가지 표현 비교
# 방식 A: String JSON
> SET user:42 '{"name":"Alice","age":30,"email":"alice@example.com"}'
OK
> GET user:42
# 방식 B: Hash
> HSET user:42 name Alice age 30 email alice@example.com
(integer) 3
> HGETALL user:42
선택 기준 표
| 기준 | String JSON | Hash |
|---|---|---|
| 객체 전체 읽기·쓰기 가 주력 | ◯ 빠름 | △ HGETALL 도 OK |
| 부분 필드 업데이트 자주 | △ 매번 전체 read·modify·write | ◯ HSET 한 줄 |
| 부분 필드 읽기 자주 | △ 매번 JSON 파싱 | ◯ HGET/HMGET |
| TTL을 필드별로 | X | X (7.4+ 일부 지원) |
| 중첩 객체(객체 안 객체) | ◯ JSON 자연스러움 | X (Hash는 flat) |
| 메모리 효율 (작은 객체) | △ JSON 오버헤드 | ◯ ziplist 최적화 |
| Spring Data Redis 기본 | RedisTemplate.opsForValue |
HashOperations |
결정 가이드
- 객체를 통째로 한 번에 read/write 하는 비중이 90% — String JSON
- 필드 한두 개를 자주 업데이트 (예: 사용자 online status) — Hash
- 객체 안에 배열이나 중첩 객체가 있음 — String JSON (또는 RedisJSON 모듈, 74편)
- 필드별로 atomic(원자적, 중간 끼어듦 없는 한 번 연산) 카운터가 필요 (
HINCRBY) — Hash
여기서 정말 중요한 시험 함정 — Hash는 중첩이 안 돼요. flat(평면 구조, 한 단계 깊이) 한 field → value 만 가능합니다. 객체 안에 객체가 들어가는 데이터(예: {"address": {"city": "Seoul"}}) 는 Hash로 자연스럽게 안 풀려요. 그럴 때는 String JSON 또는 RedisJSON 으로 갑니다.
한 줄 정리 — Hash = 부분 업데이트와 부분 읽기가 잦은 flat 한 객체. JSON String = 전체 read/write 가 잦거나 중첩 구조가 필요한 객체.
객체 캐싱 패턴 — User Profile 예제
실무에서 가장 흔한 자리예요. 사용자 프로필을 Redis에 캐싱하는 패턴 두 가지를 비교해 봐요.
패턴 1: String JSON 통째로 (가장 단순)
import json
import redis
r = redis.Redis(decode_responses=True)
def get_user(uid):
cached = r.get(f"user:{uid}")
if cached:
return json.loads(cached)
user = db.query("SELECT * FROM users WHERE id = ?", uid)
r.set(f"user:{uid}", json.dumps(user), ex=3600)
return user
read 가 많고 write 가 적은 자리에 어울려요. 캐시 hit 시 JSON 한 번 파싱하면 끝나거든요.
패턴 2: Hash 로 필드 단위
def get_user(uid):
cached = r.hgetall(f"user:{uid}")
if cached:
return cached
user = db.query("SELECT * FROM users WHERE id = ?", uid)
r.hset(f"user:{uid}", mapping=user)
r.expire(f"user:{uid}", 3600)
return user
def update_user_email(uid, email):
r.hset(f"user:{uid}", "email", email) # 한 줄!
write 가 자주 일어나는 자리에서 빛나요. 이메일만 바꾸는 케이스에 전체 객체를 read, modify, write 안 해도 되니까요.
Spring Data Redis 매핑
Spring Boot 에서 두 패턴을 어떻게 쓰는지 보면:
// String JSON 패턴
@Cacheable(value = "users", key = "#uid")
public User getUser(Long uid) {
return userRepository.findById(uid).orElseThrow();
}
// Hash 패턴
@RedisHash("users")
public class User {
@Id
private Long id;
private String name;
private String email;
// ...
}
// Spring Data Redis가 자동으로 HSET/HGET 으로 처리
@RedisHash 어노테이션을 붙이면 Hash 기반 저장으로 자동 매핑돼요. 73편 Spring 연동에서 깊이 들어갑니다.
카운터 그룹 패턴
Hash가 특히 빛나는 또 한 자리는 한 객체에 카운터 여러 개를 묶는 경우예요. 자전거 한 대의 rides, crashes, owners 통계를 한 키에 묶는 식이죠.
> HSET bike:1:stats rides 0 crashes 0 owners 0
(integer) 3
> HINCRBY bike:1:stats rides 1
(integer) 1
> HINCRBY bike:1:stats rides 1
(integer) 2
> HINCRBY bike:1:stats crashes 1
(integer) 1
> HMGET bike:1:stats rides crashes
1) "2"
2) "1"
String 카운터로 만들면 키가 3개로 흩어져요 — bike:1:rides, bike:1:crashes, bike:1:owners. Hash로 묶으면 키 한 개에 필드 3개로 정리됩니다.
장점:
- 키 개수 감소 — Redis 키 수가 수천만 → 수백만 으로 줄어듦 (운영 모니터링에 유리)
- 묶음 조회 효율 —
HGETALL한 번으로 모든 카운터 한 번에 - TTL 일괄 —
EXPIRE bike:1:stats 86400으로 통계 키 통째로 만료
단점:
- 필드별 TTL 불가
- Hash 크기가 수만 필드 이상 으로 커지면 운영 부담 (한 키가 너무 큼)
Hash 내부 최적화 — ziplist vs hashtable
여기까지 따라오셨다면 한 가지 의문이 들 거예요 — Hash가 왜 작은 객체에서 메모리 효율이 좋다고 하나요? 답은 내부 구조 자동 전환에 있어요.
Redis는 Hash를 두 가지 자료구조 중 하나로 저장합니다:
- listpack(이전 ziplist, 연속 메모리에 차례로 쌓는 압축 포맷) — 작은 Hash 용, 메모리 사용을 최소로 줄임
- hashtable(전통적 해시 테이블) — 큰 Hash 용, 접근 속도가 빠름
전환 기준 (기본값):
- 필드 수 ≤ 128개 AND 모든 필드·값 크기 ≤ 64바이트 → listpack
- 그 외 → hashtable
설정 = hash-max-listpack-entries · hash-max-listpack-value (redis.conf).
작은 객체(예: User 5~10개 필드) 는 listpack 에 들어가서 메모리 효율이 매우 좋아요. Hash가 작은 객체에 유리하다는 말이 여기서 나옵니다.
시험 직전 한 번 더 — Redis Hash 함정 압축 노트
- Hash = field → value 쌍, Java HashMap·Python dict 와 같은 모델
- 핵심 명령 5종 =
HSET·HGET·HGETALL·HMGET·HINCRBY - 보조 명령 5종 =
HEXISTS·HDEL·HKEYS·HVALS·HLEN HSET반환값 = 새로 만들어진 필드 수 (덮어쓰기는 0)HGET없는 필드 =nilHGETALL= O(N), 큰 Hash는HSCAN으로 페이징HINCRBY= atomic 카운터, Hash 안 여러 카운터에 자주 쓰임- TTL = 키 단위만 (필드별 TTL은 7.4+ 일부 지원, 일반적이지 않음)
- Hash는 중첩 X — flat 한 field-value 만 가능, 중첩은 JSON String 또는 RedisJSON
- JSON String vs Hash 선택 = 부분 업데이트 잦으면 Hash, 통째 read/write 잦으면 String JSON
- 객체 캐싱 = 두 패턴 (String JSON 단순 / Hash 필드 단위 갱신)
- Spring Data Redis =
@RedisHash어노테이션으로 자동 매핑 (73편) - 카운터 그룹 패턴 — 한 객체에 여러 카운터 묶기 (rides·crashes·owners)
- 키 1개에 필드 N개 = String 키 N개보다 운영 모니터링 유리
- 내부 구조 자동 전환 = listpack(작은 Hash) ↔ hashtable(큰 Hash)
- listpack 기준 = 필드 ≤ 128 AND 값 크기 ≤ 64바이트
- 설정 =
hash-max-listpack-entries·hash-max-listpack-value - 작은 객체는 Hash가 메모리 효율 매우 좋음 (listpack 덕분)
HGETALL큰 Hash에 쏘면 = O(N) 응답 시간 부담- 한 Hash가 수만 필드 이상 = 운영 부담, 분할 검토
공식 문서: Redis Hashes 에서 28개 Hash 명령어 전체 reference 를 확인할 수 있어요.
시리즈 다른 편 (앞뒤 글 모음)
이전 글:
- 45편 — 데이터베이스 관리 생성·복제·드롭
- 46편 — 백업과 복구 pg_dump·PITR
- 47편 — Redis란 + PostgreSQL과의 역할 분담
- 48편 — Redis 데이터 타입 13종 한 번에 정리
- 49편 — Redis String 깊이 + 분산 락 패턴
다음 글: