이 케이스에서 얻어 갈 것

상황

회원 프로필 캐시 파이프라인입니다. 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일 안에 프로필을 수정한 회원은 정상이었고, 그보다 오래된 회원은 전부 사라져 있었습니다.

case08 — cleanup.policy 오설정으로 상태 토픽의 최신 값이 사라지는 흐름 정상 흐름에서 상태 저장용 토픽과 Kafka Streams changelog 토픽은 cleanup.policy 를 compact 로 두어 키별 최신 값을 계속 보존합니다. 어긋나는 지점에서 이 토픽에 cleanup.policy=delete 와 retention.ms 7일이 적용되면 삭제 정책이 세그먼트 단위로 동작해 오래된 세그먼트를 통째로 버립니다. 결과적으로 마지막 갱신이 7일보다 오래된 키는 그 최신 값까지 함께 사라지고, 상태 저장소를 changelog 로 복구할 때 그 키들이 빠진 상태로 되살아납니다. 이미 삭제된 데이터는 복구할 수 없습니다. 처방은 kafka-configs.sh 로 정책을 확인하고 compact 로 되돌리며, Streams 내부 토픽 설정을 임의로 덮어쓰지 않는 것입니다. 1. 정상 흐름 상태 토픽은 compact 키별 갱신 이벤트 cleanup.policy=compact 키별 최신 값 유지 상태 복구 가능 compact 는 같은 키의 옛 값만 지우고 최신 값은 남깁니다 — 시간이 지나도 사라지지 않습니다. Kafka Streams 의 changelog 토픽은 이 성질에 의존해 상태 저장소를 복구합니다. 2. 어긋나는 지점 삭제 정책이 세그먼트를 통째로 버립니다 cleanup.policy=delete retention.ms 7일 7일 지난 세그먼트 삭제 최신 값도 함께 소멸 delete 는 키를 보지 않습니다 — 세그먼트의 시간·크기 조건만 보고 통째로 버립니다. 마지막 갱신이 오래된 키일수록 먼저 사라집니다. 자주 갱신되는 키는 남아서 더 늦게 발견됩니다. 3. 결과 복구된 상태에 구멍이 생깁니다 changelog 에서 복구 오래된 키 누락 집계 결과 어긋남 되돌릴 수 없음 상태 저장소는 changelog 를 처음부터 재생해 복원하므로, 지워진 키는 복원되지 않습니다. 집계·조인 결과가 조용히 틀려지고 원인을 로그에서 찾을 수 없습니다. 처방 확인: kafka-configs.sh --bootstrap-server :9092 --describe --entity-type topics --entity-name X 복원: kafka-configs.sh --alter --entity-type topics --entity-name X --add-config cleanup.policy=compact (이미 삭제된 데이터는 복구 불가) 두 정책을 함께 쓰려면 cleanup.policy=delete,compact 로 명시합니다 — 의도한 조합인지 확인합니다. Streams 내부 토픽(changelog · repartition)은 애플리케이션이 만든 설정을 임의로 바꾸지 않습니다.
delete 정책과 compact 정책의 결과 차이 — 같은 키별 이력 로그에 두 정책을 적용했을 때 남는 데이터, 그리고 세그먼트 단위 삭제가 오래된 키의 최신값까지 함께 지우는 지점

관측된 증상

메트릭이 어떻게 보였는가

브로커 로그 — 삭제는 정상 동작으로 기록됩니다

UnifiedLog가 세그먼트를 삭제할 때 INFO 레벨로 이유를 남깁니다. 경고나 오류가 아니라 정보 로그입니다. 정상 동작이기 때문입니다.

kafka-1 server.log — 8일째 새벽
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

오프셋으로 본 증거

log-start-offset 이 0이 아니면 데이터가 삭제된 것입니다
$ 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단계 — 두 정책의 차이를 정확히 안다

cleanup.policy 값별 동작. 타입은 list이고 기본값은 delete입니다.
동작 기준 설정 용도
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).

docker-compose.yml — 정리 주기를 짧게 준 재현 전용 구성
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
재현 절차 — 같은 데이터로 delete / compact 두 토픽의 결과를 비교
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로는 복구할 수 없습니다. 원본이 있는 곳에서 다시 채워 넣어야 합니다.

복구 절차 — 원본 DB에서 전체 스냅샷을 다시 발행
# 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

토픽 정의를 선언적으로 관리하고, 상태 토픽에는 모든 정리 관련 설정을 명시합니다. 기본값에 의존하는 칸을 남기지 않습니다.

변경 후 — 선언적 정의 (IaC)
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

알림 — 리텐션 삭제를 감지한다

예방 체크리스트

시험 포인트

공식 문서 출처