새소식

Welcome to the tech blog of Junior Backend Developer Seulbin Kang!

Study

(CS Study) Redis 기본 자료구조 깊게 파헤치기 - Set & Hash (마지막)

  • -

👀 들어가기 전에

Redis를 처음 접하고 무작정 String형 자료구조만 써본 상태에서 조금 더 깊게 알아보고 싶다라는 마음으로 여태 기본 자료구조 String, List를 봐왔습니다. 이제 마지막으로 남은 기본 자료구조인 Set과 Hash에 대해서 알아보고 Redis 기본 자료구조 파헤치기를 마무리하려고 합니다.

 

이번 글에서는 Redis의 Set과 Hash 자료구조를 내부 인코딩 방식부터 주요 명령어의 시간복잡도, 실무 활용 관점까지 기술적으로 깊이있게 정리해보겠습니다.


👀 본론

1. Redis Set

1) Set의 핵심 특성

Redis의 Set은 중복되지 않는 문자열 값의 집합입니다.

Java의 Set 인터페이스와 동일한 개념으로 같은 값을 여러 번 추가하려 해도 Set에는 한 번만 저장됩니다.

 

Set을 이해하는데 가장 중요한 포인트는 순서가 없다는 것입니다.

List는 삽입 순서를 보장하지만, Set은 그렇지 않습니다. 

a,b,c 순서로 저장했더라도 꺼낼 때 동일한 순서가 보장되지는 않습니다. 이는 내부적으로 해시 값을 기반으로 저장하기 때문입니다.

 

Set은 최대 2³² - 1개(약 42억개)의 요소를 저장할 수 있으며, 이는 List와 동일합니다

다만, List보다 메모리를 더 많이 사용하게 되는데, 이는 각 요소마다 hashtable의 오버헤드가 추가되기 때문입니다.

 

2) 인코딩 방식 : intset vs. hashtable

Redis Set은 상황에 따라 두 가지 인코딩 방식을 사용한다.

 

intset은 모든 요소가 정수이고, 요소의 개수가 적을 때 사용되는 메모리 효율적인 구조입니다.

set-max-intset-entries 설정값보다 요소 수가 적고, 모든 요소가 64비트 정수로 표현될 수 있을 때 사용됩니다.

intset은 정렬된 정수 배열로 이진 탐색을 통해 O(log n)의 시간복잡도로 검색이 가능합니다.

 

hashtable은 요소가 많거나 정수가 아닌 값이 포함될 때 사용된다. 해시 함수를 통해 각 요소를 버킷에 매핑하는 구조로

검색,삽입,삭제 모두 평균 O(1)의 시간복잡도를 가진다. Set의 가장 큰 장점인 특정 값의 포함 여부를 즉시 확인이 가능한 이유가 바로 이 hashtable 인코딩 덕분입니다.

 

3) 주요 명령어

a. SADD - Set에 하나 이상의 요소를 추가합니다.

SADD key member [member ...]

 

새롭게 추가된 요소의 개수를 반환하며 이미 존재하는 값은 무시되고 반환 개수에도 제외됩니다.

Key가 존재하지 않으면 빈 Set을 생성 후 추가하며 별도의 초기화 명령이 필요하지 않습니다.

내부적으로 해싱 처리를 통해 중복 검사를 수행하지만 매우 빠르게 동작하므로 중복 여부를 걱정하며 값 추가를 망성일 필요가 없는 것입니다.

 

b. SREM - 하나 이상의 요소를 삭제합니다.

SREM key member [member ...]

 

실제로 삭제된 요소의 개수를 반환하며 존재하지 않는 요소는 개수에 포함되지 않습니다.

Key가 없으면 0을 반환하고, Set이 완전히 비어버리면 List와 동일하게 Key 자체가 삭제됩니다.

시간복잡도는 O(n)이며, 여기서 n은 삭제를 요청한 member의 개수입니다.

