← all posts
DEV 2026.05.02 · 13 min read Intermediate

MySQL 복제는 어떻게 일관성을 지키는가

Binary Log 3단계 복제 구조부터 GTID 기반 자동 페일오버, Spring AbstractRoutingDataSource 구현까지 — 비동기 복제의 구조적 특성과 그 대가를 추적한다.


방금 결제를 완료했는데 주문 확인 페이지에 “PENDING” 상태가 표시된다. DB 로그를 보면 Primary에는 분명 PAID로 커밋됐다. 이 버그는 왜 로컬에서 재현되지 않고, 왜 프로덕션에서만 간헐적으로 나타나는가?

복제의 출발점: Binary Log 3단계 구조

MySQL 복제는 세 단계로 이루어진다.

Primary → Binary Log 기록. 모든 DML이 Binary Log에 기록된다. InnoDB의 Redo Log와는 별개로 MySQL 서버 레벨에서 관리되며, sync_binlog=1을 설정해야 COMMIT마다 fsync가 일어나 내구성이 보장된다. 이 설정 없이 Primary가 크래시하면 OS 버퍼에만 있던 이벤트가 사라지고, 이미 Replica에 전송된 이벤트가 Primary에는 없는 “ahead of primary” 상태가 발생한다.

IO Thread → Relay Log 수신. Replica의 IO Thread가 Primary에 연결을 유지하며 Binary Log 이벤트를 가져와 Relay Log 파일에 기록한다. IO Thread가 SQL Thread와 분리된 이유는 버퍼 역할 때문이다. SQL Thread가 느려도 IO Thread는 계속 이벤트를 받아두므로, Relay Log가 쌓이는 한 Primary는 영향을 받지 않는다.

SQL Thread → DB에 적용. Relay Log 이벤트를 순서대로 자신의 DB에 적용한다. 기본은 단일 스레드이며, MySQL 8.0의 LOGICAL_CLOCK 병렬 복제를 활성화하면 Primary가 동시에 커밋한 트랜잭션 그룹을 여러 워커가 병렬로 처리해 Lag을 줄일 수 있다.

비동기 복제의 구조적 특성

세 단계가 독립적으로 동작하는 결과로, MySQL 복제는 본질적으로 **최종 일관성(Eventual Consistency)**이다.

Primary: COMMIT 완료 → 클라이언트에 응답
          ↓ (네트워크 지연)
IO Thread: Binary Log 이벤트 수신 → Relay Log 기록
          ↓ (처리 속도 차이)
SQL Thread: Relay Log 적용 → Replica DB 반영

각 단계마다 시간이 소요된다. 정상 환경에서는 수 ms지만, 대형 트랜잭션(100만 건 UPDATE)이나 Primary 쓰기 TPS가 SQL Thread 처리 속도를 초과할 때는 수 분까지 Lag이 쌓인다.

Seconds_Behind_Source가 0이어도 완전히 동기화됐다고 볼 수 없다. 이 값은 SQL Thread가 현재 적용 중인 이벤트의 Primary 타임스탬프와 현재 시각의 차이다. IO Thread가 아직 받지 못한 이벤트는 이 계산에 포함되지 않는다. End-to-End Lag을 정확히 측정하려면 pt-heartbeat처럼 Primary에 주기적으로 타임스탬프를 기록하고 Replica에서 그 차이를 읽는 방식이 필요하다.

Binary Log 포맷 선택

Row-Based(RBR)는 변경된 Row의 실제 Before/After 이미지를 기록해 결정론적 복제를 보장한다. Statement-Based(SBR)는 SQL 텍스트를 기록해 로그 크기가 작지만, NOW(), UUID(), RAND() 같은 비결정적 함수가 Primary와 Replica에서 다른 결과를 낼 수 있다. MySQL 8.0의 기본값은 RBR이며 실무에서도 RBR을 권장한다.

GTID: 트랜잭션의 전역 여권번호

Position 기반 복제의 가장 큰 약점은 Failover다. Primary가 장애를 일으키면 각 Replica가 mysql-binlog.000023:1234567처럼 서로 다른 Position을 가리키고 있다. 새 Primary를 선출하면 다른 Replica들이 “어디서부터 이어받아야 하나”를 Binary Log 파일 단위로 수동 계산해야 한다. 전문 DBA가 수십 분을 소요하는 작업이다.

