[CacheDB로 응답시간 빠르게 만들기] Redis 의 대표적인 자료 형식과 기능

Hyunjun Kim·2025년 5월 3일
0

Data_Engineering

목록 보기
62/153

2 Redis 의 대표적인 자료 형식과 기능

Redis 자료구조 실습은 cli를 통해서 진행한다. 자료구조 실습을 위한 설치는 다음 문서를 참고한다.

실습에서 다루는 commands는 해당 자료구조의 모든 commands를 포함하지않는다. 대표 commands를 소개한다. 모든 commands의 리스트와 각각의 특징은 링크에서 확인할 수 있다.


2.1 Redis의 Key

Redis 의 모든 자료구조는 Key-value 형식이다. 저장과 조회는 key를 기준으로 한다.
Key 는 binary sequence로 binary-safe하다.
즉, string(empty string포함) 이나 어떤 파일을 binary로 변환한 값이나 상관이 없다.
앞에서부터 byte단위로 비교한다.


Key 설계와 관련해서 다음과 같은 것을 고려해야한다.

2.1.1 Avoid long key

Key의 길이(크기)가 너무 길면 다음과 같은 문제가 발생할 수 있다.

  • 메모리 사용량 증가
  • Key 비교 연산 시 성능 저하 / 비용 증가

Redis는 최대 512MB까지 key를 허용하지만, 일반적으로 1KB(1024 bytes)를 넘기지 않도록 관리하는 것이 좋다.
Key에 사용되는 데이터가 크다면, hashing을 통해서 key의 unique함을 보장하면서도 특정 길이로 줄이는 방법을 추천한다.

  • SHA1(160bit, 20bytes), SHA256(256bits, 32bytes) 등을 추천한다.

SHA1 어떤 input을 넣던지 같은 input에 대해서 해싱 결과가 같은 랜덤한 바이트의 연속이 나오고
160비트, 20바이트를 써서 표현 할 수 있다.

2.1.2 Avoid too short key

Avoid long key 가이드를 따르기 위해서 일부 사용자는 너무 짧은 키를 고안한다.

  • 예를 들어서 user:1000:followers(user id 1000 사용자의 follower를 저장하는 자료구조) 를 표현하기 위해서 u1000fw와 같은 식으로 줄이는 경우이다.

이는 메모리를 조금이라도 더 적게 사용하기 때문에 좋은 선택같아 보인다. 하지만 크게 차이가 없고, 비교연산 등에서도 큰 차이가 없다. 하지만 줄인 key 의 의미를 파악할 수 없는 경우 개발, 운영 과정에서 버그나 문제를 야기할 수 있다. 이것은 성능보다 더 큰 문제를 야기할 수 있다.

또한 의미를 알아보기 힘든 축약어로 한 자료구조의 키를 설계하면, Redis 클러스터를 이용하는 경우 key의 분산을 위해서 다른 키도 이와같이 설계해야 한다. 그렇지 않으면 데이터의 분산이 골고루 이루어 지지 않고, 클라이언트의 요청이 한 노드에 몰려서 부하가 분산이 되지 않기 때문이다.

여러 종류의 키를 이런식으로 설계하면 결국에는 어떤 키가 어떤 의미인지 따로 문서 등이 없다면 알아보기 힘들게 되고, 이것은 심각한 버그나 문제를 야기할 수 있다.
따라서 Key가 사이즈가 크지도 않은데 일부러 축약어 등으로 과도하게 줄이는 것은 바람직하지 않다.

  • 의미 파악이 어렵고 가독성이 낮아 유지보수와 운영 중 실수를 유발할 수 있다.

  • Redis 클러스터 환경에서 key 분산이 제대로 되지 않을 수 있어 부하가 한쪽에 집중될 위험이 있다.
    Key 길이를 줄이는 것이 성능에 큰 영향을 주지 않으므로, 축약보다 명확한 의미 전달이 더 중요하다

2.1.3 Try to stick with schema

키안에 포함되어 있는 정보가 의미가 있다면, schema를 가지는 것은 좋은 방법이다.

<예시>

  • object-type:id

    • user:1000
  • dash(-), dot(.) 으로 구분한 multi word

    • comment:4321:reply.to
    • comment:4321:reply-to

이 방법은 키의 내용과 의미가 일치하고, 비교연산에서도 같은 자리를 비교하게 되므로 비용 효율적이다.



코멘트 데이터를 비교할 때는, 너무 짧은 키를 피해야 한다는 점(Avoid too short key)도 중요하지만, 일관된 스키마를 유지하는 것(Try to stick with schema)이 더욱 중요하다.
스키마를 정해두면 특정 키 구조 내에서 같은 위치의 정보를 비교하게 되어 비교 연산이 효율적이다.

