목차
- 1개월 차 — 단일 노드의 한계와 샤드 설계
- 2개월 차 — 클러스터 모드 활성화와 슬롯 분배
- MOVED 리다이렉트와 클라이언트 호환성
- 3개월 차 — Failover 검증과 컷오버
- 운영에 들어가서 터진 의외의 것
- 다음엔 이렇게 할 것
변경 전: 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 명령을 차단한다. 보안 정책이라 우회할 방법이 없다.
예를 들어, 대신 두 가지 방법이 있다.
- 백업/복원: 단일 노드의 RDB 스냅샷을 새 클러스터를 만들 때 시드로 사용
- 이중 쓰기 + 비동기 복제: 애플리케이션이 새 클러스터에도 같이 쓰고, 백그라운드 워커가 기존 데이터를 복사
물론, 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%를 가지고 있었다. 그 키가 모두 같은 슬롯에 묶이니 한 샤드에 몰린 것이다. 측정은 했는데 설계에 반영을 안 한 게 문제였다.
해결책 두 가지가 떠올랐다.
- VIP 사용자만 해시태그 분리:
user:profile:{123}→user:profile:{123:shard0},user:profile:{123:shard1}같이 더 잘게 쪼개고 애플리케이션 레벨에서 조합 - 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샤드 클러스터로 만들어두면 나중에 확장이 쉽다. 단일 노드에서 클러스터로 옮기는 비용이 처음부터 클러스터로 시작하는 비용보다 훨씬 크다. 보수적인 입장에서도 이건 양보가 된다.
- Failover 테스트를 데이터 이관 전에 한다. 빈 클러스터 상태에서
test-failover를 돌리고 클라이언트 동작을 측정하면 컷오버 후 첫 알람 때 당황하지 않는다. 토폴로지 갱신 주기, 타임아웃 길이가 운영 들어가기 전에 검증된다. - 키 분포를 사전 측정하고 설계에 반영한다.
--bigkeys,--memkeys로 핫키를 미리 찾고 해시태그 설계를 그 분포에 맞춘다. 균등 분포 가정은 위험하다. 측정만 하고 설계에 반영 안 하면 측정이 의미가 없다.
반면, 다음엔 ElastiCache Serverless를 한 번 시험해볼 생각이다. 자동 샤딩과 자동 스케일이 어디까지 잘 되는지, 비용 모델이 실제 운영에서 합리적인지 보고 싶다.
관련 글
- DynamoDB TTL 설정 TIL — 100만 건 자동 삭제, 비용 0원의 이면 – DynamoDB TTL 설정의 진짜 가치는 비용보다 운영 부담 제거에 있다. 자동 삭제 메커니즘과 실수하기 쉬운 함정 네 가지를 정리한 T…
- EKS KEDA 설치 설정 회고: SQS·Kafka 트리거로 오토스케일링 다시 설계한 3개월 – HPA만으로 버티던 EKS 워커 클러스터에 KEDA를 도입한 3개월의 기록이다. SQS와 Kafka Lag 기반으로 스케일링을 다시 설계했…
- EKS 비용 최적화 실전: Karpenter, Spot, 리소스 요청 3단계 비교 – EKS 월 청구서가 세 배 가까이 뛴 뒤 Karpenter 전환, Spot 혼합, 리소스 요청 튜닝을 3단계로 적용해 약 40% 줄인 기록…