- Published on
MySQL 8.0 리플리카 지연 5원인과 Redis 튜닝
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버가 바쁘면 가장 먼저 드러나는 증상 중 하나가 replica lag(리플리카 지연)입니다. 쓰기는 프라이머리에서 처리되는데, 읽기는 리플리카로 분산한다고 가정했을 때 리플리카가 따라오지 못하면 읽기 성능도, 데이터 신선도도 같이 무너집니다.
이 글은 MySQL 8.0 기준으로 리플리카 지연을 만드는 대표 원인 5가지를 관측 지표와 진단 쿼리 중심으로 정리하고, 지연을 “없애기”보다 “지연이 문제로 번지지 않게” 만드는 Redis 캐시 튜닝까지 함께 다룹니다.
관련해서 DB 병목이 애플리케이션 장애로 확대되는 패턴(캐시 꼬임, stale 데이터 등)은 Next.js 14 App Router RSC 캐시 꼬임 해결에서도 유사한 관점으로 참고할 수 있습니다.
리플리카 지연을 보기 전에: 어떤 지연을 말하나
MySQL 복제는 크게 두 축으로 봅니다.
- 전송 지연: 프라이머리의 바이너리 로그가 리플리카로 전달되는 속도
- 적용 지연: 리플리카 SQL 스레드(또는 워커)가 릴레이 로그를 실제 테이블에 반영하는 속도
MySQL 8.0에서는 performance_schema.replication_* 테이블로 더 세밀한 관측이 가능합니다.
기본 상태 확인 쿼리
-- 1) 복제 채널 상태
SELECT CHANNEL_NAME, SERVICE_STATE, LAST_ERROR_NUMBER, LAST_ERROR_MESSAGE
FROM performance_schema.replication_connection_status;
-- 2) 적용(어플라이) 상태
SELECT CHANNEL_NAME, SERVICE_STATE, LAST_ERROR_NUMBER, LAST_ERROR_MESSAGE,
LAST_APPLIED_TRANSACTION, LAST_APPLIED_TRANSACTION_ORIGINAL_COMMIT_TIMESTAMP,
LAST_APPLIED_TRANSACTION_END_APPLY_TIMESTAMP
FROM performance_schema.replication_applier_status;
-- 3) 워커별 지연/오류(병렬 복제 사용 시 특히 중요)
SELECT CHANNEL_NAME, WORKER_ID, SERVICE_STATE, LAST_ERROR_NUMBER, LAST_ERROR_MESSAGE,
LAST_APPLIED_TRANSACTION
FROM performance_schema.replication_applier_status_by_worker
ORDER BY WORKER_ID;
Seconds_Behind_Source(구 Seconds_Behind_Master)만 보던 방식은 “어디가 막혔는지”를 설명해주지 못합니다. 위 테이블 기반으로 연결(전송) 문제인지, 적용(리플레이) 문제인지를 먼저 가르는 게 좋습니다.
원인 1) 리플리카 I/O 병목: 디스크가 릴레이 로그를 못 버팀
리플리카는 릴레이 로그를 디스크에 쓰고, 다시 읽어서 적용합니다. 즉, 리플리카는 쓰기와 읽기를 동시에 요구하는 워크로드가 되기 쉽습니다.
관측 포인트
- 리플리카에서
iostat기준await상승,util100% 근접 performance_schema에서 applier가 처리 속도를 못 따라감- InnoDB flush 관련 대기 증가
점검/튜닝 체크리스트
- 스토리지: gp2에서 gp3로 변경, IOPS 보장, 로컬 NVMe 사용 등
innodb_flush_log_at_trx_commit:- 리플리카는 내구성 요구가 프라이머리보다 낮은 경우가 많아
2로 완화할 수 있습니다(정책에 따라 결정).
- 리플리카는 내구성 요구가 프라이머리보다 낮은 경우가 많아
sync_binlog:- 리플리카에서 바이너리 로그를 굳이 남기지 않으면
log_bin비활성화(체인 복제 아니면 고려)
- 리플리카에서 바이너리 로그를 굳이 남기지 않으면
-- 리플리카에서만(정책 허용 시) 완화 예시
SET PERSIST innodb_flush_log_at_trx_commit = 2;
-- 체인 복제/포인트인타임 복구 요구 없으면 log_bin 비활성화도 검토
주의: 설정 완화는 장애 시 데이터 손실 가능성을 키웁니다. “리플리카는 재구성 가능”이라는 전제가 있을 때만 선택하세요.
원인 2) 단일 스레드 적용 병목: 병렬 복제가 사실상 꺼져 있음
MySQL 8.0에서 병렬 복제는 기본적으로 개선되었지만, 설정이 보수적이거나 워크로드 특성상 병렬화가 안 되면 SQL 적용이 한 줄로 서게 됩니다.
흔한 신호
replication_applier_status_by_worker에서 특정 워커만 일하고 나머지는 놀고 있음- 트랜잭션이 동일 스키마/동일 테이블에 집중되어 충돌
핵심 설정
replica_parallel_workers: 워커 수replica_parallel_type:LOGICAL_CLOCK가 일반적으로 병렬성에 유리
-- 병렬 복제 활성화 예시
SET PERSIST replica_parallel_workers = 8;
SET PERSIST replica_parallel_type = 'LOGICAL_CLOCK';
추가로, 트랜잭션이 너무 커서 한 번에 적용 시간이 길면 병렬 워커가 있어도 효과가 제한됩니다. 그 경우는 다음 원인(대형 트랜잭션)과 같이 봐야 합니다.
원인 3) 대형 트랜잭션/배치 작업: 한 방에 밀어 넣어 리플리카가 숨참
리플리카는 커밋 단위로 적용합니다. INSERT ... SELECT 대량 적재, 대규모 UPDATE 배치, 인덱스 재구성 등은 리플리카에서 긴 단일 트랜잭션으로 재현되어 지연을 폭발시킵니다.
진단 힌트
- 프라이머리에서 배치 시간대에만 지연이 급증
- 리플리카에서 특정 테이블에 락 대기가 길어짐
완화 전략
- 배치를 작은 청크로 쪼개기(예: PK 범위로 나눠 커밋)
- 온라인 DDL 도구 사용(가능하면)
- 인덱스/스키마 변경은 트래픽 저점 시간대로 이동
-- 청크 업데이트 예시(개념)
UPDATE orders
SET status = 'ARCHIVED'
WHERE id BETWEEN 100000 AND 110000;
-- 커밋 후 다음 구간 진행
대형 트랜잭션은 데드락/락 경합도 같이 키웁니다. 데드락 분석 관점은 MySQL InnoDB 데드락 1213 로그로 재현·해결도 함께 참고하면 좋습니다.
원인 4) 락 경합과 긴 쿼리: 리플리카에서도 읽기 때문에 막힌다
많이 놓치는 포인트가 “리플리카는 읽기 전용이니까 락 경합이 덜하겠지”입니다. 하지만 리플리카는 **적용(쓰기)**을 하는 동시에 **서비스 트래픽(읽기)**도 받습니다. 읽기 쿼리가 느리거나, 불필요하게 큰 범위를 스캔하면 버퍼풀 압박과 I/O를 유발하고, 결국 apply가 느려집니다.
체크리스트
- 리플리카에만 존재하는 느린 쿼리(읽기 쿼리) 확인
EXPLAIN으로 인덱스 미사용 쿼리 제거slow_query_log를 리플리카에도 켜서 관측(성능 영향 고려)
-- 리플리카에서 느린 쿼리 로깅(상황에 따라 임시)
SET PERSIST slow_query_log = ON;
SET PERSIST long_query_time = 0.5;
리플리카를 “읽기 분산 장치”로 쓰는 순간, 리플리카는 사실상 혼합 워크로드 서버가 됩니다. 이때는 캐시로 읽기 부하를 앞단에서 흡수하는 게 실효성이 큽니다.
원인 5) 네트워크/복제 연결 문제: 전송이 흔들리면 지연은 누적된다
전송 지연은 적용 지연보다 더 빨리 눈에 띄기도 합니다. 특히 클라우드 환경에서 AZ 간 트래픽, NAT 경유, 방화벽/보안그룹 변경, 순간 패킷 드랍 등이 있으면 I/O 스레드가 끊겼다 붙었다 하며 릴레이 로그가 끊기고, 결국 지연이 누적됩니다.
진단 포인트
replication_connection_status에서LAST_ERROR_MESSAGE에 네트워크 관련 메시지Aborted_connects,Connection_errors_*증가- 지연이 “계단식”으로 증가(끊긴 동안 누적)
SHOW GLOBAL STATUS LIKE 'Aborted_connects';
SHOW GLOBAL STATUS LIKE 'Connection_errors%';
쿠버네티스/EKS 환경에서 네트워크가 간헐적으로 흔들리는 케이스는 EKS Pod egress 간헐 끊김 - SNAT·NAT GW 추적법 같은 관점으로도 원인 분리가 가능합니다.
리플리카 지연을 “완화”하는 Redis 캐시 튜닝 전략
리플리카 지연을 0으로 만드는 건 비용이 큽니다. 대신 실무에서는 다음 목표가 더 현실적입니다.
- 리플리카가 조금 늦어도 서비스는 안정적으로 동작
- DB 읽기 QPS를 캐시로 흡수해 리플리카 apply 리소스를 확보
- “최신성 요구”가 있는 요청만 프라이머리로 보내거나, 캐시를 무효화/갱신
1) 캐시 키 설계: 데이터 최신성 요구를 먼저 분류
- 강한 최신성 필요: 결제 직후 주문 상태, 재고, 권한 등
- 프라이머리 읽기 또는 write-through/이벤트 기반 캐시 갱신
- 약한 최신성 허용: 상품 목록, 인기글, 통계성 데이터
- TTL 캐시 + 지연 허용
키 예시(버전 포함):
product:v3:detail:123feed:v2:user:456:page:1
버전을 올리면 전체 무효화를 간단히 할 수 있어, 리플리카 지연으로 인한 “오래된 값”을 빠르게 걷어낼 수 있습니다.
2) TTL 튜닝: 짧게가 답이 아니라, 분산이 답이다
동일 TTL을 대량 키에 적용하면 만료 시점이 몰려 thundering herd가 발생합니다. TTL에 지터를 섞어 만료를 분산하세요.
// Node.js 예시: TTL에 지터 추가
const base = 60; // 60s
const jitter = Math.floor(Math.random() * 15); // 0~14s
const ttl = base + jitter;
await redis.set(key, value, { EX: ttl });
- 목록/피드:
30~120s+ 지터 - 상세(자주 조회, 덜 변함):
5~30m+ 지터 - 통계성:
1~10m+ 지터
3) 캐시 미스 폭주 방지: 단일 비행(싱글플라이트) + 소프트 TTL
캐시가 비었을 때 동시에 수백 요청이 DB로 붙으면 리플리카가 더 느려집니다. 대표 패턴 두 가지:
- 싱글플라이트: 같은 키는 한 요청만 원본 조회, 나머지는 대기
- 소프트 TTL: TTL이 지나도 잠깐은 기존 값을 서빙하고 백그라운드 갱신
// 간단한 락 기반 싱글플라이트(개념)
const lockKey = `lock:${key}`;
const gotLock = await redis.set(lockKey, '1', { NX: true, EX: 5 });
if (gotLock) {
const fresh = await queryFromDb();
await redis.set(key, JSON.stringify(fresh), { EX: 60 });
await redis.del(lockKey);
return fresh;
}
// 락을 못 잡으면 짧게 대기 후 캐시 재조회
await new Promise(r => setTimeout(r, 50));
const cached = await redis.get(key);
if (cached) return JSON.parse(cached);
return queryFromDb();
4) Redis 메모리/퇴출 정책: maxmemory-policy를 워크로드에 맞추기
캐시는 결국 “버릴 데이터”를 정하는 정책 싸움입니다.
- 단순 캐시라면
allkeys-lru또는allkeys-lfu가 일반적으로 무난 - TTL 기반이면
volatile-ttl/volatile-lru도 선택지(단, TTL 없는 키는 보호됨)
또한 다음을 같이 점검하세요.
maxmemory설정이 너무 작아 eviction이 과도한지used_memory_rss가 급증(메모리 단편화)- 큰 value로 네트워크/CPU가 비싸지 않은지(JSON 압축/필드 축소)
# redis.conf 예시
maxmemory 8gb
maxmemory-policy allkeys-lfu
5) 캐시 일관성: 리플리카 지연이 있을 때 더 중요한 무효화 전략
리플리카 지연이 있는 시스템에서 가장 위험한 조합은 다음입니다.
- 쓰기 직후 읽기를 리플리카로 보내고
- 캐시도 오래 유지되어
- 사용자가 “바뀌었어야 할 값”을 계속 봄
실무적 해법은 보통 3가지 중 하나를 섞습니다.
- Write-through: 쓰기 시 DB와 캐시를 함께 갱신
- Invalidate: 쓰기 시 해당 키를 삭제하고 다음 읽기에 재생성
- Event-driven: CDC/메시지로 캐시 갱신(규모가 커질수록 선호)
Invalidate 예시:
-- DB 업데이트 후
UPDATE product SET price = 12000 WHERE id = 123;
// 애플리케이션에서 캐시 무효화
await redis.del('product:v3:detail:123');
여기서 중요한 건 “무효화가 실패해도 안전한가”입니다. 실패해도 TTL로 언젠가 사라지게 만들고, 최신성 민감 요청은 프라이머리로 라우팅하는 식으로 방어선을 2겹 이상 두는 게 안정적입니다.
운영 체크리스트: MySQL과 Redis를 함께 본다
- MySQL 리플리카
- 전송/적용 상태를
performance_schema로 분리 관측 - 병렬 복제 설정과 워커 편중 여부 확인
- 배치 트랜잭션 크기 관리(청크/커밋)
- 리플리카 읽기 쿼리의 인덱스/슬로우 로그 점검
- 전송/적용 상태를
- Redis
- TTL 지터로 만료 분산
- 싱글플라이트/소프트 TTL로 미스 폭주 방지
maxmemory-policy를 캐시 성격에 맞게 설정- 쓰기 경로에서 invalidate 또는 write-through로 일관성 확보
마무리
MySQL 8.0 리플리카 지연은 대개 “한 가지 원인”이 아니라, 디스크/I/O, 병렬성 부족, 대형 트랜잭션, 리플리카 읽기 부하, 네트워크 흔들림이 겹치면서 커집니다. 그래서 해결도 한 방이 아니라 관측으로 병목을 특정하고, DB에서 가능한 만큼 줄인 뒤, 남는 읽기 부하는 Redis 캐시 튜닝으로 흡수하는 조합이 가장 비용 대비 효과가 좋습니다.
다음 단계로는 (1) 지연이 사용자 경험에 영향을 주는 API를 분류하고, (2) 해당 API에만 강한 최신성 전략(프라이머리 라우팅/이벤트 기반 캐시)을 적용해 “전체 비용”을 통제하는 접근을 권합니다.