실수 케이스 · 8
상태 토픽 데이터가 조용히 사라졌다
회원 프로필 스냅샷 토픽은 "키별 최신값을 영구 보관하는 저장소"로 설계됐습니다.
그런데 cleanup.policy를 지정하지 않았습니다.
기본값은 delete이고 retention.ms 기본값은 7일입니다.
정확히 8일째, 새 파드가 캐시를 부트스트랩하면서
회원 240만 명 중 96만 명의 프로필이 사라진 것을 발견했습니다.
이 케이스에서 얻어 갈 것
delete와compact의 차이, 그리고 두 정책이 세그먼트 단위로 동작한다는 것을 설명할 수 있습니다.cleanup.policy변경이 이미 삭제된 데이터를 되살리지 못한다는 것을 근거와 함께 압니다.- 컴팩션이 활성 세그먼트를 건드리지 않는다는 점과 그 실무적 의미를 압니다.
compact,delete조합, tombstone,min.cleanable.dirty.ratio의 함정을 구분할 수 있습니다.
상황
회원 프로필 캐시 파이프라인입니다. user-profile-snapshot 토픽은
파티션 12개, 복제 계수 3, 브로커 3대에 있고
회원 240만 명의 최신 프로필을 회원 ID를 키로 보관합니다.
하루 유입은 약 18만 건(프로필 변경 이벤트)입니다.
설계 의도는 명확했습니다. "이 토픽 하나를 처음부터 끝까지 읽으면 전체 회원의 현재 프로필을 재구성할 수 있다" — 즉 컴팩션 토픽입니다. 프로필 서비스 파드가 기동할 때 이 토픽을 전부 읽어 로컬 캐시를 만들고, 그 뒤로는 tail만 따라갑니다.
kafka-topics.sh --bootstrap-server kafka-1:9092 --create \
--topic user-profile-snapshot \
--partitions 12 --replication-factor 3 \
--config min.insync.replicas=2
# ★ --config cleanup.policy=compact 가 없습니다.
# 기본값 delete + retention.ms 기본값 604800000(7일)이 적용됩니다.
토픽을 만든 뒤 3일간은 문제가 없었습니다. 파드를 재배포해도 캐시가 정상적으로 채워졌습니다. 7일이 지나기 전까지는 삭제 대상 세그먼트가 없었기 때문입니다.
8일째 오전, 프로필 서비스를 재배포했습니다. 신규 파드가 캐시를 부트스트랩했고, 그 파드로 라우팅된 요청 중 40%가 "회원 정보 없음"으로 응답했습니다. 최근 8일 안에 프로필을 수정한 회원은 정상이었고, 그보다 오래된 회원은 전부 사라져 있었습니다.
delete 정책과 compact 정책의 결과 차이 —
같은 키별 이력 로그에 두 정책을 적용했을 때 남는 데이터,
그리고 세그먼트 단위 삭제가 오래된 키의 최신값까지 함께 지우는 지점
관측된 증상
메트릭이 어떻게 보였는가
- Kafka 지표 전부 정상. lag 0, 에러 0, ISR 정상. 리텐션 삭제는 정상 동작이므로 어떤 경고도 없습니다.
- 토픽 크기: 8일째에 증가가 멈추고 평평해졌습니다. 이것이 유일한 Kafka 쪽 단서입니다.
- 파티션 log-start-offset: 0에서 갑자기 큰 값으로 점프. 가장 확실한 지문입니다.
- 캐시 적재 건수: 신규 파드에서 240만 → 144만으로 감소.
- "회원 정보 없음" 응답률: 0.1% → 40%.
브로커 로그 — 삭제는 정상 동작으로 기록됩니다
UnifiedLog가 세그먼트를 삭제할 때 INFO 레벨로 이유를 남깁니다.
경고나 오류가 아니라 정보 로그입니다. 정상 동작이기 때문입니다.
INFO [UnifiedLog partition=user-profile-snapshot-3, dir=/var/lib/kafka/data] Deleting segment LogSegment(baseOffset=0, size=1073215488, lastModifiedTime=1760419203118, largestRecordTimestamp=1760419198004) due to log retention time 604800000ms breach based on the largest record timestamp in the segment
INFO [UnifiedLog partition=user-profile-snapshot-3, dir=/var/lib/kafka/data] Incremented log start offset to 1188402 due to segment deletion
INFO [UnifiedLog partition=user-profile-snapshot-7, dir=/var/lib/kafka/data] Deleting segment LogSegment(baseOffset=0, size=1072940544, lastModifiedTime=1760419214771, largestRecordTimestamp=1760419209660) due to log retention time 604800000ms breach based on the largest record timestamp in the segment
오프셋으로 본 증거
$ kafka-get-offsets.sh --bootstrap-server kafka-1:9092 \
--topic user-profile-snapshot --time -2 # -2 = earliest
user-profile-snapshot:0:1191033
user-profile-snapshot:3:1188402
user-profile-snapshot:7:1190774
...
$ kafka-get-offsets.sh --bootstrap-server kafka-1:9092 \
--topic user-profile-snapshot --time -1 # -1 = latest
user-profile-snapshot:0:1495882
user-profile-snapshot:3:1492117
user-profile-snapshot:7:1494203
...
# earliest 가 0 이 아니라는 것 = 앞쪽 세그먼트가 삭제되었다는 뜻입니다.
# 컴팩션 토픽이라면 earliest 는 0 에 가깝게 유지됩니다
# (컴팩션은 세그먼트를 재작성하지만 log-start-offset 을 밀지 않습니다).
원인 분석
1단계 — 설정을 확인한다
--describe만 실행하면 명시적으로 설정한 값만 보입니다.
기본값으로 동작하는 설정은 나타나지 않으므로 --all을 반드시 붙여야 합니다.
이것이 이 사고가 오래 발견되지 않은 이유이기도 합니다.
--all 없이 보면 문제를 못 찾습니다$ kafka-configs.sh --bootstrap-server kafka-1:9092 \
--entity-type topics --entity-name user-profile-snapshot --describe
Dynamic configs for topic user-profile-snapshot are:
min.insync.replicas=2 sensitive=false synonyms={DYNAMIC_TOPIC_CONFIG:min.insync.replicas=2}
# ★ cleanup.policy 가 보이지 않습니다. "설정 안 함 = 기본값"입니다.
$ kafka-configs.sh --bootstrap-server kafka-1:9092 \
--entity-type topics --entity-name user-profile-snapshot --describe --all \
| grep -E 'cleanup.policy|retention.ms|segment'
cleanup.policy=delete sensitive=false synonyms={DEFAULT_CONFIG:log.cleanup.policy=delete}
retention.ms=604800000 sensitive=false synonyms={DEFAULT_CONFIG:log.retention.ms=604800000}
segment.bytes=1073741824 sensitive=false synonyms={DEFAULT_CONFIG:log.segment.bytes=1073741824}
segment.ms=604800000 sensitive=false synonyms={DEFAULT_CONFIG:log.roll.ms=604800000}
# ★ 여기서 delete 가 드러납니다.
2단계 — 두 정책의 차이를 정확히 안다
| 값 | 동작 | 기준 설정 | 용도 |
|---|---|---|---|
delete기본값 |
보관 시간·크기 한계를 넘긴 오래된 세그먼트를 버림 | retention.ms(604800000), retention.bytes(-1) |
이벤트 스트림. 지나간 것은 필요 없는 데이터 |
compact |
키별 최신값을 유지하고 같은 키의 이전 값을 제거 | min.cleanable.dirty.ratio(0.5), min.compaction.lag.ms(0), max.compaction.lag.ms |
상태 스냅샷, changelog, 설정 저장소 |
compact,delete |
둘 다 적용. 오래된 세그먼트는 리텐션으로 버리고, 남은 세그먼트는 컴팩션 | 위 둘 전부 | "최신값을 유지하되 아주 오래된 것은 버려도 되는" 경우 |
| 빈 목록 | 무한 보관. 어떤 정리 정책도 적용되지 않음 | — | 감사 로그 등. 디스크 관리를 직접 해야 합니다 |
3단계 — 핵심: 정책 변경은 소급 적용되지 않는다
사고를 인지한 팀은 즉시 cleanup.policy=compact로 바꿨습니다.
그리고 사라진 데이터가 돌아오지 않는다는 것을 확인했습니다. 당연합니다.
4단계 — 컴팩션으로 바꾼 뒤에도 즉시 정리되지 않는다
compact로 바꾸고 나면 이번엔 "왜 중복이 안 없어지느냐"는 질문이 나옵니다.
컴팩션에는 세 가지 지연 요인이 있습니다.
| 요인 | 기본값 | 의미 |
|---|---|---|
| 활성 세그먼트 제외 | — | 공식 문서는 "현재 쓰이고 있는 마지막 세그먼트를 제외한 모든 세그먼트가 컴팩션 대상"이며 "활성 세그먼트는 모든 메시지가 최소 컴팩션 지연 시간보다 오래되었더라도 컴팩션되지 않는다"고 명시합니다. 즉 최신 데이터는 항상 중복 상태로 남아 있습니다. |
min.cleanable.dirty.ratio |
0.5 | 로그의 50% 이상이 "dirty"(미압축)가 되어야 정리를 시도합니다. 유입이 적은 토픽은 이 비율에 오래 도달하지 못합니다. |
min.compaction.lag.ms /max.compaction.lag.ms |
0 / 9223372036854775807 |
min은 "이 시간이 지나기 전에는 컴팩션하지 않음"(하한),
max는 "이 시간이 지나면 dirty ratio와 무관하게 대상이 됨"(상한).
max가 사실상 무한이므로 기본 상태에서는 dirty ratio에만 의존합니다.
|
5단계 — tombstone 과 키 필수 조건
컴팩션 토픽에서 키가 있고 value가 null인 레코드는 tombstone으로
해석되어 그 키의 삭제를 의미합니다. tombstone 자체도
delete.retention.ms(기본 86400000 = 1일)가 지나면 정리됩니다.
스토리지·리텐션·컴팩션의 원리 전체는 7장 스토리지·리텐션·컴팩션에서 다룹니다.
재현 방법
리텐션과 세그먼트 설정을 아주 작게 주면 몇 분 안에 재현됩니다. 아래 compose는 Apache Kafka의 공식 단일 노드 예제입니다 (3노드 구성은 예제 1).
services:
broker:
image: apache/kafka:4.3.1
hostname: broker
container_name: broker
ports:
- '9092:9092'
environment:
KAFKA_NODE_ID: 1
KAFKA_PROCESS_ROLES: 'broker,controller'
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: 'CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT'
KAFKA_LISTENERS: 'CONTROLLER://:29093,PLAINTEXT://:19092,PLAINTEXT_HOST://:9092'
KAFKA_ADVERTISED_LISTENERS: 'PLAINTEXT://broker:19092,PLAINTEXT_HOST://localhost:9092'
KAFKA_CONTROLLER_QUORUM_VOTERS: '1@broker:29093'
KAFKA_CONTROLLER_LISTENER_NAMES: 'CONTROLLER'
KAFKA_INTER_BROKER_LISTENER_NAME: 'PLAINTEXT'
CLUSTER_ID: '4L6g3nShT-eMCtK--X86sw'
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0
KAFKA_LOG_DIRS: '/tmp/kraft-combined-logs'
# 재현 전용 — 운영에서는 절대 이렇게 두지 마세요
KAFKA_LOG_RETENTION_CHECK_INTERVAL_MS: 5000 # 기본 300000
KAFKA_LOG_CLEANER_BACKOFF_MS: 3000
docker compose up -d
K=/opt/kafka/bin
# 1. delete 정책 토픽 (기본값). 세그먼트를 8KiB, 리텐션을 20초로 줍니다.
docker exec -it broker $K/kafka-topics.sh --create --topic snap-delete \
--partitions 1 --replication-factor 1 --bootstrap-server localhost:9092 \
--config segment.bytes=1048576 --config retention.ms=20000 \
--config segment.ms=10000
# 2. compact 정책 토픽. 컴팩션이 바로 돌게 dirty ratio 를 낮춥니다.
docker exec -it broker $K/kafka-topics.sh --create --topic snap-compact \
--partitions 1 --replication-factor 1 --bootstrap-server localhost:9092 \
--config cleanup.policy=compact --config segment.bytes=1048576 \
--config segment.ms=10000 --config min.cleanable.dirty.ratio=0.01 \
--config delete.retention.ms=1000
# 3. 두 토픽에 같은 데이터를 넣는다.
# 키 100개(user-1..user-100)를 각각 30번 갱신 → 총 3000건
gen() {
for round in $(seq 1 30); do
for u in $(seq 1 100); do
echo "user-$u:{\"userId\":\"user-$u\",\"rev\":$round}"
done
done
}
for t in snap-delete snap-compact; do
gen | docker exec -i broker $K/kafka-console-producer.sh --topic "$t" \
--bootstrap-server localhost:9092 \
--property parse.key=true --property key.separator=:
done
# 4. 리텐션·컴팩션이 돌 시간을 준다
sleep 60
# 5. 결과를 비교한다
echo "--- earliest offset (0 이 아니면 앞쪽이 삭제된 것)"
docker exec -it broker $K/kafka-get-offsets.sh --bootstrap-server localhost:9092 \
--topic snap-delete --time -2
docker exec -it broker $K/kafka-get-offsets.sh --bootstrap-server localhost:9092 \
--topic snap-compact --time -2
echo "--- 남아 있는 고유 키 개수 (기대: compact=100, delete=100보다 적음)"
for t in snap-delete snap-compact; do
n=$(docker exec -i broker $K/kafka-console-consumer.sh --topic "$t" \
--from-beginning --timeout-ms 15000 --bootstrap-server localhost:9092 \
--property print.key=true --property print.value=false 2>/dev/null \
| sort -u | wc -l)
echo "$t unique keys = $n"
done
# → snap-delete 는 100 미만 (오래된 키가 세그먼트째로 사라짐)
# snap-compact 는 100 (모든 키의 최신값이 남음)
변형 — 정책을 바꿔도 돌아오지 않는다
# 위 재현 후 snap-delete 를 compact 로 바꿉니다
docker exec -it broker $K/kafka-configs.sh --bootstrap-server localhost:9092 \
--entity-type topics --entity-name snap-delete \
--alter --add-config cleanup.policy=compact
sleep 60
# earliest offset 을 다시 봅니다 → 되돌아가지 않습니다
docker exec -it broker $K/kafka-get-offsets.sh --bootstrap-server localhost:9092 \
--topic snap-delete --time -2
# 고유 키 개수도 늘어나지 않습니다
docker exec -i broker $K/kafka-console-consumer.sh --topic snap-delete \
--from-beginning --timeout-ms 15000 --bootstrap-server localhost:9092 \
--property print.key=true --property print.value=false 2>/dev/null | sort -u | wc -l
kafka-dump-log# 어떤 세그먼트가 남아 있는지
docker exec broker sh -c 'ls -la /tmp/kraft-combined-logs/snap-compact-0/'
# 세그먼트 안의 레코드 헤더를 확인 (--deep-iteration 으로 개별 레코드까지)
docker exec broker sh -c '/opt/kafka/bin/kafka-dump-log.sh \
--files /tmp/kraft-combined-logs/snap-compact-0/00000000000000000000.log \
--print-data-log --deep-iteration' | head -30
# tombstone 보내기 — value 가 null 이어야 합니다.
# 빈 문자열("")은 tombstone 이 아닙니다. null.marker 로 마커를 지정하고
# 그 마커를 값 자리에 넣으면 실제 null 로 전송됩니다.
echo "user-7:NULL" | docker exec -i broker /opt/kafka/bin/kafka-console-producer.sh \
--topic snap-compact --bootstrap-server localhost:9092 \
--property parse.key=true --property key.separator=: \
--property null.marker=NULL
# delete.retention.ms(위에서 1000ms) 가 지나면 tombstone 자체도 정리됩니다
sleep 30
docker exec -i broker /opt/kafka/bin/kafka-console-consumer.sh --topic snap-compact \
--from-beginning --timeout-ms 15000 --bootstrap-server localhost:9092 \
--property print.key=true 2>/dev/null | grep user-7
# 키 없는 레코드를 보내면 거부됩니다
echo "no-key-record" | docker exec -i broker /opt/kafka/bin/kafka-console-producer.sh \
--topic snap-compact --bootstrap-server localhost:9092
# → Compacted topic cannot accept message without key in topic partition snap-compact-0
해결
즉시 조치 — 더 이상 사라지지 않게 만든다
복구보다 출혈을 멈추는 것이 먼저입니다.
cleanup.policy는 동적 토픽 설정이라 재시작 없이 즉시 적용됩니다.
# 1. 프로듀서가 모든 레코드에 키를 넣는지 먼저 확인한다.
# 키 없는 레코드가 있으면 compact 로 바꾼 순간 전송이 실패합니다.
kafka-console-consumer.sh --bootstrap-server kafka-1:9092 \
--topic user-profile-snapshot --max-messages 200 \
--property print.key=true --property print.value=false \
| grep -c '^null'
# → 0 이어야 합니다.
# 2. 정책을 변경한다 (즉시 적용, 재시작 불필요)
kafka-configs.sh --bootstrap-server kafka-1:9092 \
--entity-type topics --entity-name user-profile-snapshot \
--alter --add-config cleanup.policy=compact
# 3. delete 쪽 설정이 남아 있지 않은지 확인한다.
# compact 만 쓸 때는 retention.* 오버라이드를 제거하는 편이 안전합니다.
kafka-configs.sh --bootstrap-server kafka-1:9092 \
--entity-type topics --entity-name user-profile-snapshot \
--alter --delete-config retention.ms
# 4. 확인
kafka-configs.sh --bootstrap-server kafka-1:9092 \
--entity-type topics --entity-name user-profile-snapshot --describe --all \
| grep -E 'cleanup.policy|retention'
복구 — Kafka 밖에서 재적재한다
사라진 96만 명의 프로필은 Kafka로는 복구할 수 없습니다. 원본이 있는 곳에서 다시 채워 넣어야 합니다.
# 1. cleanup.policy=compact 로 이미 바뀐 상태에서 진행합니다.
# (delete 상태에서 재적재하면 7일 뒤 같은 사고가 반복됩니다)
# 2. 원본 DB에서 전체 회원 프로필을 키(회원 ID)와 함께 발행합니다.
# 같은 키를 다시 쓰므로 컴팩션이 중복을 정리해 줍니다.
psql -At -F'|' -c "SELECT user_id, row_to_json(p) FROM user_profiles p" \
| awk -F'|' '{print $1 ":" $2}' \
| kafka-console-producer.sh --bootstrap-server kafka-1:9092 \
--topic user-profile-snapshot \
--property parse.key=true --property key.separator=: \
--producer-property acks=all --producer-property compression.type=lz4
# 3. 파티션별 고유 키 수가 회복되었는지 확인한 뒤 캐시를 재적재합니다.
kafka-get-offsets.sh --bootstrap-server kafka-1:9092 \
--topic user-profile-snapshot --time -1
근본 해결 — 토픽 정의를 코드로 관리한다
토픽을 사람이 CLI로 만들었고, 중요한 설정이 "지정하지 않음"으로 남았습니다. 리뷰에서도 없는 줄은 눈에 보이지 않습니다.
kafka-topics.sh --create --topic user-profile-snapshot \
--partitions 12 --replication-factor 3 \
--config min.insync.replicas=2
# cleanup.policy 없음 → delete
# retention.ms 없음 → 604800000
토픽 정의를 선언적으로 관리하고, 상태 토픽에는 모든 정리 관련 설정을 명시합니다. 기본값에 의존하는 칸을 남기지 않습니다.
topics:
- name: user-profile-snapshot
partitions: 12
replicationFactor: 3
configs:
# 이 토픽의 성격을 명시 — 상태 스냅샷
cleanup.policy: compact # delete 를 포함하지 않는다
min.insync.replicas: "2"
min.cleanable.dirty.ratio: "0.3" # 상태 토픽은 조금 더 자주 정리
delete.retention.ms: "86400000" # tombstone 보관 1일 (기본값 명시)
segment.bytes: "268435456" # 256MiB — 컴팩션 단위를 작게
max.compaction.lag.ms: "3600000" # 1시간 안에는 반드시 정리 대상이 되게
compression.type: producer
알림 — 리텐션 삭제를 감지한다
-
log-start-offset이 0에서 증가하는 것을 감지합니다. 상태 토픽에서는 이것이 곧 사고입니다.kafka-get-offsets --time -2를 주기적으로 수집하세요. -
브로커 로그에서
Deleting segment문자열을 상태 토픽 이름과 함께 알림 대상으로 잡습니다. -
컴팩션 상태 메트릭을 봅니다.
공식 문서가 언급하는
uncleanable-partitions-count,max-clean-time-secs,max-compaction-delay-secs를 모니터링하라고 명시하고 있습니다. - 토픽별 고유 키 수를 정기 배치로 측정해 원본과 대조합니다. 이 사고를 유일하게 조기에 잡을 수 있는 지표입니다.
예방 체크리스트
시험 포인트
이어서 볼 곳
공식 문서 출처
- Topic Configs —
cleanup.policy— 기본값delete, list 타입,compact,delete조합 동작, 빈 목록 = 무한 보관 - Design — Log Compaction — tombstone, 활성 세그먼트 제외, 오프셋 불변·순서 유지 보장, 모니터링 메트릭 3종
- Topic Configs —
min.cleanable.dirty.ratio·min.compaction.lag.ms·max.compaction.lag.ms— 기본값 0.5 / 0 /Long.MAX_VALUE - Topic Configs —
delete.retention.ms— 기본값 86400000 - Topic Configs —
retention.ms·segment.bytes·segment.ms— 기본값 604800000 / 1073741824 / 604800000 - Broker Configs —
log.cleaner.enable— 기본값true, deprecated 및 5.0 제거 예정 - Broker Configs —
log.retention.check.interval.ms— 기본값 300000 - Apache Kafka 소스 (4.3) —
UnifiedLog의 세그먼트 삭제 로그 문자열,LogValidator의 "Compacted topic cannot accept message without key"