이 글에서 얻는 것

  • Primary/Replica 복제 구조가 왜 빠르고 위험한지를 이해합니다.
  • 복제 지연(Lag) 때문에 발생하는 읽기 불일치를 설계로 막을 수 있습니다.
  • 읽기/쓰기 분리 라우팅과 Failover 기준을 실무 관점으로 정리합니다.
  • MySQL과 PostgreSQL의 복제 차이를 비교하고, 모니터링 메트릭을 구성할 수 있습니다.

1) 복제(Replication) 기본 구조

Client
  ├─ Write → Primary
  └─ Read  → Replica (1..N)
  • Primary: 쓰기/변경 트랜잭션 담당
  • Replica: Primary의 로그를 복제 받아 읽기 처리

복제는 결국 쓰기 로그(바이너리 로그/WAL) 를 replica가 적용하는 구조입니다. 빠르지만, 항상 지연이 존재합니다.

복제 방식 비교

방식작동 원리장점단점
비동기(Async)Primary가 커밋 후 로그를 Replica에 전송쓰기 성능 최대, 구성 단순Lag 발생, Primary 장애 시 데이터 유실 가능(RPO > 0)
반동기(Semi-sync)최소 1개 Replica ACK를 받은 후 커밋 완료데이터 유실 최소화(RPO ≈ 0)쓰기 지연 증가(네트워크 RTT만큼)
동기(Sync)모든 Replica 적용 완료까지 커밋 대기완전한 정합성쓰기 성능 크게 저하, 실무에서 거의 사용 안 함

실무 선택 기준:

  • 일반 서비스(주문/게시판): 비동기 + 모니터링이면 충분
  • 금융/결제처럼 유실이 치명적: 반동기(Semi-sync)
  • 글로벌 분산(Multi-region): 비동기 + 애플리케이션 레벨 보상 트랜잭션

2) 복제 지연(Lag)이 만드는 문제

증상

  • 방금 만든 주문이 조회 API에서 안 보임
  • 결제 직후 결제 상태가 PENDING으로 남음
  • 관리자 페이지에서 방금 수정한 설정이 반영 안 됨

원인

  • Replica가 Primary의 변경을 아직 반영하지 못함

Lag이 커지는 대표 원인들

  1. 대량 DML: UPDATE ... WHERE status = 'old' 같은 대량 변경은 replica에서도 순차 적용
  2. DDL 실행: ALTER TABLE이 테이블 락을 잡으면 후속 복제가 밀림
  3. Replica 자원 부족: CPU/IO가 Primary보다 낮은 스펙이면 처리 속도가 뒤처짐
  4. 네트워크 지연: Cross-AZ/Cross-Region 복제 시 물리적 RTT

해결 전략 (실무 기준)

1. Read-After-Write 보장 구간 쓰기 직후 N초 동안은 Primary 읽기를 강제합니다.

// 쓰기 시점 기록
@Transactional
public Order createOrder(OrderCommand cmd) {
    Order order = orderRepository.save(cmd.toEntity());
    // 세션/쿠키에 "최근 쓰기 시각" 기록
    ReadAfterWriteContext.markWriteTime();
    return order;
}

// 읽기 시 판단
public DataSource resolveDataSource(boolean readOnly) {
    if (readOnly && ReadAfterWriteContext.isWithinWriteWindow(Duration.ofSeconds(3))) {
        return primaryDataSource; // 3초 이내면 Primary에서 읽기
    }
    return readOnly ? replicaDataSource : primaryDataSource;
}

2. 세션/요청 스코프 고정 특정 트랜잭션/요청은 Primary 고정합니다. 특히 “쓰기 → 바로 결과 확인” 시나리오에 적합합니다.

3. Lag 기반 라우팅 Replica의 lag가 임계치 이상이면 읽기를 차단하거나 Primary로 폴백합니다.

// ProxySQL/MaxScale 등 미들웨어에서 설정 가능
// 또는 애플리케이션에서 주기적 lag 체크
@Scheduled(fixedRate = 1000)
public void checkReplicaLag() {
    int lagSeconds = replicaJdbc.queryForObject(
        "SHOW REPLICA STATUS", /* Seconds_Behind_Source */);
    replicaHealthy = lagSeconds < LAG_THRESHOLD_SECONDS;
}

3) MySQL vs PostgreSQL 복제 비교

MySQL 복제 (Binary Log 기반)