hashtable 인코딩에서 개별 요소 탐색 자체는 O(1)이지만 여러 member를 삭제할 경우 그 수에 비례합니다.

 

c. SMEMBERS - Set에 포함된 모든 값을 배열 형태로 반환합니다.

SMEMBERS key

 

반환되는 요소의 순서는 보장되지 않으며, 실행할 때마다 다른 순서로 반환될 수 있습니다.

시간복잡도는 O(n)으로 Set의 크기에 비례합니다.

Redis는 싱글스레드 기반으로 돌아가기 때문에 크기가 큰 Set에 이 명령을 사용하면 다른 작업을 대기시키게 됩니다.

이런 경우 커서 기반으로 일부씩 조회하는 SSCAN을 사용하는 것이 더 빠를 것이라 생각됩니답

 

d. SISMEMBER - 특정 값이 Set에 존재하는지 확인합니다.

SISMEMBER key member

존재하면 1, 존재하지 않거나 Key가 없으면 0을 반환합니다.

O(1)의 시간복잡도로 Set의 크기와 무관하게 즉시 결과를 반환하게 됩니다.

해시 테이블을 통한 값 탐색이 매우 빠르기 때문에 이것이 Set을 선택하는 가장 큰 이유가 될 것 같습니다.

 

Redis 6.2부터는 SMISMEMBER key member [member ...]로 여러 값의 존재 여부를 한 번에 확인 가능합니다

 

e. 집합 연산 명령어

집합 연산 명령어는 자주 쓰일 일은 없어 보이지만, 성능 측면에서는 의미가 있는 것 같습니다.

직접 데이터를 가져와 어플리케이션 코드로 계산하는 것보다는 네트워크 전송량 관점에서 효율적입니다.

  • SINTER: 여러 Set의 교집합을 반환합니다. 시간복잡도는 O(n*m)입니다.
    (n : 가장 작은 Set의 크기, m : Set의 수)
  • SUNION: 여러 Set의 합집합을 반환합니다. 시간복잡도는 O(n)입니다.
    (n : 모든 Set의 전체 요소 수)
  • SDIFF: 여러 Set의 차집합을 반환합니다. 시간복잡도는 O(n)입니다.
    (n : 모든 Set의 전체 요소 수)

4) Set의 실무 활용

Set은 List보다 사용 빈도는 낮은 편인 것 같습니다. (제가 겪어본 바로는...)

허나, 중복 없는 체크가 필요한 상황에 쓰기 적합할 것 같습니다.

대표적인 사례로는 MAU (월간 활성 사용자)나 DAU(일간 활성 사용자) 집계 시, 특정 사용자가 이미 집계에 포함되었는지 O(1)로 확인하는 용도로 활용할 수 있을 것 같습니다.

 

5) 특수한 Set : Sorted Set

게임에서의 전투력 순위, 월별 거래 금액 TOP N 처럼 순위가 필요한 데이터를 다룰 때는 이 Sorted Set이 편의를 제공합니다.

Set처럼 중복되지 않는 요소를 저장하지만, 각 요소마다 score라는 부동 소수점 숫자값을 함께 갖는다는 점이 주요한 차이점입니다.

Redis는 이 score를 기준으로 요소들을 자동으로 정렬된 상태를 유지합니다.

 

Sorted Set의 각 요소는 member와 score 두 부분으로 구성됩니다.

member는 실제 저장되는 문자열 값이고, score는 정렬 기준이 되는 부동소수점 값이 됩니다.

게임 리더 보드를 예시로 들면 사용자 이름이 member, 게임 점수가 score가 됩니다.

 

요소를 새로 추가하면 Redis가 score를 기준으로 자동으로 올바른 위치에 삽입하며 score가 동일한 경우에는

member의 사전 순으로 보조 정렬됩니다.

 

Sorted Set은 Set보다 메모리를 더 많이 사용합니다.

