ElastiCache Redis 클러스터 샤딩 설정 — 단일 노드에서 클러스터 모드로 전환한 3개월 회고

목차

변경 전: cache.r6g.xlarge 단일 노드 1대. 메모리 26GB 중 22GB 사용. p99 응답 지연 18ms 부근. OOM 알람이 주 2회.

변경 후: cache.r6g.large × 3샤드 × 2복제(replica). 샤드당 13GB, 총 39GB 가용. p99 응답 지연 7ms대. OOM 알람 0회.

그러나, ElastiCache Redis 클러스터 샤딩 설정으로 단일 노드를 클러스터 모드로 전환한 결과다. 3개월 걸렸다. 빠르진 않았다.

결국, 이 글은 그 3개월을 시간순으로 풀어본 회고다. 잘된 점만 정리하지 않는다. 중간에 MOVED 리다이렉트로 막혔던 구간, 클라이언트 라이브러리 호환성에서 헤맨 시간, Failover 검증을 너무 늦게 한 후회까지 같이 적었다. 비슷한 전환을 앞둔 사람이라면 같은 길을 다시 걷지 않아도 되도록.

1개월 차 — 단일 노드의 한계와 샤드 설계

예를 들어, 1개월 차에는 코드를 한 줄도 바꾸지 않았다. 측정만 했다. 단일 노드를 클러스터로 쪼갠다고 해서 알아서 잘 분산되는 건 아니다. 키가 어떻게 분포돼 있는지, 어떤 키 패턴이 핫스팟을 만드는지 먼저 봐야 한다. 보수적인 입장이라면 더더욱 그렇다. 잘 도는 단일 노드를 굳이 건드릴 이유가 없다면 안 건드리는 게 맞다. 다만 OOM 알람이 주 2회씩 울리고 있었으니 더 미룰 수 없는 상태였다.

키 분포 측정

# 키 패턴별 메모리 사용량 추출
redis-cli -h prod-cache.xxx.cache.amazonaws.com --bigkeys
redis-cli -h prod-cache.xxx.cache.amazonaws.com --memkeys --memkeys-samples 1000

# INFO memory로 사용량 추세 확인
redis-cli -h prod-cache.xxx.cache.amazonaws.com INFO memory | grep used_memory_human

--bigkeys는 가장 큰 키를, --memkeys는 메모리 사용량 상위 키를 뽑아준다. 둘 다 SCAN 기반이라 운영 중에도 돌릴 수 있다. 측정 결과 한 사용자(데이터의 30%를 차지하는 VIP 계정)가 핫스팟이라는 게 드러났다. 이 정보가 나중에 샤드 분포 문제로 이어지는 복선이 된다.

샤드 수 결정

이처럼, 샤드 수를 정할 때 후보가 셋 있었다. 비용과 운영 복잡도를 같이 봤다.

구성 노드 사양 총 메모리 시간당 비용(서울 리전, 작성 시점 기준) 샤드 수
A r6g.large × 3 + 복제 3 39GB $0.252 × 6 = $1.512 3
B r6g.xlarge × 2 + 복제 2 52GB $0.504 × 4 = $2.016 2
C r6g.large × 4 + 복제 4 52GB $0.252 × 8 = $2.016 4

(가격은 2026년 5월 기준이며, 정확한 금액은 AWS 콘솔 또는 ElastiCache Pricing 페이지에서 확인하는 게 안전하다.)

즉, A를 골랐다. 비용이 가장 낮으면서 샤드 수가 충분했다. 4샤드는 운영 복잡도만 올라간다고 봤다. 2샤드는 한 샤드가 죽었을 때 절반이 영향받는 게 부담이었다. 3샤드는 절충점에 가까웠다.

키 해시태그 설계

같은 사용자의 데이터가 같은 슬롯에 들어가게 하려면 해시태그를 써야 한다. Redis는 기본적으로 키 전체를 해시해서 0~16383 슬롯 중 하나에 매핑한다. 키에 {...}이 있으면 중괄호 안만 해시한다.

# 변경 전: 같은 user_id의 데이터가 다른 샤드로 흩어짐
user:profile:123
user:cart:123
user:session:123

# 변경 후: 같은 슬롯에 모여 MULTI/EXEC, 파이프라인 동작 보장
user:profile:{123}
user:cart:{123}
user:session:{123}

이처럼, 이걸 안 하면 트랜잭션을 못 쓴다. CROSSSLOT 에러가 나온다. 자세한 슬롯 매핑 규칙은 Redis Cluster Specification에 정리되어 있다. 내부적으로 CRC16 모듈로 16384를 쓴다.

2개월 차 — 클러스터 모드 활성화와 슬롯 분배

