Published on

MySQL 리플리카 지연 폭증, Redis 캐시로 막기

Authors

서버가 멀쩡한데도 API 응답이 갑자기 느려지고, 읽기 전용 쿼리까지 타임아웃이 나기 시작한다면 의외로 자주 범인이 되는 것이 MySQL replica lag 입니다. 평소에는 리드 레플리카로 읽기 트래픽을 분산해두었는데, 특정 시점부터 레플리카가 마스터를 따라잡지 못하면서 애플리케이션이 오래된 데이터를 보거나, 레플리카 커넥션 풀이 쌓이며 전체 지연이 연쇄적으로 커집니다.

이 글은 "리플리카 지연이 폭증하는 순간"에 Redis 캐시를 방파제처럼 세워서 읽기 트래픽을 흡수하고, 서비스 품질을 유지하는 방법을 실전 관점으로 정리합니다. 단순히 "캐시를 붙이자"가 아니라, 어떤 지표로 판단하고 어떤 키를 잡고 어떻게 무효화하며, cache stampedethundering herd 를 어떻게 피할지까지 포함합니다.

1) 리플리카 지연이 폭증하는 전형적인 시나리오

리플리카 지연은 보통 아래 중 하나로 급격히 커집니다.

  • 대량 쓰기/배치 작업: INSERT ... SELECT, 대규모 UPDATE, 백필, 마이그레이션
  • 인덱스/스키마 변경: 온라인 DDL이라도 레플리카 적용 비용이 커질 수 있음
  • 레플리카 I/O 병목: 디스크 IOPS 부족, 버퍼풀 미스, 스토리지 크레딧 고갈
  • 긴 트랜잭션: 마스터에서 커밋이 늦어지거나, 레플리카에서 적용이 밀림
  • 네트워크 이슈: 마스터와 레플리카 간 전송 지연

폭증 구간에서는 보통 다음 현상이 동시에 보입니다.

  • 레플리카에서 Seconds_Behind_Master 가 급격히 증가
  • 애플리케이션의 읽기 쿼리 p95, p99 급등
  • 커넥션 풀 대기 증가, 스레드/워커 고갈
  • 레플리카를 바라보는 엔드포인트에서 5xx 증가

네트워크나 보안 설정 문제로 DB 연결 자체가 불안정할 때는 원인이 다를 수 있습니다. 예를 들어 EKS 환경에서 RDS로의 타임아웃이 섞여 보인다면 인프라 레벨도 같이 점검해야 합니다: EKS Pod→RDS 504 타임아웃 - SG·NACL·NAT 10분 진단

2) "캐시로 막는다"의 정확한 목표 정의

리플리카 지연을 캐시로 막는다고 할 때 목표는 보통 3가지입니다.

  1. 읽기 트래픽을 Redis로 우회해서 레플리카 부하를 즉시 낮춘다
  2. 지연 구간 동안 일관성 요구를 재정의한다
    • 일부 화면은 "약간 오래된 데이터"를 허용
    • 결제/권한/잔액 등은 반드시 강한 일관성으로 마스터 조회
  3. 폭주를 막는 제어장치를 둔다
    • 캐시 미스가 동시에 터져 DB를 더 때리지 않게 singleflight 와 락 사용

즉, Redis는 성능 최적화 도구이기도 하지만, 장애 완화 도구이기도 합니다.

3) 관측: 리플리카 지연을 "자동 감지"해야 한다

사람이 대시보드를 보고 수동으로 캐시 전략을 바꾸는 것은 늦습니다. 보통은 애플리케이션이 아래 신호를 보고 자동으로 동작을 바꾸게 합니다.

  • DB에서 읽어온 Seconds_Behind_Source 또는 Seconds_Behind_Master
  • 프록시(예: ProxySQL)나 DB 라우터에서 레플리카 헬스
  • 애플리케이션 레벨에서 레플리카 쿼리 타임아웃, 에러율

MySQL 8 기준으로는 다음처럼 확인합니다.

