Kafka 토픽을 나눈 순서가 보장되는 append-only 로그 단위. 병렬성과 순서를 동시에 결정한다.
병렬성의 상한이다
한 파티션은 컨슈머 그룹 안에서 '한 컨슈머' 만 읽는다
- 그룹의 최대 병렬성 = 파티션 수
- 컨슈머를 파티션보다 많이 띄우면 남는 건 그냥 논다
처리량을 늘리려면 컨슈머가 아니라 파티션을 먼저 늘려야 한다
@KafkaListener(topics = "orders", groupId = "order-consumer", concurrency = "3")
void onOrder(OrderPlaced e) { ... } // 이 인스턴스가 컨슈머 스레드 3개를 띄운다
인스턴스를 여러 대 띄워도 groupId가 같으면 전체 스레드가 하나의 그룹으로 파티션을 나눠 갖는다.
순서와 병렬성을 함께 얻는 법 — 키
template.send("orders", event.orderId(), event); // (토픽, 키, 값)
같은 키 → 같은 파티션 (해시)
- 한 주문의 이벤트는 순서대로
- 서로 다른 주문은 병렬로
전역 순서를 원하면 파티션 1개뿐 → 병렬성을 포기하게 된다
파티션은 늘리기만 하고 줄일 수 없다
늘리면 key → partition 매핑이 바뀐다
- 그 시점 이후로 같은 키가 다른 파티션으로 갈 수 있다
- 순서 보장이 경계에서 깨진다
그래서 처음에 여유 있게 잡는다. 다만 너무 많으면
- 파일 핸들 · 메모리 · 리밸런스 시간 · 종단 지연이 는다
기본 보장은 at-least-once
컨슈머가 처리하고 오프셋을 커밋하기 직전에 죽으면
- 재시작 후 그 메시지를 다시 처리한다 → 중복
그래서 실무의 답은 '멱등 컨슈머' 다
- 메시지 고유 id 로 중복 검사
- UPSERT
- 조건부 갱신 (WHERE status = 'PENDING')
정확히 한 번(EOS) 도 있지만(멱등 프로듀서 + 트랜잭션) 비용과 복잡도가 커서, 대개 at-least-once + 멱등 컨슈머가 더 단순하고 견고하다.
실패 처리 — 독약 메시지를 치운다
@Bean
DefaultErrorHandler errorHandler(KafkaTemplate<String, Object> template) {
var recoverer = new DeadLetterPublishingRecoverer(template); // "<토픽>.DLT" 로
return new DefaultErrorHandler(recoverer, new FixedBackOff(1000L, 3L));
}
역직렬화 실패는 재시도해도 영원히 실패한다 — 재시도 없이 즉시 DLT로 분류해야 그 파티션이 막히지 않는다.
면접 함정
- ❌ "Kafka는 순서를 보장한다" → 파티션 단위로만 보장한다.
- ❌ "컨슈머를 늘리면 계속 빨라진다" → 파티션 수가 상한이다.
리밸런싱이 잦으면 무슨 일이 생기나
컨슈머가 들고 날 때마다 파티션을 다시 나눈다
- 그동안 소비가 멈춘다
- 커밋 전 처리분이 다른 컨슈머에게 재배정된다 → 중복
가장 흔한 원인: max.poll.interval.ms 초과 한 번에 가져온 레코드를 처리하는 데 이 시간(기본 5분)을 넘기면 죽은 것으로 보고 그룹에서 쫓아낸다 → 리밸런싱 → 또 느려짐 → 악순환
max.poll.records=100 # 처리가 느리면 이것부터 줄인다
max.poll.interval.ms=300000
group.instance.id=order-consumer-1 # 정적 멤버십 — 재시작해도 리밸런싱을 피한다
session.timeout.ms=45000
밀리고 있는지 보는 지표
kafka.consumer.fetch.manager.records.lag 파티션별 랙 (Micrometer) kafka.consumer.fetch.manager.records.lag.max
알림은 순간값이 아니라 '추세' 로 건다
- 배포마다 튀었다 회복되므로 순간값이면 오탐이 쏟아진다