그러나, 설계가 끝났으니 만들 차례다. ElastiCache는 콘솔에서도 만들 수 있지만 CLI로 적어두면 재현이 쉽다. 그리고 옵션 하나 빠뜨리면 처음부터 다시 만들어야 하는 구조라 명세를 코드로 두는 게 안전하다.

aws elasticache create-replication-group \
  --replication-group-id prod-cache-cluster \
  --replication-group-description "Production Redis Cluster" \
  --engine redis \
  --engine-version 7.1 \
  --cache-node-type cache.r6g.large \
  --num-node-groups 3 \
  --replicas-per-node-group 2 \
  --automatic-failover-enabled \
  --multi-az-enabled \
  --cache-parameter-group-name default.redis7.cluster.on \
  --cache-subnet-group-name prod-subnet-group \
  --security-group-ids sg-xxxxxxxx \
  --transit-encryption-enabled \
  --at-rest-encryption-enabled

핵심 옵션 두 개를 강조한다.

  • --num-node-groups 3 — 샤드 수
  • --cache-parameter-group-name default.redis7.cluster.on — 클러스터 모드 활성화 파라미터 그룹

여기서 한 번 막혔다. 처음에 default.redis7(클러스터 모드 OFF)로 만들었더니 --num-node-groups가 1로 강제되더라. 파라미터 그룹의 cluster-enabled 값이 yes여야 한다. ElastiCache는 생성 후 이 파라미터를 못 바꾼다. 다시 만들어야 한다는 뜻이다. 시행착오는 이 한 번이 처음이자 마지막이었다.

클러스터 상태 확인

반면, 생성 후 클러스터가 제대로 슬롯을 나눠 가졌는지 확인한다.

redis-cli -h prod-cache-cluster.xxx.clustercfg.cache.amazonaws.com -p 6379 \
  --tls CLUSTER NODES

redis-cli -h prod-cache-cluster.xxx.clustercfg.cache.amazonaws.com -p 6379 \
  --tls CLUSTER SLOTS

CLUSTER NODES 결과는 마스터/복제 관계와 슬롯 범위를 보여준다. 정상이라면 마스터 3개가 0~5460, 5461~10922, 10923~16383을 각각 나눠 가진다. 셋 다 합쳐서 16384 슬롯을 빠짐없이 채워야 한다. 비어 있는 슬롯이 있으면 그 슬롯에 해당하는 키 요청이 모두 실패한다.

데이터 이관

따라서, 데이터 이관은 redis-cli로 직접 SCAN + MIGRATE를 못 한다. ElastiCache는 MIGRATE 명령을 차단한다. 보안 정책이라 우회할 방법이 없다.

예를 들어, 대신 두 가지 방법이 있다.

  1. 백업/복원: 단일 노드의 RDB 스냅샷을 새 클러스터를 만들 때 시드로 사용
  2. 이중 쓰기 + 비동기 복제: 애플리케이션이 새 클러스터에도 같이 쓰고, 백그라운드 워커가 기존 데이터를 복사

물론, 1번을 골랐다. 짧은 정지를 감수했다. 유지보수 윈도우를 잡고 12분짜리 정지 후 컷오버했다. 이중 쓰기 방식은 코드 변경 범위가 너무 컸다. 캐시 데이터의 짧은 정지가 서비스 전체 정지로 이어지지 않는 구조였기에 가능했던 선택이다.

# 단일 노드의 자동 스냅샷을 시드로 사용
aws elasticache create-replication-group \
  --replication-group-id prod-cache-cluster \
  --snapshot-name automatic.prod-cache-2026-04-15 \
  --engine redis \
  --engine-version 7.1 \
  --cache-node-type cache.r6g.large \
  --num-node-groups 3 \
  --replicas-per-node-group 2 \
  --cache-parameter-group-name default.redis7.cluster.on

스냅샷이 클러스터 모드 OFF에서 ON으로 시드 가능한지가 관건이었다. AWS 공식 문서에 따르면 가능하다(ElastiCache Restore from Backup, 2026년 5월 기준). 스냅샷에 들어 있는 키 분포가 새 클러스터의 슬롯에 맞춰 자동으로 재분배된다. 검증을 위해 스테이징에서 한 번 더 돌려보고 운영에 적용했다.

MOVED 리다이렉트와 클라이언트 호환성

한편, 여기서 가장 많이 헤맸다. 시행착오의 9할이 이 구간이다.

클러스터 모드의 핵심 동작은 이렇다. 클라이언트가 슬롯 6824에 해당하는 키를 샤드 1에 요청한다. 그런데 그 키는 샤드 2에 있다. 그러면 샤드 1이 MOVED 6824 10.0.2.15:6379 응답을 보낸다. 클라이언트는 다시 그 주소로 요청해야 한다.

