배경
운영 중인 Django 백엔드의 Redis 캐시가 ElastiCache 클러스터(Primary 1 + Replica 2)로 구성되어 있었지만, 설정이 Primary Endpoint 하나만 가리키고 있어서 모든 읽기/쓰기 트래픽이 Primary 노드에 집중되고 있었다. ElastiCache가 제공하는 Reader Endpoint를 활용해 읽기는 Replica로, 쓰기는 Primary로 분산하는 작업을 했다.
1. ElastiCache 엔드포인트 종류
ElastiCache Redis 클러스터를 생성하면 세 종류의 엔드포인트가 제공된다.
| 엔드포인트 | 연결 대상 | 용도 |
|---|---|---|
| Primary Endpoint | Primary 노드 고정 | 읽기 + 쓰기 |
Reader Endpoint (-ro) |
Replica 노드들 (라운드로빈) | 읽기 전용 |
개별 노드 Endpoint (-001, -002...) |
특정 노드 고정 | 직접 접근 (권장하지 않음) |
Reader Endpoint 동작 원리
Reader Endpoint는 DNS 레벨 로드밸런서다. 클라이언트가 DNS 조회를 할 때마다 AWS Route 53이 살아있는 Replica 노드의 IP를 라운드로빈으로 반환한다.
클라이언트 → my-cluster-ro.xxxx.cache.amazonaws.com
↓ DNS 조회
AWS Route 53
↙ ↘
Replica-002 IP Replica-003 IP (번갈아 반환)
장점:
- 코드에서 노드 IP를 직접 관리할 필요 없음
- Primary 장애 발생 시 Replica 중 하나가 Primary로 승격되어도 Reader Endpoint는 남은 Replica를 자동으로 바라봄 → 다운타임 최소화
개별 노드 엔드포인트(
-001,-002)를 직접 사용하면 Failover 때 자동으로 전환되지 않아서 위험하다.
2. Django에서 복수의 캐시 백엔드 설정
Django는 settings.py의 CACHES 딕셔너리에 여러 캐시 백엔드를 동시에 등록할 수 있다. 키가 "default" 인 백엔드가 cache 객체(from django.core.cache import cache)에 해당한다.
# settings.py
REDIS_URL = os.getenv("REDIS_URL") # Primary Endpoint
REDIS_READER_URL = os.getenv("REDIS_READER_URL") # Reader Endpoint
if REDIS_URL:
CACHES = {
# 쓰기 전용 — Primary Endpoint
"default": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": REDIS_URL,
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
"SOCKET_CONNECT_TIMEOUT": 5,
"SOCKET_TIMEOUT": 5,
},
},
# 읽기 전용 — Reader Endpoint (Replica)
# REDIS_READER_URL 미설정 시 Primary로 fallback
"replica": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": REDIS_READER_URL if REDIS_READER_URL else REDIS_URL,
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
"SOCKET_CONNECT_TIMEOUT": 5,
"SOCKET_TIMEOUT": 5,
},
},
}
else:
# 로컬 개발 환경 — 동일 location의 LocMemCache로 read-after-write 보장
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
"LOCATION": "unique-snowflake",
},
"replica": {
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
"LOCATION": "unique-snowflake", # 같은 location → 같은 인메모리 저장소 공유
},
}
로컬 환경에서 LocMemCache의 LOCATION 동작
LocMemCache는 LOCATION 값을 모듈 레벨 딕셔너리의 키로 사용한다. 따라서 같은 LOCATION을 지정하면 두 백엔드가 동일한 인메모리 저장소를 공유한다. 덕분에 로컬에서는 cache.set() 후 바로 replica_cache.get()이 가능하다(read-after-write 보장).
3. 읽기/쓰기 분리 적용 패턴
from django.core.cache import cache, caches
# 모듈 레벨에서 replica 캐시 객체 생성
replica_cache = caches["replica"]
def some_function():
cache_key = "my_key"
# 읽기 → Replica
cached = replica_cache.get(cache_key)
if cached:
return cached
# 원본 데이터 조회
data = fetch_from_source()
# 쓰기 → Primary
cache.set(cache_key, data, timeout=3600)
return data
Replication Lag을 허용할 수 있는 조건
Replica 읽기는 Primary에 쓴 데이터가 복제되기 전에 조회하면 이전 값이 나올 수 있다(Replication Lag). 아래 조건을 모두 만족하면 실용적으로 문제없다:
- TTL이 충분히 길다 — 짧은 TTL(수 초)이면 Lag이 상대적으로 크게 느껴질 수 있음
- 캐시 미스 시 원본에서 재조회하는 로직이 있다 — Graceful degradation이 이미 구현되어 있어서 최악의 경우 API 재호출로 복구 가능
4. 테스트 코드 조정 포인트
읽기를 replica_cache로 전환하면 캐시 히트 테스트에서 사전 데이터를 replica_cache에 삽입해야 한다.
from django.core.cache import cache, caches
replica_cache = caches["replica"]
class MyCacheTest(TestCase):
def setUp(self):
cache.clear()
replica_cache.clear() # 추가
def tearDown(self):
cache.clear()
replica_cache.clear() # 추가
def test_캐시_히트(self):
# 변경 전: cache.set(cache_key, data, timeout=600)
# 변경 후: 뷰가 replica_cache.get()으로 읽으므로 replica에 삽입
replica_cache.set(cache_key, data, timeout=600)
response = self.client.get(...)
self.assertEqual(response.status_code, 200)
로컬 테스트 환경에서는 "default"와 "replica" 모두 동일한 LocMemCache 저장소를 공유하므로 cache.set()과 replica_cache.set()이 사실상 동일하게 동작한다. 그럼에도 의미적 명확성을 위해 테스트 코드도 맞춰주는 것이 좋다.
5. CloudWatch로 검증하기
배포 후 실제로 Replica 노드에 연결이 맺어지고 있는지 AWS CloudWatch에서 확인할 수 있다.
CLI로 노드별 CurrConnections 조회
for node in my-cluster-001 my-cluster-002; do
echo "=== $node ==="
aws cloudwatch get-metric-statistics \
--namespace AWS/ElastiCache \
--metric-name CurrConnections \
--dimensions Name=CacheClusterId,Value=$node \
--start-time $(date -u -v-15M +%Y-%m-%dT%H:%M:%SZ) \
--end-time $(date -u +%Y-%m-%dT%H:%M:%SZ) \
--period 60 \
--statistics Average \
--region ap-northeast-2 \
--query 'sort_by(Datapoints, &Timestamp)[*].{Time:Timestamp,Avg:Average}' \
--output table
done
기대 결과
| 노드 | 분리 전 | 분리 후 |
|---|---|---|
| Primary (001) | 높음 | 낮음 (쓰기만) |
| Replica (002) | 0 또는 매우 낮음 | 높음 ✅ |
실제로 배포 후 확인한 결과, Primary는 5
7개, Replica는 37
38개의 연결을 유지하고 있었다. Replica의 연결 수가 훨씬 많은 이유는 캐시 읽기 요청이 모두 Reader Endpoint를 통해 Replica로 라우팅되고 있기 때문이다.
6. 오늘 새로 알게 된 것 요약
- ElastiCache의 Reader Endpoint는 DNS 라운드로빈으로 Replica에 부하를 분산하며, Failover 시에도 자동으로 살아있는 Replica를 가리킨다
- Django
CACHES에 여러 백엔드를 등록하면caches["이름"]으로 각각 접근 가능하다 LocMemCache는 같은LOCATION을 쓰면 인메모리 저장소를 공유한다 → 로컬 테스트에서 read-after-write 문제 없음- Replication Lag은 캐시 미스 시 원본 재조회 로직(Graceful degradation)이 있으면 실용적으로 허용 가능하다
django_redis의SOCKET_CONNECT_TIMEOUT/SOCKET_TIMEOUT옵션으로 Redis 연결 타임아웃을 설정할 수 있다
References
'🌈 프로그래밍 > 삽질' 카테고리의 다른 글
| AWS Lambda 환경에서 S3 Presigned URL이 만료되는 문제 해결기 (0) | 2026.02.19 |
|---|---|
| Obsidian Claudian 플러그인 설치 및 트러블슈팅 가이드 (0) | 2026.02.12 |
| 안드로이드 로컬 앱을 빌드하는 과정 (2) | 2024.11.17 |
| zsh: command not found: mysql (0) | 2023.01.10 |
| [ postgres ] DataTypeNotSupportedError: Data type "Object" in "~" is not supported by "postgres" database. (0) | 2021.12.02 |