[Kafka] 카프카 로그: 고성능, 영속성

카프카 로그란

카프카의 로그는 메시지를 오프셋(Offset) 순서로 끝에만 이어붙이는 append-only 파일 구조이다. 이 구조가 카프카의 성능과 영속성, 순서 보장을 모두 만들어낸다.

카프카의 로그는 메시지를 오프셋 순서로 이어붙이기만 하는 append-only 파일이다. 파티션(Partition)마다 이 로그가 하나씩 있고, 새 메시지는 항상 그 파일의 끝에 더해진다. 기존 메시지의 자리를 바꾸거나 중간을 수정하는 연산은 존재하지 않는다.

이 로그 구조는 일반 토픽만이 아니라 __consumer_offsets 같은 내부 토픽에도 그대로 적용된다. 압착이라는 정책 하나만 다르게 걸릴 뿐, 파일 형식과 저장 방식은 동일하다.

구분로그 기반(카프카)큐 기반(RabbitMQ 등)
저장 방식append-only 파일에 순서대로 추가큐 안에서 메시지 상태를 개별 관리
처리량 특성쓰기와 읽기 모두 순차 접근ack 시점에 따라 접근이 흩어짐
영속성 보장모든 메시지가 기본적으로 디스크와 복제본에 남음durable 큐만 선택적으로 디스크에 남음
순서 보장 범위파티션 내부큐 내부(단일 컨슈머일 때)

카프카가 고성능인 이유

카프카가 빠른 이유는 접근 패턴을 디스크가 가장 잘 처리하는 순차 패턴 하나로 고정했기 때문이다.

분산 커밋 로그

append-only 로그는 쓰기 연산 자체가 단순하다. 파일 끝에 바이트를 더하기만 하면 되므로, 브로커는 메시지마다 상태를 추적하거나 기존 데이터를 찾아 갱신할 필요가 없다. 게다가 파티션 하나하나가 독립된 로그이므로, 서로 다른 브로커에 흩어진 파티션에 각자 쓰기가 동시에 일어나도 서로 간섭하지 않는다. 이 두 특징이 합쳐져 파티션 수를 늘리는 것만으로 쓰기 처리량이 수평으로 늘어난다.

브로커 3대에 파티션 12개를 고르게 나누면, 각 브로커는 자신이 맡은 파티션의 로그만 관리하면 된다. 프로듀서와 컨슈머는 각자 담당 리더에게 직접 접속하므로, 트래픽이 늘어도 특정 브로커 하나가 전체 쓰기를 떠안지 않는다.

디스크 순차 읽기와 랜덤 디스크 읽기

프로듀서는 파일 끝에만 쓰고 컨슈머는 오프셋 순서로만 읽으므로 디스크 헤드가 한 방향으로만 움직인다. 순차적으로 디스크에서 데이터를 꺼내오기 때문에 랜덤 액세스로 디스크에 접근할 필요가 없다. 즉, 시간복잡도는 O(1) 수준이기에 MySQL에서 인덱스로 데이터를 가져오는 랜덤 액세스보다 수백, 수천배는 빠르게 읽기가 가능하다.

RabbitMQ 같은 큐는 메시지마다 미확인(unacked)과 확인됨(acked) 상태를 추적해야 한다. 컨슈머가 ack를 보내는 순간 그 메시지는 큐에서 제거 대상이 되고, ack가 도착하는 시점은 컨슈머마다 제각각이라 삭제가 저장소 여기저기서 흩어져 일어난다. 카프카는 개별 메시지를 지우지 않고 보존 기간이 지난 세그먼트 파일을 통째로 지우므로 삭제도 순차이다.

구분카프카RabbitMQ
쓰기 패턴파티션 로그 끝에만 추가큐 안에서 메시지 상태를 개별 관리
삭제 방식세그먼트 파일 단위로 통째로 삭제ack된 메시지를 건별로 제거
디스크 접근 패턴쓰기와 읽기 모두 순차 위주ack 시점에 따라 랜덤 접근 발생
확장 방식파티션 단위 수평 확장큐 단위 수직 확장 중심

영속성

카프카의 영속성은 프로세스가 살아 있는 동안 메모리에 붙잡아두는 것이 아니라, 디스크에 쓰고 다른 브로커의 복제본에도 남기는 이중 보장에서 나온다.

카프카가 영속적인 이유

카프카는 메시지가 들어올 때마다 즉시 fsync를 호출하지 않는다. flush.messages와 flush.ms는 강제 플러시 주기를 설정할 수 있다. 또한 카프카가 영속적이라 불리는 이유는 플러시 뿐만 아니라, ISR(In-Sync Replicas)에 속한 여러 브로커가 각자 페이지 캐시에 같은 데이터를 들고 있다는 데 있다. 한 브로커가 디스크에 채 쓰기 전에 죽어도, 이미 복제를 받은 다른 브로커가 그 메시지를 갖고 있다.

# 브로커의 플러시 주기 설정. 기본값은 사실상 OS 에 맡기는 쪽이다
# flush.messages: 이 개수만큼 쓰면 강제로 fsync (기본값이 매우 커서 거의 발동하지 않음)
flush.messages=9223372036854775807
# flush.ms: 이 시간이 지나면 강제로 fsync (기본값 없음, OS 스케줄에 맡김)
flush.ms=9223372036854775807

