리드 레플리카는 읽기 부하를 분산하는 가장 손쉬운 확장 수단이지만, 동시에 서비스 데이터 정합성을 가장 흔하게 무너뜨리는 원인이기도 합니다. 방금 작성한 글이 목록에서 사라지거나 결제 직후 잔액이 이전 값으로 보이는 현상 대부분은 복제 지연(replication lag)에서 비롯됩니다. “레플리카를 붙이면 읽기가 빨라진다”는 명제는 맞지만, 그 이면에는 소스와 레플리카 사이의 시간 격차라는 대가가 있습니다.
이 글에서는 MySQL 복제의 동작 원리부터 지연을 정확히 측정·진단하는 법, 병렬 복제·라우팅·읽기 후 쓰기 정합성(read-your-writes)까지 운영 현장의 문제를 단계별로 다룹니다. Seconds_Behind_Source 하나만 보고 안심하다 큰코다치는 이유도 살펴봅니다.
복제 파이프라인은 어디서 지연되는가
MySQL 비동기 복제는 세 스레드의 이어달리기입니다. 소스는 커밋된 트랜잭션을 바이너리 로그(binlog)에 기록하고, 레플리카의 I/O 스레드가 이를 네트워크로 받아와 릴레이 로그(relay log)에 씁니다. 마지막으로 SQL 스레드(applier)가 릴레이 로그를 읽어 실제 데이터에 적용합니다.
지연은 이 세 구간 어디서든 발생하며 원인이 다르면 처방도 다릅니다. I/O 지연은 네트워크 대역폭이나 소스의 binlog 쓰기 속도, SQL 지연은 적용 병렬도·느린 쿼리·락 경합 문제입니다. 그런데 흔히 보는 Seconds_Behind_Source는 SQL 스레드 기준의 지연만 대략 나타냅니다.
-- 레플리카에서 복제 상태 확인 (MySQL 8.0+)
SHOW REPLICA STATUSG
-- 핵심 필드
-- Replica_IO_Running / Replica_SQL_Running : 두 스레드 정상 여부
-- Seconds_Behind_Source : SQL 스레드 지연 추정치(초)
-- Retrieved_Gtid_Set : I/O 스레드가 받은 GTID
-- Executed_Gtid_Set : SQL 스레드가 적용한 GTID
Seconds_Behind_Source는 지금 적용 중인 트랜잭션의 소스 커밋 시각과 레플리카 현재 시각의 차이인데, 이 값은 자주 거짓말을 합니다. 소스 트래픽이 잠시 없으면 0으로 떨어지고, I/O 스레드가 binlog를 못 받아오면 SQL 스레드가 적용할 게 없어 역시 0으로 보입니다.
Seconds_Behind_Source를 믿지 말아야 하는 이유
대표적인 함정은 I/O 스레드가 뒤처졌는데 SQL 스레드는 다 따라잡은 경우입니다. 소스에서 대량 쓰기로 binlog가 폭증하면 I/O 스레드가 네트워크로 다 받아오지 못하는데, 이미 받은 릴레이 로그는 금방 적용되니 Seconds_Behind_Source는 0에 가깝습니다. 실제로는 소스의 최신 데이터가 레플리카에 도착조차 하지 않은 상태인데 말입니다.
더 정확한 진단은 GTID 집합을 직접 비교하는 것입니다. 소스 gtid_executed와 레플리카 Retrieved_Gtid_Set·Executed_Gtid_Set을 비교하면 I/O 지연과 SQL 지연을 분리합니다.
-- 소스에서: 현재까지 커밋된 전체 GTID
SELECT @@GLOBAL.gtid_executed;
-- 레플리카에서: 받은 것 vs 적용한 것 — 그 차이가 곧 미적용 트랜잭션
SELECT RECEIVED_TRANSACTION_SET -- I/O 스레드가 받은 GTID
FROM performance_schema.replication_connection_status;
-- gtid_executed vs RECEIVED 갭 = I/O(수신) 지연
-- RECEIVED vs 적용된 gtid_executed 갭 = SQL(적용) 지연
운영에서는 하트비트 테이블 방식이 가장 신뢰할 만합니다. 소스에서 1초마다 타임스탬프를 갱신하는 행을 두고 레플리카에서 그 값을 현재 시각과 비교하면 전체 파이프라인을 관통하는 실제 지연이 나옵니다.
-- 소스: 하트비트 테이블 (예: 1초 주기 이벤트/크론으로 갱신)
CREATE TABLE heartbeat (
id TINYINT PRIMARY KEY,
ts TIMESTAMP(6) NOT NULL
);
REPLACE INTO heartbeat (id, ts) VALUES (1, NOW(6));
-- 레플리카: 실제 end-to-end 지연 계산 (밀리초 단위)
SELECT
TIMESTAMPDIFF(MICROSECOND, ts, NOW(6)) / 1000 AS lag_ms
FROM heartbeat WHERE id=1;
지연의 근본 원인: 단일 스레드 적용의 한계
전통적인 복제의 가장 큰 병목은 적용이 단일 스레드로 순차 진행된다는 점이었습니다. 소스는 수십 개 커넥션으로 병렬 쓰기를 받지만 레플리카는 그 트랜잭션을 한 줄로 세워 하나씩 적용하므로, 소스의 쓰기 처리량이 레플리카의 적용 속도를 넘는 순간 지연이 무한히 벌어집니다.
여기에 다음 요인이 겹치면 지연이 폭발합니다.
- 큰 트랜잭션: 수백만 행을 한 트랜잭션에서
UPDATE/DELETE하면 그 하나가 끝날 때까지 뒤의 모든 트랜잭션이 대기합니다. - 레플리카 하드웨어 열세: 레플리카를 낮은 사양으로 두면 적용 속도가 원천적으로 부족합니다.
- 동기 fsync 부담:
sync_binlog=1,innodb_flush_log_at_trx_commit=1은 안전하지만 디스크 I/O가 적용 처리량을 제약합니다.
진단할 때는 replication_applier_status_by_worker에서 각 워커의 APPLYING_TRANSACTION을 확인합니다. 특정 워커 하나가 오래 붙잡혀 있다면 큰 트랜잭션이나 락 대기가 원인일 가능성이 높습니다.
병렬 복제로 적용 속도 끌어올리기
MySQL 5.7 이후의 병렬 복제(multi-threaded replication)는 이 병목을 정면으로 해결합니다. 핵심은 replica_parallel_type과 replica_parallel_workers이며, LOGICAL_CLOCK 타입은 소스에서 동시에 커밋된 트랜잭션을 레플리카에서도 병렬로 적용합니다.
# my.cnf (레플리카) — 병렬 복제 설정
[mysqld]
replica_parallel_type = LOGICAL_CLOCK
replica_parallel_workers = 8 # 코어 수 이내로
replica_preserve_commit_order = ON # 소스와 동일한 커밋 순서 보장
binlog_transaction_dependency_tracking = WRITESET # 병렬도↑
replica_preserve_commit_order = ON은 병렬로 적용하되 커밋 순서를 소스와 동일하게 유지해 GTID 갭이나 인과관계 역전을 막습니다. 이 옵션 없이 병렬 복제를 켜면 순서 의존적인 읽기에서 미묘한 정합성 버그가 생깁니다. WRITESET(소스 측)은 각 트랜잭션이 건드린 행의 해시를 binlog에 기록해 서로 다른 행을 만진 트랜잭션을 병렬 적용하게 하며, 단순 COMMIT_ORDER보다 병렬도가 훨씬 높습니다.
애플리케이션 라우팅: 어디까지 레플리카로 보낼까
비동기 복제인 이상 지연은 0이 되지 않으므로, 지연을 견딜 수 있는 읽기와 최신성이 필수인 읽기를 애플리케이션에서 구분하는 것이 핵심 방어선입니다. 목록·검색·통계·대시보드처럼 수 초 지연이 문제되지 않는 읽기는 레플리카로, 방금 쓴 데이터를 즉시 다시 읽는 경우(작성 직후 상세 페이지, 결제 직후 잔액 확인)는 소스로 보냅니다.
# 읽기 라우팅 (의사코드)
def pick_read(source, replicas, max_lag_ms=1000, require_fresh=False):
if require_fresh: # 최신성 필수 → 무조건 소스
return source
healthy = [r for r in replicas # 지연 임계치 이내인 노드만 후보
if r.lag_ms() < max_lag_ms]
if not healthy:
return source # 다 뒤처졌으면 소스로 폴백
return min(healthy, key=lambda r: r.lag_ms())
# 목록 → pick_read(..., require_fresh=False) # 레플리카
# 결제 확인 → pick_read(..., require_fresh=True) # 소스
지연 기반 헬스체크로 레플리카를 자동 격리하는 것이 실전에서 큰 차이를 만듭니다. ProxySQL의 max_replication_lag를 설정하면 프록시가 지연을 주기적으로 폴링해 임계치 초과 노드로는 쿼리를 보내지 않으므로, 애플리케이션 코드를 건드리지 않고도 문제 노드를 읽기 풀에서 빼고 회복되면 다시 넣을 수 있습니다.
읽기 후 쓰기 정합성 보장하기
“방금 쓴 걸 소스에서 읽으면 된다”는 규칙은 요청 경계를 넘으면 깨집니다. 글을 쓰고(쓰기 → 소스) 리다이렉트된 상세 페이지가 별도 요청으로 레플리카를 읽으면 그 레플리카가 아직 해당 트랜잭션을 적용하지 못했을 수 있습니다. 이를 정확히 푸는 방법이 GTID 기반 대기입니다. 쓰기 후 소스가 반환한 GTID를 기록해 두고, 레플리카 읽기 전에 그 GTID가 적용될 때까지 잠깐 기다립니다.
-- 레플리카 읽기 전: 해당 GTID 적용까지 대기 (0=완료, -1=타임아웃)
SELECT WAIT_FOR_EXECUTED_GTID_SET(
'3e11fa47-71ca-11e1-9e33-c80aa9429562:1-42', -- 쓰기 시 캡처한 GTID
1 -- timeout(초)
);
0을 반환하면 레플리카가 해당 트랜잭션까지 따라잡았다는 뜻이므로 안전하게 레플리카에서 읽고, 타임아웃(-1)이면 소스로 폴백합니다. “무조건 소스로 보내기”보다 세밀해서, 정상 상황에서는 대부분의 읽기를 레플리카로 흘려보내면서도 정합성을 지킵니다.
모니터링과 경보 설계
복제 지연은 서서히 쌓이다 임계점에서 급격히 무너지므로 지표를 여러 층위로 봐야 합니다. Seconds_Behind_Source만 대시보드에 걸면 앞의 함정에 그대로 빠집니다.
- 하트비트 end-to-end 지연: I/O + SQL을 관통하는 실제 지연. 가장 신뢰할 지표.
- I/O 지연(수신 갭): 소스
gtid_executed와Retrieved_Gtid_Set의 차이. - SQL 지연(적용 갭):
Retrieved와Executed의 차이. - 스레드 상태:
Replica_IO_Running·Replica_SQL_Running이Yes가 아니면 복제가 멈춘 것이므로 최우선.
# mysqld_exporter + Prometheus 경보 규칙 예시 (PromQL)
# 1) 복제 스레드가 멈춤 → 즉시 긴급
- alert: MySQLReplicationStopped
expr: mysql_slave_status_slave_sql_running == 0
or mysql_slave_status_slave_io_running == 0
for: 30s
labels: { severity: critical }
# 2) 지연 60초 초과 5분 지속 → 경고
- alert: MySQLReplicationLagHigh
expr: mysql_slave_status_seconds_behind_master > 60
for: 5m
labels: { severity: warning }
경보 임계치는 서비스의 정합성 요구에 맞춥니다. 결제 계열은 5초, 분석/리포트 계열은 수 분까지 허용해도 됩니다. 절대값보다 지연이 계속 증가하는 추세인지가 더 중요한 위험 신호입니다.
마무리
MySQL 복제 지연은 측정·진단·라우팅·정합성 보장이 맞물린 운영 과제입니다. 핵심은 Seconds_Behind_Source에만 의존하지 말고 하트비트로 실제 end-to-end 지연을 재며 GTID 갭으로 I/O 병목과 적용 병목을 분리하는 것입니다. 병렬 복제로 적용 처리량을 확보하고, 애플리케이션에서는 지연 허용 여부로 읽기를 라우팅하며, 정합성이 필수인 경로는 GTID 대기로 방어합니다.
결국 비동기 복제의 본질은 일관성과 확장성의 트레이드오프입니다. 지연을 0으로 만들려 애쓰기보다, 각 읽기가 어느 정도 지연을 견딜 수 있는지 정의하고 그에 맞는 안전장치를 두는 편이 견고합니다.
자주 묻는 질문
Q. 병렬 복제를 켰는데도 지연이 줄지 않습니다. 왜 그럴까요?
A. 워크로드가 특정 행·테이블에 집중되면 writeset 충돌이 잦아 병렬도가 오르지 않습니다. 소스에서 WRITESET이 켜져 있는지 확인하고, replication_applier_status_by_worker에서 여러 워커가 실제로 동시에 일하는지 보세요. 워커 하나만 바쁘다면 큰 트랜잭션이나 핫스팟 행 경합이 원인이므로, 병렬 설정이 아니라 쓰기 패턴을 개선해야 합니다.
Q. Seconds_Behind_Source가 계속 0인데 사용자는 오래된 데이터를 본다고 합니다. 무엇을 확인해야 하나요?
A. 전형적인 I/O 스레드 지연입니다. SQL 스레드는 받은 릴레이 로그를 다 적용했지만 I/O 스레드가 소스의 최신 binlog를 아직 못 받아온 상태라 SQL 기준 지연은 0으로 보입니다. 소스 gtid_executed와 레플리카 Retrieved_Gtid_Set을 비교해 수신 갭을 확인하고 네트워크 대역폭과 소스의 binlog 생성 속도를 점검하세요. 하트비트 테이블을 쓰면 이런 상황이 지표에 드러납니다.