Blog
kafkadisaster-recoverytestinggame-dayreliability

[Kafka DR ⑤] DR 훈련과 검증 — 복제만으론 부족하다

복제 메트릭이 '초록불'이라고 복구가 보장되는 건 아닙니다. MM2 heartbeat로 실시간 RPO를 측정하고, Game Day 훈련으로 실제 RTO를 재고, 크로스 클러스터 일관성 검증으로 DR을 '증명'하는 방법을 정리합니다.

Data Dynamics2026년 6월 17일24 min read

여러분의 DR 클러스터는 지금 이 순간에도 복제를 받고 있습니다. 대시보드의 복제 메트릭은 초록불이고, MirrorMaker 2는 멈추지 않고 돌아갑니다. 그런데 질문 하나. 정말 지금 주(primary) 클러스터가 죽으면, DR로 넘어가서 서비스가 살아날까요? 컨슈머는 끊긴 지점부터 정확히 이어서 처리할까요? RTO 30분이라는 약속은 측정된 숫자입니까, 아니면 희망입니까?

이 질문에 자신 있게 답하지 못한다면, 여러분의 DR은 자산이 아니라 부채입니다. 한 번도 실행해 본 적 없는 DR은 "복구된다고 믿는 설정"일 뿐, "복구되는 시스템"이 아닙니다. 이번 글은 Kafka DR 시리즈의 마지막 편으로, 복제를 검증 가능한 보장으로 바꾸는 방법 — 측정, 훈련(Game Day), 일관성 검증, 관측성 — 을 다룹니다.

이 글에서 배우는 것

  • 복제가 "초록불"인데도 복구가 보장되지 않는 이유
  • MM2 heartbeat로 실시간 RPO(복제 지연)를 측정하고 예산을 넘기면 알람을 띄우는 법
  • Game Day(DR 훈련)를 설계하고 실제 RTO를 측정하는 법
  • 크로스 클러스터 일관성 검증 — 메시지 카운트, 체크섬, 카나리, offset 변환 정확성
  • 훈련이 드러내는 단골 실패들과 검증 루프를 자동화하는 법

1. 복제가 "초록불"이라는 것의 함정

DR을 구축한 팀이 가장 흔히 빠지는 착각은 **"복제가 돌아가니까 복구된다"**는 믿음입니다. 둘은 전혀 다른 명제입니다.

복제가 정상이라는 것은 "주 클러스터의 데이터가 DR 클러스터로 흘러가고 있다"는 뜻입니다. 복구된다는 것은 "주 클러스터가 사라졌을 때, DR 클러스터만으로 프로듀서와 컨슈머가 정상적으로 동작을 재개한다"는 뜻입니다. 그 사이에는 검증되지 않은 가정이 잔뜩 끼어 있습니다.

복제가 보장하는 것복제가 보장하지 않는
메시지가 DR로 흘러감컨슈머가 정확한 offset에서 재개함
토픽 데이터가 미러링됨ACL·토픽 설정·스키마가 동기화됨
MM2 커넥터가 RUNNING 상태DNS/엔드포인트 전환이 동작함
복제 처리량이 정상DR 클러스터가 전체 트래픽을 감당함
__consumer_offsets가 복제됨offset 변환이 정확해 중복/누락이 없음

한 번도 페일오버를 실행해 본 적 없는 DR은, 백업을 한 번도 복원해 본 적 없는 백업과 같습니다. 존재만으로는 아무것도 증명하지 못합니다.

DR을 증명하는 유일한 방법은 주기적으로, 통제된 환경에서 실제로 페일오버를 실행해 보는 것입니다. 그 전에, 우리가 무엇을 측정하고 있는지부터 분명히 합시다.


2. 복제 지연 측정 = 살아 있는 RPO

RPO는 문서가 아니라 실시간 지표다

RPO(Recovery Point Objective)는 "재해 시 잃어도 되는 데이터의 양"입니다. DR 설계 문서에 "RPO 5초"라고 적는 것은 쉽습니다. 하지만 지금 이 순간의 실제 RPO는 주 클러스터와 DR 클러스터 사이의 **복제 지연(replication lag)**과 같습니다. 복제가 5초 밀려 있다면, 지금 재해가 나면 마지막 5초치 메시지를 잃습니다. 복제 지연이 곧 살아 있는 RPO입니다.

따라서 RPO를 지키는 일은 곧 복제 지연을 측정하고, 그것이 RPO 예산을 넘기면 알람을 띄우는 일입니다.

