🌈 프로그래밍/TIL

ElastiCache Redis Read/Write Splitting 적용기 - 장애에서 배운 이중화 전략

수구리 2026. 3. 5. 18:22
반응형

실제 프로덕션 Redis 장애를 겪고, 읽기/쓰기 분리를 적용하면서 배운 것들을 정리합니다.


배경: Redis 장애가 터졌다

어느 날 프로덕션 서비스가 전체적으로 느려졌다. API 응답 시간이 수십 배로 치솟고, Sentry에는 Redis timeout 에러가 쏟아졌다. 원인은 AWS ElastiCache t-type 인스턴스의 CPU Credit 소진이었다.

t-type 인스턴스는 CPU Credit 기반으로 동작한다. 평소에는 Credit을 적립하고, 트래픽이 몰리면 Credit을 소모하면서 버스트 성능을 제공한다. 그런데 이날은 트래픽 급증으로 Credit이 바닥나버렸고, CPU 성능이 baseline(5~40%)으로 제한되면서 Redis가 극도로 느려진 것이다.

Automatic Failover는 왜 동작하지 않았나

ElastiCache는 Primary-Replica 구조에서 Automatic Failover를 지원한다. Primary가 죽으면 Replica가 자동 승격되는 기능이다. 그런데 이번에는 발동하지 않았다.

이유는 단순했다. 노드가 죽지 않았기 때문이다.

노드 다운 / 네트워크 단절 → 헬스체크 응답 없음 → Failover 발동
CPU Credit 소진 → 느리지만 응답함 → "정상"으로 판단 → Failover 미발동

ElastiCache 입장에서는 느리더라도 응답하는 노드를 "정상"으로 보기 때문에, Credit 소진 상황에서는 Failover가 발동하지 않는다. 이건 상당히 위험한 맹점이다.

결국 수동으로 노드 타입을 업그레이드하여 해결했다.


이중화 작업: Read/Write Splitting

장애 이후, 두 가지를 개선하기로 했다.

  1. 읽기/쓰기 분리 — Primary의 부하를 줄여 Credit 소진 위험을 낮춘다
  2. 코드 레벨의 Redis 사용 패턴 정비 — 감사 중 발견한 버그들을 수정한다

구조 설계

기존에는 모든 Redis 작업이 Primary 하나를 통해 이루어졌다. Replica가 2대나 있었지만 읽기 트래픽을 전혀 받지 않고, 복제 수신만 하고 있었다.

Before:
  App → Primary (읽기 + 쓰기 모두)
        Replica (놀고 있음)
        Replica (놀고 있음)

After:
  App → Primary (쓰기만)
      → Replica (읽기)

싱글톤 패턴으로 Write Client와 Read Client를 분리 관리하도록 했다.

class _RedisSingleton:
    def __init__(self):
        self._write_client = None
        self._read_client = None
        self._write_lock = threading.Lock()
        self._read_lock = threading.Lock()

    def _create_client(self, host):
        return redis.Redis(
            host=host,
            port=settings.REDIS_PORT,
            decode_responses=True,
            socket_timeout=settings.REDIS_SOCKET_TIMEOUT,
            socket_connect_timeout=settings.REDIS_CONNECT_TIMEOUT,
            retry_on_timeout=True,
            # ...
        )

    def get_write_client(self):
        if self._write_client is None:
            with self._write_lock:
                if self._write_client is None:  # double-checked locking
                    self._write_client = self._create_client(settings.REDIS_URL)
        return self._write_client

    def get_read_client(self):
        if self._read_client is None:
            with self._read_lock:
                if self._read_client is None:
                    self._read_client = self._create_client(settings.REDIS_READER_URL)
        return self._read_client

환경 설정에서는 REDIS_READER_URL이 없으면 REDIS_URL로 fallback하도록 해서, 환경변수 미설정 시에도 안전하게 동작한다.

REDIS_READER_URL = os.getenv("PROD_REDIS_READER_URL", REDIS_URL)

하위 호환

기존에 get_redis_client()를 사용하던 코드가 있으므로, 이 함수는 write client를 반환하는 alias로 유지했다. 기존 코드가 깨지지 않으면서 점진적으로 마이그레이션할 수 있다.

def get_redis_client():
    """하위 호환용 alias — write client 반환"""
    return get_redis_write_client()

코드 감사 중 발견한 버그 3가지

이중화 작업을 위해 코드베이스 전체의 Redis 사용처를 감사하면서 3가지 버그를 발견했다.

1. SET과 EXPIRE를 따로 호출하고 있었다

# Before — 두 명령 사이에 프로세스가 죽으면 TTL 없는 키가 영원히 남음
r.set(key, value)
r.expire(key, timedelta(days=1))

