Kafka 프로듀서와 컨슈머는 발행과 구독이라는 두 축으로 움직인다

Kafka를 처음 붙일 때 가장 헷갈리는 지점은 대개 컨슈머 쪽이다. “서버를 여러 대로 늘렸는데 왜 같은 메시지를 두 번 처리하지?” 또는 반대로 “왜 한 서버만 메시지를 가져가고 나머지는 노는지?” 같은 질문이 그렇다. 답은 거의 항상 컨슈머 그룹 설정에 있다. Kafka에서 메시지는 발행(produce)과 구독(consume)이라는 두 축으로 움직인다. 프로듀서는 발행자, 컨슈머는 구독자다. 이 글에서는 프로듀서와 컨슈머 각각의 핵심 동작 원리를 정리하고, 실무에서 컨슈머 그룹과 오프셋을 어떻게 설계해야 하는지까지 짚는다

프로듀서는 메시지를 비동기로 발행한다

프로듀서는 메시지를 발행하는 주체다. 보통 애플리케이션 서버가 프로듀서 역할을 맡아 Kafka로 메시지를 보낸다

여기서 눈여겨볼 점은 발행이 대부분 비동기로 동작한다는 것이다. Spring, Node 같은 프레임워크로 서버를 띄우고, 그 서버가 Kafka로 메시지를 보낸다. 이때 서버와 Kafka를 연결하는 Spring Kafka 같은 클라이언트 라이브러리는 메시지를 비동기로 처리하도록 설계되어 있다. 비즈니스 로직과 별개로 메시지를 비동기 발행함으로써 서버 응답 성능을 지키기 위한 설계다. 발행 스레드가 브로커 응답을 기다리며 블로킹되지 않기 때문에, 메시지 전송이 요청 처리 속도를 끌어내리지 않는다

파티션 분산은 프로듀서가 아니라 Kafka가 알아서 한다

메시지가 발행되면 Kafka는 그 메시지를 토픽 안의 여러 파티션에 나눠 저장한다. 중요한 것은 이 분산이 프로듀서의 역할이 아니라는 점이다. 프로듀서는 파티션 배치를 신경 쓸 필요 없이 그저 메시지를 발행하면 되고, 어느 파티션에 넣을지는 클라이언트의 파티셔너가 결정한다

여기서 흔히 “키가 없으면 라운드로빈으로 분산된다”고 설명하는데, 이는 구버전 기준이다. Kafka 2.4부터 키 없는 메시지의 기본 파티셔너는 스티키 파티셔너(sticky partitioner) 로 바뀌었다. 라운드로빈이 메시지 하나하나를 파티션에 번갈아 넣는다면, 스티키 파티셔너는 한 파티션에 배치가 찰 때까지 메시지를 몰아 넣은 뒤 다음 파티션으로 넘어간다. 긴 시간으로 보면 결과적으로 파티션에 고르게 분산되는 점은 같지만, 배치를 더 크게 만들어 전송 횟수를 줄이고 지연을 낮춘다

정리하면 키를 지정하지 않은 메시지는 파티션에 고르게 흩어진다. 방식이 라운드로빈이냐 스티키냐의 차이일 뿐, “고르게 분산된다”는 결과는 동일하다

순서를 보장하려면 키를 지정해서 발행한다

문제는 이렇게 분산 저장하면 메시지 순서가 섞인다는 것이다. Kafka는 파티션 내부에서만 순서를 보장한다. 서로 다른 파티션에 들어간 메시지 사이의 순서는 보장하지 않는다. 특정 대상의 메시지를 시간 순서대로 쌓고 싶다면, 프로듀서가 발행할 때부터 키를 지정해야 한다. 키를 지정하면 브로커는 키의 해시를 기준으로 파티션을 결정한다

partition = hash(key) % 파티션 개수

같은 키를 가진 메시지는 항상 같은 파티션으로 라우팅된다. 예를 들어 주문 ID를 키로 쓰면, 같은 주문에 대한 이벤트는 모두 같은 파티션에 시간 순서대로 쌓인다. 그 파티션 안에서는 순서가 보장되므로, 결과적으로 해당 주문의 이벤트 순서가 지켜진다. 실무에서는 순서 보장이 필요한 경우가 많기 때문에, 키를 지정해서 발행하는 쪽이 오히려 일반적이다

컨슈머는 설계할 요소가 프로듀서보다 많다

컨슈머는 브로커로부터 메시지를 구독해 가져오는 주체다. 데이터를 받아서 실제로 처리하는 쪽이기 때문에, 발행만 하면 되는 프로듀서보다 결정할 것이 많다. 크게 두 가지, 컨슈머 그룹오프셋 관리를 알아야 한다

컨슈머 그룹 – 같은 그룹은 나눠 갖고, 다른 그룹은 각자 갖는다

컨슈머 그룹은 같은 그룹 ID를 가진 컨슈머들이 하나의 논리적 단위를 이뤄 하나 이상의 토픽을 함께 구독하는 개념이다. 여기서 반드시 기억해야 할 원리가 하나 있다. 하나의 파티션은 같은 그룹 안에서 오직 하나의 컨슈머에게만 할당된다