앞으로 Redis 키를 설계할 때는 다음과 같은 절차를 따르는 것이 좋다:
1. 스키마를 먼저 정의한다.
2. 각 구성 요소의 길이가 너무 길거나 짧지 않은지 검토한다.

또한 운영 중 데이터가 많지 않은데도 Redis 성능이 불안정하거나 응답 속도가 들쭉날쭉하다면, 키의 길이나 패턴을 점검해보는 것이 좋다. 대부분의 키는 코드에서 자동 생성되므로, 예기치 않게 너무 긴 문자열이 포함되었을 가능성도 고려해야 한다.





2.2 Strings

2.2.1 Strings의 특징

Redis의 String 은 bytes를 그대로 저장한다. 즉, text 뿐만 아니라 어떤 object나 binary를 bytes 로 serialized 한 형태로 저장한다.

  • 하나의 String 은 512MB를 넘을 수 없다.

2.2.2 Commands

SET : 지정한 key에 원하는 value 를 저장한다.

  • 함께 쓰일 수 있는 옵션들
    EX seconds -- 지정한 시간(초, seconds)만큼 지난 뒤에 expire
    PX milliseconds -- 지정한 시간(밀리초, milliseconds)만큼 지난 뒤에 expire (Precision / Partial)
    EXAT timestamp-seconds -- 지정한 시간(unix timestamp seconds)에 expire
    PXAT timestamp-milliseconds -- 지정한 시간(unix timestamp milliseconds)에 expire
    NX -- SETNX와 같음 (set if not exists)
    XX -- Key가 존재하면 Set.
    KEEPTTL -- Key가 존재하는 시간. EX, PX 류와 동시사용 불가.
    GET -- Set하기 이전의 string을 리턴. 이전에 존재하지 않았으면 nil을 리턴. SET 이전에 존재하던 값(value)이 String이 아니면 Set을 취소하고 error를 리턴

SETNX : 지정한 key가 존재하지 않으면, key와 value를 저장한다.

  • lock을 구현할 때 유용하다.
  • 분산 환경에서 deduplication 을 적은 비용으로 구현할 수 있다.

GET : 지정한 key에 저장된 value를 가져온다.

MGET : 여러개의 key에 저장된 값을 한 번에 가져온다.


2.2.3 Counter commands

지정된 key에 대해서 숫자를 atomic하게 증가/감소 시키고 그 결과를 응답한다. 분산환경에서 덧셈 뺄셈 연산을 lock free로 구현할 때 많이 쓰인다.

Strings의 SET command로 초기 숫자를 세팅하고 시작한다.

  • INCR 지정한 key의 value를 1씩 증가시킨다.

    • counter 를 lock free로 구현할 때 유용하다.
    • rate limiter를 구현할 때도 쓸 수 있다.
  • INCRBY 지정한 key의 value로 정수형 숫자를 가지고, 그 숫자를 atomic하게 숫자를 증가/감소 시킨다.

  • INCRBYFLOAT 지정한 key의 value로 소숫점 숫자를 가지고, 그 숫자를 atomic하게 숫자를 증가/감소 시킨다.



2.3 Lists

지정한 Key에 리스트를 저장할 수 있다.

2.3.1 특징

  • 선입선출 구조(FIFO) 또는 후입선출 구조(LIFO) 모두 구현 가능하다.

    • 왼쪽(Left)은 Head, 오른쪽(Right)은 Tail 역할을 한다.
  • 리스트에 저장 가능한 최대 원소 수는 2^32 - 1 (약 43억 개)이다.

    • 내부적으로 원소 수를 4바이트(32비트) 정수로 관리한다고 추정할 수 있다.
  • 다양한 큐/스택 구조 구현이 가능하다

    • Stack, Queue, Reliable Queue, Circular List 등

2.3.2 사례

Stack, Queue 등을 구현하는데 사용한다.


