[Kafka] 수동 커밋(AckMode MANUAL)과 멱등성: 메시지 중복 처리 원인과 해결
![[Kafka] 수동 커밋(AckMode MANUAL)과 멱등성: 메시지 중복 처리 원인과 해결](https://blog.kakaocdn.net/dna/cu0ScF/dJMcadWyuCB/AAAAAAAAAAAAAAAAAAAAAHebzC9UoZ49hqX6UcpzFa4HVH6hXjffUu2VNVXfWf_i/img.png?credential=yqXZFxpELC7KVnFOS48ylbz2pIh7yKj8&expires=1782831599&allow_ip=&allow_referer=&signature=uFDzkJTPUwlNF36CVRa8Ni%2BFWtM%3D)
[Kafka] 수동 커밋(AckMode MANUAL)과 멱등성: 메시지 중복 처리 원인과 해결
카프카에서 수동 커밋을 켜면 메시지가 중복 처리될 수 있어요. 수동 커밋은 중복을 막는 도구가 아니라, 오히려 중복을 전제로 깔고 가는 방식이거든요. 그래서 수동 커밋과 멱등성은 거의 항상 같이 가요.
전달 보장(at-least-once)이 무슨 뜻인지를 먼저 짚고, 자동 커밋의 문제, ackMode 종류, MANUAL과 MANUAL_IMMEDIATE의 차이, 커밋 성능, 중복이 생기는 정확한 이유, 멱등성을 만드는 네 가지 방법, 자주 만나는 문제까지 풀어 갈게요. 컨슈머 기본 설정은 @KafkaListener 컨슈머 설정 글에서 다뤘으니 여기서는 커밋과 중복에 집중할게요.
01. 수동 커밋의 본질, 커밋 시점이 전달 보장을 정해요
컨슈머가 "여기까지 처리했다"고 카프카에 알리는 게 오프셋 커밋이에요. 카프카는 이 커밋된 오프셋을 기준으로 다음에 어디서부터 읽을지 정해요. 그래서 이 커밋을 처리 전에 하느냐 후에 하느냐가 전달 보장을 가르죠.

- 처리 후 커밋 (at-least-once) — 최소 한 번. 처리와 커밋 사이에 죽으면 그 메시지가 다시 와요. 가장 흔한 선택이에요.
- 커밋 후 처리 (at-most-once) — 최대 한 번. 커밋부터 하고 처리하다 죽으면 그 메시지는 유실돼요. 데이터 손실을 감수해야 해서 거의 안 써요.
- 정확히 한 번 (exactly-once) — 카프카 트랜잭션을 써야 가능해요. 수동 커밋의 상위 주제라 따로 다뤄요.
실무에서 대부분은 at-least-once를 골라요. 유실보다는 중복이 낫고, 중복은 멱등성으로 막을 수 있으니까요. 즉 "처리를 확실히 끝낸 다음에 커밋한다"가 기본 전략이에요.
02. 자동 커밋은 왜 위험한가요
카프카 컨슈머는 enable.auto.commit=true면 백그라운드에서 주기적으로(기본 5초, auto.commit.interval.ms) 오프셋을 자동 커밋해요. 편해 보이지만 문제가 있어요. 자동 커밋은 "실제 처리가 끝났는지"와 무관하게 시간만 보고 커밋하거든요.

poll로 100건을 받아서 30건만 처리한 시점에 자동 커밋 타이머가 돌면, 100건까지 커밋돼버려요. 그 직후 컨슈머가 죽으면 처리 못 한 70건은 영영 안 와요. 유실이죠. 반대로 처리는 다 했는데 커밋 전에 죽으면 중복이 와요.
결국 자동 커밋은 유실과 중복을 둘 다 통제하기 어려워요. 그래서 스프링 카프카는 enable.auto.commit=false를 기본으로 두고, 대신 ackMode로 "처리 흐름에 맞춰" 커밋해요.
03. ackMode, 커밋 시점의 종류
스프링 카프카는 자동 커밋 대신 ackMode로 커밋 시점을 관리해요. 종류가 여러 개예요.
오프셋 커밋의 저수준 동작은 2021년 기록 Consumer Offset 관리에서도 다뤘어요.
RECORD— 레코드 하나 처리할 때마다 커밋. 가장 안전하지만 커밋이 잦아요.BATCH(기본) — 한 번의poll()로 받은 묶음을 다 처리한 뒤 커밋.TIME— 지정한 시간 간격마다 커밋.COUNT— 지정한 레코드 수마다 커밋.COUNT_TIME— 시간 또는 개수 중 먼저 도달하는 쪽.MANUAL/MANUAL_IMMEDIATE— 리스너에서Acknowledgment로 직접 커밋.
기본 BATCH는 한 poll 묶음을 다 처리한 뒤 커밋해요. 그래서 묶음 중간에 실패하고 컨슈머가 죽으면, 이미 처리한 앞쪽까지 다시 받아요. 한 건 단위로 정확히 끊고 싶거나, 처리 로직 안에서 "이 시점에 커밋"을 직접 잡고 싶을 때 수동 커밋(MANUAL)을 써요.
04. MANUAL vs MANUAL_IMMEDIATE
수동 커밋은 처리를 끝낸 뒤 acknowledge()를 호출해서 커밋해요. 리스너 파라미터로 Acknowledgment를 받으면 돼요.
@KafkaListener(topics = "orders", groupId = "order-service")
public void consume(Order order, Acknowledgment ack) {
process(order); // 처리 먼저
ack.acknowledge(); // 끝난 뒤 커밋 → at-least-once
}
두 모드는 acknowledge() 이후 커밋이 일어나는 시점이 달라요.

순서가 중요하고 커밋을 빨리 반영해야 하면 MANUAL_IMMEDIATE, 처리량이 중요하면 MANUAL로 묶는 식이에요.
함정 하나. 수동 커밋을 쓰면서 예외도 던지지 않고 acknowledge()만 건너뛰는 경로가 있으면, 그 오프셋은 커밋되지 않아요(예외를 던지면 에러 핸들러가 별도로 처리해줘요). 예외 분기에서 ack를 건너뛰는 코드가 흔한 범인이에요. lag은 쌓이는데 처리는 되는 것처럼 보이는 미스터리가 대개 이거예요. 모든 경로에서 ack가 불리는지, 아니면 에러 핸들러가 대신 처리하는지 꼭 확인해요.
05. 커밋 성능, 매 건 동기 커밋은 비싸요
커밋은 브로커로 가는 네트워크 호출이에요. 그래서 레코드마다 동기 커밋하면 처리량이 크게 떨어져요. 카프카에는 두 가지 커밋 방식이 있어요.
- commitSync — 동기. 브로커가 커밋을 받았다고 응답할 때까지 기다려요. 안전하지만 느려요. 실패 시 재시도도 해요.
- commitAsync — 비동기. 응답을 안 기다리고 바로 다음으로 넘어가요. 빠르지만 실패해도 그냥 넘어가서 보장이 약해요.
raw 컨슈머에서는 이 둘을 섞어 써요. 정상 루프에서는 commitAsync로 빠르게 처리하고, 종료하거나 리밸런스가 일어나기 직전에는 commitSync로 확실하게 마무리하는 패턴이에요.
try {
while (running) {
var records = consumer.poll(Duration.ofMillis(100));
process(records);
consumer.commitAsync(); // 정상 루프: 빠르게
}
} finally {
consumer.commitSync(); // 종료 직전: 확실하게
consumer.close();
}
왜 commitAsync는 실패해도 재시도를 안 할까요. 비동기 커밋이 재시도를 하면, 나중에 보낸 더 큰 오프셋 커밋이 먼저 성공하고 재시도된 옛 오프셋이 그걸 덮어쓸 수 있어요. 그러면 커밋 위치가 뒤로 가서 대량 중복이 생겨요. 그래서 일부러 재시도 안 하게 설계된 거고, 마지막에 commitSync로 한 번 확정하는 패턴이 그 빈틈을 메워요.
스프링 카프카에서는 이걸 컨테이너의 syncCommits 속성으로 제어해요. 기본은 동기라 안전하고, 처리량이 더 중요하면 비동기로 바꿀 수 있어요. 어느 쪽이든 "정상은 빠르게, 마무리는 확실하게"라는 균형이 핵심이에요.
06. 그런데도 중복은 생겨요
처리 후 커밋(at-least-once)을 쓰는 한 중복은 구조적으로 생겨요. 대표적인 두 경우예요.
첫째, 처리는 끝냈는데 ack 하기 전에 컨슈머가 죽는 경우예요. 오프셋이 안 올라갔으니 재시작하면 그 메시지를 다시 받아 또 처리해요. 처리 시간이 길수록 이 창이 넓어져요.
둘째, 처리가 느려서 컨슈머가 추방되는 경우예요. max.poll.interval.ms(기본 5분) 안에 다음 poll()로 못 돌아오면 브로커가 컨슈머를 죽은 걸로 보고 그룹에서 빼요. 그러면 리밸런싱이 일어나 다른 컨슈머가 그 파티션을 넘겨받고, 이미 처리한 메시지를 다시 처리해요. 게다가 추방된 컨슈머가 뒤늦게 하는 커밋은 브로커가 거부해요.
raw 컨슈머에서 직접 커밋할 때의 함정. 커밋해야 하는 오프셋은 "마지막 처리한 오프셋 + 1"이에요. 커밋된 오프셋은 "다음에 읽을 위치"라서요. 처리한 오프셋을 그대로 커밋하면 그 한 건이 중복되거나 유실돼요. 스프링의 acknowledge()는 이걸 알아서 처리해주니 직접 +1을 신경 쓸 일은 없지만, 원리는 알아두면 좋아요.
07. 멱등성, 두 번 처리해도 결과가 같게
중복은 못 없애니, 같은 메시지를 두 번 처리해도 결과가 같게 만들어요. 그게 멱등성이에요.

소비자 쪽에서 만드는 방법은 크게 네 가지예요.
방법 1 — 처리 이력 테이블
메시지 고유 ID를 처리 전에 테이블에 기록하고, 이미 있으면 건너뛰어요. 가장 직관적이에요.
if (processedRepository.existsById(message.getId())) {
return; // 이미 처리함 → skip
}
process(message);
processedRepository.save(new Processed(message.getId()));
방법 2 — DB unique 제약 · upsert
결과를 쓰는 테이블 자체에 멱등성을 맡겨요. unique 제약이나 upsert를 쓰면 같은 메시지가 두 번 와도 행이 하나만 남아요.
INSERT INTO orders (order_id, amount)
VALUES (?, ?)
ON CONFLICT (order_id) DO NOTHING;
방법 3 — Redis SETNX + TTL
짧은 시간 안의 중복은 Redis로 막아요. SET key value NX EX 60처럼 키가 없을 때만 쓰고 TTL을 줘요. TTL이 지나면 같은 메시지가 와도 못 막으니, 단독으로 쓰기보다 보조 수단이에요.
방법 4 — 멱등 연산으로 설계
연산 자체를 멱등하게 만들어요. 값을 덮어쓰는(set) 연산은 몇 번 적용해도 결과가 같아요. 반대로 balance += amount 같은 증분 연산은 중복에 취약하니, 이력으로 막거나 set 연산으로 바꿔야 해요.
네 방법 모두 "이 메시지가 그 메시지인가"를 판정할 키가 필요해요. 여기서 함정 — 카프카의 오프셋을 멱등성 키로 쓰면 안 돼요. 오프셋은 재처리·파티션 증설 때 같은 메시지라도 달라질 수 있거든요. 주문 ID, 이벤트 ID처럼 메시지를 만들 때(프로듀서) 부여한, 비즈니스 의미가 있는 고유 키를 써야 해요.
08. 자주 만나는 문제
오프셋이 안 올라가요 (lag이 계속 늘어남)
수동 커밋인데 acknowledge()가 안 불리는 경로가 있는 거예요. 예외가 났을 때 ack를 건너뛰면 그 오프셋은 커밋이 안 돼요. 모든 경로에서 ack가 불리는지, 또는 에러 핸들러가 처리하는지 확인해요.
같은 메시지가 계속 반복돼요
처리에 실패해 ack를 안 하면 그 메시지를 계속 다시 받아요. 무한 재처리죠. 실패 메시지는 재시도 한도를 두고 DLQ로 빼야 해요. 이건 에러 핸들링과 DLQ 편에서 다뤄요.
중복이 가끔 보여요
at-least-once에서는 정상이에요. 멱등성으로 흡수하는 게 맞아요. 중복을 0으로 만들려면 트랜잭션(exactly-once)이 필요한데, 비용이 커서 꼭 필요한 흐름에만 써요.
수동 커밋, 이렇게 굳혀요
수동 커밋은 at-least-once 보장을 얻는 대신 중복과 커밋 비용을 떠안는 선택이에요. 그래서 커밋과 멱등성을 한 묶음으로 설계해야 안전해져요.
- 자동 커밋의 유실·중복을 피하려고 처리 흐름에 맞춰 직접 커밋한다.
- 중복은 없앨 수 없으니 고유 키 기반 멱등성으로 흡수한다.
- 커밋 비용은
MANUAL묶음 커밋이나 비동기 커밋으로 줄인다. - 모든 처리 경로에서 ack가 불리는지(또는 에러 핸들러가 받는지) 확인한다.
기본 설정은 @KafkaListener 컨슈머 설정에서 잡고, 리밸런싱 때 생기는 재처리는 리밸런싱과 파티션 할당, 실패 메시지를 빼내는 일은 에러 핸들링과 DLQ에서 이어집니다.
출처: Spring for Apache Kafka — Committing Offsets (AckMode) · Manual Acknowledgment · Kafka — max.poll.interval.ms
'백엔드' 카테고리의 다른 글
📚 같이 보면 좋은
"이 포스팅은 쿠팡 파트너스 활동의 일환으로, 일정액의 수수료를 제공받습니다."