예를 들어, 문제는 클라이언트 라이브러리가 이걸 알아서 처리해줘야 한다는 것이다. 안 그러면 매번 MOVED 에러를 받아서 애플리케이션 레벨에서 깨진다. 게다가 ASK 리다이렉트라는 게 따로 있다. 슬롯 마이그레이션 중일 때 임시로 다른 노드를 가리키는 응답이다. MOVED는 영구적, ASK는 일회성. 라이브러리가 둘 다 처리해야 한다.

Python (redis-py)

# 변경 전 — 단일 노드용
import redis
r = redis.Redis(host='prod-cache.xxx.cache.amazonaws.com', port=6379)

# 변경 후 — 클러스터용
from redis.cluster import RedisCluster
r = RedisCluster(
    host='prod-cache-cluster.xxx.clustercfg.cache.amazonaws.com',
    port=6379,
    decode_responses=True,
    skip_full_coverage_check=False,
    ssl=True,  # transit encryption ON일 때 필수
    ssl_cert_reqs=None,
)

clustercfg 엔드포인트를 써야 한다. 일반 엔드포인트는 단일 노드용이다. 클러스터용 Configuration Endpoint는 DNS가 모든 노드를 라운드로빈으로 돌려주는 구조여서, 클라이언트가 한 번 연결하면 토폴로지를 받아 직접 노드와 통신한다.

redis-py 4.2 이상에서 cluster 지원이 통합됐다. 그 전 버전이라면 redis-py-cluster 패키지를 따로 써야 한다. 작성 시점 기준으로는 redis-py 5.x를 쓰는 게 자연스럽다.

Java (Lettuce)

RedisClusterClient client = RedisClusterClient.create(
    RedisURI.Builder.redis("prod-cache-cluster.xxx.clustercfg.cache.amazonaws.com")
        .withPort(6379)
        .withSsl(true)
        .build()
);

client.setOptions(ClusterClientOptions.builder()
    .topologyRefreshOptions(ClusterTopologyRefreshOptions.builder()
        .enablePeriodicRefresh(Duration.ofSeconds(30))
        .enableAllAdaptiveRefreshTriggers()
        .build())
    .build());

Java 쪽은 토폴로지 자동 갱신을 켜야 한다. Failover로 마스터가 바뀌면 클라이언트가 모르고 옛날 마스터로 보내다가 타임아웃이 난다. 30초 주기 갱신 + adaptive refresh trigger 조합으로 안정화됐다. 이 설정 없이 갔다면 운영 첫날 알람이 쏟아졌을 가능성이 높다.

파이프라인과 트랜잭션

즉, 파이프라인은 같은 슬롯에 있는 키만 묶을 수 있다. 다른 슬롯이면 CROSSSLOT 에러가 난다. 해시태그를 미리 걸어둔 이유가 여기에 있다.

pipe = r.pipeline()
pipe.set('user:profile:{123}', 'A')
pipe.set('user:cart:{123}', 'B')
pipe.execute()  # 같은 슬롯이라 OK

pipe.set('user:profile:{123}', 'A')
pipe.set('user:profile:{456}', 'B')
pipe.execute()  # CROSSSLOT 에러

KEYS, SCAN 같은 명령도 한 노드에서만 돌아간다. 클러스터 전체를 스캔하려면 노드별로 따로 돌려야 한다. 운영 도구를 다시 짜야 했다. 캐시 정리 스크립트를 노드별 SCAN으로 다시 작성하는 데 며칠이 들었다.

3개월 차 — Failover 검증과 컷오버

그러나, 여기서 늦게 한 걸 후회했다. Failover 검증은 이관 직전이 아니라 이관 직후, 아니 빈 클러스터를 만들자마자 했어야 했다.

ElastiCache의 자동 Failover는 마스터 노드가 죽으면 복제 노드 중 하나가 마스터로 승격되는 동작이다. 그런데 이게 얼마나 걸리는지, 그동안 클라이언트가 어떻게 동작하는지 측정 안 하면 운영 들어가서 터진다. 검증의 비용은 작고, 검증 안 한 후폭풍의 비용은 크다.

# 강제 Failover 테스트
aws elasticache test-failover \
  --replication-group-id prod-cache-cluster \
  --node-group-id 0001

체감상 30~60초 사이에 클라이언트가 일부 요청에서 타임아웃이 났다. 이게 클라이언트의 토폴로지 갱신 주기와 관련이 있다. Lettuce의 30초 주기 갱신을 안 켰다면 더 길어졌을 거다. 같은 테스트를 샤드별로 한 번씩 총 3번 돌리고 모든 노드의 승격 동작을 확인했다.