MM2 heartbeat로 엔드투엔드 지연 측정하기

복제 지연을 정확히 재는 가장 좋은 도구는 MirrorMaker 2의 heartbeat입니다(KIP-382). MM2는 MirrorHeartbeatConnector를 통해 주 클러스터의 heartbeats 토픽에 일정 주기로 타임스탬프가 찍힌 하트비트 메시지를 produce합니다. 이 메시지는 다른 일반 메시지와 똑같이 DR 클러스터로 복제됩니다. DR 쪽에서 복제된 <source>.heartbeats 토픽을 소비하면서, 메시지에 박힌 produce 시각과 현재 시각의 차이를 계산하면 엔드투엔드 복제 지연을 직접 얻을 수 있습니다.

# MM2 connect-mirror-maker.properties (발췌)
clusters = primary, dr
primary.bootstrap.servers = primary-broker:9092
dr.bootstrap.servers = dr-broker:9092
 
# primary -> dr 복제 흐름 활성화
primary->dr.enabled = true
 
# heartbeat / checkpoint 활성화 (RPO 측정과 offset 변환의 핵심)
primary->dr.emit.heartbeats.enabled = true
primary->dr.emit.checkpoints.enabled = true
emit.heartbeats.interval.seconds = 5
emit.checkpoints.interval.seconds = 30
 
# offset 동기화 (consumer offset 변환의 정확도)
primary->dr.sync.group.offsets.enabled = true
sync.group.offsets.interval.seconds = 30

DR 쪽에서 복제된 heartbeat를 읽어 실시간 지연을 계산하는 측정기는 다음과 같이 단순합니다.

from kafka import KafkaConsumer
import json, time
 
# DR 클러스터로 복제된 heartbeat 토픽: "<source-alias>.heartbeats"
consumer = KafkaConsumer(
    "primary.heartbeats",
    bootstrap_servers="dr-broker:9092",
    auto_offset_reset="latest",
    value_deserializer=lambda v: v,  # heartbeat payload는 바이너리
)
 
for msg in consumer:
    # heartbeat 메시지에는 produce된 시각이 기록되어 있다
    produced_at_ms = extract_heartbeat_timestamp(msg)   # KIP-382 페이로드 파싱
    replication_lag_ms = int(time.time() * 1000) - produced_at_ms
 
    # 이 값이 곧 "살아 있는 RPO"
    emit_metric("kafka_dr_replication_lag_ms", replication_lag_ms)
 
    # RPO 예산(예: 5초)을 넘기면 알람
    if replication_lag_ms > 5_000:
        page_oncall(f"DR 복제 지연 {replication_lag_ms}ms — RPO 예산 초과")

커넥터 레벨 지표도 함께 본다

heartbeat 기반 엔드투엔드 지연 외에, MirrorSourceConnector가 노출하는 JMX 메트릭으로 복제를 보강 모니터링합니다.

지표의미알람 기준(예시)
replication-latency-ms (max/avg)레코드가 source→target에 도달하기까지 걸린 시간max > RPO 예산
record-age-ms복제된 레코드의 나이(원본 produce 이후 경과)지속적으로 증가
byte-rate / record-rate복제 처리량0으로 급락(복제 정지)
checkpoint-latency-mscheckpoint 발행 지연freshness 임계 초과
Connector/Task statusRUNNING / FAILEDFAILED 즉시

핵심 규칙은 하나입니다. 복제 지연이 RPO 예산을 넘기면 알람을 띄운다. RPO는 슬라이드의 숫자가 아니라, 매 순간 측정되고 위반 시 누군가를 깨우는 살아 있는 SLO여야 합니다.


3. Game Day — DR 훈련을 일정에 넣어라

왜 정기 훈련인가

DR이 동작하는지 아는 방법은 단 하나, 실제로 페일오버를 해보는 것입니다. 그것도 한 번이 아니라 정기적으로. 시스템은 끊임없이 바뀝니다. 새 토픽이 생기고, ACL이 추가되고, 컨슈머 그룹이 늘어나고, 인프라가 업그레이드됩니다. 지난 분기에 통과한 DR이 이번 분기에도 통과한다는 보장은 없습니다. 그래서 우리는 Game Day — 통제된 시간 창에서 의도적으로 페일오버를 실행하는 훈련 — 를 분기 또는 월 단위로 일정에 못 박습니다.

Game Day 시나리오를 스크립트로 만든다

