🌈 프로그래밍/삽질

ElastiCache Redis 읽기/쓰기 분리 (Primary / Replica)

수구리 2026. 2. 20. 17:15
반응형

배경

운영 중인 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.pyCACHES 딕셔너리에 여러 캐시 백엔드를 동시에 등록할 수 있다. 키가 "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 동작

LocMemCacheLOCATION 값을 모듈 레벨 딕셔너리의 키로 사용한다. 따라서 같은 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). 아래 조건을 모두 만족하면 실용적으로 문제없다:

  1. TTL이 충분히 길다 — 짧은 TTL(수 초)이면 Lag이 상대적으로 크게 느껴질 수 있음
  2. 캐시 미스 시 원본에서 재조회하는 로직이 있다 — 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_redisSOCKET_CONNECT_TIMEOUT / SOCKET_TIMEOUT 옵션으로 Redis 연결 타임아웃을 설정할 수 있다

References

반응형