Kafka 컨슈머 설계 판단 기준, 5가지 질문으로 끝낸다

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) 이다. 여기서 기억할 것은 세 가지다

  1. 오프셋은 파티션마다 따로 매겨진다. 토픽 전체에 하나가 아니다.
  2. “어디까지 읽었는지”는 컨슈머 그룹마다 따로 기록된다. 이 기록 행위가 커밋(commit) 이다.
  3. 컨슈머는 커밋된 다음 오프셋부터 읽는다.

실제 컨슈머 그룹의 오프셋 조회 결과를 보면 이 세 가지가 한눈에 들어온다

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)
수신 순서시각
1132분 56초
2133분 05초
3333분 20초
4333분 36초
5233분 11초
6233분 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로 설정하고 커밋을 프레임워크가 직접 관리한다. 그리고 기본 AckModeBATCH다.

AckMode커밋 시점
RECORD레코드 1건 처리마다
BATCHpoll() 배치 처리 완료 후 (기본값)
TIME / COUNT / COUNT_TIME시간·건수 기준
MANUALack.acknowledge() 호출 후 배치 완료 시점에 커밋
MANUAL_IMMEDIATEack.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.ymlspring.kafka.consumer.* 값이 반영되지 않는다. Map에 담은 항목만 적용되기 때문이다. 위 예시에서 동작이 올바른 이유는 yml 때문이 아니라, spring-kafka가 기본적으로 enable.auto.commitfalse로 두기 때문이다. 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.resetearliest과거 주문 내역까지 분석 대상
커밋 방식자동 커밋 허용통계는 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.resetlatest (기본값)과거 알림은 보낼 필요 없음
커밋 방식중요도에 따라 수동 커밋 검토알림 유실 허용 여부에 따라 결정

이 구조는 서버를 늘릴 때마다 새 그룹 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부터 DefaultPartitionerUniformStickyPartitioner가 deprecated 되었고 4.0에서 제거되었다. 3.3 이상에서는 partitioner.class를 지정하지 않는 것이 권장 방식이다

정리

Kafka에서 프로듀서는 단순하다. 토픽에 발행만 하면 되고, 고민할 것은 키를 줄지 말지 정도다. 어려운 쪽은 컨슈머이며, 그 어려움은 다섯 개의 질문으로 분해된다. 한 줄로 요약하면, Kafka 설계는 “누가 받을지(group.id), 어떤 순서로 받을지(key), 어디서부터 받을지(auto.offset.reset), 실패하면 어떻게 할지(커밋 + DLQ)”를 정하는 일이고, 그 답이 유효하려면 파티션 수가 충분해야 한다

출처 – 인프런 강의 중 [핵심만 빠르게 끝내는 실전 카프카(kafka)]

참고 자료