훈련의 모든 단계는 사람의 즉흥 판단이 아니라 **사전에 작성된 스크립트(runbook)**를 따라야 합니다. 4편에서 만든 runbook을 이 통제된 창에서 처음부터 끝까지 실행하는 것이 핵심입니다.

#!/usr/bin/env bash
# game-day-failover.sh — 통제된 페일오버 훈련 스크립트
set -euo pipefail
 
DRILL_ID="gameday-$(date +%Y%m%d)"
log() { echo "[$(date -u +%H:%M:%S)] $*"; }
 
# 0) 사전 점검: 복제 지연이 RPO 예산 안에 있는가
log "[$DRILL_ID] pre-check: 복제 지연 확인"
./check_replication_lag.sh --max-ms 5000 || { log "복제 지연 초과 — 훈련 중단"; exit 1; }
 
# 1) T0 기록 — RTO 측정 시작점
T0=$(date +%s)
log "[$DRILL_ID] T0=$T0 페일오버 시작"
 
# 2) (선택) 주 클러스터 트래픽 차단으로 재해 시뮬레이션
./simulate_primary_outage.sh
 
# 3) DR로 컨슈머 그룹 offset 전환 (MM2 checkpoint 기반 변환)
log "[$DRILL_ID] consumer offset 변환 적용"
./apply_translated_offsets.sh --from primary --to dr
 
# 4) DNS / 엔드포인트를 DR로 전환
log "[$DRILL_ID] 엔드포인트 전환 (primary -> dr)"
./switch_endpoint.sh --target dr
 
# 5) 애플리케이션이 DR에서 정상 처리 재개했는지 확인
log "[$DRILL_ID] 서비스 헬스 체크 (재개 확인)"
./wait_for_healthy.sh --cluster dr --timeout 600
 
# 6) T1 기록 — 실제 RTO 계산
T1=$(date +%s)
log "[$DRILL_ID] 실제 RTO = $((T1 - T0))초 (목표: 1800초)"

목표 RTO vs 실제 RTO

RTO(Recovery Time Objective)는 "재해부터 서비스 복구까지 허용되는 시간"입니다. Game Day의 가장 중요한 산출물은 실제로 측정된 RTO입니다. 위 스크립트에서 T1 - T0가 바로 그 숫자입니다.

항목목표 RTO실제 측정 RTO판정
offset 변환 적용5분7분⚠️ 초과
엔드포인트 전환(DNS TTL)2분9분❌ TTL 과다
컨슈머 재개 확인5분4분
전체30분42분

목표를 넘긴 항목 하나하나가 다음 분기까지 고쳐야 할 개선 과제가 됩니다. 위 예시에서는 DNS TTL이 너무 길어 전환이 9분이나 걸렸습니다 — 페일오버 전용으로 TTL을 30초로 낮추는 개선이 도출됩니다. 측정하지 않으면 30분이라는 약속은 그저 슬라이드 위의 글자일 뿐입니다.


4. 일관성 검증 — 복제됐다고 똑같은 건 아니다

복제 지연이 0이고 RTO가 목표 안이어도, 데이터가 실제로 일치하는지는 별개 문제입니다. 일관성 검증은 자동화된 크로스 클러스터 점검으로 이뤄집니다.

4-1. 토픽/파티션별 메시지 카운트 대조

가장 기본적인 점검은 토픽·파티션 단위로 양쪽 클러스터의 메시지 수(또는 high watermark 차이)를 비교하는 것입니다.

def compare_message_counts(primary_admin, dr_admin, topic):
    p_offsets = primary_admin.end_offsets_per_partition(topic)
    # MM2는 DR에 "<source>.topic" 으로 미러링하므로 접두사 고려
    d_offsets = dr_admin.end_offsets_per_partition(f"primary.{topic}")
 
    diffs = {}
    for partition, p_end in p_offsets.items():
        d_end = d_offsets.get(partition, 0)
        # 복제 지연만큼의 차이는 정상, RPO 예산 환산치를 초과하면 이상
        diffs[partition] = p_end - d_end
 
    return diffs   # 양수가 지속/누적되면 복제 누락 의심

4-2. 샘플 체크섬

카운트가 같아도 내용이 다를 수 있습니다. 각 파티션에서 일정 offset 구간을 샘플링해 메시지 페이로드의 체크섬(예: CRC32/해시)을 양쪽에서 계산해 비교하면, 카운트가 못 잡는 변조·순서 어긋남을 잡아냅니다.