각 요소마다 member, score, skiplist의 여러 레벨 포인터까지 저장해야하기 때문입니다.

저장 용량은 두 자료구조 모두 동일하게 최대 2³² - 1개이지만, 역시 메모리 제한을 염두에 두어야합니다.

 

5-1) 인코딩 방식 : listpack & skiplist+hashtable

listpack은 Set의 intset과 마찬가지로 요소 수가 적고 크기가 작을 때 사용되는 메모리 효율적인 압축구조입니다.

이 역시 zset-max-listpack-entries (기본값 :128)와 zset-max-listpack-value (기본값 : 64바이트) 

두 조건을 모두 만족할 때 사용됩니다.

 

조건을 초과하면 skiplist + hashtable 결합 구조로 전환됩니다. 

skiplist는 여러 레벨의 포인터를 유지하여 O(logn)의 빠른 범위 검색을 지원하며 

hashtable은 member 기반의 O(1) 직접 조회를 담당합니다.

두 구조를 함께 사용하기에 메모리 사용 측면에서는 더 사용하지만, 순위 조회 / 범위 검색 / 멤버 직접 접근 등

다양한 연산을 모두 효율적으로 처리 가능합니다.

 

5-2) 주요 명령어 - 추가 및 업데이트

a. ZADD - Score와 member쌍으로 요소를 추가하거나 업데이트합니다.

ZADD key score member [score member ...]

 

Key나 member가 없으면 자동으로 생성되며 반환값은 새로 추가된 요소의 개수만 포함합니다.

여러 요소를 한 번에 넣을 때 일부는 추가, 일부는 업데이트되는 경우 추가된 것만 개수에 포함하게 됩니다.

 

ZADD는 다양한 옵션을 제공합니다.

  • NX : member 가 존재하지 않을 때만 추가합니다. (기존값 업데이트 불가)
ZADD leaderboard NX 100 Alice
  • XX : member가 이미 존재할 때만 업데이트합니다. (새 요소 추가 불가)
ZADD leaderboard XX 150 Bob
  • GT : 새로운 score가 기존 score보다 클 때만 업데이트합니다.
ZADD leaderboard GT 200 Charlie
  • LT : 새로운 score가 기존 score보다 작을 때만 업데이트합니다.
ZADD leaderboard LT 50 Charlie
  • CH : 반환값에 score가 변경된 요소도 포함합니다. (기본은 추가된 요소만 반환합니다.)
ZADD leaderboard CH 200 Alice
  • INCR : score를 덮어쓰는게 아니라 지정한 값만큼 새로 증가시킵니다. 새로운 score 값을 반환합니다.
ZADD leaderboard INCR 20 Alice

 

b. ZINCRBY - member의 score를 지정한 값만큼 증가시킵니다.

ZINCRBY key increment member

 

기존 score에 increment를 더한 새로운 score를 반환합니다. member가 없으면 0으로 간주하고 increment값이 새로운 score가 됩니다. 원자적 연산이기 때문에 Race Condition없이 안전하게 점수를 누적하는 것이 가능합니다.

 

5-3) 주요 명령어 - 범위 조회

a. ZRANGE - 지정한 순위 범위의 요소들을 반환합니다.

ZRANGE key start stop [WITHSCORES]

 

start와 stop은 순위를 나타내는 인덱스로, score가 가장 낮은 요소가 0번입니다.

음수 인덱스도 사용가능하며 -1은 마지막 요소를 의미합니다.

WITHSCORES 옵션을 붙이면 score도 함께 반환하며 반환값은 정수나 소수점 모두 문자열 형태로 반환합니다.

높은 score부터 내림차순으로 조회하려면 REV 옵션을 사용합니다.

과거에 사용하던 ZREVRANGE는 Redis 6.2부터 deprecated되었으므로 ZRANGE ...REV 형태로의 사용이 권장됩니다.

ZRANGE leaderboard 0 -1 REV WITHSCORES   -- 높은 score부터 내림차순 반환

 