# After — 원자적 처리
r.set(key, value, ex=86400)

SETEXPIRE를 별도로 호출하면, 두 명령 사이에 프로세스가 죽거나 예외가 발생할 경우 TTL이 설정되지 않은 키가 영구적으로 남는다. ex= 파라미터를 사용하면 한 번의 명령으로 처리된다.

2. 읽기인데 Primary를 사용하고 있었다

순수 조회 로직인데 Primary에 연결하고 있어서 Replica가 놀고 있었다. Read Client로 전환했다.

3. 캐시 히트 시 응답 타입 불일치

캐시에 저장할 때는 json.dumps(data["avg"])로 값만 저장하는데, 캐시에서 꺼낼 때는 JSON 문자열을 그대로 반환하고 있었다. 캐시 miss 시에는 {"avg": 값} 구조를 반환하므로 타입이 불일치했다.

# Before — 캐시 히트 시 JSON 문자열 그대로 반환
if cached_value is not None:
    result = cached_value  # 문자열이 그대로 응답에 포함됨

# After — 역직렬화 + 구조 래핑
if cached_value is not None:
    result = {"avg": json.loads(cached_value)}

클라이언트 선택 기준: 언제 Read, 언제 Write?

모든 사용처를 분류하면서 정리한 기준이다.

Read Client (Replica)

순수 조회 로직에 사용한다. Cache-aside 패턴에서 캐시를 읽을 때가 대표적이다.

r = get_redis_read_client()
cached = r.get("stats:daily")
if cached is not None:
    return json.loads(cached)

Replication lag으로 인해 Primary에 쓴 직후 Replica에서 읽으면 데이터가 없을 수 있다. 하지만 Cache-aside 패턴에서는 cache miss 시 DB에서 다시 계산하므로 서비스에 영향이 없다.

Write Client (Primary)

쓰기 연산(SET, DELETE, HSET, ZADD 등)에 사용한다.

중요한 것은 Read-then-Write 패턴이다. 읽은 값을 기반으로 쓰기 여부를 결정하는 경우, 반드시 Write Client 하나로 읽기와 쓰기를 모두 처리해야 한다.

# Read-then-Write — 반드시 write client로 통일
w = get_redis_write_client()
existing = w.get(key)
if existing:
    return existing
w.set(key, new_value, ex=259200)

만약 Read Client로 읽고 Write Client로 쓰면, replication lag 동안 "없다"고 판단해서 중복 생성하는 race condition이 발생할 수 있다.

분산 락

분산 락은 당연히 Write Client를 사용해야 한다. SET NX로 락을 획득하고, Lua 스크립트로 안전하게 해제하는 패턴이다.

with redis_lock("user:123:refund", timeout=10):
    process_refund(user_id=123)

Failover 테스트

이중화를 적용한 후, 실제로 Failover가 동작하는지 DEV 환경에서 검증했다.

aws elasticache test-failover \
  --replication-group-id <replication-group-id> \
  --node-group-id 0001

여기서 --node-group-id개별 노드가 아니라 샤드(노드 그룹)를 가리킨다. ElastiCache가 건강한 Replica를 자동으로 선택해서 Primary로 승격한다.

클러스터 구조:
└── Node Group 0001 (샤드)          ← --node-group-id는 이것
    ├── node-001  ← Primary         ← 개별 노드 ID와 다름
    ├── node-002  ← Replica
    └── node-003  ← Replica

테스트 결과

항목 결과
Failover 소요 시간 ~30초
애플리케이션 재연결 ~3분 후 안정화
데이터 유실 없음
DNS 자동 전환 정상
코드 변경 필요 여부 불필요

Primary → Replica 역할이 자동 교체되고, DNS Endpoint가 새 Primary를 가리키면서 코드 변경 없이 복구되었다.

AWS 콘솔 "승격"과 CLI test-failover의 차이

이건 반드시 알아야 하는 차이점이다.

test-failover:  같은 클러스터 안에서 역할만 교체
              ┌──────────────────────┐
              │  Primary ←→ Replica  │
              └──────────────────────┘

콘솔 "승격":    Replica가 독립 클러스터로 분리됨
              ┌────────────┐  ┌────────────┐
              │  Primary   │  │  Primary   │  ← 별개의 2개 클러스터
              └────────────┘  └────────────┘
  콘솔 "승격" CLI test-failover
동작 Replica를 독립 클러스터로 분리 같은 클러스터 내에서 역할만 교체
결과 클러스터가 깨짐 클러스터 구조 유지
DNS 기존 Endpoint 변경 안 됨 자동으로 새 Primary를 가리킴
코드 변경 새 엔드포인트로 변경 필요 불필요