2.3.3 Commands

  • LPUSH : 새 원소를 Head에 추가한다.
  • RPUSH : 새 원소를 Tail에 추가한다.
  • LPOP : Head의 원소를 지우고 리턴한다.
  • RPOP : Tail의 원소를 지우고 리턴한다.
  • LLEN : 리스트의 길이를 리턴한다.
  • LMOVE : atomic하게 한 리스트의 원소들을 다른 리스트로 옮긴다.
    • Reliable Queue를 구현하는데 사용할 수 있다. (LMOVE, LREM 이용)
    • Circular List를 만들 수 있다. (source, destination을 같은 리스트로)
  • LTRIM : 지정한 range 만큼의 원소들을 남기고 나머지를 지운다.
    • -1 은 마지막 원소를 뜻한다.
    • 주의: 지우는 대상이 많을 수록 시간이 오래걸린다. O(N)
  • BLPOP : LPOP과 같다.
    • 만약 리스트가 비어있다면 새로운 원소가 들어올 때까지 기다린다.(blocking) 단, timeout발생 이전까지 기다린다.
  • BLMOVE : LMOVE와 같다.
    • source의 리스트가 비어있다면 새로운 원소가 들어올 때까지 기다린다.


2.4 Sets

Redis에서 Set 자료구조를 표현할 수 있다.

2.4.1 특징

  1. Set정렬되지 않은 유니크한 문자열 데이터(member)를 저장하는 집합 자료구조로, 하나의 키(key)가 집합 이름이 되고, 그 아래에 중복 없는 문자열 멤버들을 저장한다.

  2. 대부분의 관련 명령어(command)는 내부적으로 해시 테이블 기반으로 구현되어 있어 시간복잡도가 O(1) 이다. 하지만, 일부 예외적인 명령어는 그렇지 않으므로 시간 복잡도에 주의가 필요하다.

  3. 하나의 Set에는 최대 2^32 - 1 (약 43억 개)의 멤버를 저장할 수 있지만, 실무에서는 보통 10만 개 이하로 관리하는 것이 권장된다.

    • 실무 환경에서는 시간 복잡도가 O(1)인 명령어 위주로 구성된 Set 사용이 일반적으로 권장되며, 이는 성능 이슈를 최소화하기 위한 관행이다.

2.4.2 사례

  1. 유일한 원소를 저장/조회할 때 쓴다.
  2. 관계를 표현할 때 쓴다.
  3. 교집합, 합집합, 차집합 등의 operation을 하고 싶을 때 쓴다.

2.4.3 Commands

  • SADD : Set 에 새로운 member를 추가한다.
  • SREM : Set 에서 특정 member를 삭제한다.
  • SMEMBERS : Set에 저장된 모든 member를 리턴한다.
    • O(N)의 시간복잡도를 가지고, memeber가 많은 경우 레디스에 부하를 주거나 다른 commands를 느리게할 수 있으므로 주의해야한다. SMEMBERS보다는 SSCAN을 통해서 부분조회하는 것을 권장한다.
  • SISMEMBER : 주어진 String이 Set에 존재하는지 확인한다.
    • SMEMBERS 와 마찬가지로 O(N)의 시간복잡도를 가진다.
  • SINTER : 주어진 두 개 이상의 Set에 모두 존재하는 member들을 리턴한다. (교집합)
    • worst case O(N*M) - N은 cardinality, M은 비교대상이 되는 Set의 갯수
  • SCARD : Set의 size(cardinality)를 조회한다.

Redis는 단일 스레드 기반의 구조로, 명령어를 순차적으로 처리하며 내부적으로 별도의 락(lock)을 사용하지 않기 때문에 매우 빠른 응답 속도를 제공한다.
하지만 SMEMBERS, SINTER와 같이 시간 복잡도가 O(N) 이상인 명령어는, 데이터 양이 많아질 경우 Redis 전체 처리 성능에 병목을 유발할 수 있다.

예를 들어, SMEMBERS가 한 번 실행될 때 1ms가 소요된다면, 이를 5번 연속 실행할 경우 최소 5ms가 걸리게 되고, 이 시간 동안 다른 모든 요청은 블로킹 상태로 대기하게 된다.

Redis는 고속 처리를 목적으로 메모리 기반(in-memory)으로 동작하기 때문에, 이런 느린 명령어 사용은 시스템의 비용 효율성과 설계 취지를 훼손할 수 있다.
따라서 프로덕션 환경에서는 가능한 한 O(1)의 시간 복잡도를 갖는 명령어 위주로 구성하는 것이 성능 최적화 및 안정적인 운영을 위한 권장 사항이다.



2.5 Sorted sets

정렬기능이 있는 Set 자료구조 이다.

2.5.1 특징

  1. unique string 데이터(member)를 score 정보로 정렬된 형태의 집합으로 저장할 때 쓴다.

  2. 같은 score 정보를 가진 memeber가 여러개 있다면, 문자열순(lexicographically)으로 정렬한다.


2.5.2 사례

  • Ranking: 높은 score 순으로 실시간 정렬을 가진 자료가 필요할 때 쓸 수 있다.
  • Sliding-Window를 가진 Rate Limiter를 구현할 수 있다.