즉 한 그룹 안의 여러 컨슈머는 파티션을 나눠서 점유한다. 특정 파티션을 같은 그룹의 여러 컨슈머가 동시에 소비하는 일은 없다. 반대로 여러 컨슈머 그룹이 한 토픽을 구독하면, 각 그룹은 파티션을 독립적으로 점유한다. 그래서 같은 파티션의 메시지를 서로 다른 그룹이 각자 가져갈 수 있다

이 두 경우를 그림으로 나눠 보자.

한 그룹 안에 여러 컨슈머 – 메시지는 한 번만 처리된다

파티션 8개를 컨슈머 4대가 나눠 점유하는 구조다

Topic (8 partitions)          Consumer Group A
┌─────────────┐
│ Partition 0 │──┐
│ Partition 1 │──┴──────────▶ Consumer 1
│ Partition 2 │──┐
│ Partition 3 │──┴──────────▶ Consumer 2
│ Partition 4 │──┐
│ Partition 5 │──┴──────────▶ Consumer 3
│ Partition 6 │──┐
│ Partition 7 │──┴──────────▶ Consumer 4
└─────────────┘

파티션 0·1은 Consumer 1이, 2·3은 Consumer 2가 점유하는 식으로 나뉜다. 각 파티션은 그룹 안에서 하나의 컨슈머만 구독하므로, 메시지 하나는 컨슈머가 4대든 딱 한 번만 수신·처리된다

이 구조가 필요한 대표적 상황이 있다. 주문 서버가 발행한 메시지를 상품 서버가 받아 재고를 차감한다고 하자. 트래픽이 늘어 상품 서버를 N대로 확장했다. 이 N대를 하나의 컨슈머 그룹 ID로 묶으면, N대가 파티션을 나눠 분담하기 때문에 재고 차감 메시지는 정확히 한 번만 처리된다. 하나의 파티션에는 하나의 컨슈머만 할당되기 때문이다. 같은 작업을 여러 서버로 나눠 처리하되 중복은 막아야 할 때, 컨슈머들을 하나의 그룹 ID로 묶는다

여러 컨슈머 그룹 – 메시지가 전파된다

이번엔 두 개의 컨슈머 그룹이 같은 토픽을 구독하는 경우다

Topic (3 partitions)     Consumer Group 1        Consumer Group 2
┌─────────────┐
│ Partition 1 │──┬───────▶ Consumer 1           ┌───▶ Consumer 1
│ Partition 2 │──┤         Consumer 2           ├───▶ Consumer 2
│ Partition 3 │──┘                              └───▶ Consumer 3
└──────┬──────┘                                        ▲
       └───────────────────────────────────────────────┘
     (두 그룹이 같은 파티션을 각자 독립적으로 점유)

두 그룹은 파티션을 독립적으로 점유하므로, 같은 파티션에 들어온 메시지를 그룹 1과 그룹 2가 각각 받아 처리한다. 파티션 1에 메시지가 하나 들어오면 그룹 1의 컨슈머도, 그룹 2의 컨슈머도 그 메시지를 가져간다. 메시지는 한 번 발행됐지만 각 그룹이 같은 메시지를 각자 소비하므로, 메시지가 여러 그룹으로 전파되는 효과가 생긴다

실무 예시로 옮기면 이렇다. 주문 서버가 발행한 “주문 완료” 메시지를 상품 서버(재고 차감), 주문 통계 서버(집계), 알림 서버(푸시 발송)가 모두 받아야 한다. 이때 세 서버의 컨슈머 그룹 ID를 서로 다르게 설정한다. 그러면 하나의 메시지가 세 그룹으로 전파되어 각 서버가 자기 몫의 작업을 독립적으로 수행한다

그룹 ID 설계가 곧 메시지 흐름 설계다

같은 작업을 나눠서 하되 중복 없이 한 번만 처리해야 하면 하나의 그룹으로 묶는다. 같은 메시지를 여러 용도로 각각 처리해야 하면 그룹을 분리한다. 컨슈머 그룹 ID를 어떻게 설정할지는 개발자가 용도와 의도에 맞게 결정해야 하는, 아키텍처 차원의 선택이다

오프셋 – 어디까지 읽었는지 기록하는 번호

오프셋은 파티션 안에서 메시지의 순서를 나타내는 고유 번호다. 이 오프셋을 컨슈머 그룹이 관리한다

컨슈머가 메시지를 정상적으로 수신·처리하면 오프셋을 커밋(commit) 한다. 커밋은 “여기까지 읽고 처리했다”고 확정 짓는 행위다. 메시지를 받아 확실히 처리한 뒤 커밋하고, 다음 오프셋으로 넘어간다. 한마디로 오프셋은 내가 어디까지 읽었는지를 기록하는 숫자다

Partition 0
┌───┬───┬───┬───┬───┬───┬───┬───┬───┬────┬────┬────┐
│ 0 │ 1 │ 2 │ 3 │ 4 │ 5 │ 6 │ 7 │ 8 │ 9  │ 10 │ 11 │ ← Producer가 계속 추가
└───┴───┴───┴───┴───┴───┴───┴───┴───┴────┴────┴────┘
                          ▲           ▲
                Group A committed  Group B committed
                   offset = 6        offset = 9