GTID(source_uuid:transaction_id)는 모든 트랜잭션에 전역 고유 ID를 부여한다. 각 서버는 자신이 적용한 GTID 집합(gtid_executed)을 항상 추적한다. Failover 시 Replica가 새 Primary에 연결할 때 SOURCE_AUTO_POSITION=1 한 줄이면 충분하다.

CHANGE REPLICATION SOURCE TO
    SOURCE_HOST = 'new-primary-host',
    SOURCE_AUTO_POSITION = 1;
START REPLICA;

Replica가 “나는 uuid:1-9500까지 적용했다”고 전송하면, 새 Primary가 자동으로 9501-10000을 보내준다. Orchestrator 같은 도구와 조합하면 Primary 장애 감지부터 최신 Replica 선출, 나머지 Replica 재연결까지 1~5분 내에 자동 처리된다.

GTID 환경에서 복제 에러를 건너뛸 때도 정밀하다. 예전의 SQL_REPLICA_SKIP_COUNTER는 몇 번째 이벤트를 건너뛸지 지정하는 방식이라 멀티스레드 환경에서 의도치 않은 이벤트를 건너뛸 수 있었다. GTID 방식은 특정 GTID를 빈 트랜잭션으로 표시해 정확히 해당 트랜잭션만 건너뛴다.

Spring에서 R/W 분리 구현

애플리케이션 레벨에서 R/W 분리를 구현하려면 두 가지 핵심 컴포넌트가 필요하다.

**AbstractRoutingDataSource**는 여러 DataSource를 등록하고 런타임에 선택한다. determineCurrentLookupKey()를 오버라이드해 현재 트랜잭션의 readOnly 속성을 라우팅 키로 사용한다.

@Override
protected Object determineCurrentLookupKey() {
    boolean isReadOnly = TransactionSynchronizationManager
        .isCurrentTransactionReadOnly();
    return isReadOnly ? DataSourceType.REPLICA : DataSourceType.PRIMARY;
}

**LazyConnectionDataSourceProxy**가 없으면 R/W 분리가 동작하지 않는다. @Transactional AOP가 진입할 때 readOnly 속성이 아직 false(기본값)인 상태에서 Connection을 획득하면, readOnly=true로 설정되기 전에 이미 Primary가 선택된다. LazyConnectionDataSourceProxy는 실제 Connection 획득을 첫 쿼리 실행 시점까지 지연시켜 readOnly 설정 후에 라우팅 결정이 이루어지도록 보장한다.

@Primary
@Bean
public DataSource dataSource(DataSource routingDataSource) {
    return new LazyConnectionDataSourceProxy(routingDataSource);
}

@Transactional(readOnly=true)는 MySQL에 SET TRANSACTION READ ONLY 힌트를 전송한다. InnoDB는 읽기 전용 트랜잭션에서 Read View 생성을 최적화하고, Hibernate는 FlushMode.NEVER로 Dirty Checking을 비활성화한다. Replica 라우팅 효과 외에도 약간의 성능 개선이 따른다.

트레이드오프

R/W 분리는 Primary 읽기 부하를 줄이고 읽기 처리량을 수평 확장하는 대신 복잡도를 높인다. 트랜잭션 전파(REQUIRED)로 인해 readOnly=false 트랜잭션 안에서 호출된 readOnly=true 메서드는 기존 트랜잭션에 참여하므로 Replica가 아닌 Primary를 사용한다. Replica가 장애를 일으킬 때 Primary로 Fallback하는 로직도 별도로 구현해야 한다. 결제, 재고, 잔액처럼 강한 일관성이 필요한 데이터는 R/W 분리 대상에서 제외하고 항상 Primary에서 읽어야 한다.

정리

  • MySQL 복제는 Binary Log → Relay Log → SQL Thread의 3단계 비동기 파이프라인이다. 각 단계의 독립성이 Lag의 원인이며, 이는 제거할 수 없는 구조적 특성이다.
  • sync_binlog=1innodb_flush_log_at_trx_commit=1을 함께 써야 Binary Log와 Redo Log 양쪽의 내구성이 보장된다.
  • GTID는 SOURCE_AUTO_POSITION=1로 Failover를 자동화하고, 복잡한 Binary Log Position 계산을 없앤다.
  • Spring R/W 분리는 AbstractRoutingDataSource + LazyConnectionDataSourceProxy의 조합 없이는 동작하지 않는다.

다음 글에서는 이 복제 파이프라인 위에서 트랜잭션 격리 수준이 어떻게 작동하는지, 그리고 MVCC가 Replication Lag과 맞물릴 때 어떤 현상이 발생하는지 추적한다.