이 지점에서 카프카의 영속성이 단일 디스크 쓰기가 아니라 복제라는 구조에 있다는 점을 분명히 해야 한다. acks=all과 min.insync.replicas를 함께 쓰지 않으면, 복제가 끝나기 전에 리더 하나만 응답해버려 이 이중 보장이 그대로 반쪽이 된다.

log.dirs에 여러 디스크 경로를 지정하면, 브로커는 새 파티션을 만들 때마다 디스크 사용량이 가장 적은 경로에 배정한다. 디스크 하나가 고장 나면 그 디스크에 있던 파티션만 영향을 받고, 나머지 디스크의 파티션은 그대로 서비스된다.

로그를 디스크에 보관하는 구조

각 파티션의 로그는 파일 하나가 아니라 여러 세그먼트(Segment) 파일로 쪼개 저장된다. .log, .index, .timeindex 세 파일이 짝을 이룬다.

파티션의 세그먼트 여러 개 중 오직 하나만 쓰기가 일어나는 액티브 세그먼트(Active Segment)다. 나머지는 전부 닫힌(closed) 상태로, 더 이상 바뀌지 않는 읽기 전용 파일이다. 액티브 세그먼트가 segment.bytes(기본 1GB)나 segment.ms(기본 7일) 중 먼저 도달하는 조건에 닿으면 롤링(Rolling)이 일어난다.

# 세그먼트 롤링 트리거. 둘 중 먼저 도달하는 쪽이 이긴다
segment.bytes=1073741824
segment.ms=604800000

이 두 값 중 하나에 닿으면 다음 순서로 세그먼트가 넘어간다.

  1. 현재 쓰던 오프셋을 파일명으로 삼아 새 액티브 세그먼트(.log)를 만든다.
  2. 기존 액티브 세그먼트를 닫고, 그 시점의 .index와 .timeindex를 확정한다.
  3. 이후 모든 쓰기는 새로 만든 세그먼트에만 들어간다.
  4. 닫힌 세그먼트는 그때부터 보존 판정의 대상이 된다.

닫힌 세그먼트에는 두 가지 정책이 걸린다. cleanup.policy=delete(기본값)는 retention.ms나 retention.bytes를 넘은 세그먼트 파일을 통째로 지운다. cleanup.policy=compact는 삭제 대신, 같은 키를 가진 레코드 중 가장 최신 값만 남기고 나머지를 지운다. 값이 null인 레코드(tombstone)를 보내면 그 키 자체를 완전히 제거할 수도 있다.

# retention.bytes 는 세그먼트 전체 크기가 이 값을 넘으면 오래된 세그먼트부터 지운다
# retention.ms 와 함께 걸려 있으면 둘 중 먼저 도달하는 조건이 이긴다
retention.bytes=10737418240
retention.ms=604800000
# as-is: 삭제 정책만 쓸 때. 같은 키로 덮어쓴 옛 값이 세그먼트에 그대로 쌓인다
cleanup.policy=delete
retention.ms=604800000
 
# to-be: 최신 상태만 필요한 토픽은 압착으로 옛 값을 걷어낸다
cleanup.policy=compact
min.cleanable.dirty.ratio=0.5
# 이미 만든 토픽의 정책을 압착으로 바꾸고, 파티션별 세그먼트 상태를 확인한다
kafka-topics.sh --bootstrap-server localhost:9092 \
  --alter --topic user-profile --config cleanup.policy=compact
 
kafka-topics.sh --bootstrap-server localhost:9092 \
  --describe --topic user-profile
구분카프카RabbitMQ
기본 동작모든 메시지가 로그에 appenddurable 큐만 선택적으로 디스크 기록
내구성 근거ISR 여러 대의 복제미러링 큐 설정 시 선택적 복제
삭제 방식retention 만료 시 세그먼트 파일 삭제, 또는 compactionack 시점에 메시지 건별 삭제
재처리 가능성보존 기간 안에서는 재생 가능ack 이후에는 재생 불가

로그 설정

로그 설정은 지나간 이력으로 메시지들을 사용할지, 지금 값으로서만 사용할지에서 선택해볼 수 있다.

상황선택근거
최신 상태만 필요한 토픽(사용자 프로필, 세션)cleanup.policy=compact오래된 값을 지워도 최신 값만 있으면 충분해 디스크를 아낄 수 있다
감사 로그, 이벤트 히스토리처럼 전체 이력이 필요cleanup.policy=delete + 충분한 retention.ms압착하면 중간 이력이 사라져 히스토리를 재구성할 수 없다
순서가 도메인 정합성에 직결(계좌, 주문 상태)같은 키로 파티셔닝 + enable.idempotence=true파티션 순서와 시퀀스 검증이 함께 있어야 재시도에도 순서가 유지된다
순서보다 처리량이 중요(로그 수집, 메트릭)키 없이 Sticky Partitioner에 맡긴다파티션 분산이 고르게 되어 병렬 처리량이 커진다
디스크 용량이 빠듯한데 삭제도 압착도 여유가 없다segment.bytes를 줄여 세그먼트를 잘게 쪼갠다보존 판정이 세그먼트 파일 단위라, 세그먼트가 크면 만료된 데이터도 한동안 자리를 차지한다