Primary: binlog 기록 → Replica: IO Thread가 binlog 수신 → relay log 기록 → SQL Thread가 적용

Primary (my.cnf)

server-id=1
log_bin=mysql-bin
binlog_format=ROW          # STATEMENT보다 안전(함수/랜덤 결과 일관성)
sync_binlog=1              # 커밋마다 디스크 flush (데이터 안전)
innodb_flush_log_at_trx_commit=1  # ACID 보장

Replica (my.cnf)

server-id=2
relay_log=relay-bin
read_only=ON
replica_parallel_workers=4   # 병렬 복제 (MySQL 8.0+)
replica_parallel_type=LOGICAL_CLOCK  # 동일 커밋 그룹 병렬 적용

Replica 연결

CHANGE REPLICATION SOURCE TO
  SOURCE_HOST='primary.db',
  SOURCE_USER='repl',
  SOURCE_PASSWORD='***',
  SOURCE_AUTO_POSITION=1;   -- GTID 기반 (위치 추적 자동화)
START REPLICA;

Semi-sync 활성화 (MySQL 8.0+)

-- Primary
INSTALL PLUGIN rpl_semi_sync_source SONAME 'semisync_source.so';
SET GLOBAL rpl_semi_sync_source_enabled = 1;
SET GLOBAL rpl_semi_sync_source_timeout = 1000; -- 1초 대기 후 비동기 폴백

-- Replica
INSTALL PLUGIN rpl_semi_sync_replica SONAME 'semisync_replica.so';
SET GLOBAL rpl_semi_sync_replica_enabled = 1;

PostgreSQL 복제 (WAL 기반)

PostgreSQL은 WAL(Write-Ahead Log)을 스트리밍으로 복제합니다.

Primary (postgresql.conf)

wal_level = replica            # logical로 올리면 논리적 복제도 가능
max_wal_senders = 5
synchronous_standby_names = '' # 비동기 (이름 설정 시 동기)

Replica 구성

# pg_basebackup으로 초기 복제
pg_basebackup -h primary.db -D /var/lib/postgresql/data -U repl -P -R
# -R 옵션이 standby.signal + primary_conninfo를 자동 생성

핵심 차이 요약

구분MySQLPostgreSQL
복제 로그Binary LogWAL
복제 단위행(ROW) / 문장(STATEMENT)물리적 블록 변경
병렬 복제LOGICAL_CLOCK 기반기본 단일 프로세스 (PG16+에서 개선 중)
논리적 복제X (별도 솔루션)내장 지원 (Publication/Subscription)
Failover 도구MySQL InnoDB Cluster / OrchestratorPatroni / pg_auto_failover

4) 읽기/쓰기 분리 라우팅 (Spring 예시)

기본 구현: AbstractRoutingDataSource

public class RoutingDataSource extends AbstractRoutingDataSource {
    @Override
    protected Object determineCurrentLookupKey() {
        return TransactionSynchronizationManager.isCurrentTransactionReadOnly()
                ? "replica" : "primary";
    }
}

DataSource 설정 (Spring Boot)

@Configuration
public class DataSourceConfig {
    
    @Bean
    public DataSource routingDataSource(
            @Qualifier("primaryDs") DataSource primary,
            @Qualifier("replicaDs") DataSource replica) {
        
        RoutingDataSource routing = new RoutingDataSource();
        Map<Object, Object> targets = Map.of(
            "primary", primary,
            "replica", replica
        );
        routing.setTargetDataSources(targets);
        routing.setDefaultTargetDataSource(primary);
        return routing;
    }
}

서비스 레이어 사용

@Transactional(readOnly = true)
public OrderView getOrder(Long id) {
    return orderRepository.findView(id);  // → replica
}

@Transactional
public void placeOrder(OrderCommand cmd) {
    orderService.create(cmd);  // → primary
    // 바로 조회가 필요하면 primary를 강제하거나 캐시 사용
}

주의: readOnly=true가 곧 replica로 가는 것은 아닙니다. AbstractRoutingDataSource를 직접 구현해야 하며, 복제 지연을 고려한 강제 옵션이 필요합니다.

미들웨어 기반 분리 (ProxySQL / MaxScale)

애플리케이션 코드 변경 없이 SQL 라우팅을 하고 싶다면 DB 프록시를 사용할 수 있습니다.

# ProxySQL 예시: 읽기 쿼리를 replica hostgroup(20)으로 라우팅
mysql_query_rules:
  - match_pattern: "^SELECT"
    destination_hostgroup: 20   # replica group
  - match_pattern: ".*"
    destination_hostgroup: 10   # primary group