4-3. 엔드투엔드 카나리 메시지

가장 강력한 검증은 **카나리(canary)**입니다. 주 클러스터에 고유 ID를 박은 카나리 메시지를 일정 주기로 produce하고, DR 클러스터에서 그 메시지가 (a) 도착했는지 (b) 얼마나 늦게 도착했는지를 확인합니다. 이 한 번의 왕복이 produce → 복제 → 도착의 전체 경로를 실제로 통과시킵니다.

import uuid, time
 
def run_canary(primary_producer, dr_consumer, topic="dr-canary"):
    canary_id = str(uuid.uuid4())
    sent_at = time.time()
    # 주 클러스터에 카나리 produce
    primary_producer.send(topic, key=canary_id, value=str(sent_at))
    primary_producer.flush()
 
    # DR 클러스터에서 같은 ID가 도착하는지 대기
    deadline = time.time() + 30
    for msg in dr_consumer:                      # "primary.dr-canary" 구독
        if msg.key == canary_id:
            arrived_lag = time.time() - sent_at
            emit_metric("kafka_dr_canary_lag_s", arrived_lag)
            return True
        if time.time() > deadline:
            page_oncall(f"카나리 {canary_id} 30초 내 DR 미도착")
            return False

4-4. offset 변환 정확성 검증 (가장 중요)

Kafka DR에서 가장 미묘하고 자주 깨지는 부분이 컨슈머 offset 변환입니다. 주 클러스터의 컨슈머 그룹 offset은 DR 클러스터에서 같은 숫자를 가리키지 않습니다 — 파티션마다 복제 시작점이 다르기 때문입니다. MM2는 MirrorCheckpointConnector로 source→target offset 매핑(__consumer_offsets 변환)을 만들어 줍니다. 이 변환이 정확한지를 테스트 컨슈머 그룹으로 직접 페일오버시켜 검증해야 합니다.

# offset 변환 검증: 테스트 컨슈머 그룹을 DR로 페일오버시킨다
def validate_offset_translation(test_group, dr_admin, checkpoint_reader):
    # 1) MM2 checkpoint에서 변환된 offset을 읽는다 (RemoteClusterUtils)
    translated = checkpoint_reader.translate_offsets(
        group=test_group, source="primary", target="dr"
    )
    # 2) DR에서 해당 그룹을 변환된 offset으로 시작시킨다
    dr_admin.alter_consumer_group_offsets(test_group, translated)
 
    # 3) 재개 후 처리 결과를 검증한다
    resumed = consume_until_idle(test_group, cluster="dr")
 
    # 큰 갭(누락)도, 큰 중복도 없어야 한다
    assert resumed.gap < ACCEPTABLE_GAP, f"offset 갭 과다: {resumed.gap}"
    assert resumed.duplicates < ACCEPTABLE_DUP, f"중복 과다: {resumed.duplicates}"

검증 합격 기준은 명확합니다. 테스트 컨슈머 그룹이 DR로 넘어가 큰 갭(누락) 없이, 큰 중복 없이 처리를 재개해야 합니다. 약간의 중복은 정상입니다 — Kafka DR은 본질적으로 at-least-once이므로, 컨슈머는 멱등(idempotent)해야 합니다(6장 참고).


5. 관측성 — DR을 항상 보이게 하라

검증은 일회성 이벤트가 아니라 상시 가시성입니다. 다음 지표들을 대시보드에 올리고 임계 위반 시 알람을 겁니다.

영역대시보드 패널알람 조건
복제 지연(RPO)replication_lag_ms, heartbeat 기반 엔드투엔드 지연RPO 예산 초과
heartbeat 지연heartbeat 도착 간격 / 최신성heartbeat 끊김 또는 지연 급증
커넥터 상태MirrorSource/Checkpoint/Heartbeat 커넥터·태스크 statusFAILED 또는 PAUSED
checkpoint 최신성마지막 checkpoint 이후 경과 시간freshness 임계 초과
카나리카나리 왕복 지연 / 미도착률미도착 또는 지연 급증
RTO 추세분기별 측정 RTO 추이목표 RTO 초과

여기서 자주 놓치는 것이 **checkpoint 최신성(freshness)**입니다. checkpoint가 멈추면 offset 변환이 낡아지고, 그 상태로 페일오버하면 컨슈머가 엉뚱한 지점에서 재개합니다. heartbeat와 checkpoint는 "복제가 살아 있다"가 아니라 "복구가 가능하다"를 말해 주는 지표라는 점에서, 일반 처리량 지표보다 우선순위가 높습니다.