장애 상황에서 콘솔의 "승격" 버튼을 누르면 상황이 더 나빠진다. 반드시 CLI test-failover를 사용해야 한다.


Failover 시 Redis Lock은 안전한가?

Redis는 비동기 복제를 사용한다. Primary에 쓴 데이터가 Replica에 복제되기 전에 Failover가 발생하면, 최근 쓰기(= 방금 획득한 락)가 유실될 수 있다.

하지만 실질적 위험은 낮다.

  • 분산 락 — DB 레벨의 상태 검증이 최종 안전장치로 병행되므로, 락이 유실되더라도 이중 처리를 DB에서 차단한다
  • Celery 태스크 중복 방지 — 대부분 멱등성 작업이라 중복 실행되어도 결과가 동일하다
  • Failover 중 ~30초 동안은 Redis 연결 자체가 실패하므로 새 요청도 차단된다

분산 락의 완벽한 안전성이 필요하다면 Redlock 알고리즘이나 별도의 분산 락 서비스를 고려해야 하지만, 대부분의 서비스에서는 DB 레벨 검증과 병행하는 것으로 충분하다.


배포 후 부하 변화 예측

배포 전 프로덕션 클러스터의 실제 메트릭을 확인했다.

지표 Primary Replica 1 Replica 2
EngineCPU 0.82% 0.30% 0.32%
연결 수 297 7 10
읽기(GetTypeCmds) 243/s 8/s 11/s
쓰기(SetTypeCmds) 509/s 305/s 305/s

Primary가 전체 읽기의 93%를 처리하고 있었고, Replica는 복제 수신만 하고 있었다.

배포 후에는 Cache-aside 패턴의 GET 명령들이 Replica로 이동하면서:

지표 현재 배포 후 예상
Primary 읽기 ~243/s 60-140/s (4075% 감소)
Primary 읽기 비중 93% 35-55%
Replica 읽기 비중 7% 45-65%

현재 CPU 사용률이 매우 낮아서 체감 성능 차이보다는, 피크 시간대의 안전 마진 확보CPU Credit 소진 위험 감소가 주된 효과다.


장애 대응 가이드: CPU Credit 소진 시

CPU Credit 소진은 Automatic Failover가 발동하지 않으므로 수동 대응이 필요하다.

감지

  • API 응답 시간이 전체적으로 급증
  • 모니터링 도구에서 Redis timeout 에러 다수
  • CloudWatch CPUCreditBalance가 0에 근접

대응

Redis 느려짐 감지
  ↓
CPUCreditBalance 확인 → 0 근접?
  ↓ Yes
수동 Failover 실행 (CLI test-failover) → ~30초 복구
  ↓
트래픽 원인 파악 + 필요시 노드 타입 업그레이드

Failover는 시간 벌기다. 승격된 노드도 t-type이므로 트래픽이 계속 높으면 다시 소진된다. 근본적으로는 노드 타입 업그레이드(t → r/m)가 필요하다.

다만, 평소 CPU Credit이 최대치에서 감소하지 않고 CPU 사용률이 1% 미만이라면 t-type으로 충분하다. r-type은 비용이 6배이므로, Credit이 지속 감소하는 패턴이 관찰될 때 전환을 검토하면 된다.


정리: 배운 것들

  1. t-type의 CPU Credit 소진은 Automatic Failover가 발동하지 않는다 — 노드가 "살아있기" 때문. 이걸 모르면 장애 대응이 늦어진다.
  2. AWS 콘솔의 "승격"과 CLI test-failover는 완전히 다른 동작이다 — 콘솔 승격은 클러스터를 분리시키므로 장애 상황을 악화시킨다.
  3. Read/Write Splitting의 핵심은 "어떤 패턴이냐"다 — 순수 조회는 Replica, Read-then-Write는 반드시 Primary. 잘못 분리하면 replication lag으로 인한 race condition이 발생한다.
  4. 코드 감사는 이중화보다 더 큰 가치를 줄 수 있다 — 이번에 발견한 비원자적 SET+EXPIRE, 타입 불일치 버그는 이중화와 무관하게 수정이 필요한 것들이었다.
  5. 환경변수 fallback은 안전한 배포의 핵심이다REDIS_READER_URL이 없으면 REDIS_URL로 fallback하도록 해서, 환경변수 설정 누락 시에도 서비스가 정상 동작한다.
  6. 분산 락은 Redis 단독으로 완벽하지 않다 — 비동기 복제 특성상 Failover 시 락이 유실될 수 있으므로, DB 레벨 검증을 최종 안전장치로 병행해야 한다.
반응형