중요한 점은 오프셋 관리가 컨슈머 그룹마다 독립적이라는 것이다. 위 그림에서 그룹 A는 6번까지, 그룹 B는 9번까지 처리했다고 각자 기록한다. 같은 토픽·파티션이라도 그룹마다 자신만의 커밋된 오프셋을 갖는다. 앞서 본 “그룹은 독립적으로 점유한다”는 원리가 오프셋에도 그대로 적용되는 셈이다

자동 커밋보다 수동 커밋이 일반적이다

오프셋 커밋은 자동과 수동 두 방식이 있다. enable.auto.committrue로 두면 클라이언트가 일정 주기로 오프셋을 자동 커밋한다. 편리하지만 위험이 있다. 메시지를 아직 처리하지 못했는데 오프셋만 먼저 커밋되면, 그 사이 컨슈머가 죽었을 때 메시지가 유실된 것처럼 처리에서 누락될 수 있다. 그래서 유실되면 안 되는 중요한 데이터를 다룰 때는 자동 커밋을 끄고 수동으로 커밋하는 경우가 더 많다

# Spring Kafka 예시: 자동 커밋 해제
spring:
  kafka:
    consumer:
      enable-auto-commit: false

자동 커밋을 끄면, 메시지 수신과 처리가 정상적으로 끝났을 때만 직접 커밋을 호출한다. “처리 성공 → 커밋 → 다음 오프셋”의 순서를 개발자가 통제하기 때문에, 처리에 실패한 메시지가 커밋으로 건너뛰어지는 일을 막을 수 있다. 이것이 좀 더 일반적인 컨슈머 설정 방식이다

auto.offset.reset – 새 그룹이 어디서부터 읽을지 정한다

auto.offset.reset은 새로운 컨슈머 그룹이 토픽을 처음 구독할 때, 즉 저장된 오프셋 정보가 없을 때 어디서부터 읽을지 결정하는 옵션이다. 이미 오프셋을 커밋해 둔 기존 그룹에는 영향을 주지 않는다. 어디까지나 “시작점이 없는 새 그룹”에만 적용된다

예시로 이해하는 쪽이 빠르다. 주문 완료 메시지가 이미 오래 쌓여 왔고, 상품 재고 서버가 그 메시지를 계속 처리해 온 상황을 가정하자.

과거 ─────────────────────────▶ 현재
메시지:  0   1   2   3   [4]  ← 방금 들어온 새 메시지
     └─ 상품 서버가 이미 처리해 온 구간 ─┘

여기에 두 종류의 서버를 새로 붙인다고 하자

첫째, 로그 분석 서버. 과거에 쌓인 메시지까지 전부 다시 읽어 로그를 재구성해야 한다. 이 경우 가장 오래된 메시지부터 읽어야 하므로 earliest로 설정한다

spring:
  kafka:
    consumer:
      auto-offset-reset: earliest   # 0번부터 전부 읽는다

둘째, 알림 서버. 지금 이후로 새로 발행되는 메시지에만 알림을 보내면 된다. 과거 주문까지 소급해 알림을 보내면 사용자에게 혼선을 준다. 이 경우 최신 메시지부터만 읽도록 latest로 설정한다

spring:
  kafka:
    consumer:
      auto-offset-reset: latest     # 4번(구독 시점 이후)부터 읽는다

같은 토픽을 구독하더라도, 그룹의 용도에 따라 시작점이 달라진다. 과거 데이터를 재처리해야 하는 로그 서버는 earliest, 앞으로의 이벤트만 다루는 알림 서버는 latest가 맞다

짚고 넘어갈 예외

몇 가지 경계 조건은 강의 흐름에서 다루지 않았지만 실무에서 부딪힌다. 한 그룹의 컨슈머 수가 파티션 수보다 많으면, 남는 컨슈머는 아무 파티션도 할당받지 못하고 논다. 앞의 예시에서 컨슈머를 8대 넘게 붙여도 파티션이 8개면 처리량은 더 늘지 않는다. 컨슈머 확장의 상한은 파티션 수라는 뜻이다. 또한 auto.offset.reset은 기존 오프셋이 없을 때만 작동하므로, 이미 커밋 기록이 있는 그룹의 시작점을 바꾸려면 이 옵션이 아니라 오프셋 리셋을 직접 수행해야 한다

정리

정리하면 프로듀서 쪽 핵심은 두 가지다. 발행은 비동기로 이뤄지고, 순서가 필요하면 키를 지정해 같은 파티션에 몰아넣는다. 컨슈머 쪽 핵심도 두 가지다. 같은 작업을 중복 없이 나눠 하려면 하나의 그룹으로 묶고, 같은 메시지를 여러 용도로 쓰려면 그룹을 분리한다. 그리고 유실이 곤란한 데이터는 수동 커밋으로 처리 성공 이후에만 오프셋을 옮긴다. 이 판단들이 곧 메시지 흐름의 설계다

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