IT/기타

Kafka MirrorMaker 2 offset translation, 그거 진짜 믿으면 큰일난다

gfrog 2026. 7. 22. 00:19

DR 훈련 하다가 죽을 뻔한 이야기. 지난달에 우리 팀은 서울 리전 → 도쿄 리전 Kafka 클러스터 사이 MirrorMaker 2(MM2) 페일오버를 실제로 눌러봤다. 스테이징에서 몇 번 성공했고, 문서에도 "consumer group offset은 자동 translation 된다"라고 되어 있으니까 마음이 편했다.

결론부터 말하면, 우리는 특정 컨슈머 그룹에서 메시지 4만 건을 재처리했다. idempotent하게 짜놓은 게 그나마 다행이었지, 안 그랬으면 리포트 팀이 한 달치 데이터를 다시 만들었어야 했다. 그리고 그 원인이 대부분 "offset translation을 너무 믿었다"에서 시작한다.

액티브-패시브인데 왜 lag가 음수로 뜨죠

우리 구성은 이렇다. 서울(source) → 도쿄(target) 단방향 MM2, replication.policy.class는 기본값(DefaultReplicationPolicy)이라 도쿄에는 seoul.orders, seoul.payments 같은 접두어 붙은 토픽이 생긴다. 컨슈머 그룹 offset도 MM2가 MirrorCheckpointConnector로 계속 동기화하고 있었다.

훈련 시나리오는 단순했다. 서울 브로커를 죽이고, 도쿄에서 컨슈머 그룹을 그대로 살려서(offset translation된 위치부터) 재개한다. 정확히 이 시나리오 그대로 눌렀다.

한 15분쯤 지나서 SRE 채널에 알람이 떴다. 어떤 컨슈머 그룹의 consumer_lag음수로 튀어나온 것이다. 처음엔 지표 파티프인이 이상한가 했다. Grafana 쿼리 다시 짜보고, kafka-consumer-groups.sh 로 직접 확인했는데, 정말로 committed offset이 log end offset보다 크게 잡혀 있었다. 어떻게 이럴 수가 있지?

Empty partition 함정

원인을 파는 데 두 시간이 걸렸다. 문제가 된 파티션은 서울에서 트래픽이 거의 없던 파티션이었다. 정확히는, 우리가 며칠 전에 리텐션을 3일로 줄이면서 옛날 메시지가 다 삭제됐고, 그 사이 이 파티션엔 새 메시지가 안 들어오고 있었다.

그러니까 서울 쪽 상태:

  • 파티션 HWM(high water mark): 0 (다 지워짐)
  • 컨슈머 그룹 committed offset: 1,203,847 (예전에 소비했던 지점)

도쿄 쪽 상태:

  • 파티션 HWM: 0 (미러링된 데이터도 없음)
  • MM2가 sync한 컨슈머 offset: 1,203,847

바로 이거다. Apache Kafka JIRA에 등록된 KAFKA-12635 이슈. 소스 파티션이 비었는데 소스 컨슈머 그룹의 offset이 0이 아니면, MM2는 translation 없이 원본 offset을 그대로 target 그룹에 박아넣는다. 그래서 target log는 비었는데 committed offset은 100만이 넘는 상태가 된다.

Lenses가 최근에 쓴 MM2 복잡성 정리 글에서도 이 케이스가 여전히 실무에서 자주 꼬이는 항목으로 나온다. Aiven 문서 known issues 페이지에도 대놓고 "negative lag는 데이터 손실 버그일 수 있다"라고 경고가 붙어있다. 이걸 훈련 전에 봤어야 했는데.

auto.offset.reset=latest가 폭탄이 되는 이유

이 상태에서 컨슈머를 그냥 재개했다면 어떻게 됐을까. 다행히 컨슈머 설정이 auto.offset.reset=latest였다. 이게 무슨 뜻이냐면, committed offset이 log end offset보다 크면(즉, 리셋이 필요한 상황이면) 최신 지점부터 다시 읽는다는 뜻이다. 그래서 이후에 새 메시지가 들어오면 그 뒤부터 정상적으로 소비한다. 겉으로 보기엔 문제가 없어 보인다.

