Redis를 클러스터로 구성한다고 해서 하나의 Redis 노드가 멀티스레드로 명령을 처리하는 것은 아니다. 핵심은 각 노드는 여전히 주로 싱글스레드로 명령을 처리하지만, 데이터를 여러 노드에 나눠 담아 전체 처리량과 저장 용량을 늘린다는 점이다.

그래서 Redis Cluster를 이해할 때는 “싱글스레드인데 어떻게 확장하지?”라는 질문에서 시작하는 편이 좋다. 답은 노드 하나를 더 빠르게 만드는 것이 아니라, 키 공간을 여러 노드로 나누는 것이다.

클러스터여도 각 노드는 싱글스레드다

Redis는 명령 실행을 단순하고 빠르게 유지하기 위해 오랫동안 싱글스레드 모델을 사용해왔다. Redis 6 이후 I/O 멀티스레딩이 들어왔지만, 명령 실행 자체는 여전히 단일 스레드 모델을 기반으로 이해하는 것이 맞다.

┌─────────────┐  ┌─────────────┐  ┌─────────────┐
│  Node 1     │  │  Node 2     │  │  Node 3     │
│ Slot 0-5460 │  │ Slot 5461-  │  │ Slot 10923- │
│             │  │    10922    │  │    16383    │
└─────────────┘  └─────────────┘  └─────────────┘

클러스터 전체로 보면 여러 노드가 각자 맡은 슬롯의 요청을 처리하므로 처리량이 늘어난다. 하지만 특정 키 하나에 요청이 몰리면 그 키를 가진 노드가 병목이 될 수 있다. Redis Cluster가 모든 핫스팟을 자동으로 없애주는 것은 아니다.

키는 해시 슬롯으로 라우팅된다

Redis Cluster는 전체 키 공간을 16,384개의 해시 슬롯으로 나눈다. 클라이언트가 키를 요청하면 CRC16 해시를 계산하고, 그 값을 16,384로 나눈 나머지를 슬롯 번호로 사용한다. 각 슬롯은 특정 노드에 할당되어 있다.

def get_slot(key):
    return crc16(key) % 16384
 
 
def get_node(key, cluster_slots):
    slot = get_slot(key)
    for node, (start, end) in cluster_slots.items():
        if start <= slot <= end:
            return node
    raise Exception("Slot not found")

여러 키를 같은 슬롯에 넣어야 하는 경우에는 해시 태그를 사용할 수 있다.

user:{123}:profile  → slot = hash("{123}")
user:{123}:session  → slot = hash("{123}")

이렇게 하면 두 키가 같은 슬롯에 들어가므로 같은 슬롯 안에서만 가능한 멀티 키 연산을 사용할 수 있다. 다만 해시 태그를 과하게 쓰면 특정 슬롯으로 트래픽이 몰릴 수 있다.

MOVED와 ASK

클라이언트가 잘못된 노드로 요청을 보내면 Redis는 리다이렉션 응답을 돌려준다. MOVED는 슬롯이 완전히 다른 노드로 이동했다는 뜻이다. 클라이언트는 슬롯 맵을 갱신해야 한다.

-MOVED 3999 127.0.0.1:6381

ASK는 슬롯이 마이그레이션 중이라는 뜻이다. 일시적으로 다른 노드에 요청해야 하지만, 슬롯 맵을 영구적으로 바꾸면 안 된다.

-ASK 3999 127.0.0.1:6381

이런 동작 때문에 Redis Cluster를 사용할 때는 클러스터를 이해하는 Smart Client를 쓰는 경우가 많다. 클라이언트가 슬롯 맵을 캐시하고, 필요한 경우 MOVEDASK에 맞춰 요청을 다시 보낸다.

Set<HostAndPort> nodes = new HashSet<>();
nodes.add(new HostAndPort("redis1", 6379));
nodes.add(new HostAndPort("redis2", 6379));
nodes.add(new HostAndPort("redis3", 6379));
 
JedisCluster jedisCluster = new JedisCluster(nodes);

