MySQL Replication은 왜 '보낸 것'과 '도착한 것'이 다른가
비동기 복제의 구조적 지연부터 Binary Log 포맷, GTID Failover, Semi-Sync, 병렬 복제, Spring 라우팅까지 — 복제 파이프라인 전체를 하나의 트레이드오프 지도로 추적한다.
- 01 MySQL 쿼리 최적화의 공통 원리 — 인덱스를 죽이는 패턴들
- 02 MySQL 파티셔닝은 언제 써야 하는가
- 03 MySQL Replication은 왜 '보낸 것'과 '도착한 것'이 다른가
- 04 MySQL 백업은 왜 --single-transaction 없이 믿을 수 없나
- 05 MySQL 설계 결정은 왜 처음이 전부인가
- 06 MySQL은 어디서 얼마나 걸리는가
- 07 MySQL 보안 설계의 세 기둥 — 권한, 연결, 환경 분리
MySQL Replication의 핵심 약속은 단순하다 — Source에서 커밋된 데이터를 Replica에서도 읽을 수 있게 한다. 그런데 이 약속은 정확히 언제, 얼마나 지켜지는가? Source에서 COMMIT이 반환된 순간, Replica에는 그 데이터가 없을 수 있다. 왜 그런가?
복제 파이프라인의 구조
복제는 세 단계 파이프라인이다.
Source: COMMIT → Binary Log 기록 → Dump Thread 전송
Replica: IO Thread 수신 → Relay Log 기록 → SQL Thread 재실행
비동기 복제에서 Source는 Replica의 수신 여부와 무관하게 클라이언트에 응답을 반환한다. 이 설계 덕분에 Replica 장애가 Source 성능에 영향을 주지 않는다. 그러나 대가가 있다 — Source 장애 시 Dump Thread가 전송을 시작하기 전에 서버가 다운되면, 해당 트랜잭션은 어디에도 없다.
Binary Log와 InnoDB Redo Log는 별개다. Redo Log는 엔진 크래시 복구용(물리적, 인스턴스 내부), Binary Log는 복제와 시점 복구용(논리적, 네트워크 전송 가능)이다. 두 로그는 2단계 커밋(2PC)으로 일관성을 맞춘다 — Binary Log에 먼저 기록하고, 그다음 InnoDB commit 상태를 기록한다. Binary Log에 있으나 InnoDB commit이 없으면 크래시 복구 시 커밋으로 처리된다.
Binary Log 포맷: 크기 vs 정확성
Binary Log를 어떤 형식으로 기록하느냐에 따라 복제 안전성과 저장 비용이 결정된다.
STATEMENT 방식은 SQL 문 자체를 기록한다. INSERT INTO logs VALUES (UUID(), NOW())를 Replica에서 재실행하면 UUID와 NOW()가 재평가되어 Source와 다른 값이 저장된다. 비결정적 함수는 복제 불일치를 일으킨다.
ROW 방식은 변경된 각 행의 Before/After 값을 기록한다. 항상 정확하지만 UPDATE orders SET status='PAID' WHERE created_at < '2020-01-01'처럼 500만 건을 건드리면 수십 GB Binary Log가 생성된다. MySQL 5.7.7 이후 기본값이다.
MIXED는 기본적으로 STATEMENT를 쓰다가 비결정적 함수를 감지하면 ROW로 전환한다. 전환 기준이 복잡해 예측이 어렵다.
ROW 포맷 환경에서 대용량 배치 DML은 Binary Log를 폭증시킨다. binlog_row_image=MINIMAL을 설정하면 Before 이미지에 PK만, After 이미지에 변경된 컬럼만 기록하여 크기를 50~90% 줄일 수 있다. 단, pt-table-checksum 같은 일부 도구와 호환성을 확인해야 한다.
GTID: Failover 위치 계산을 없애다
포지션 기반 복제에서 Source 장애 후 Failover는 수동 계산이 필요하다. 각 Replica가 바라보던 구 Source의 Binary Log 파일명과 위치를 새 Source의 Binary Log에 매핑해야 한다. 실수하면 트랜잭션이 중복 적용되거나 누락된다.
GTID(Global Transaction ID)는 각 트랜잭션에 server_uuid:transaction_id 형식의 전역 식별자를 부여한다. Replica는 자신이 처리한 GTID 집합(GTID_EXECUTED)을 알고 있고, 새 Source는 이 집합과 자신이 가진 집합의 차집합만 전송한다.
-- 포지션 기반
CHANGE REPLICATION SOURCE TO
SOURCE_LOG_FILE='mysql-bin.000005',
SOURCE_LOG_POS=1234567; -- 수동 계산
-- GTID 기반
CHANGE REPLICATION SOURCE TO
SOURCE_AUTO_POSITION=1; -- 끝
Failover 소요 시간이 15분1시간에서 30초3분으로 줄어드는 이유다.
GTID의 비용은 SQL 패턴 제약이다. CREATE TABLE ... SELECT는 DDL과 DML이 하나의 이벤트에 섞여 단일 GTID로 처리할 수 없다. 트랜잭션 내 임시 테이블 DDL, MyISAM과 InnoDB 혼합 트랜잭션도 불가하다. enforce_gtid_consistency=WARN으로 먼저 위반 패턴을 로그에서 확인한 뒤 코드를 수정하고, 이후 단계적으로 ON으로 전환하는 것이 안전하다.
Replication Lag: 두 종류의 병목
SHOW REPLICA STATUS에서 Seconds_Behind_Source=0이라도 안심할 수 없다. 이 값은 SQL Thread가 현재 처리 중인 이벤트의 원본 타임스탬프와 현재 시각의 차이다. SQL Thread가 쉬고 있을 때는 0이 표시되지만, IO Thread가 Source를 아직 따라잡지 못한 상태일 수 있다.
병목 진단은 세 위치를 비교하는 것에서 시작한다.
① Source 최신 위치 (SHOW BINARY LOG STATUS)
② Read_Source_Log_Pos ← IO Thread 수신 위치
③ Exec_Source_Log_Pos ← SQL Thread 실행 위치
① ≈ ②, ② >> ③ → SQL Thread 병목
① >> ② → IO Thread 병목
IO Thread 병목은 Binary Log 압축(binlog_transaction_compression=ON)이나 네트워크 대역폭으로 접근한다. SQL Thread 병목은 병렬 복제(replica_parallel_type=LOGICAL_CLOCK, replica_parallel_workers=8)로 접근한다.
그러나 두 해결책 모두 단일 대용량 트랜잭션에는 효과가 없다. 500만 건 UPDATE를 하나의 트랜잭션으로 실행하면 SQL Thread 하나가 수분 동안 그 이벤트를 처리하며 후속 모든 트랜잭션을 차단한다. 근본 해결은 배치 DML을 LIMIT 1000씩 청크로 나누고 사이에 SLEEP(0.1)을 두는 것이다.
Semi-Sync: ACK 한 번의 비용과 보장
비동기 복제가 기본이라면, Semi-Sync는 “최소 1개 Replica의 Relay Log 수신 확인 후 커밋 완료”를 보장하는 절충점이다.
MySQL 5.7 이후 기본인 After-Sync(Loss-less) 방식은 다음 순서로 동작한다.
Binary Log 기록 → Replica 전송 → ACK 수신 → InnoDB 커밋 → 클라이언트 응답
InnoDB 커밋이 ACK 이후에 일어나므로, 장애 시 다른 세션에서도 보이지 않았던 데이터가 Replica에 없는 “팬텀 커밋” 문제가 없다.
추가 지연은 네트워크 RTT다. 같은 데이터센터라면 약 1ms, 원거리라면 수십 ms다. rpl_semi_sync_source_timeout(권장: 1,000~2,000ms) 초과 시 자동으로 비동기로 강등되고, Replica가 복귀하면 자동으로 Semi-Sync로 돌아온다. 강등 기간의 트랜잭션은 여전히 유실 위험이 있으므로 Rpl_semi_sync_source_no_tx가 0보다 크면 즉시 알람을 받아야 한다.
Spring에서 Replica 라우팅의 함정
AbstractRoutingDataSource로 @Transactional(readOnly=true)를 Replica로, 나머지를 Source로 라우팅하는 구현은 두 가지 함정이 있다.
첫째, LazyConnectionDataSourceProxy가 없으면 동작하지 않는다. Spring 트랜잭션은 시작 시점에 커넥션을 획득하는데, 이때 readOnly 플래그가 아직 설정되지 않아 determineCurrentLookupKey()가 항상 Source를 반환한다. LazyConnectionDataSourceProxy는 실제 쿼리 실행 시점까지 커넥션 획득을 미뤄 readOnly 플래그를 확인 가능하게 한다.
둘째, 라우팅 AOP에 @Order(Ordered.HIGHEST_PRECEDENCE)가 없으면 트랜잭션 AOP가 먼저 실행되어 커넥션이 이미 획득된 후 라우팅 결정이 내려진다.
쓰기 직후 같은 트랜잭션 안에서 읽기가 필요하다면, 읽기 메서드에 propagation=Propagation.REQUIRED를 명시한다. 이렇게 하면 부모 트랜잭션(Source)의 커넥션을 재사용해 Stale Read가 발생하지 않는다.
@Transactional // Source
public OrderResponse processOrder(OrderRequest req) {
Order order = orderRepository.save(new Order(req));
return getDetail(order.getId()); // Source 커넥션 재사용
}
@Transactional(readOnly = true, propagation = Propagation.REQUIRED)
public OrderResponse getDetail(Long orderId) {
// 부모가 Source → Source에서 읽기 보장
return orderRepository.findById(orderId).map(OrderResponse::new).orElseThrow();
}
결제, 재고 확인처럼 Stale Read가 비즈니스 손실로 이어지는 경우는 항상 Source에서 읽어야 한다.
정리
- 비동기 복제는 Source 성능을 보호하지만,
COMMIT 완료 ≠ Replica 반영이다. - Binary Log 포맷은 ROW가 기본값이고 정확성을 보장하지만, 대용량 DML은 청크로 나눠야 한다.
- GTID는 Failover 위치 계산을 없애는 대신 일부 SQL 패턴을 허용하지 않는다.
- Lag 진단은 IO Thread 병목과 SQL Thread 병목을 구분하는 것에서 시작한다. 병렬 복제는 SQL Thread 병목에만 효과적이다.
- Semi-Sync는 데이터 유실 위험을 네트워크 RTT 비용으로 구매하는 선택이다.
- Spring 라우팅에서 LazyConnectionDataSourceProxy와 AOP 순서, 트랜잭션 전파 설정을 놓치면 라우팅이 묵묵히 동작하지 않는다.
복제 파이프라인의 모든 결정은 “성능 보장”과 “일관성 보장” 사이 어딘가에 놓인다. 어느 쪽을 얼마나 포기할지가 아키텍처의 핵심 질문이다.