로그 컴팩션과 보존 - 상태 토픽이 보장하는 것과 하지 않는 것
Kafka 토픽에는 두 가지 정리 정책이 있다. 기본은 시간이나 크기로 지우는 delete이고, 다른 하나가 키별 최신 값만 남기는 compact다(Topic Configs: cleanup.policy). 후자는 토픽을 “변경 로그”가 아니라 “현재 상태의 스냅샷”으로 쓸 수 있게 하는데, 그 보장의 범위가 생각보다 좁다.
두 정책의 차이
cleanup.policy=delete | cleanup.policy=compact | |
|---|---|---|
| 지우는 기준 | retention.ms, retention.bytes | 같은 키의 옛 값 |
| 남는 것 | 최근 구간 전체 | 키마다 최소 하나 |
| 용도 | 이벤트 스트림 | 상태 스냅샷, 설정, 조회용 테이블 |
컴팩션은 키가 있는 메시지를 전제한다. 키가 null이면 컴팩션 대상이 아니다.
Kafka 자체가 이 기능을 쓴다. __consumer_offsets가 컴팩션 토픽이고, 키는 (그룹, 토픽, 파티션), 값은 오프셋이다. 오프셋 이력 전체가 필요한 것이 아니라 마지막 값만 필요하므로 이 정책이 맞다.
컴팩션이 실제로 하는 일
로그 클리너가 백그라운드에서 돈다. 활성 세그먼트(지금 쓰고 있는 파일, 세그먼트 구조)는 건드리지 않고, 닫힌 세그먼트들을 읽어 키별 최신 값만 남긴 새 세그먼트로 바꾼다(Kafka Design: Log Compaction).
이 동작에서 세 가지 성질이 나온다.
즉시 일어나지 않는다. min.cleanable.dirty.ratio(기본 0.5)를 넘어야 정리를 시작한다. 즉 정리되지 않은 부분이 절반쯤 쌓여야 돈다. 토픽 설정 문서도 이 비율이 로그에서 중복이 차지할 수 있는 공간의 상한이라고 설명한다(0.5면 로그의 최대 50%가 중복일 수 있다). 그래서 컴팩션 토픽을 읽어도 같은 키의 옛 값이 여러 번 나올 수 있다. 소비자는 그것을 감당해야 하고, 보통 “나중 것으로 덮어쓰기”면 자연히 해결된다.
오프셋은 유지된다. 메시지가 사라져도 오프셋 번호는 그대로다. 중간에 구멍이 생기므로, 오프셋을 연속 번호로 가정하는 코드는 깨진다. 지워진 오프셋도 유효한 위치로 남는다. 그 위치에서 읽기 시작하면 그 다음으로 남아 있는 메시지부터 받는다.
순서는 유지된다. 파티션 안에서 남은 메시지의 상대 순서는 바뀌지 않는다. 설계 문서는 “Compaction will never re-order messages, just remove some.”라고 적는다(컴팩션은 메시지 순서를 바꾸지 않고 일부를 지울 뿐이다).
삭제: 툼스톤
컴팩션 토픽에서 키를 지우려면 값이 null인 메시지(툼스톤)를 보낸다. 컴팩션이 돌면 그 키의 옛 값들이 사라지고, 툼스톤 자체도 delete.retention.ms(기본 24시간) 뒤에 사라진다.
이 유예 기간이 필요한 이유는 소비자 때문이다. 툼스톤이 즉시 사라지면, 잠시 멈췄다 돌아온 소비자가 “삭제됐다”는 사실을 못 보고 옛 값을 계속 들고 있게 된다. 그래서 유예 기간은 소비자가 삭제를 알아챌 시간이다.
여기서 운영 제약이 나온다. 설계 문서는 소비자가 delete.retention.ms보다 오래 밀리면 삭제 표시를 놓칠 수 있다고 적는다. 그런 소비자는 처음부터 다시 읽어야 안전하다. 처음부터 읽는 소비자도 이 시간 안에 로그 끝까지 읽어야 한다. 토픽 설정 문서가 이 값을 오프셋 0부터 읽는 소비자가 읽기를 끝내야 하는 시간의 경계로도 설명하는 이유다.
상태 토픽이 보장하는 것
compact 토픽을 처음부터 끝까지 읽으면, 각 키의 마지막 값을 모두 얻는다. 이것이 Kafka Streams의 KTable, Connect의 상태 저장, 캐시 워밍업이 서 있는 기반이다.
보장하지 않는 것도 분명하다.
- 정확히 한 번만 나온다는 보장이 없다. 아직 정리되지 않은 중복이 나온다.
- 특정 시점의 일관된 스냅샷이 아니다. 읽는 동안에도 새 메시지가 들어온다.
- 삭제된 키를 언제까지 알 수 있는지에 상한이 있다.
- 무한히 자라지 않는다는 보장도 없다. 키의 종류가 무한히 늘면(예: 키에 UUID를 쓰면) 컴팩션은 아무것도 줄이지 못한다. 키의 카디널리티(서로 다른 키의 개수)가 유한해야 이 정책이 의미가 있다.
마지막 항목이 실무에서 가장 자주 어긋난다. “컴팩션을 켜면 알아서 줄어들겠지”라고 두면, 키가 계속 새로 생기는 토픽은 그대로 자란다.
둘을 함께 쓰기
cleanup.policy=compact,delete로 두면 컴팩션도 하고 보존 기간도 적용한다. “최근 N일치 안에서 키별 최신 값”이 된다. 키 카디널리티가 커질 수 있는 상태 토픽에서 안전장치로 쓴다.
이 설명이 깨지는 곳
- 컴팩션 토픽을 이벤트 이력으로 쓰면 안 된다. 옛 값이 사라지므로 “무슨 일이 있었는가”에 답하지 못한다. 이력이 필요하면
delete정책의 별도 토픽이다. - 로그 클리너가 밀릴 수 있다.
log.cleaner.threads가 모자라거나 디스크가 느리면 정리가 쌓인다. 브로커 지표로 확인해야 한다. - 컴팩션은 저장 공간을 위한 기능이 아니다. 부수적으로 줄어들긴 하지만, 목적은 “키별 최신 값 유지”다.
min.compaction.lag.ms가 있으면 그만큼 지난 메시지만 정리된다. 설계 문서는 이 값을 각 메시지가 정리되지 않은 머리(head) 구간에 머무는 시간의 하한으로 설명한다. 소비자가 그 안에 따라잡으면 모든 메시지를 본다.
무엇을 재면 확인되는가
- 컴팩션 토픽을 처음부터 읽어 같은 키가 몇 번 나오는지 센다.
dirty.ratio설정별로 다르다. - 툼스톤을 보내고
delete.retention.ms전후로 소비자를 붙여 삭제를 인지하는지 확인한다. - 키 카디널리티를 계속 늘려 가며 토픽 크기가 줄어드는지 본다. 줄지 않는 것을 직접 보는 것이 이해에 빠르다.
실무와의 접점
이후 ParityPay 5편에서 쓴 Outbox 테이블은 DB에 만든 로그이고, 그 글의 한계에 “테이블 정리를 하지 않았다”를 적었다. Kafka의 두 정책이 그 문제의 두 답에 해당한다. 시간 기준으로 지울 것인가(delete), 키별 최신만 남길 것인가(compact). Outbox는 발행 의도의 이력이므로 전자가 맞고, 상태 스냅샷 테이블이라면 후자의 사고방식이 맞다.
정리
- 컴팩션 토픽의 소비자는 같은 키의 중복과 오프셋 구멍을 감당하도록 짜야 한다.
delete.retention.ms보다 오래 멈춘 소비자는 처음부터 다시 읽는다.- 키가 계속 새로 생기는 토픽이라면
compact,delete로 보존 기간을 함께 건다.
댓글
아직 댓글이 없습니다