프록시를 앞에 두는 방식도 있다. 이 경우 클라이언트는 단순해지지만, 프록시가 추가 홉이 되고 장애 지점이 될 수 있다.

Client → Proxy → Redis Nodes

노드를 추가하거나 제거할 때

클러스터에 노드를 추가하면 끝이 아니다. 새 노드가 실제 트래픽을 받으려면 기존 노드가 가진 슬롯 일부를 새 노드로 옮겨야 한다. 이 과정을 리샤딩이라고 한다.

redis-cli --cluster add-node new_host:6379 existing_host:6379
redis-cli --cluster reshard existing_host:6379
redis-cli --cluster rebalance existing_host:6379

노드를 제거할 때도 마찬가지다. 먼저 그 노드가 가진 슬롯을 다른 노드로 옮긴 뒤 제거해야 한다.

redis-cli --cluster reshard existing_host:6379 \
  --cluster-from <node-id> \
  --cluster-to <target-node-id> \
  --cluster-slots <num>
 
redis-cli --cluster del-node existing_host:6379 <node-id>

운영 관점에서는 리샤딩 중 지연 시간 증가, 슬롯 이동 중 ASK 리다이렉션, 특정 노드의 메모리 사용량을 함께 봐야 한다.

복제는 장애 대응을 위한 장치다

클러스터가 데이터를 여러 노드에 나눠 담는 구조라면, 복제는 특정 노드 장애에 대비하는 구조다. 마스터 노드의 데이터를 복제본이 따라가고, 마스터에 장애가 나면 복제본을 승격할 수 있다.

복제를 직접 구현한다고 생각하면 기본 아이디어는 두 가지다. 먼저 현재 상태를 스냅샷으로 복제본에 전달하고, 이후 변경 명령을 로그로 계속 보내는 방식이다. Redis의 RDB와 AOF를 떠올리면 이해하기 쉽다.

class ReplicationLog:
    def __init__(self):
        self.log = []
        self.offset = 0
 
    def append(self, command):
        self.log.append({
            "offset": self.offset,
            "command": command,
            "timestamp": time.time(),
        })
        self.offset += 1
 
    def get_commands_since(self, offset):
        return [entry for entry in self.log if entry["offset"] > offset]

실제로는 네트워크 파티션, 복제 지연, 승격 시 데이터 유실 가능성까지 고려해야 한다. 복제본이 있다고 해서 항상 최신 데이터를 가진다는 뜻은 아니다.

Redis Cluster와 직접 샤딩

샤딩 방식은 크게 Range Sharding, Hash Sharding, Consistent Hashing으로 나눠볼 수 있다. Range Sharding은 범위 조회에 유리하지만 특정 범위에 트래픽이 몰리면 핫스팟이 생기기 쉽다. Hash Sharding은 데이터를 비교적 고르게 나눌 수 있지만 범위 조회에는 불리하고, 노드 수가 바뀔 때 데이터 이동이 커질 수 있다. Consistent Hashing은 노드 추가와 제거 시 이동량을 줄일 수 있지만 구현과 운영 복잡도가 올라간다.

Redis Cluster는 16,384개 슬롯을 기반으로 이런 샤딩 문제를 어느 정도 표준화한다. 운영은 단순해지고 클라이언트 생태계도 활용할 수 있다. 대신 같은 슬롯 안에서만 멀티 키 연산이 가능하고, 슬롯 구조의 제약을 받아야 한다.

애플리케이션에서 직접 샤딩하면 더 자유롭게 설계할 수 있지만, 슬롯 관리, 리밸런싱, 장애 대응을 모두 직접 책임져야 한다. 대부분의 경우에는 Redis Cluster를 먼저 고려하고, 특수한 요구가 있을 때 직접 샤딩을 고민하는 편이 안전하다.

Redis Cluster의 핵심은 “Redis가 멀티스레드가 된다”가 아니라 “키 공간을 나눠 여러 싱글스레드 노드가 함께 처리한다”는 점이다. 그래서 클러스터를 설계할 때는 슬롯 분포, 핫키, 리샤딩, 복제 지연, 클라이언트 라우팅을 함께 봐야 한다.