SHOW REPLICA STATUS\G
-- 또는
SHOW SLAVE STATUS\G

여기서 Seconds_Behind_MasterN 초를 넘으면 "레플리카는 신뢰하지 않는다" 같은 라우팅 정책을 발동할 수 있습니다.

간단한 정책 예시

  • lag <= 2s: 평상시 (레플리카 읽기 허용)
  • 2s < lag <= 30s: 캐시 TTL을 늘리고, 일부 API는 캐시 우선
  • lag > 30s: 레플리카 읽기 차단, 캐시 우선 + DB는 마스터 일부만 허용

4) Redis 캐시 패턴 3가지: 상황별로 선택

4-1) Cache-aside (Lazy loading)

가장 기본 패턴입니다.

  1. Redis에서 키 조회
  2. 없으면 DB 조회
  3. Redis에 저장 후 반환

장점은 단순함, 단점은 미스 폭주 시 DB가 터질 수 있다는 점입니다.

4-2) Read-through (라이브러리/프록시가 자동 로딩)

애플리케이션이 캐시를 직접 다루지 않고, 캐시 계층이 DB에서 읽어 채워주는 형태입니다. 운영 단순화가 장점이지만, 장애 시 디버깅이 더 어려울 수 있습니다.

4-3) Stale-while-revalidate (SWR)

리플리카 지연 폭증에서 특히 강력합니다.

  • 캐시가 "조금 오래되어도" 일단 응답을 주고
  • 백그라운드에서 갱신

사용자는 빠른 응답을 받고, DB는 갱신 트래픽만 감당하면 됩니다.

5) 핵심: 캐시 스탬피드 방지(동시 미스 폭주 차단)

리플리카가 느려질 때 캐시 TTL이 동시에 만료되면, 수천 요청이 한 번에 DB로 떨어지며 레플리카를 더 죽입니다. 이를 막는 대표 기법은 아래 3가지입니다.

  1. TTL 지터(jitter): TTL에 랜덤을 섞어 동시 만료를 분산
  2. 분산 락: 한 요청만 DB를 치고 나머지는 대기 또는 stale 반환
  3. SWR: stale을 즉시 반환하고 갱신은 1명만 수행

Redis 분산 락 + SWR 예시 (Node.js)

아래 코드는 stale 을 허용하면서도, 갱신은 락을 잡은 1개 요청만 수행하게 합니다.

import Redis from 'ioredis'

const redis = new Redis(process.env.REDIS_URL)

function jitter(ttlSeconds, ratio = 0.1) {
  const delta = Math.floor(ttlSeconds * ratio)
  return ttlSeconds - delta + Math.floor(Math.random() * (2 * delta + 1))
}

// value: { data, freshUntil, staleUntil }
export async function getWithSWR({
  key,
  fetcher,
  freshTtl = 10,
  staleTtl = 60,
}) {
  const now = Date.now()
  const cached = await redis.get(key)

  if (cached) {
    const parsed = JSON.parse(cached)
    if (now <= parsed.freshUntil) {
      return parsed.data
    }

    // stale 구간: 일단 반환하고, 백그라운드 갱신 시도
    if (now <= parsed.staleUntil) {
      refreshInBackground({ key, fetcher, freshTtl, staleTtl }).catch(() => {})
      return parsed.data
    }
  }

  // 캐시 없음 또는 stale도 만료: 락을 잡고 동기 갱신
  return await refreshWithLock({ key, fetcher, freshTtl, staleTtl })
}

async function refreshInBackground({ key, fetcher, freshTtl, staleTtl }) {
  const lockKey = `${key}:lock`
  const lock = await redis.set(lockKey, '1', 'NX', 'PX', 3000)
  if (!lock) return

  try {
    const data = await fetcher()
    const now = Date.now()
    const payload = {
      data,
      freshUntil: now + freshTtl * 1000,
      staleUntil: now + staleTtl * 1000,
    }
    await redis.set(key, JSON.stringify(payload), 'EX', jitter(staleTtl))
  } finally {
    await redis.del(lockKey)
  }
}