근데 여기서 미묘한 문제가 있다. 우리는 소스에서 예전에 컨슈머 그룹이 있던 시점의 데이터를 아직 처리 완료했다고 판단하고 있었다. 서울 → 도쿄로 replication되면서 딱 그 시점의 뒷 데이터부터는 도쿄에도 들어와 있었고, 컨슈머는 그 부분을 못 보고 넘어가버렸다.

한편 auto.offset.reset=earliest로 설정된 다른 컨슈머 그룹이 있었는데, 얘는 반대 문제를 일으켰다. Offset이 log end보다 크니까 리셋 발생 → earliest로 이동 → offset 0부터 다시 소비. 이미 처리했던 메시지 4만 건을 재처리한 게 여기다. Idempotent 처리 로직 덕분에 부작용은 없었지만, DB write가 4만 건 튀면서 batch job 하나가 실패 알람을 울렸다.

솔직히 여기까지 오면서 든 생각은 "MM2가 offset을 자동으로 translation 해준다는 말은 반쪽 진실이구나"였다. Best-effort지 guarantee가 아니다. 이거 문서에 좀 더 크게 써놓으면 좋겠는데.

뭘 바꿨나

일단 훈련 직후에 팀 내부에서 몇 가지 결론을 냈다. 아직 계속 다듬는 중이다.

첫째, MM2 내부 토픽 모니터링을 강화했다. mm2-offset-syncs.*.internal*.checkpoints.internal 토픽의 message rate를 대시보드에 걸었다. 이게 갑자기 0으로 떨어지면 MirrorCheckpointConnector가 뻗은 거니까 알람이 뜬다. 이거 안 하는 팀이 의외로 많은 것 같은데, MM2 쓴다면 이건 진짜 필수다.

둘째, 훈련 전 offset 검증 스크립트를 만들었다. 훈련 시작 전에 target 클러스터의 모든 컨슈머 그룹에 대해 committed offset > log end offset 케이스를 스캔하고, 걸리면 훈련 중단한다.

#!/bin/bash
# check-mm2-offset-drift.sh
BOOTSTRAP=$1
GROUP_PREFIX=${2:-"seoul."}

kafka-consumer-groups.sh --bootstrap-server "$BOOTSTRAP" --list \
  | grep "^${GROUP_PREFIX}" \
  | while read -r group; do
    kafka-consumer-groups.sh --bootstrap-server "$BOOTSTRAP" \
      --describe --group "$group" \
      --offsets 2>/dev/null \
      | awk 'NR>1 && $5 != "-" && $6 != "-" && ($6 + 0) < 0 {
          print "DRIFT: group=" $1 " topic=" $2 " part=" $3 " current=" $4 " end=" $5 " lag=" $6
        }'
  done

돌려보면 우리 클러스터에서만 12개 그룹이 걸렸다. 그중 절반은 이번에 처음 알게 된 것들이다. 훈련 전에 이거 돌려서 정리했다면 그날 저녁이 훨씬 편했을 것이다.

셋째, DR 페일오버 런북에 명시적으로 offset reset 정책을 넣었다. 크리티컬한 컨슈머 그룹은 페일오버 직후 자동 재개하지 않고, DR 담당자가 target 쪽 offset을 눈으로 확인한 뒤에 수동으로 시작한다. 자동화의 반대 방향으로 가는 결정이라 조금 찜찜한데, MM2 translation의 신뢰도가 지금 수준에선 이게 안전하다고 결론 내렸다.

여기까지 정리

MM2가 나쁜 도구라는 얘기는 아니다. Cross-region replication을 이만큼 쉽게 세팅할 수 있는 오픈소스 대안이 딱히 없다. 다만 "offset이 알아서 맞춰진다"는 문구를 그대로 믿고 페일오버 자동화 짜면 언젠가 크게 데인다. 특히 트래픽이 적거나 리텐션이 짧은 파티션이 하나라도 섞여 있으면 거의 확정이다.

혹시 우리처럼 MM2로 DR 구성한 팀 있으면, 다음 훈련 전에 target 쪽 negative lag 스캔부터 한 번 돌려보시길. 미리 발견하면 데이터 재처리 안 해도 된다.

다음 글에선 이 참에 검토했던 대안 — Confluent Cluster Linking, Redpanda cross-cluster replication — 을 실무 관점에서 짧게 비교해볼까 한다. 아직 우리도 결론은 못 냈다.