카프카에서 acks와 auto.commit이 실제로 고르는 것
유실을 0으로 만드는 설정은 없다. acks와 min.insync.replicas, enable.auto.commit이 정하는 건 메시지를 잃을지 두 번 처리할지의 선택이다.
egg4분 읽기
지난 글에서 카프카는 읽어도 메시지가 남는 로그라고 썼다. 그렇다고 안 잃는다는 뜻은 아니다.
“유실 제로 설정”이라는 제목을 자주 보는데 대개 acks=all 하나로 끝난다. 그것만으로는 안 되는 것 같아서 문서를 찾아봤다.
돌려본 적은 없고 아래는 공식 문서를 읽고 정리한 내용이다. 기본값은 2026년 10월 기준이고, 3.0에서 한 번 크게 바뀌었으니 버전을 먼저 확인하는 게 좋겠다.
넣는 쪽 - acks는 언제 성공이라고 할지를 정한다
프로듀서가 메시지를 보내면 브로커가 받았다고 답을 준다. acks는 그 답을 언제 주느냐를 정하는 값이다.
0은 답을 안 기다린다. 던지고 바로 다음 일을 하니 제일 빠르고 브로커가 죽어 있어도 모른다.
1은 리더 브로커가 자기 디스크에 썼을 때 답한다. 복제본에 넘어가기 전에 리더가 죽으면 그 메시지는 없어진다.
all은 복제본들이 따라잡을 때까지 기다린다. 3.0부터 프로듀서 기본값이 이거라, 요즘 클라이언트로 새로 짰다면 이미 all이다. 바뀐 배경은 KIP-679에 적혀 있다.
acks=all만으로는 부족하다
여기서 한 겹이 더 있다. all이 기다리는 대상은 전체 복제본이 아니라 ISR, 그러니까 지금 리더를 제대로 따라오는 복제본 목록이다.
복제본 3벌 중 둘이 느려지거나 죽으면 ISR에 리더 혼자 남는다. 이 상태에서도 acks=all은 리더 하나만 보고 성공이라고 답한다. 그리고 그 리더가 죽으면 메시지가 사라진다.
그래서 같이 봐야 하는 게 min.insync.replicas다. ISR이 이 숫자보다 적으면 아예 쓰기를 거절하라는 설정인데, 기본값이 1이라 위 상황을 그대로 통과시킨다.
흔히 권하는 조합은 복제본 3벌에 min.insync.replicas=2다. 최소 두 곳에 들어가야 성공으로 치고, 한 대가 죽어도 쓰기는 계속되고, 두 대가 죽으면 성공했다고 거짓말하는 대신 에러를 낸다.
자동 생성된 토픽은 복제본이 1벌인 경우가 있다. 그러면 acks를 뭘로 주든 브로커 한 대에만 있는 상태라 의미가 없다.
unclean.leader.election.enable도 같은 이야기다. 따라잡지 못한 복제본을 리더로 승격시킬지 정하는 값인데, 켜면 쓰기를 못 받는 상황은 줄지만 밀린 만큼의 메시지를 버리게 된다. 기본값은 꺼짐이고 그대로 두는 편이 낫다.
안 잃으려고 하면 중복이 생긴다
네트워크가 끊겨서 브로커가 잘 받았는데 답만 못 돌아오는 경우가 있다. 프로듀서는 실패로 알고 다시 보내고 브로커에는 같은 메시지가 두 벌 쌓인다.
이걸 막는 게 idempotent producer다. 메시지마다 번호를 붙여 브로커가 중복을 걸러낸다. 이것도 3.0부터 기본으로 켜져 있다.
다만 걸러주는 범위는 한 프로듀서 세션 안이다. 애플리케이션이 죽었다 살아나 같은 데이터를 다시 만들어 보내면 그건 카프카가 알 방법이 없다.
읽는 쪽 - enable.auto.commit은 유실 쪽으로 샌다
소비자는 어디까지 읽었는지를 offset으로 기록한다. enable.auto.commit은 그 기록을 알아서 남길지 정하는 값이고 기본이 켜짐, 간격은 5초다.
문제는 기록하는 시점이 처리를 끝낸 시점이 아니라는 데 있다. poll로 100건을 받아왔고 30건째 처리하다 죽었다면, 이미 100건까지 읽은 것으로 커밋되어 있어서 나머지 70건은 아무도 처리하지 않은 채 넘어간다.
카프카는 멀쩡히 들고 있는데 애플리케이션 쪽에서 잃는 경우다. 브로커 설정을 아무리 조여도 여기서 새면 소용이 없다.
그래서 auto commit을 끄고 처리가 끝난 뒤에 직접 커밋한다. 이러면 순서가 뒤집힌다. 30건째까지 처리하고 커밋 전에 죽으면, 다시 살아났을 때 1건째부터 다시 읽는다. 앞의 30건은 두 번 처리된다.
그래서 고르는 건 유실이냐 중복이냐
문서에서는 이 둘을 at-most-once와 at-least-once라고 부른다. 처리 전에 커밋하면 많아야 한 번, 처리 후에 커밋하면 적어도 한 번.
실무에서 쓰는 쪽은 대개 후자다. 중복은 소비자를 멱등하게 만들어 막을 수 있지만 사라진 메시지는 어디서도 되찾을 수 없으니까.
멱등하게 만드는 방법은 도메인마다 다르다. 메시지 ID를 유니크 키로 잡아 두 번째 insert가 실패하게 두거나, 이미 처리한 ID를 어딘가에 기록해두고 건너뛰거나, 애초에 결과가 같아지는 연산(잔액을 더하지 말고 값을 덮어쓰기)으로 짜거나.
exactly-once라는 말도 있기는 하다. 트랜잭션 프로듀서로 쓰기와 offset 커밋을 한 묶음으로 처리하고 소비자는 isolation.level=read_committed로 읽는 방식인데, 카프카에서 읽어 카프카에 쓰는 구간에서만 성립한다. 중간에 외부 API를 호출하거나 다른 DB에 쓰면 그 부분은 여전히 직접 막아야 한다.
파티션은 늘릴 순 있어도 줄일 수 없다
설정은 아니지만 되돌릴 수 없다는 점에서 같이 알아둘 만한 것 같다.
파티션은 나중에 늘릴 수 있다. 줄이는 명령은 없다. 데이터를 어디로 합칠지 정할 방법이 없으니 안 만들어둔 것으로 보인다.
더 성가신 건 늘리는 쪽이다. 키가 있는 메시지는 키의 해시를 파티션 개수로 나눈 나머지로 목적지가 정해지는데, 개수가 바뀌면 그 나머지도 바뀐다. 같은 주문 ID가 어제는 2번, 오늘은 5번 파티션으로 간다는 뜻이다.
순서 보장은 파티션 안에서만 되니 이 순간 순서가 어긋난다. 2번 파티션에 아직 안 읽힌 생성 이벤트가 남아 있는데 취소 이벤트가 5번으로 들어가면 취소가 먼저 처리될 수 있다.
파티션 개수를 정하는 계산식이 여럿 돌아다니지만 이 비가역성 앞에서는 숫자 정확도가 별로 중요하지 않은 듯 싶다. 적게 시작해서 늘리라는 조언과 순서가 중요하면 처음부터 넉넉히 잡으라는 조언이 갈리는데, 순서를 쓰고 있다면 아무래도 후자인 것 같다.
결론. 유실 제로라는 설정은 없다
정리하면 손대는 자리는 넷이다. 넣는 쪽의 acks, 브로커의 min.insync.replicas와 복제본 수, 읽는 쪽의 커밋 시점, 그리고 소비자의 멱등성.
앞의 셋을 다 조여도 마지막이 없으면 중복이 그대로 남는다. 설정으로 고르는 건 유실을 없애느냐가 아니라 유실과 중복 중 어느 쪽을 떠안느냐인 셈이다.
여기까지는 전부 문서를 읽고 정리한 것이라 어디까지가 실제와 맞는지는 아직 모르겠다. 언젠가 브로커 세 대를 띄워놓고 min.insync.replicas를 1로 두고 리더를 죽여볼 생각이다. 진짜 날아가는지는 그때 가서 확인해봐야겠지?
Filed under 개발