async function refreshWithLock({ key, fetcher, freshTtl, staleTtl }) {
  const lockKey = `${key}:lock`
  const lock = await redis.set(lockKey, '1', 'NX', 'PX', 5000)

  if (!lock) {
    // 누군가 갱신 중이면 잠깐 기다렸다가 캐시 재조회
    await new Promise(r => setTimeout(r, 80))
    const cached = await redis.get(key)
    if (cached) return JSON.parse(cached).data
    // 그래도 없으면 최후로 DB 호출
  }

  try {
    const data = await fetcher()
    const now = Date.now()
    const payload = {
      data,
      freshUntil: now + freshTtl * 1000,
      staleUntil: now + staleTtl * 1000,
    }
    await redis.set(key, JSON.stringify(payload), 'EX', jitter(staleTtl))
    return data
  } finally {
    if (lock) await redis.del(lockKey)
  }
}

포인트는 다음입니다.

  • freshUntilstaleUntil 을 분리해 "조금 오래된 값"을 안전하게 활용
  • 락은 짧게 잡고(PX), 실패 시에는 stale 반환으로 사용자 경험 유지
  • TTL에는 jitter 를 넣어 만료 분산

6) 어떤 데이터부터 캐시해야 효과가 큰가

리플리카 지연 폭증에서 가장 먼저 캐시해야 하는 것은 보통 아래입니다.

  • 읽기 비중이 압도적으로 큰 API: 홈 피드, 상품 상세, 인기 목록
  • 동일 키로 반복 조회되는 데이터: userId 기반 프로필, productId 기반 상세
  • DB에서 조인/정렬/집계가 비싼 쿼리

반대로 캐시를 조심해야 하는 것은 아래입니다.

  • 잔액, 재고, 권한, 결제 상태처럼 "틀리면 사고"인 데이터
  • 개인정보가 섞인 응답(키 설계와 암호화, 접근통제가 필요)

7) 캐시 키 설계: 버전과 범위를 먼저 잡아라

캐시 키는 시간이 지나면서 반드시 진화합니다. 처음부터 "버전"을 포함시키면 마이그레이션이 쉬워집니다.

  • profile:v1:user:123
  • product:v3:id:987:locale:ko-KR
  • feed:v2:user:123:page:1

키에 포함할 것

  • 엔티티 식별자(id)
  • 변형 요인(언어, 통화, AB 실험 버킷, 권한 스코프)
  • 스키마 버전(v1, v2)

키에 넣지 말아야 할 것

  • 너무 높은 카디널리티(예: timestamp 를 그대로 포함)
  • 불필요한 요청별 파라미터(캐시 효율 급락)

8) 무효화 전략: "정확"보다 "안전"을 선택하는 구간

리플리카 지연 폭증 시에는 완벽한 무효화가 어려울 수 있습니다. 대신 다음 우선순위가 현실적입니다.

  1. 짧은 TTL + SWR: 무효화가 조금 늦어도 TTL로 수습
  2. 이벤트 기반 무효화: 쓰기 성공 시 관련 키 삭제
  3. 버전 올리기: 대규모 변경 시 v1 에서 v2 로 키 프리픽스 변경

쓰기 후 무효화 예시 (Spring Boot)

@Service
public class ProfileService {
  private final StringRedisTemplate redis;
  private final ProfileRepository repo;

  public ProfileService(StringRedisTemplate redis, ProfileRepository repo) {
    this.redis = redis;
    this.repo = repo;
  }

  @Transactional
  public void updateNickname(long userId, String nickname) {
    repo.updateNickname(userId, nickname);

    String key = "profile:v1:user:" + userId;
    redis.delete(key);
  }
}

여기서 중요한 점은 "DB 쓰기 트랜잭션이 성공한 뒤"에 무효화를 수행하는 것입니다. 더 엄격히 하려면 트랜잭션 커밋 이후 훅을 사용하거나, 아웃박스 패턴으로 이벤트를 발행해 무효화를 비동기로 처리합니다.

