Theme:

사용자 프로필처럼 여러 속성을 가진 객체를 Redis에 저장할 때, String에 JSON을 넣는 것과 Hash를 쓰는 것 중 어느 쪽이 나을까요?

개념 정의

Redis Hash는 필드-값 쌍의 컬렉션입니다. 프로그래밍 언어의 Map이나 Dictionary와 유사한 구조로, 하나의 키 아래에 여러 필드를 저장합니다. 내부적으로 원소가 적을 때는 listpack, 많아지면 hashtable로 인코딩됩니다.

왜 필요한가

객체 데이터를 Redis에 저장하는 세 가지 방법을 비교해봅니다.

BASH
# 방법 1: 필드별 String 키
SET user:1:name "Alice"
SET user:1:age "30"
SET user:1:email "alice@example.com"
# 문제: 키가 3개 → 키 오버헤드 3배, 원자적 조회 불가

# 방법 2: JSON String
SET user:1 '{"name":"Alice","age":30,"email":"alice@example.com"}'
# 문제: name만 바꾸려면 전체를 읽고 → 수정 → 다시 저장

# 방법 3: Hash (권장)
HSET user:1 name "Alice" age "30" email "alice@example.com"
# 장점: 키 1개, 부분 업데이트 가능, 메모리 효율적

기본 명령어

설정

BASH
# 필드 설정 (여러 필드 한 번에 가능)
HSET user:1 name "Alice" age "30" city "Seoul"

# 필드가 없을 때만 설정
HSETNX user:1 name "Bob"   # name이 이미 있으므로 무시됨

# 정수 필드 원자적 증가
HINCRBY user:1 age 1       # 31
HINCRBYFLOAT user:1 score 0.5  # 부동소수점 증가

조회

BASH
# 단일 필드 조회 — O(1)
HGET user:1 name            # "Alice"

# 여러 필드 조회 — O(N)
HMGET user:1 name age city  # ["Alice", "31", "Seoul"]

# 모든 필드-값 조회 — O(N)
HGETALL user:1
# 1) "name"  2) "Alice"
# 3) "age"   4) "31"
# 5) "city"  6) "Seoul"

# 모든 필드명만 — O(N)
HKEYS user:1                # ["name", "age", "city"]

# 모든 값만 — O(N)
HVALS user:1                # ["Alice", "31", "Seoul"]

# 필드 존재 확인 — O(1)
HEXISTS user:1 email        # 0 (없음)

# 필드 수 — O(1)
HLEN user:1                 # 3

# 필드 값의 문자열 길이 — O(1)
HSTRLEN user:1 name         # 5

삭제

BASH
# 특정 필드 삭제
HDEL user:1 city            # 1 (삭제된 필드 수)
HDEL user:1 city email      # 여러 필드 동시 삭제 가능

# 모든 필드가 삭제되면 키 자체가 삭제됨

내부 인코딩

listpack (소규모)

필드 수가 적고 값이 작을 때 사용됩니다.

PLAINTEXT
listpack: [field1][value1][field2][value2]...[fieldN][valueN]
  • 연속된 메모리 블록에 순차적으로 저장
  • 조회 시 O(N) 순차 탐색 (N이 작으므로 CPU 캐시 히트율이 높음)
  • 메모리 효율이 매우 좋음

hashtable (대규모)

PLAINTEXT
hashtable:
  bucket[0] → (field1, value1) → NULL
  bucket[1] → (field3, value3) → (field5, value5) → NULL
  bucket[2] → NULL
  bucket[3] → (field2, value2) → NULL
  ...
  • O(1) 평균 조회 시간
  • 각 엔트리에 포인터 오버헤드 (약 64바이트/엔트리)
  • 해시 충돌 시 체이닝

전환 조건

BASH
# 전환 설정 확인
CONFIG GET hash-max-listpack-entries  # 기본: 128
CONFIG GET hash-max-listpack-value    # 기본: 64 (바이트)

# 전환 테스트
HSET test f1 "short"
OBJECT ENCODING test     # "listpack"

# 값이 64바이트를 초과하면 전환
HSET test f2 "a]value_that_is_longer_than_64_bytes_aaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
OBJECT ENCODING test     # "hashtable"

전환은 단방향입니다. 한번 hashtable로 전환되면 필드를 줄여도 listpack으로 돌아가지 않습니다.

메모리 사용량 비교

BASH
# 100개 필드, 각 값이 10바이트인 Hash
# listpack 인코딩: ~2.5KB
# hashtable 인코딩: ~9KB

# listpack이 약 3.5배 메모리 효율적

설정값을 조정하면 더 많은 Hash를 listpack으로 유지할 수 있지만, 필드가 많아지면 O(N) 탐색 비용이 증가합니다.

HSCAN — 안전한 필드 순회

BASH
# 필드가 많은 Hash를 안전하게 순회
HSCAN user:1 0 MATCH name* COUNT 10
# 1) "0"            ← 커서 (0이면 완료)
# 2) 1) "name"
#    2) "Alice"
PYTHON
# Python에서 HSCAN 사용
for field, value in r.hscan_iter('large_hash', match='config:*', count=100):
    print(f"{field}: {value}")