5-4) 주요 명령어 - 순위 조회

a. ZRANK / ZREVRANK - 특정 member의 순위를 반환합니다.

ZRANK key member
ZREVRANK key member

 

ZRANK는 score가 낮을 수록 0에 가까운 순위를 반환하고 ZREVRANK는 score가 높을수록 0에 가까운 순위를 반환합니다.

member가 존재하지 않으면 nil을 반환합니다.

게임 등수처럼 높은 점수가 상위 순위인 경우 ZREVRANK를 주로 사용합니다.

ZRANK leaderboard Alice      -- Alice의 오름차순 순위 반환
ZREVRANK leaderboard Alice   -- Alice의 내림차순 순위 반환 (리더보드 등수)

 

Redis 7.2부터는 ZRANK와 ZREVRANK에 WITHSCORES 옵션을 사용해 순위와 score를 동시에 조회할 수 있습니다.

 

5-5) 주요 명령어 - 삭제

a. ZREM - 하나 이상의 member를 삭제합니다.

ZREM key member [member ...]

 

삭제된 요소의 개수를 반환하며, 존재하지 않는 member는 개수에 포함되지 않습니다.

SortedSet이 완전히 비어버리면, Key 자체가 자동으로 삭제됩니다.

ZREM leaderboard xeulbn   -- leaderboard에서 xeulbn 삭제

2. Redis Hash

1) Hash의 핵심 특성

Redis의 Hash는 필드(Field)와 값(Value)쌍의 컬렉션입니다.

하나의 Redis 키 안에 여러 필드를 저장할 수 있으며 각 필드는 자신의 값을 독립적으로 저장하게 됩니다.

프로그래밍 언어의 HashMap, Dictionary, Object와 유사한 구조라고 보면 될 것 같습니다.

 

예를 들어,

user:1000

 

이라는 Hash 키 안에 name, email, age 필드를 각각 다른 값으로 저장할 수 있으며 이는 관계형 DB에서 흔히 보는 row와 구조가 유사합니다. Hash의 필드와 값은 모두 문자열이며, String과 동일하게 Binary Safe하기에 어떤 데이터든 저장가능합니다.

(앞, String에 대한 글 참조 : https://xeulbn-dev.tistory.com/40 )

 

2) 인코딩 방식 : listpack vs. hashtable

Hash도 두 가지 인코딩을 사용합니다.

listpack은 Redis 7.0이전 버전에서 사용하던 ziplist를 대체한 메모리 효율적인 압축 인코딩 방식입니다.

아래 두 조건을 모두 만족할 때 사용합니다.

  1. Hash의 필드 개수가 hash-max-listpack-entries 이하인 경우 (기본값 : 512)
  2. 모든 필드 이름과 값의 크기가 hash-max-listpack-value 이하인 경우 (기본값 : 64바이트)

listpack은 순차적으로 저장하는 구조이기 때문에 필드 접근 시 처음부터 탐색해야하며,

시간 복잡도는 O(n)입니다. 다만, listpack 자체가 매우 작은 구조이므로 조건 범위 안에서는 실질적으로 매우 빠르게 동작합니다.

 

hashtable 인코딩으로 전환되면 필드 접근이 O(1)이 되어 빠르지만, 메모리 사용량은 listpack보다 큽니다.

Hash는 최대 2³² - 1개의 필드를 저장할 수 있으나, 실무에서는 메모리의 한계로 인하여 일반적으로 10~20개 수준의 필드를 갖는 형태로 사용할 것으로 추정됩니다.

 

3) String 다중 키 vs. Hash : 메모리의 관점에서

동일한 데이터를 

user:1000:name, user:1000:email, user:1000:age

 

처럼 여러 개의 String 키로 저장할 수도 있고, user:1000이라는 Hash 키 하나로 관리할 수도 있습니다.

 

