String — 가장 기본적이지만 가장 많이 쓰이는 타입
"문자열"이라는 이름만 보면 단순해 보이는데, 왜 Redis의 가장 핵심적인 타입이 되었을까요?
개념 정의
Redis String은 Redis에서 가장 기본적인 데이터 타입입니다. "문자열"이라는 이름이지만, 실제로는 텍스트, 정수, 부동소수점, 직렬화된 JSON, 심지어 이미지 바이너리까지 저장할 수 있는 바이너리 안전(binary-safe) 타입입니다. 최대 512MB까지 저장 가능합니다.
왜 필요한가
Redis String이 가장 많이 쓰이는 이유는 범용성에 있습니다.
- 캐싱: API 응답, DB 쿼리 결과를 직렬화하여 저장
- 카운터: INCR/DECR로 원자적 증감
- 분산 락: SET NX로 락 획득
- 세션 관리: 사용자 세션 데이터 저장
- 속도 제한(Rate Limiting): INCR + EXPIRE 조합
내부 구조 — SDS (Simple Dynamic String)
Redis는 C 언어로 작성되었지만, C의 기본 문자열(char*)을 사용하지 않습니다. 대신 **SDS(Simple Dynamic String)**라는 자체 문자열 구조를 사용합니다.
C 문자열의 한계
// C 문자열
char *str = "Hello";
strlen(str); // O(N) — 전체를 순회해야 길이를 알 수 있음
// \0(null terminator)이 중간에 있으면 문자열이 끊김
// 버퍼 오버플로우 위험
SDS 구조
struct sdshdr {
uint32_t len; // 현재 문자열 길이
uint32_t alloc; // 할당된 총 크기
unsigned char flags; // SDS 타입 (sdshdr5, 8, 16, 32, 64)
char buf[]; // 실제 데이터 (null terminator 포함)
};
┌──────┬───────┬───────┬──────────────────┐
│ len │ alloc │ flags │ H e l l o \0 ... │
│ 5 │ 10 │ │ buf[] │
└──────┴───────┴───────┴──────────────────┘
↑ SDS 포인터가 가리키는 위치
SDS의 장점
| 특성 | C 문자열 | SDS |
|---|---|---|
| 길이 조회 | O(N) | O(1) — len 필드 |
| 바이너리 안전 | X (\0에서 끊김) | O (len으로 길이 관리) |
| 버퍼 오버플로우 | 위험 | 자동 확장 |
| 재할당 횟수 | 매번 | 사전 할당으로 최소화 |
SDS 메모리 사전 할당
문자열 크기 < 1MB: 2배 할당
"Hello"(5바이트) → alloc = 10바이트 (5 * 2)
문자열 크기 >= 1MB: 기존 + 1MB 할당
2MB 문자열 → alloc = 3MB (2MB + 1MB)
이 전략으로 문자열이 자주 수정될 때 메모리 재할당 횟수를 줄입니다.
String의 세 가지 인코딩
Redis String은 값의 특성에 따라 세 가지 인코딩을 사용합니다.
1. int — 정수값
SET counter 12345
OBJECT ENCODING counter
# "int"
# 범위: LONG_MIN ~ LONG_MAX (64비트 정수)
# RedisObject 내부에 정수값을 직접 저장 (포인터 대신)
2. embstr — 짧은 문자열 (44바이트 이하)
SET name "Alice"
OBJECT ENCODING name
# "embstr"
# RedisObject(16바이트) + SDS 헤더 + 데이터를
# 하나의 연속 메모리 블록에 저장
# 메모리 할당 1회, CPU 캐시 친화적
┌─────────────────────────────────────────┐
│ RedisObject │ SDS header │ "Alice\0" │
│ 16 bytes │ 3 bytes │ 6 bytes │
└─────────────────────────────────────────┘
↑ 하나의 malloc() 호출로 할당
3. raw — 긴 문자열 (45바이트 이상)
SET long_str "a]very_long_string_that_is_more_than_44_bytes_long..."
OBJECT ENCODING long_str
# "raw"
# RedisObject와 SDS가 별도의 메모리 블록
# 메모리 할당 2회
┌──────────────┐ ┌──────────────────────┐
│ RedisObject │────→│ SDS header + 데이터 │
│ 16 bytes │ ptr │ │
└──────────────┘ └──────────────────────┘
malloc #1 malloc #2
인코딩 전환
# int → embstr/raw: 문자열 연산 시
SET num 123
OBJECT ENCODING num # "int"
APPEND num "abc" # "123abc"
OBJECT ENCODING num # "raw"
# embstr → raw: 수정 시 (embstr은 읽기 전용)
SET name "Alice"
OBJECT ENCODING name # "embstr"
APPEND name "!" # "Alice!"
OBJECT ENCODING name # "raw" (embstr은 수정 불가이므로 raw로 전환)
embstr은 읽기 전용입니다. 수정이 필요하면 raw로 전환된 후 수정됩니다.
INCR/DECR — 원자적 카운터
# 원자적 증가 (키가 없으면 0에서 시작)
SET page:views 0
INCR page:views # 1
INCR page:views # 2
INCRBY page:views 10 # 12
# 원자적 감소
DECR stock:item:123 # -1 (키가 없으면 0에서 시작)
DECRBY stock:item:123 5 # -6
# 부동소수점 증가
SET price 9.99
INCRBYFLOAT price 0.01 # "10"
INCRBYFLOAT price -1.5 # "8.5"
왜 원자적인가?
Redis는 싱글 스레드로 명령어를 처리하므로, INCR 실행 중에 다른 명령어가 끼어들 수 없습니다.
Thread A: INCR counter → 읽기(0) → 증가(1) → 저장(1)
Thread B: INCR counter → 읽기(1) → 증가(2) → 저장(2)
↑ A가 끝난 후에야 B가 실행됨
실전 패턴
패턴 1: 분산 락 (SET NX EX)
# 락 획득 시도
SET lock:order:123 "worker-1" NX EX 30
# OK → 락 획득 성공
# (nil) → 이미 다른 워커가 락을 보유
# 작업 완료 후 락 해제 (본인이 가진 락만 해제)
# Lua 스크립트로 원자적 확인 + 삭제
EVAL "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end" 1 lock:order:123 "worker-1"
패턴 2: 캐시 (SET EX)
import redis
import json
r = redis.Redis(decode_responses=True)
def get_user(user_id):
cache_key = f"cache:user:{user_id}"
# 캐시 확인
cached = r.get(cache_key)
if cached:
return json.loads(cached)
# DB에서 조회
user = db.query(f"SELECT * FROM users WHERE id = {user_id}")
# 캐시 저장 (1시간 TTL + 랜덤 지터)
import random
ttl = 3600 + random.randint(0, 300)
r.set(cache_key, json.dumps(user), ex=ttl)
return user
패턴 3: Rate Limiting (INCR + EXPIRE)
def is_rate_limited(user_id, limit=100, window=60):
key = f"rate:{user_id}:{int(time.time()) // window}"
current = r.incr(key)
if current == 1:
r.expire(key, window) # 첫 요청 시 TTL 설정
return current > limit
패턴 4: GETSET / GETDEL
# 이전 값을 가져오면서 새 값 설정 (6.2+에서는 SET ... GET 사용)
SET counter 100
GETSET counter 0 # 100 반환, counter는 0으로 리셋
# 6.2+: SET counter 0 GET
# 값을 가져오면서 삭제 (6.2+)
GETDEL temp:data # 값 반환 후 키 삭제
MGET/MSET — 배치 연산
# 여러 키를 한 번에 설정
MSET user:1:name "Alice" user:1:age "30" user:1:city "Seoul"
# 여러 키를 한 번에 조회
MGET user:1:name user:1:age user:1:city
# 1) "Alice"
# 2) "30"
# 3) "Seoul"
# MSETNX: 모든 키가 없을 때만 설정 (원자적)
MSETNX key1 "v1" key2 "v2"
# 하나라도 존재하면 전체가 실패
MGET/MSET은 파이프라이닝보다 간단하지만, 키 수가 많으면 서버를 블로킹할 수 있습니다. 적절한 배치 크기(100~1000개)를 유지하는 것이 좋습니다.
SUBSTR / GETRANGE / SETRANGE
문자열의 부분 조작도 가능합니다.
SET greeting "Hello, World!"
# 부분 문자열 조회
GETRANGE greeting 0 4 # "Hello"
GETRANGE greeting 7 -1 # "World!"
# 부분 덮어쓰기
SETRANGE greeting 7 "Redis" # "Hello, Redis!"
# 문자열 추가
APPEND greeting " :)" # "Hello, Redis! :)"
# 문자열 길이
STRLEN greeting # 16
메모리 사용량 비교
같은 데이터를 다른 방식으로 저장했을 때의 메모리 차이입니다.
# 방법 1: 각 필드를 별도 String으로 저장
SET user:1:name "Alice" # ~56 bytes (키 오버헤드)
SET user:1:age "30" # ~56 bytes
SET user:1:email "a@b.c" # ~64 bytes
# 총: ~176 bytes
# 방법 2: Hash로 저장 (원소가 적으면 listpack 인코딩)
HSET user:1 name "Alice" age "30" email "a@b.c"
# 총: ~100 bytes (하나의 키 오버헤드)
# 방법 3: JSON 직렬화하여 String으로 저장
SET user:1 '{"name":"Alice","age":30,"email":"a@b.c"}'
# 총: ~100 bytes (하나의 키, 하지만 부분 업데이트 불가)
소규모 객체 데이터는 Hash가 메모리 효율적이고 부분 업데이트도 가능합니다.
정리
Redis String은 단순해 보이지만 내부에 깊은 최적화가 숨어 있습니다.
- SDS는 O(1) 길이 조회, 바이너리 안전, 버퍼 사전 할당을 제공합니다
- 인코딩은 int(정수), embstr(44바이트 이하), raw(45바이트 이상)로 자동 선택됩니다
- INCR/DECR은 싱글 스레드 덕분에 자연스럽게 원자적입니다
SET NX EX는 분산 락,SET EX는 캐시,INCR + EXPIRE는 속도 제한의 기본 패턴입니다- 여러 필드가 있는 객체 데이터는 String보다 Hash 사용을 고려해보는 것이 좋습니다