HGETALL은 필드가 수천 개 이상일 때 서버를 블로킹할 수 있으므로, 대규모 Hash에서는 HSCAN을 사용해야 합니다.

필드별 TTL (Redis 7.4+)

Redis 7.4부터 Hash의 개별 필드에 TTL을 설정할 수 있습니다.

BASH
# 필드별 만료 시간 설정
HSET user:1 session_token "abc123"
HEXPIRE user:1 3600 FIELDS 1 session_token  # 1시간 후 필드 만료

# 필드별 TTL 확인
HTTL user:1 FIELDS 1 session_token

# 밀리초 단위
HPEXPIRE user:1 60000 FIELDS 1 temp_data

이전 버전에서는 키 단위로만 TTL을 설정할 수 있어서, 필드별 만료가 필요하면 별도의 키로 분리하거나 Lua 스크립트를 사용해야 했습니다.

Hash vs String(JSON) 비교

기준HashString(JSON)
부분 조회HGET field — O(1)전체 GET → 파싱
부분 업데이트HSET field valueGET → 수정 → SET
중첩 구조불가 (flat만)가능
메모리 (소규모)listpack — 효율적embstr/raw
메모리 (대규모)hashtable — 오버헤드raw — 컴팩트
원자적 증감HINCRBY불가 (GET→수정→SET)
스키마 변경필드 추가/삭제 용이JSON 구조 변경 필요

권장 사용 기준

  • 필드가 적고 부분 업데이트가 필요: Hash
  • 중첩 구조가 깊거나 전체 조회가 대부분: String(JSON)
  • 카운터 필드가 있음: Hash (HINCRBY 활용)

실전 패턴

패턴 1: 사용자 프로필

PYTHON
def create_user(user_id, name, email, age):
    key = f"user:{user_id}"
    r.hset(key, mapping={
        'name': name,
        'email': email,
        'age': age,
        'created_at': int(time.time()),
        'login_count': 0
    })
    r.expire(key, 86400 * 30)  # 30일 TTL

def increment_login(user_id):
    r.hincrby(f"user:{user_id}", 'login_count', 1)

def get_user(user_id):
    data = r.hgetall(f"user:{user_id}")
    if not data:
        return None
    return data

패턴 2: 설정/구성 관리

PYTHON
def update_config(service, key, value):
    r.hset(f"config:{service}", key, value)

def get_config(service, key, default=None):
    value = r.hget(f"config:{service}", key)
    return value if value is not None else default

def get_all_config(service):
    return r.hgetall(f"config:{service}")

패턴 3: 쇼핑 카트

PYTHON
def add_to_cart(user_id, product_id, quantity):
    r.hset(f"cart:{user_id}", product_id, quantity)
    r.expire(f"cart:{user_id}", 86400 * 7)  # 7일 유지

def update_quantity(user_id, product_id, delta):
    new_qty = r.hincrby(f"cart:{user_id}", product_id, delta)
    if new_qty <= 0:
        r.hdel(f"cart:{user_id}", product_id)

def get_cart(user_id):
    return r.hgetall(f"cart:{user_id}")

def cart_size(user_id):
    return r.hlen(f"cart:{user_id}")

패턴 4: HRANDFIELD로 랜덤 샘플링 (6.2+)

BASH
# 랜덤 필드 1개
HRANDFIELD user:1

# 랜덤 필드 3개 (중복 없이)
HRANDFIELD user:1 3

# 랜덤 필드-값 쌍 3개
HRANDFIELD user:1 3 WITHVALUES

# 중복 허용 (음수로 지정)
HRANDFIELD user:1 -5   # 5개, 중복 가능

메모리 최적화 팁

작은 Hash를 많이 만드는 전략

BASH
# 나쁜 예: 하나의 거대한 Hash
HSET all_users user:1:name "Alice"
HSET all_users user:1:age "30"
HSET all_users user:2:name "Bob"
# → hashtable 인코딩, 메모리 낭비

# 좋은 예: 적절한 크기의 Hash로 분리
HSET user:1 name "Alice" age "30"
HSET user:2 name "Bob" age "25"
# → 각각 listpack 인코딩, 메모리 효율적

설정 최적화

BASH
# listpack 유지 범위를 넓히면 메모리 절약
# 단, 필드 조회 시 O(N) 탐색 비용 증가
CONFIG SET hash-max-listpack-entries 256  # 기본 128
CONFIG SET hash-max-listpack-value 128    # 기본 64

정리

Redis Hash는 객체 데이터를 저장하기에 최적화된 타입입니다.

  • listpack 인코딩은 소규모 Hash에서 hashtable 대비 3~4배 메모리 효율적입니다
  • HSET/HGET으로 부분 업데이트/조회가 가능하여 String(JSON) 대비 유리합니다
  • HINCRBY로 특정 필드의 원자적 증감이 가능합니다
  • 대규모 Hash에서는 HSCAN으로 안전하게 순회해야 합니다
  • Redis 7.4부터 필드별 TTL(HEXPIRE)이 지원됩니다
  • 중첩 구조가 필요하면 String(JSON)을, flat한 객체라면 Hash를 선택하는 것이 좋습니다
댓글 로딩 중...