9) 리플리카 지연 시 라우팅: 레플리카를 버리고 캐시로 버틴다

캐시만 붙인다고 끝나지 않습니다. 레플리카가 느릴 때는 애플리케이션 라우팅도 바뀌어야 합니다.

  • 평상시: read 는 레플리카, write 는 마스터
  • 지연 폭증: read 는 Redis 우선, 미스 시 마스터(선별적으로), 레플리카는 차단

이때 "마스터로 다 보내면 되지 않나"는 위험합니다. 마스터는 쓰기를 처리해야 하므로, 읽기까지 몰리면 전체 장애로 번질 수 있습니다. 따라서 다음처럼 읽기 API를 등급화하는 것이 실전적입니다.

  • S등급(필수, 강일관성): 마스터 읽기 허용(예: 결제 상태)
  • A등급(중요, 약일관성 허용): Redis 우선, 미스 시 마스터 제한적으로
  • B등급(덜 중요): stale만 제공하거나, 기능 축소

10) 운영 팁: 캐시가 또 다른 장애점이 되지 않게

Redis를 방파제로 쓰려면 Redis 자체가 병목이 되지 않게 해야 합니다.

  • 커넥션 수, CPU, 네트워크 대역폭 모니터링
  • 큰 값(대형 JSON) 저장 지양, 필요 시 압축 또는 필드 분리
  • MGET 과 파이프라이닝으로 RTT 절감
  • 핫키 탐지(특정 키에 트래픽 집중) 및 샤딩 고려

애플리케이션이 리소스 고갈로 503을 내기 시작하면 캐시 이전에 풀/스레드부터 무너졌을 가능성이 큽니다. 특히 자바 계열이라면 커넥션 풀 고갈 패턴을 함께 점검하는 것이 좋습니다: Spring Boot 3 503? HikariCP 풀 고갈 원인·해결

11) "캐시로 막기"의 한계와 근본 처방

Redis 캐시는 증상을 완화하지만, 리플리카 지연의 원인을 제거하지는 못합니다. 장기적으로는 아래를 병행해야 합니다.

  • 레플리카 스펙 상향 또는 스토리지 튜닝(버퍼풀, IOPS)
  • 쓰기 폭주 작업의 스로틀링(배치 속도 제한)
  • 무거운 쿼리 최적화 및 인덱스 재설계
  • 레플리카 수평 확장, 읽기 분산 전략 개선

특히 "평소엔 빠른데 특정 조건에서만 갑자기 느려지는 쿼리"가 리플리카 적용/읽기 부하를 같이 키우는 경우가 많습니다. 인덱스와 실행 계획을 점검하는 습관이 중요합니다. (PostgreSQL 글이지만 진단 사고방식은 그대로 적용됩니다) PostgreSQL 인덱스 안타는 이유 9가지와 해결

12) 체크리스트: 장애 순간에 바로 적용할 것

  • SHOW REPLICA STATUS\G 로 지연 확인, 임계치 초과 시 레플리카 읽기 차단
  • Redis 캐시 우선 정책으로 전환(특히 목록/상세/프로필)
  • SWR + 락 + TTL 지터 로 스탬피드 방지
  • 읽기 API 등급화로 마스터 보호
  • 캐시 키에 v1 버전 포함, 무효화는 "쓰기 성공 후 삭제"부터

마무리

MySQL 리플리카 지연 폭증은 "읽기 확장"을 위해 붙여둔 레플리카가, 역설적으로 전체 장애의 트리거가 되는 순간입니다. 이때 Redis 캐시는 단순 가속이 아니라, 레플리카를 잠시 포기하고도 서비스를 유지하기 위한 완충지대가 됩니다.

핵심은 SWR 로 사용자 응답을 지키고, TTL 지터 로 동시 미스를 제어하며, "어떤 데이터는 stale을 허용한다"는 제품/정책 결정을 기술로 구현하는 것입니다. 이를 준비해두면 배치 한 번, 트래픽 스파이크 한 번에 서비스가 흔들리는 일을 크게 줄일 수 있습니다.