ProxySQL vs 애플리케이션 라우팅 비교:

  • ProxySQL: 코드 변경 없음, 연결 풀링/캐시 내장, 운영 복잡도 증가
  • 애플리케이션: 세밀한 제어 가능(Read-After-Write 등), 추가 인프라 불필요

5) Failover 기준 (운영 룰)

  • 자동 Failover: Primary 다운 감지 후 Replica 승격
  • 수동 승격: 데이터 정합성 점검 후 승격 (업무 중요도에 따라)

승격 전 체크리스트

  • Replica lag < 1s (또는 0)
  • 최근 트랜잭션 손실 허용 여부 (RPO 확인)
  • 쓰기 재시도/중복 처리 안전성 (멱등성)
  • 애플리케이션 커넥션 풀이 새 Primary를 바라보는지 확인
  • 구 Primary가 다시 올라올 경우 “쓰기 차단(read_only)” 설정

Failover 시나리오별 대응

[정상 상태]
  App → Primary (Write)
  App → Replica (Read)

[Primary 장애 발생]
  1. 헬스체크 실패 감지 (3~5회 연속)
  2. Replica lag = 0 확인 (데이터 손실 최소화)
  3. Replica를 Primary로 승격 (read_only OFF)
  4. DNS/VIP 전환 또는 앱 커넥션 풀 재설정
  5. 구 Primary 복구 시 → 새 Primary의 Replica로 재구성

자동 Failover 도구

도구대상 DB특징
MySQL InnoDB ClusterMySQL 8.0+Group Replication 기반, MySQL Router 연동
OrchestratorMySQLHTTP API, 토폴로지 시각화, 유연한 정책
PatroniPostgreSQLetcd/Consul 기반 리더 선출, K8s 친화적
AWS RDS Multi-AZMySQL/PostgreSQL관리형, 자동 DNS 전환 (60~120초)

6) 복제 모니터링 메트릭 (운영 필수)

복제를 운영하면서 반드시 감시해야 할 메트릭들입니다.

MySQL

SHOW REPLICA STATUS\G

-- 핵심 확인 항목:
-- Seconds_Behind_Source: 복제 지연 초 (0이면 최신)
-- Replica_IO_Running: Yes (binlog 수신 정상)
-- Replica_SQL_Running: Yes (relay log 적용 정상)
-- Last_Error: 복제 에러 메시지

PostgreSQL

-- Primary에서 확인
SELECT client_addr, state, sent_lsn, write_lsn, flush_lsn, replay_lsn,
       (sent_lsn - replay_lsn) AS replay_lag_bytes
FROM pg_stat_replication;

-- Replica에서 확인
SELECT now() - pg_last_xact_replay_timestamp() AS replay_lag;

Prometheus + Grafana 대시보드 구성 권장 지표

지표설명알림 임계치 (예시)
replication_lag_seconds복제 지연 초Warning: > 5s, Critical: > 30s
replica_io_thread_runningIO 스레드 상태0이면 즉시 알림
replica_sql_thread_runningSQL 스레드 상태0이면 즉시 알림
replication_lag_bytesWAL/binlog 바이트 차이환경별 설정

자주 하는 실수

  • “읽기 전용 트랜잭션이면 무조건 replica"로 라우팅함 → Read-after-write 깨짐
  • 복제 지연 모니터링 없이 운영함 → 장애 시 데이터 누락
  • Failover 후 애플리케이션이 여전히 구 Primary에 쓰기 시도 → Split-brain
  • Replica 스펙을 Primary보다 낮게 잡음 → Lag이 점점 누적
  • Semi-sync 타임아웃을 너무 짧게 설정 → 네트워크 순단 시 비동기로 폴백되어 데이터 유실 가능

연습

  1. SHOW REPLICA STATUS에서 Seconds_Behind_Source를 읽고 알림 임계치를 정해보세요.
  2. 쓰기 직후 조회 API를 Primary로 강제하는 정책을 ReadAfterWriteContext로 구현해보세요.
  3. 장애 시나리오(Primary down)에서 읽기 라우팅을 어떻게 바꿀지 문서화해보세요.
  4. ProxySQL을 로컬에서 구성하고, SELECT는 replica, INSERT/UPDATE는 primary로 가는지 확인해보세요.

관련 심화 학습