Kafka를 처음 붙일 때 대부분 프로듀서부터 본다. KafkaTemplate을 주입받고 send()를 호출하면 메시지가 나간다. 여기까지는 반나절이면 끝난다. 문제는 컨슈머다. 그룹 ID를 어떻게 나눌지, 키를 줄지, 커밋을 자동으로 할지 수동으로 할지, 실패한 메시지를 어떻게 할지를 전부 정해야 한다. 이 결정을 미루고 기본값으로 띄우면 나중에 “메시지가 사라졌다”거나 “한 대만 받는다”는 문제로 돌아온다
결론부터 말하면 Kafka 컨슈머 설계는 다섯 개의 질문으로 정리된다. 이 글은 그 다섯 질문과 각 질문에 답하는 근거를 정리한 것이다.
□ 1. 메시지를 나눠 처리해야 하나, 모두가 받아야 하나? → group.id □ 2. 순서가 뒤바뀌면 안 되는 단위가 있나? → key □ 3. 새 그룹이 과거 메시지를 읽어야 하나? → auto.offset.reset □ 4. 메시지가 유실되면 안 되나? → 커밋 전략 + DLQ □ 5. 파티션 수는 충분한가? → num.partitions
다섯 항목 모두 컨슈머 쪽 결정이다. 프로듀서에서 고민할 것은 2번의 키 하나뿐이다. Kafka 설계의 무게중심이 어디에 있는지 보여주는 목록이기도 하다
질문에 답하기 전에 – 토픽과 파티션의 차이
다섯 질문 전부가 파티션 위에서 작동한다. 이 구분을 먼저 짚어야 나머지가 따라온다
- 토픽(Topic) = 논리적인 분류. “회원 관련 메시지는
member-topic으로” 같은 주제 이름이다 - 파티션(Partition) = 물리적인 저장소. 메시지가 append 되어 쌓이는 로그 파일이다
토픽에 발행하면 실제로는 그 토픽에 속한 파티션 중 하나에 저장된다
member-topic (논리적 이름)
┌──────────────────────┴─────────────────────────────┐
┌──▼──────────┐ ┌─────────────┐ ┌─────────────┐ ┌───────▼─────┐
│ Partition 0 │ │ Partition 1 │ │ Partition 2 │ │ Partition 3 │
│ [0][1][2] │ │ │ │ [0] │ │ [0][1][2] │
└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘
▲ 메시지가 순서대로 append 되고, 각 칸의 번호가 offset
각 칸에 붙은 번호가 오프셋(offset) 이다. 여기서 기억할 것은 세 가지다
- 오프셋은 파티션마다 따로 매겨진다. 토픽 전체에 하나가 아니다.
- “어디까지 읽었는지”는 컨슈머 그룹마다 따로 기록된다. 이 기록 행위가 커밋(commit) 이다.
- 컨슈머는 커밋된 다음 오프셋부터 읽는다.
실제 컨슈머 그룹의 오프셋 조회 결과를 보면 이 세 가지가 한눈에 들어온다
GROUP TOPIC PARTITION CURRENT-OFFSET LOG-END-OFFSET LAG member-topic-log-group member-topic 0 4 4 0 member-topic-log-group member-topic 1 0 0 0 member-topic-log-group member-topic 2 1 1 0 member-topic-log-group member-topic 3 4 4 0 member-topic-static-group2 member-topic 0 4 4 0 member-topic-static-group2 member-topic 2 1 1 0 member-topic-static-group2 member-topic 3 4 4 0
파티션 0번은 4까지 읽었고 1번은 비어 있다. 오프셋이 파티션별로 따로 관리된다는 뜻이다. 같은 member-topic인데 두 그룹이 각자 기록을 갖고 있다. 그룹이 다르면 오프셋도 완전히 별개라는 뜻이다. LAG은 아직 안 읽은 메시지 수이며, 운영 모니터링에서 가장 먼저 보는 지표다. 이 값이 계속 증가하면 컨슈머가 유입량을 못 따라가고 있다는 신호다
커밋 기록은 Kafka 내부 토픽 __consumer_offsets에 남는다. 3번 질문에서 이 기록이 함정으로 작동한다.
질문 1. 나눠 받을 것인가, 모두 받을 것인가
group.id가 같은 컨슈머들의 묶음을 컨슈머 그룹이라 한다. 이 값 하나가 메시지 전달 방식을 완전히 바꾼다. 같은 그룹 ID를 쓰면 파티션을 나눠 가진다
member-topic
P0 P1 P2 P3
│ │ │ │
└──┬──┘ └──┬──┘
│ │
┌───▼───┐ ┌───▼───┐ 같은 그룹
│ C1 │ │ C2 │ → 메시지 1건은 둘 중 "한 대"만 받음
└───────┘ └───────┘
다른 그룹 ID를 쓰면 각자 전체 파티션을 점유한다.
member-topic P0 P1 P2 P3 │ ╲ │ ╲ │ ╲ │ ┌▼─────▼┐ ┌▼─────▼┐ 다른 그룹 │ C1 │ │ C2 │ → 메시지 1건을 "둘 다" 받음 └───────┘ └───────┘
컨슈머 2대를 띄우고 그룹 ID만 바꿔가며 확인하면 기동 로그의 파티션 점유 현황에서 차이가 그대로 드러난다.
같은 그룹 ID → C1: 파티션 2개 / C2: 파티션 2개 (나눠 점유) 다른 그룹 ID → C1: 파티션 4개 / C2: 파티션 4개 (각각 전체 점유)
같은 그룹일 때는 발행한 메시지가 두 컨슈머 중 한 쪽에서만 로그로 찍힌다. 다른 그룹일 때는 양쪽 모두 찍힌다. 파티션 점유 개수가 곧 수신 여부로 이어진다는 것을 보여준다.
| 구분 | 같은 group.id | 다른 group.id |
|---|---|---|
| 파티션 | 나눠서 점유 | 각각 전체 점유 |
| 오프셋 | 그룹 내에서 공유 | 그룹별로 독립 관리 |
| 메시지 수신 | 그룹 내 1대만 | 모든 그룹이 각각 |
| 용도 | 처리량 확장 (scale-out) | 이벤트 전파 (pub/sub) |
판단 기준: 같은 일을 여러 대가 나눠서 빨리 처리하려는 것이면 같은 그룹 ID다. 서로 다른 일을 각자 하려는 것이면 다른 그룹 ID다
질문 2. 순서가 뒤바뀌면 안 되는 단위가 있는가
Kafka에서 순서가 보장되는지 묻는다면 답은 조건부다
토픽 전체의 순서는 보장되지 않는다. 파티션 내부의 순서만 보장된다
키를 지정하면 같은 키가 항상 같은 파티션으로 간다. 그래서 같은 키끼리는 순서가 보장된다. 발행 시 키를 넘기는 코드는 인자 하나 차이다
// 키 없이 발행 — 파티션이 분산되어 순서 보장 없음
kafkaTemplate.send("member-topic", memberData);
// 키를 지정해서 발행 — 같은 키는 같은 파티션
kafkaTemplate.send("member-topic", String.valueOf(id), data);
// 토픽 ↑ key ↑ value
키가 있을 때 파티션은 murmur2(key) % 파티션수로 결정된다. 나머지 연산이므로 같은 키는 항상 같은 결과를 낸다. 이것이 순서 보장의 전부다. 수신 측에서는 헤더로 키를 꺼낼 수 있다
@KafkaListener(topics = "member-topic",
groupId = "${spring.kafka.consumer.member-topic-log-group-id}",
containerFactory = "kafkaListener")
public void consumer1(
@Header(KafkaHeaders.RECEIVED_KEY) String key,
String message
) {
System.out.println("key 값 : " + key);
System.out.println("컨슈머 메시지 수신 : " + message);
}
@Header(KafkaHeaders.RECEIVED_KEY)를 붙이면 키와 본문이 분리되어 주입된다. 키를 로직에 쓰거나 로그로 남길 때 필요하다. 실제로 확인해보면 결과가 명확하다. 컨슈머를 내려둔 채 키를 바꿔가며 6건을 발행하고, 컨슈머를 다시 올려 한꺼번에 수신한 결과다
발행 순서: key=1(홍길동1) → key=1(홍길동2) → key=2(홍길동3)
→ key=2(홍길동4) → key=3(홍길동5) → key=3(홍길동6)
| 수신 순서 | 키 | 시각 |
|---|---|---|
| 1 | 1 | 32분 56초 |
| 2 | 1 | 33분 05초 |
| 3 | 3 | 33분 20초 |
| 4 | 3 | 33분 36초 |
| 5 | 2 | 33분 11초 |
| 6 | 2 | 33분 15초 |
발행은 1,1,2,2,3,3 순서였는데 수신은 1,1,3,3,2,2다. 2번 키를 3번보다 먼저 발행했는데 3번이 먼저 도착했다. 서로 다른 파티션에 저장되었기 때문이며, 파티션 간 순서는 애초에 보장 대상이 아니다. 반면 각 키 내부의 시각은 전부 오름차순이다. 키 1은 32:56 → 33:05, 키 3은 33:20 → 33:36, 키 2는 33:11 → 33:15다. 같은 키 안에서는 순서가 한 번도 뒤바뀌지 않았다
판단 기준: “이 단위 안에서는 순서가 절대 뒤바뀌면 안 된다”는 단위를 찾아 그것을 키로 잡는다. 채팅방 ID, 주식 종목 코드, 주문 ID, 사용자 이메일이 전형적인 후보다. 그런 단위가 없으면 키를 주지 않는 편이 처리량에 유리하다
단, 특정 키에 트래픽이 몰리면 그 파티션만 과부하가 된다. 이를 핫 파티션이라 한다. 순서 보장 단위를 너무 크게 잡으면 결국 파티션 하나에 부하가 집중되므로, 보장 범위와 분산 사이에서 균형을 잡아야 한다
질문 3. 새 그룹이 과거 메시지를 읽어야 하는가
새로 투입한 컨슈머 그룹이 이미 쌓여 있던 메시지를 읽을지 결정하는 값이 auto.offset.reset이다
| 설정 | 동작 |
|---|---|
earliest | 파티션에 남아 있는 과거 메시지까지 전부 읽음 |
latest | 앞으로 발행되는 메시지만 읽음 (기본값) |
여기에 실무에서 가장 많이 걸리는 함정이 있다. auto.offset.reset은 커밋된 오프셋이 없을 때만 적용된다. 이미 한 번이라도 실행된 그룹 ID는 __consumer_offsets에 기록이 남아 있어, 설정을 earliest로 바꾸고 재시작해도 과거 메시지를 다시 읽지 않는다
그래서 이 설정을 테스트하려면 기존에 쓰지 않던 새 그룹 ID가 필요하다
member-topic-static-group-id: member-topic-static-group2 # 완전히 새로운 그룹 ID auto-offset-reset: earliest
그룹 ID를 member-topic-static-group에서 member-topic-static-group2로 바꾸고 재시작하면, 그때 비로소 지금까지 발행한 모든 메시지를 처음부터 수신한다. 설정을 바꿨는데 동작이 안 바뀐다면 십중팔구 그룹 ID를 그대로 둔 것이다
판단 기준: 과거 데이터가 결과에 영향을 주는 업무인지 본다. 로그 분석이나 통계 집계처럼 누적값이 필요하면 earliest다. 알림 발송이나 재고 감소처럼 “지금부터의 이벤트”만 의미 있으면 기본값 latest를 그대로 둔다
질문 4. 메시지가 유실되면 안 되는가
가장 중요한 질문이다. 그리고 답이 가장 길다
자동 커밋은 실패를 유실로 만든다
예외를 잡아서 로그만 남기는 흔한 형태의 리스너를 보자.
@KafkaListener(topics = "member-topic",
groupId = "${spring.kafka.consumer.member-topic-log-group-id}",
containerFactory = "kafkaListener")
public void consumer1(@Header(KafkaHeaders.RECEIVED_KEY) String key, String message) {
try {
System.out.println("컨슈머 메시지 수신1 : " + message);
// 실무라면 이 자리에 DB 저장이나 외부 API 호출이 들어간다
if (key.equals("1")) {
throw new IllegalArgumentException("예상치 못한 예외 발생");
}
System.out.println("처리 완료");
} catch (Exception e) {
e.printStackTrace();
}
}
키 1로 발행하면 예외가 나고 catch로 빠진다. 컨슈머를 재시작해도 그 메시지는 다시 오지 않는다. 처리에 실패했는데 오프셋은 이미 넘어간 것이다.
여기서 흔히 “Kafka가 주기적으로 자동 커밋해서” 그렇다고 설명하는데, spring-kafka를 쓰는 경우 메커니즘이 조금 다르다. spring-kafka는 2.3부터 enable.auto.commit을 명시하지 않으면 false로 설정하고 커밋을 프레임워크가 직접 관리한다. 그리고 기본 AckMode는 BATCH다.
| AckMode | 커밋 시점 |
|---|---|
RECORD | 레코드 1건 처리마다 |
BATCH | poll() 배치 처리 완료 후 (기본값) |
TIME / COUNT / COUNT_TIME | 시간·건수 기준 |
MANUAL | ack.acknowledge() 호출 후 배치 완료 시점에 커밋 |
MANUAL_IMMEDIATE | ack.acknowledge() 호출 즉시 커밋 |
AckMode.BATCH 상태에서 try-catch로 예외를 삼키면 리스너가 정상 종료한 것으로 보인다. 그러면 spring-kafka는 배치가 성공적으로 끝났다고 판단해 커밋한다. 관찰되는 현상은 “자동 커밋으로 인한 유실”과 같지만, 실제 원인은 Kafka의 백그라운드 커밋이 아니라 프레임워크의 배치 커밋이다.
수동 커밋으로 전환하기
설정 세 곳을 모두 바꿔야 한다.
# ① application.yml enable-auto-commit: false
// ② 리스너 컨테이너 팩토리에서 AckMode 변경 listener.getContainerProperties().setAckMode(ContainerProperties.AckMode.MANUAL);
// ③ 리스너에서 직접 커밋
@KafkaListener(topics = "member-topic",
groupId = "${spring.kafka.consumer.member-topic-log-group-id}",
containerFactory = "kafkaListener")
public void consumer1(
@Header(KafkaHeaders.RECEIVED_KEY) String key,
String message,
Acknowledgment ack
) {
try {
System.out.println("컨슈머 메시지 수신1 : " + message);
if (key.equals("1")) {
throw new IllegalArgumentException("예상치 못한 예외 발생");
}
System.out.println("처리 완료");
ack.acknowledge(); // 성공했을 때만 커밋
} catch (Exception e) {
e.printStackTrace();
}
}
구조가 곧 의미다. 예외가 나면 catch로 빠지므로 ack.acknowledge()에 도달하지 못하고, 따라서 커밋되지 않는다. 이 상태에서 재시작하면 같은 메시지를 다시 받는다. try-catch를 생략하고 예외를 리스너 밖으로 던지면 안 된다. spring-kafka의 DefaultErrorHandler가 개입하는데, 파라미터 없이 생성된 기본 설정은 FixedBackOff(0L, 9L)이다. 간격 없이 총 10번 시도한 뒤 실패를 ERROR 레벨로 남기고 해당 레코드를 건너뛴다. 오프셋이 넘어가므로 수동 커밋을 걸어둔 의미가 사라진다
한 가지 더 짚을 것이 있다. ConsumerFactory를 직접 만들면서 설정 Map을 손수 구성하는 코드에서는 application.yml의 spring.kafka.consumer.* 값이 반영되지 않는다. Map에 담은 항목만 적용되기 때문이다. 위 예시에서 동작이 올바른 이유는 yml 때문이 아니라, spring-kafka가 기본적으로 enable.auto.commit을 false로 두기 때문이다. yml 값을 확실히 적용하려면 KafkaProperties.buildConsumerProperties()를 기반으로 Map을 만들거나 필요한 값을 명시적으로 config.put() 해야 한다
수동 커밋만으로는 부족하다
여기까지 하면 안전해 보이지만, 현실에서는 자동 커밋과 결과가 크게 다르지 않을 수 있다. 실패했다고 서버를 재시작하지는 않고, 곧바로 다음 메시지가 들어오기 때문이다.
파티션 내부 오프셋 흐름
offset: ... [6] [7] [8] [9] [10] [11] ...
성공 성공 실패 성공 성공 성공
│ │
커밋 안 함 ──┘ └── 커밋됨 → 커밋 위치가 9로 이동
재시작해도 9번부터 시작 → 8번은 영구 유실
컨슈머는 마지막 커밋 오프셋 하나만 관리한다. 중간에 8번을 건너뛰었다는 정보는 어디에도 남지 않는다. 9번이 성공적으로 커밋되는 순간 8번은 재처리 대상에서 완전히 사라진다. 그렇다고 8번에서 계속 재시도하게 만들면 더 위험하다. 뒤에 9, 10, 11번이 쌓이면서 정상 메시지 처리가 전면 중단된다. 한 건이 줄 전체를 막는 head-of-line blocking이다
참고로 이 유실을 재현할 때 파티션 함정에 걸리기 쉽다. 키 1로 실패시킨 뒤 키 2로 성공 메시지를 보내도 확인되지 않는다. 오프셋은 파티션마다 관리되므로, 키 2가 다른 파티션으로 갔다면 키 1이 있는 파티션의 오프셋은 그대로다. 키를 여러 개 바꿔가며 발행해 실패한 메시지와 같은 파티션에 후속 메시지가 들어가도록 만들어야 유실이 드러난다
해법은 DLQ
실패한 메시지를 별도 토픽으로 보내고, 전용 컨슈머가 따로 재처리하는 구조다
Producer ──▶ member-topic ──▶ 주 Consumer ──정상──▶ 처리 완료 + 커밋
│
실패
▼
member-topic-dlq ──▶ DLQ 전용 Consumer
│
▼
재처리 / 알림 / 수동 개입
코드로는 catch 블록에 한 줄이 늘어난다.
} catch (Exception e) {
e.printStackTrace();
kafkaTemplate.send("member-topic-dlq", key, message);
}
이 한 줄로 세 가지가 해결된다. 주 컨슈머는 막히지 않고 계속 정상 메시지를 처리한다. 실패 메시지는 유실되지 않고 별도 토픽에 보존된다. 재시도 횟수나 알림 정책을 주 컨슈머와 무관하게 운영할 수 있다
판단 기준: 유실을 감당할 수 있으면 기본값으로 단순하게 간다. 감당할 수 없으면 예외 처리, 수동 커밋, DLQ 세 가지를 함께 적용한다. 셋 중 하나라도 빠지면 나머지 둘의 효과가 사라진다
① 예외 처리 (try-catch) → 프레임워크가 강제로 커밋·스킵하지 못하게 통제권 확보 ② 수동 커밋 (AckMode.MANUAL) → 성공한 메시지만 커밋 ③ DLQ 설계 → 실패 메시지를 별도 토픽에 보존해 독립 재처리
질문 5. 파티션 수는 충분한가
앞의 네 질문에 다 답했어도 파티션 수가 모자라면 설계가 작동하지 않는다
같은 그룹 안에서 컨슈머 수가 파티션 수를 넘으면 초과분은 아무 파티션도 배정받지 못하고 유휴 상태가 된다. 파티션 4개짜리 토픽에 같은 그룹 컨슈머를 5대 띄우면 1대는 놀게 된다. 즉 파티션 수가 컨슈머 확장의 상한선이다
더 까다로운 것은 나중에 고치기 어렵다는 점이다
- 파티션은 줄일 수 없다. 줄이려면 토픽을 지우고 다시 만들어야 한다
- 늘리면
murmur2(key) % 파티션수의 나눗수가 바뀌므로 기존 키와 파티션의 매핑이 깨진다. 질문 2에서 확보한 순서 보장이 그 시점부터 무너진다
브로커 기본값은 num.partitions로 설정한다. 다만 이 값은 브로커에서 자동 생성되는 모든 토픽에 적용되는 기본값이라, 토픽별로 다르게 가져가려면 토픽을 명시적으로 생성해야 한다
KAFKA_NUM_PARTITIONS: 4 # 자동 생성 토픽의 기본 파티션 수 KAFKA_AUTO_CREATE_TOPICS_ENABLE: "true" # 운영에서는 false 권장
AUTO_CREATE_TOPICS_ENABLE이 켜져 있으면 프로듀서가 처음 발행하는 순간 토픽이 자동 생성된다. 학습 환경에서는 편하지만, 운영에서는 토픽 이름에 오타가 나도 그대로 새 토픽이 만들어진다. 운영 환경에서는 false로 두고 파티션 수를 지정해 명시적으로 생성하는 편이 안전하다
판단 기준: 예상 최대 컨슈머 대수보다 파티션을 여유 있게 잡는다. 순서 보장이 필요한 토픽이라면 특히 그렇다. 나중에 늘리는 비용이 처음부터 넉넉히 잡는 비용보다 훨씬 크다
세 가지 상황에 적용해보기
다섯 질문이 실제로 어떻게 답으로 이어지는지 세 가지 요구사항에 대입해본다
신규 주문 통계 서버
요구사항은 과거에 발행된 모든 주문 메시지를 분석하고, n대로 나눠 처리하는 것이다
| 항목 | 결정 | 이유 |
|---|---|---|
| 그룹 ID | 하나의 그룹 ID로 n대 실행 | 파티션을 나눠 점유해 분산 처리 |
| 키 | 불필요 | 집계는 처리 순서에 영향받지 않음 |
auto.offset.reset | earliest | 과거 주문 내역까지 분석 대상 |
| 커밋 방식 | 자동 커밋 허용 | 통계는 1~2건 유실이 치명적이지 않음 |
| 파티션 | 서버 대수 이상 확보 | 파티션 4개면 최대 4대까지만 유효 |
다섯 질문 중 네 개가 “단순한 쪽”으로 결정된 사례다. 요구사항이 유실에 관대하면 설계가 이만큼 가벼워진다
MSA 서비스 간 메시지 브로커
주문이 발생하면 상품 서버가 재고를 줄이고, 회원명이 변경되면 상품 서버의 등록자명도 함께 바꾸는 구조다
| 항목 | 결정 | 이유 |
|---|---|---|
| 구독 대상 | order-topic + member-topic | @KafkaListener를 2개 선언 |
| 키 | 기본적으로 불필요 | 1번 상품과 2번 상품 중 어느 재고를 먼저 줄이든 무관 |
| 커밋 방식 | 수동 커밋 | 재고 감소와 회원 정보 변경은 유실 불가 |
| 예외 처리 | 필수 | 수동 커밋이 무력화되지 않도록 |
| DLQ | 필요 | 실패 이벤트를 보존해 재처리 |
@KafkaListener는 각각 독립된 리스너 컨테이너로 동작한다. 그래서 한 클래스에 여러 개를 선언해 여러 토픽을 동시에 구독할 수 있다
키를 두고는 조건이 붙는다. 같은 상품에 대한 재고 변경이 연달아 일어나고 그 순서가 결과를 바꾼다면 상품 ID를 키로 잡아야 한다. 판단 기준은 항상 “순서가 뒤바뀌면 문제가 되는가” 하나다
실시간 알림 서버
n대의 알림 서버가 처리하고, 서버 실행 이후 알림만 보내며, 알림 순서가 보장되어야 한다. 여기에는 Kafka 바깥의 제약이 하나 끼어든다. 알림 서버는 각자 자신에게 연결된 사용자의 세션 정보만 메모리에 갖고 있다
같은 그룹으로 묶으면
Kafka ──▶ [알림서버1, 알림서버2] → 둘 중 한 대만 수신
"사용자2에게 알림"을 알림서버1이 가져감 → 알림서버1엔 사용자2 연결이 없음 → 전송 실패
그룹 ID를 다르게 하면
Kafka ──▶ 알림서버1 (그룹 A) → 내가 사용자1 갖고 있나? → 있으면 전송
└──▶ 알림서버2 (그룹 B) → 내가 사용자2 갖고 있나? → 있으면 전송
n대로 확장하는 것이 목적이니 질문 1의 답은 “같은 그룹”처럼 보인다. 하지만 각 서버가 일부 사용자만 담당하므로 모든 서버가 메시지를 받아야 한다. 답은 “다른 그룹”이다
| 항목 | 결정 | 이유 |
|---|---|---|
| 그룹 ID | 서버마다 다르게 | 모든 알림 서버가 메시지를 받아야 함 |
| 키 | 사용자 이메일 | 같은 사용자의 알림 순서가 뒤바뀌면 안 됨 |
auto.offset.reset | latest (기본값) | 과거 알림은 보낼 필요 없음 |
| 커밋 방식 | 중요도에 따라 수동 커밋 검토 | 알림 유실 허용 여부에 따라 결정 |
이 구조는 서버를 늘릴 때마다 새 그룹 ID를 부여해야 하므로 운영 관리 포인트가 된다. 연결 정보를 Redis에 중앙 저장해 어느 서버가 받아도 처리할 수 있게 만드는 대안도 있다. 그렇게 하면 같은 그룹 ID로 묶어 파티션 기반 확장을 그대로 쓸 수 있다
이 기준이 통하지 않는 지점
다섯 질문은 컨슈머 설계에 한정된 체크리스트다. 커버하지 못하는 영역이 몇 군데 있다
kafkaTemplate.send()는 CompletableFuture<SendResult<K,V>>를 반환하는 비동기 호출이다. 반환값을 버리면 브로커 전송이 실패해도 애플리케이션은 알지 못한다. 컨슈머를 아무리 안전하게 설계해도 발행 단계에서 조용히 사라지면 소용이 없다.
kafkaTemplate.send("member-topic", memberData)
.whenComplete((result, ex) -> {
if (ex != null) {
log.error("Kafka 발행 실패", ex); // 재시도 / 알림 / 아웃박스 저장
}
});
whenComplete로 실패를 감지해야 재시도나 알림 같은 후속 처리를 붙일 수 있다
DB 트랜잭션과 Kafka 발행도 원자적이지 않다. @Transactional을 붙여도 “DB 커밋 + Kafka 발행”이 한 덩어리로 묶이지는 않는다. DB는 롤백됐는데 메시지는 이미 나가 있는 상황이 생긴다. 해결하려면 트랜잭셔널 아웃박스 패턴이나 Kafka 트랜잭션 연동이 필요하며, 이건 다섯 질문 바깥의 주제다
메시지 보관 기간도 전제 조건이다. Kafka는 기본 7일 후 메시지를 삭제하며, 컨슈머가 읽었는지 여부와 무관하다. 질문 3에서 earliest를 선택해도 이미 삭제된 메시지는 읽을 수 없다. 장기 보관이 필요하면 retention을 늘리거나 별도 저장소로 옮겨야 한다
키 없이 발행할 때의 파티션 분산 방식도 버전에 따라 다르다. Kafka 2.4에서 도입된 sticky 파티셔너는 배치 단위로 묶어 분산했는데, 느린 브로커에 오히려 부하가 몰리는 문제가 있었다. Kafka 3.3부터 DefaultPartitioner와 UniformStickyPartitioner가 deprecated 되었고 4.0에서 제거되었다. 3.3 이상에서는 partitioner.class를 지정하지 않는 것이 권장 방식이다
정리
Kafka에서 프로듀서는 단순하다. 토픽에 발행만 하면 되고, 고민할 것은 키를 줄지 말지 정도다. 어려운 쪽은 컨슈머이며, 그 어려움은 다섯 개의 질문으로 분해된다. 한 줄로 요약하면, Kafka 설계는 “누가 받을지(group.id), 어떤 순서로 받을지(key), 어디서부터 받을지(auto.offset.reset), 실패하면 어떻게 할지(커밋 + DLQ)”를 정하는 일이고, 그 답이 유효하려면 파티션 수가 충분해야 한다
출처 – 인프런 강의 중 [핵심만 빠르게 끝내는 실전 카프카(kafka)]
참고 자료
- Message Listener Containers :: Spring Kafka — 기본 AckMode
BATCH, 2.3부터enable.auto.commit기본false - Handling Exceptions :: Spring Kafka —
DefaultErrorHandler기본FixedBackOff(0L, 9L) - KIP-794: Strictly Uniform Sticky Partitioner — 3.3부터
DefaultPartitioner·UniformStickyPartitionerdeprecated - Choose and Change the Partition Count in Kafka | Confluent — 파티션 축소 불가, 증가 시 키 매핑 변경