2.5.3 Commands – Redis Sorted Set 명령어

Sorted Sets의 모든 commands: Link

  • Z가 앞에 붙는 것이 특징이다.

ZADD : 새로운 member를 score 값과 함께 추가한다. 이미 존재하는 member라면
score를 업데이트 한다.

ZRANGE : 주어진 Range에 해당하는 member들을 리턴한다.

ZRANK : 주어진 member의 rank를 리턴한다. Ranking은 0부터 시작하는 오름차순이다. (0 = lowest)

  • ZREVRANK 0이 가장 높은 Ranking (0=highest)

ZCARD : 주어진 key에 해당하는 memeber의 수(cardinality)를 구한다.

ZREMRANGEBYSCORE : 주어진 key에서 주어진 min, max 이내의 (inclusive) score를 가지는 member를 삭제하고, 삭제된 memeber의 수를 리턴한다.


성능 관련 참고사항

  • 시간 복잡도는 O(log(N)+M) 이다.

    • N: sorted set에 저장된 전체 member의 수
    • M: 지워질 대상이 되는 member 수
  • 위 시간복잡도 때문에 대량의 데이터가 저장되어있는 자료구조일수록 성능문제가 발생할 수 있다. 따라서 적절한 수를 유지하도록 EXPIRE 또는 ZREMRANGEBYSCORE 로 관리할 필요가 있다.

  • 데이터가 많을수록 성능 저하가 발생할 수 있으므로, 10만 개 이상이 되지 않도록 유지하는 것이 실무적인 가이드이다

예시.
우리가 유저한테 보여줘야하는 정보는 유저점수 top 100 랭킹 정보.
유저 수는 10만명. 유저 점수 데이터를 확인해보니 top100 안에 위치한 대부분의 유저는 10만점이 넘고, top 200, top 1000까지 확인해도 다들 만 점은 넘어있더라.
이런 경우엔 만점이하의 점수들은ZREMRANGEBYSCORE같은 명령어들을 통해 주기적으로 지워주면 자료가 일정 이상 커지지 않고 불필요한 데이터로 인한 검색 및 삭제 비용을 줄이고, 정렬 및 조회 성능을 안정적으로 유지할 수 있다. (필요 시 EXPIRE 명령어와 함께 사용하여 시간 기반 데이터 관리도 수행할 수 있다.)



2.5.4 Sorted Sets을 활용한 sliding-window rate limiter 구현

Rate Limiter란?

  • 단위 시간당 요청/처리량을 제한을 두기 위한 소프트웨어적 기법
  • 악성 사용, 비정상적인 동작에 의한 시스템/로직의 문제 등을 사전 방지할 때 고려하는 가장 기초적인 방법
  • 대표 사례: 결제시스템, 로그인의 단위 시간당 시도 횟수 제한

Sliding-Window란?

  • window: 판단을 위해 정해놓은 시간의 구간(1초, 1분, 1시간 등)
  • sliding-window: 판단의 대상이 되는 시간의 구간이 이동한다는 의미로, 고정적인 시간 단위로 검증하는 것이 아니라, 현재 시간에 따라서 판단의 시간 구간이 정해지는 것을 sliding-window 라고 한다.
  • 대표적으로 TCP/IP 프로토콜에서 Flow Control할때 sliding window에 기반한 판단을 한다.

pseudo code

  • 새로운 record가 들어올 때마다 다음 로직을 수행
  • 최근 일정 시간 내 요청 수가 특정 임계치(limit)를 초과하지 않도록 제한
  • 지정된 시간 범위(window) 내에 허용된 최대 요청 횟수(limit) 이상이면 거부
파라미터설명
serviceRedis의 key로 사용되는 서비스 식별자. 예: "login:user123"
timestamp현재 요청이 들어온 시간 (ms 단위, time.time()*1000 같은 값)
window슬라이딩 윈도우의 시간 범위. 예: 최근 1분 → window = 60000 (ms 단위)
limit해당 시간 범위 내 허용할 최대 요청 횟수. 예: 100 요청
input: service, timestamp(of record), window(time, milliseconds), limit

redis.call('ZREMRANGEBYSCORE', service, 0, timestamp - window)
	if redis.call('ZCARD', service) < limit
	then
		redis.call('ZADD', service, timestamp, timestamp(or id of 	record))
		return 'pass'
	else
		return 'exceeded limit'
	end


Pseudo Code 동작 설명