6. 훈련이 드러내는 단골 실패들

Game Day의 진짜 가치는 성공이 아니라 실패를 안전한 시간에 발견하는 것입니다. 실전 페일오버가 아니라 통제된 훈련에서 터지게 만드는 것이죠. 현장에서 훈련이 반복적으로 들춰내는 단골 결함들입니다.

발견되는 결함증상처방
낡은 runbook스크립트가 사라진 호스트/옛 엔드포인트를 가리킴runbook을 코드로 관리, 매 훈련마다 갱신
ACL/토픽 설정 미동기화DR에서 프로듀서/컨슈머가 권한 거부, 파티션 수 불일치ACL·토픽 config 동기화 자동화, 카운트 검증
변환되지 않은 offset컨슈머가 처음부터 다시 읽거나 큰 갭 발생checkpoint 활성화 + offset 변환 검증 상시화
비멱등 컨슈머페일오버 후 중복 처리로 부작용(중복 결제 등)멱등 키 도입, at-least-once 전제로 재설계
DNS/전환 갭TTL이 길어 클라이언트가 옛 클러스터를 계속 봄페일오버용 짧은 TTL, 헬스 기반 전환
과소 프로비저닝된 DRDR이 전체 트래픽 못 받아 지연/장애DR을 주와 동등 용량으로, 정기 부하 검증

이 표 하나하나가 "복제는 초록불인데 복구는 실패"하는 구체적 이유입니다. 그리고 전부, 실행해 보기 전에는 보이지 않는 것들입니다.


7. 검증 루프 — 한 바퀴를 계속 돈다

지금까지의 모든 활동은 한 번 하고 끝나는 체크리스트가 아니라, 끊임없이 도는 루프입니다. 복제하고, 지연을 측정하고, 페일오버를 훈련하고, 일관성과 RTO를 검증하고, 드러난 갭을 고치고, 다시 복제로 돌아갑니다.

Loading diagram…

이 루프가 돌아가는 한, DR은 "믿음"이 아니라 매 분기 갱신되는 측정된 보장으로 유지됩니다. 루프가 멈추는 순간 — 마지막 Game Day가 6개월 전이라면 — DR은 다시 부채로 미끄러집니다.


마치며 — Kafka DR 시리즈를 닫으며

다섯 편에 걸친 Kafka DR 여정을 마칩니다. DR은 어느 한 단계가 아니라 설계 → 구축 → 변환 → runbook → 훈련의 연속체입니다. 어느 하나라도 빠지면 나머지가 무력해집니다.

주제핵심 메시지
DR 설계와 토폴로지RPO/RTO를 정하고 Active-Passive/Active-Active 토폴로지를 고른다
MirrorMaker 2 복제 구축MM2로 토픽·데이터를 DR로 미러링한다
offset 변환과 컨슈머 페일오버checkpoint로 컨슈머가 끊긴 지점에서 재개하게 한다
페일오버 runbook사람이 따를 수 있는 단계별 절차를 코드로 만든다
DR 훈련과 검증 (이 글)측정·Game Day·일관성 검증으로 복구를 증명한다

이 시리즈가 전하려는 단 하나의 문장은 이것입니다. 복제만으론 부족하다. 복제는 DR의 시작일 뿐, 끝이 아닙니다. 복제는 데이터를 옮기지만, 복구를 보장하는 것은 측정과 훈련과 검증입니다. 한 번도 실행해 본 적 없는 DR은 부채이고, 매 분기 실행되고 측정되는 DR만이 자산입니다.

DR이 "재해가 났을 때"의 이야기라면, 그 형제 격인 시리즈는 "평소에 무너지는 것들"을 다룹니다. 컨슈머 랙, 메시지 유실, acks/min.insync.replicas, 리밸런스 폭풍 같은 일상의 장애를 깊게 파고드는 "Kafka 운영 트러블슈팅" 시리즈를 함께 읽어보시길 권합니다. 평소의 운영을 단단히 다져 두는 것이야말로 최고의 DR 준비이기도 하니까요.

여러분의 DR이 부채가 아니라 자산이 되기를. 다음 Game Day에서 측정한 RTO가 목표 안에 들어오기를 바랍니다.


참고 자료


— Data Dynamics 엔지니어링 팀