Hash를 사용하면 키 하나당 메타데이터가 하나면 되기에 메모리 효율면에서 Hash가 더 유리합니다.

관련된 필드들이 논리적으로 묶이는 객체 단위의 데이터라면? Hash 사용을 고려해볼 만할 것 같습니다.

 

4) 주요 명령어

a. HSET - 하나 이상의 필드와 값을 설정합니다.

HSET key field value [field value ...]

 

새로 추가된 필드 개수를 반환하며 기존 필드를 업데이트만 한 경우에는 0을 반환합니다.

Redis 4.0부터 여러 필드를 한 번에 설정할 수 있게 되었습니다.

시간복잡도는 설정하는 필드 개수 n에 대해 O(n)입니다.

 

b. HGET / HMGET - 필드 값을 조회합니다.

HGET key field
HMGET key field [field ...]

HGET은 단일 필드를, HMGET은 여러 필드를 한 번에 조회합니다.

여러 필드가 필요한 경우 HGET을 여러 번 호출하는 것보다

HMGET을 한 번 실행하는 것이 네트워크 관점에서 왕복을 줄여 더 효율적입니다.

 

또한, 여러 필드를 원자적으로 처리하여 데이터 일관성을 유지할 수 있습니다.

과거에는 여러 필드 설정을 위해 HMSET을 사용했으나, Redis 4.0이후 HSET으로 통합되었고,

공식문서 상으로도 HSET 사용을 권장하는 것을 보실 수 있습니다.

Redis 공식문서 HMSET

(출처 : https://redis.io/docs/latest/commands/hmset/ )

 

c. 필드 증가 명령어

HINCRBY key field increment
HINCRBYFLOAT key field increment

 

HINCRBY는 정수 증가, HINCRBYFLOAT은 실수 증가를 지원합니다.

음수도 허용되며, 존재하지 않는 필드에 사용하면 새 필드를 생성하게 됩니다.

가장 중요한 특성은 원자적 연산이라는 점으로 여러 클라이언트가 동시에 같은 필드를 증가시키면서 발생하는

Race Condition문제를 해결할 수 있습니다.

 

d. 기타 조회 명령어

HEXISTS key field   -- 필드 존재 여부 확인 (존재: 1, 미존재: 0)
HKEYS key           -- 모든 필드 이름을 배열로 반환 (순서 보장 X)
HVALS key           -- 모든 값을 배열로 반환 (순서 보장 X)

 

HKEYS와 HVALS는 Hash의 전체 필드 또는 값을 조회할 때 사용하지만, 실무에서는 사실 필요한 필드만 HMGET으로 조회할 것 같습니다. 전체 조회 명령이 O(n)의 시간복잡도이고, 필요한 일부 필드만 읽을 수 있는 Hash의 장점을 제대로 못살리기 때문입니다.

이에 대량 순회시에는 HSCAN을 차라리 고려할 것 같습니다. (조금씩 나눠서 읽고, non-blocking에 가까운 특성 때문입니다.)


👀 결론

Set과 Hash를 공부하면서 Redis를 단순히 캐시 저장소로 쓰고,

자료구조도 String만 알고 있던 저를 반성하게 되었습니다.

Redis는 자료구조에 따라 내부 인코딩을 자동으로 전환하면서 성능과 메모리를 동시에 최적화하는 정교한 시스템이었습니다.

 

특히 Set의 O(1) 시간복잡도가 걸리는 체크는 정말 이게 필요한 상황인가를 먼저 따져야한다는 것을, Hash는 String 키를 무분별하게 늘리기보다는 논리적 단위로 묶어 메모리를 절약하는 설계가 될 수 있음을 다시금 깨닫게 되었습니다.

Redis를 사용할 때 내부 인코딩 전환 조건과 시간복잡도를 이해하고 데이터 특성에 맞게 자료구조를 선택하는 것이 진짜로 Redis를 잘 활용하는 것임을 알게 되었습니다.

Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.