Pub/Sub과 Lua 스크립트

결국, 이건 간단하다. 클러스터 모드에서 Pub/Sub은 PUBLISH 명령이 모든 노드에 브로드캐스트된다. Lua 스크립트는 단일 슬롯 내의 키만 다룰 수 있다. 둘 다 사전에 걸린 게 거의 없어서 그대로 동작했다. 새로 추가된 sharded Pub/Sub(SPUBLISH/SSUBSCRIBE)도 있긴 한데, 기존 코드를 바꿀 만한 이유가 약해서 미뤘다.

운영에 들어가서 터진 의외의 것

여기서 한 번 놀랐다.

이처럼, CloudWatch에서 EngineCPUUtilization이 갑자기 한 샤드만 80%를 찍었다. 다른 두 샤드는 30% 부근. 분포가 균등하지 않았다. 균등 분포를 가정했던 게 무너지는 순간이다.

원인은 키 해시태그였다. {user_id} 패턴을 적용했는데, 1개월 차 측정에서 봤던 그 VIP 사용자가 데이터의 30%를 가지고 있었다. 그 키가 모두 같은 슬롯에 묶이니 한 샤드에 몰린 것이다. 측정은 했는데 설계에 반영을 안 한 게 문제였다.

해결책 두 가지가 떠올랐다.

  1. VIP 사용자만 해시태그 분리: user:profile:{123}user:profile:{123:shard0}, user:profile:{123:shard1} 같이 더 잘게 쪼개고 애플리케이션 레벨에서 조합
  2. Read replica 활용: 읽기 트래픽을 복제 노드로 분산해서 마스터 부담을 낮춤

예를 들어, 2번을 먼저 적용했다. 코드 변경이 적었기 때문이다. read_from_replicas=True 옵션으로 클라이언트가 복제 노드에서 읽게 했다.

r = RedisCluster(
    host='prod-cache-cluster.xxx.clustercfg.cache.amazonaws.com',
    port=6379,
    read_from_replicas=True,
    decode_responses=True,
    ssl=True,
)

특히, 읽기 트래픽이 분산되면서 한 샤드의 80%가 50% 부근으로 떨어졌다. 1번 해시태그 재설계는 다음 분기로 미뤘다. 동작은 안정화됐고 알람은 멈췄다. 완벽한 해결은 아니지만 운영을 굴리기엔 충분한 상태다.

모니터링 체크리스트

물론, 운영 들어가면서 CloudWatch에서 보는 메트릭이 몇 개 더 늘었다. 클러스터 모드 특유의 지표들이다.

  • EngineCPUUtilization — 샤드별로 따로 보기. 평균이 아니라 max를 봐야 핫샤드를 찾는다
  • DatabaseMemoryUsagePercentage — 샤드별로 분포가 균등한지 확인
  • ReplicationLag — 복제 지연. 보통 1초 이내. 그 이상이면 마스터가 너무 바쁘다는 신호
  • IsMaster — Failover 후 마스터가 어디로 옮겨갔는지

알람은 샤드별 임계값으로 따로 잡아야 한다. 평균 메트릭만 보면 한 샤드가 터져도 평균은 멀쩡해 보인다.

다음엔 이렇게 할 것

이번에 헤맨 구간을 다음 프로젝트에선 안 거치고 싶다. 액션 3개로 정리한다.

  1. 클러스터 모드를 기본으로 시작한다. 작은 트래픽이라도 1샤드 클러스터로 만들어두면 나중에 확장이 쉽다. 단일 노드에서 클러스터로 옮기는 비용이 처음부터 클러스터로 시작하는 비용보다 훨씬 크다. 보수적인 입장에서도 이건 양보가 된다.
  2. Failover 테스트를 데이터 이관 전에 한다. 빈 클러스터 상태에서 test-failover를 돌리고 클라이언트 동작을 측정하면 컷오버 후 첫 알람 때 당황하지 않는다. 토폴로지 갱신 주기, 타임아웃 길이가 운영 들어가기 전에 검증된다.
  3. 키 분포를 사전 측정하고 설계에 반영한다. --bigkeys, --memkeys로 핫키를 미리 찾고 해시태그 설계를 그 분포에 맞춘다. 균등 분포 가정은 위험하다. 측정만 하고 설계에 반영 안 하면 측정이 의미가 없다.

반면, 다음엔 ElastiCache Serverless를 한 번 시험해볼 생각이다. 자동 샤딩과 자동 스케일이 어디까지 잘 되는지, 비용 모델이 실제 운영에서 합리적인지 보고 싶다.

관련 글