redis.call('ZREMRANGEBYSCORE', service, 0, timestamp - window)
  1. 슬라이딩 윈도우 구간 외 요청 제거
    • 현재 timestamp로부터 window(ms) 이전까지의 요청을 모두 삭제
    • 목적: 윈도우 범위를 유지하여 오래된 요청들이 전체 개수 계산에 영향을 주지 않도록 함

if redis.call('ZCARD', service) < limit
  1. 현재 윈도우 범위 내 요청 수 확인
    • Redis Sorted Set의 전체 member 수를 구함 (삭제 이후 기준)
    • 윈도우 내 요청 개수가 limit보다 작으면 통과

redis.call('ZADD', service, timestamp, timestamp(or id))
  1. 요청 허용 → 현재 요청을 기록
    • 현재 요청의 timestamp를 score로, value로도 기록 (또는 고유 ID 사용 가능)
    • 이후 윈도우에 속한 요청으로 간주됨

return 'pass'
  • 요청 허용됨을 클라이언트에 반환

else
  return 'exceeded limit'
end
  • 요청 수가 limit을 초과하면 거부


전체 흐름 정리

상황 예시

  • 60초 동안 100건 이상 요청을 못하게 하자 → window=60000, limit=100

로직 흐름

  1. 오래된 요청 삭제
    timestamp - window보다 오래된 요청을 ZSET에서 제거 → 윈도우 밖 요청 제거

  2. 남은 요청 수 세기
    → 지금 윈도우 안에 있는 요청이 몇 개인지 확인

  3. 허용 조건 판단

    • 개수가 limit보다 작으면: ZADD로 지금 요청을 저장 → "pass"
    • 아니면: "exceeded limit" → 요청 거부


2.6 HyperLogLog

HyperLogLog란 하나의 set에서 cardinality(the number of unique data)를 추정하기 위한 데이터 구조이다. HyperLogLog를 이용하면 작은 데이터 공간을 이용해서 많은 수의 데이터의 cardinality를 추정치로 계산할 수 있다. Redis에서는 HyperLogLog를 지원하는 커맨드들이 있다.

HyperLogLog가 어떻게 작은 사이즈의 데이터구조를 가지고 많은 수의 데이터의 unique
count(cardinality)를 계산할 수 있는 지에 대한 수학적인 설명은 다음 글을 참고

2.6.1 특징

Redis의 HyperLogLog는 최대 12 KB 의 공간으로 0.81% 의 표준 오차율로 cardinality를 계산할 수 있다.

하나의 Key에 대해서는 O(1)를 보장하지만, 여러 개의 key를 한 commands에서 이용하는
경우 주의가 필요하다.

  • 하나의 Key에 대해서는 O(1)이지만, key를 여러개 준 경우 O(N) 이상으로 늘어난다.

만약 하나의 키에 대해 1μ\mus가 걸렸다고 하면 두 개의 키를 준 경우 2μ\mus가 아닌 3μ\mus 이렇게 2배 보다 더 커질 수 있다는 의미.

2.6.2 commands

  • PFADD : 주어진 Key의 HyperLogLog 자료구조에 데이터를 추가한다.
  • PFCOUNT : 주어진 Key들의 HyperLogLog count의 합을 리턴한다.
    • 이 합은 각각의 cardinality의 합이 아니라 주어진 key에 넣은 모든 데이터에 대한 cardinality 값의 합이다.
  • PFMERGE : 여러개의 HyperLogLog를 하나로 합친다. 합집합.
    • 이 합은 각각의 cardinality의 합이 아니라 주어진 key에 넣은 모든 데이터에 대한 cardinality 값의 합이다.

HyperLogLog의 모든 commands: Link

  • PF로 시작하는 것이 특징이다.

2.6.3 주의사항

대량의 데이터에 대해서 실시간 추정치 count를 하기위해서 Redis의 HyperLogLog를 이용하는 경우, 아무리 PFADD command가 O(1)를 보장하더라도, 같은 key에 대해서 처리할 수 있는 요청량은 한계가 있다. (클러스터를 쓰더라도 결국 하나의 key는 하나의 노드에서 처리한다.)

  • 링크에 따르면, 1백만개의 PFADD에 5.23초 소요
  • 초당 십만개 이상의 데이터라면 하나의 key로 HLL을 담기에는 client의 응답시간이 너
    무 느려질 수 있다.

이 경우 key를 분산해서 HyperLogLog 자료구조를 사용한 다음, count가 필요할 때
PFCOUNT 또는 PFMERGE 를 이용해서 최종 cardinality 를 구하는 것을 추천한다.

profile
Data Analytics Engineer 가 되

0개의 댓글