Kafka에서 메시지를 보내고 받는 것까지는 금방 확인할 수 있었다. 그런데 컨슈머를 껐다가 다시 켜면 어디서부터 읽을까? 컨슈머를 하나 더 띄우면 메시지는 어떻게 나뉠까? 처리하던 컨슈머가 갑자기 죽으면 같은 작업이 두 번 실행될 수도 있을까?
이 질문들을 확인하기 위해 콘솔에서 메시지를 주고받고, 이후 Python 코드로 처리와 커밋 사이에 지연을 넣어봤다. 핵심은 메시지를 읽는 것, 실제 작업을 처리하는 것, 오프셋을 커밋하는 것이 서로 다른 단계라는 점이었다.
실험 환경
Docker에서 실행 중인 kafka 컨테이너를 사용했다. 토픽은 orders, 파티션은 3개, 컨슈머 그룹은 g1이다.
| 터미널 | 역할 |
|---|---|
| 창 A | 컨슈머: g1 그룹으로 메시지 읽기 |
| 창 B | 프로듀서: 키:값 형태로 메시지 보내기 |
| 창 C | --describe로 그룹 상태 확인 |
| 창 D | 나중에 추가할 두 번째 컨슈머: 동일한 g1 그룹 |
각 창에서 컨테이너에 접속하고 Kafka 실행 파일이 있는 디렉터리로 이동했다.
docker exec -it kafka sh
cd /opt/kafka/bin토픽이 아직 없다면 다음과 같이 만든다. 복제 계수 1은 이번 단일 브로커 로컬 실험을 위한 값이다.
./kafka-topics.sh \
--bootstrap-server localhost:9092 \
--create \
--topic orders \
--partitions 3 \
--replication-factor 1토픽은 메시지를 담는 이름 붙은 로그이고, 파티션은 그 로그를 나눈 단위다. 각 파티션에는 독립적인 오프셋이 있다.
아래 명령은 원본 기록에서 터미널 폭 때문에 끊긴 옵션을 정리했다. 실행 중 표시된 안내에 따라 컨슈머의 --property는 --formatter-property, 프로듀서의 --property는 --reader-property로 바꿨다. 옵션 지원 여부는 설치한 Kafka 버전에 따라 확인해야 한다.
메시지 보내고 받기
창 A: 컨슈머 실행
./kafka-console-consumer.sh \
--bootstrap-server localhost:9092 \
--topic orders \
--group g1 \
--formatter-property print.key=true \
--formatter-property print.partition=true키와 파티션을 함께 출력하면 어떤 메시지가 어느 파티션으로 들어갔는지 볼 수 있다. 컨슈머가 시작된 뒤 창 B에서 메시지를 보냈다.
창 B: 프로듀서 실행
./kafka-console-producer.sh \
--bootstrap-server localhost:9092 \
--topic orders \
--reader-property parse.key=true \
--reader-property key.separator=:: 앞은 키, 뒤는 값으로 읽는다. 처음 입력한 기록은 다음과 같았다.
> user1:hello
>uesr2:hi
>user1:bye
>user3:hey창 A에서는 다음과 같이 받았다. 깨진 문자를 포함해 최초 관찰 로그를 그대로 남겼다.
Partition:1 �user1 hello
Partition:2 uesr2 hi
Partition:0 user1 bye
Partition:2 user3 hey여기서 바로 짚고 넘어갈 점이 있다. 첫 번째 키에는 깨진 문자가 보이고, 입력 기록에도 앞쪽 공백이 있다. uesr2라는 오타도 있다. 이 로그의 두 user1을 같은 바이트의 키라고 간주하면 안 된다. 정확히 어떤 바이트가 들어갔는지는 이 출력만으로 알 수 없다.
기본 키 기반 파티셔닝에서는 키의 바이트와 파티션 수가 같으면 같은 파티션으로 간다. 앞쪽 공백이나 다른 문자가 들어가면 다른 키다. 파티션 수나 파티셔너를 바꾸는 경우에도 기존 매핑이 유지된다고 보장할 수 없다. Kafka Producer Configs
창 C: 그룹 상태 확인
./kafka-consumer-groups.sh \
--bootstrap-server localhost:9092 \
--describe \
--group g1긴 컨슈머 ID와 호스트 정보는 줄이고, 확인할 컬럼만 정리하면 다음과 같다. 같은 A 표기는 같은 컨슈머 ID를 뜻한다.
GROUP TOPIC PARTITION CURRENT-OFFSET LOG-END-OFFSET LAG CONSUMER
g1 orders 0 1 1 0 A
g1 orders 1 1 1 0 A
g1 orders 2 2 2 0 A| 항목 | 의미 |
|---|---|
CURRENT-OFFSET | 그룹이 커밋한 오프셋. 재개할 때 다음에 읽을 위치 |
LOG-END-OFFSET | 파티션 로그 끝의 다음 오프셋 |
LAG | 이 실험에서는 LOG-END-OFFSET - CURRENT-OFFSET |
CONSUMER-ID | 해당 파티션을 현재 맡고 있는 컨슈머 |
새 토픽에 네 개를 보냈으므로 파티션 0, 1, 2에 각각 1개, 1개, 2개가 들어갔다. 하나의 컨슈머가 파티션 셋을 모두 맡고 있고, LAG는 전부 0이다.
오프셋은 마지막으로 처리한 메시지 번호와 구분해야 한다. 예를 들어 파티션의 오프셋 0을 처리했다면, 보통 다음 위치인 1을 커밋한다. 주문 번호 같은 비즈니스 ID와도 별개의 값이다.
컨슈머를 끄면 메시지는 어떻게 될까?
창 A에서 Ctrl+C로 컨슈머를 종료했다. 그 상태에서 창 B를 통해 m1부터 m5까지 다섯 개의 메시지를 보냈다.
창 C에서 다시 확인하니 활성 컨슈머가 없다는 메시지가 나왔다.
Consumer group 'g1' has no active members.
GROUP TOPIC PARTITION CURRENT-OFFSET LOG-END-OFFSET LAG CONSUMER
g1 orders 0 1 3 2 -
g1 orders 1 1 2 1 -
g1 orders 2 2 4 2 -CURRENT-OFFSET은 그대로인데 LOG-END-OFFSET만 증가했다. CONSUMER-ID는 -이고, LAG의 합은 2 + 1 + 2 = 5다.
컨슈머가 없어도 프로듀서는 메시지를 보낼 수 있고, Kafka에는 로그가 남는다. 다만 컨슈머가 읽을 때까지 무기한 보관하는 것은 아니다. 메시지는 토픽의 보존 정책에 따라 유지되므로, 장시간 중단 후에도 이어 읽으려면 필요한 데이터와 그룹 오프셋이 남아 있어야 한다. Kafka의 로그 보존과 소비자 위치 설계
다시 켜면 커밋한 위치에서 시작한다
창 A에서 같은 --group g1으로 다시 실행하니 새로 보낸 다섯 개의 메시지가 도착했다.
Partition:0 ㅕ�user1 m1
Partition:0 user1 m4
Partition:1 user4 m5
Partition:2 user2 m2
Partition:2 user3 m3이 기록에도 첫 번째 키의 깨진 문자가 남아 있다. 여기서 확인한 것은 키 매핑이 아니라, 컨슈머를 중단한 동안 쌓인 메시지를 같은 그룹으로 다시 읽었다는 사실이다.
그룹의 커밋된 오프셋이 남아 있었기 때문에 이전 메시지 전체를 처음부터 읽지 않고 이어갈 수 있었다.
다 읽었는데 LAG가 남아 있는 이유
재시작한 컨슈머가 다섯 개를 모두 출력한 직후 --describe를 실행했다. 그런데 여전히 LAG가 남아 있었다.
GROUP TOPIC PARTITION CURRENT-OFFSET LOG-END-OFFSET LAG CONSUMER
g1 orders 0 1 3 2 A-restarted
g1 orders 1 1 2 1 A-restarted
g1 orders 2 2 4 2 A-restarted읽는 것과 커밋하는 것이 별개의 동작이기 때문이었다. 화면에는 메시지가 출력됐지만, 그룹에 저장된 오프셋은 아직 이전 위치였다.
자동 커밋을 사용하는 Java 컨슈머의 auto.commit.interval.ms 기본값은 5000, 즉 5초다. 다만 정확히 5초마다 모든 비즈니스 작업의 완료를 확인해주는 장치는 아니다. 이 실험에서는 콘솔 출력 후 다음 자동 커밋이 반영되기까지 차이가 있었다. Kafka Consumer Configs
잠시 후 다시 확인하니 오프셋이 이동했다.
GROUP TOPIC PARTITION CURRENT-OFFSET LOG-END-OFFSET LAG CONSUMER
g1 orders 0 3 3 0 A-restarted
g1 orders 1 2 2 0 A-restarted
g1 orders 2 4 4 0 A-restarted여기서 다음 질문이 생겼다. 메시지는 처리했는데 커밋하기 전에 컨슈머가 죽으면 어떻게 될까?
새 컨슈머는 마지막으로 커밋된 위치부터 읽는다. 따라서 처리했지만 커밋되지 않은 메시지가 다시 전달될 수 있다. 다섯 개 모두 커밋되지 않았다면 다섯 개가 다시 올 수 있고, 일부만 커밋됐다면 해당 파티션의 남은 구간부터 다시 읽게 된다.
LAG 역시 커밋 위치를 기준으로 해석해야 한다. 출력 직후에는 실제로 읽은 양보다 뒤처져 보일 수 있고, 반대로 작업 완료 전에 커밋하면 LAG가 0이어도 실제 업무 처리가 끝났다는 뜻은 아니다.
컨슈머를 두 개 띄우면 어떻게 나뉠까?
창 D에서 창 A와 같은 명령으로 컨슈머를 하나 더 실행했다. 중요한 것은 둘 다 같은 g1 그룹이라는 점이다.
창 B에서 다음 메시지를 보냈다.
user1:a
user2:b
user3:c
user4:d
user1:e창 A에는 파티션 2의 메시지가 도착했다.
Partition:2 user2 b
Partition:2 user3 c창 D에는 파티션 0과 1의 메시지가 도착했다.
Partition:0 user1 a
Partition:0 user1 e
Partition:1 user4 d파티션 셋을 컨슈머 둘이 1:2로 나눠 맡았다. 어느 창이 두 개를 맡는지는 고정된 규칙이 아니지만, 이번 실행에서는 D가 두 개를 받았다.
파티션이 병렬 처리의 단위다
일반 컨슈머 그룹에서는 한 파티션을 같은 그룹의 여러 컨슈머에게 동시에 나눠 배정하지 않는다. 파티션이 3개라면, 해당 토픽을 읽는 그룹에서 동시에 파티션을 맡을 수 있는 컨슈머는 최대 3개다.
컨슈머 수만 늘린다고 처리량이 계속 늘어나는 것은 아니다. 파티션을 똑같은 개수로 나눠도 메시지 양이나 처리 비용이 다르면 부하는 고르게 나뉘지 않을 수 있다.
순서 보장은 파티션 안에서 이해해야 한다
이번에는 user1:a와 user1:e가 같은 파티션 0으로 들어갔고, 이를 맡은 D에서 순서대로 받았다.
Kafka가 제공하는 순서는 파티션 내부의 로그 순서다. 여러 파티션 사이의 전체 순서를 보장하는 것은 아니다. 애플리케이션이 읽은 메시지를 여러 작업 스레드에 넘긴다면, 처리 완료 순서까지 유지하는 것은 별도로 설계해야 한다. Kafka의 파티션과 소비자 설계
D가 종료되면 A가 이어받는다
이번에는 D를 종료한 뒤 창 B에서 세 개를 더 보냈다.
user1:x
user2:y
user4:zA가 세 파티션의 메시지를 모두 받았다.
Partition:0 user1 x
Partition:1 user4 z
Partition:2 user2 y창 C에서도 같은 컨슈머 ID가 모든 파티션을 맡고 있었다.
GROUP TOPIC PARTITION CURRENT-OFFSET LOG-END-OFFSET LAG CONSUMER
g1 orders 0 6 6 0 A-restarted
g1 orders 1 4 4 0 A-restarted
g1 orders 2 7 7 0 A-restarted그룹 구성원이 바뀌면서 리밸런싱이 일어났고, D가 맡던 파티션 0과 1이 A로 넘어왔다.
이어 읽을 기준은 D 프로세스의 메모리에만 있는 것이 아니다. Kafka에 그룹·토픽·파티션별로 저장된 커밋 오프셋을 새 담당자가 사용한다. 따라서 정확히는 “D가 마지막으로 읽은 곳”이 아니라 그룹이 마지막으로 커밋한 곳부터 이어받는다.
Ctrl+C 종료만으로는 장애 상황을 충분히 볼 수 없다
이번 실험은 D를 Ctrl+C로 정상 종료했다. 이런 종료 경로에서는 설정과 클라이언트 구현에 따라 마지막 커밋이나 그룹 탈퇴를 수행할 기회가 있다.
프로세스 강제 종료나 전원 장애에서는 그 기회가 없다. 이미 읽고 처리했지만 커밋하지 않은 구간이 남을 수 있다. 이 차이를 보려고 Python 코드로 실험을 이어갔다.
Python 실험 1: 갑자기 죽였는데 중복이 없었다
Python 실험에서는 컨슈머를 A와 B, 메시지를 보내는 쪽을 P로 구분했다. 콘솔 실험의 창 B와는 역할이 다르다.
컨슈머 둘을 실행한 상태에서 B를 갑자기 종료했다. 관찰한 기록에서는 A의 출력도 잠시 멈췄고, 약 45초 뒤 이어받기 시작했다.
강제 종료한 컨슈머의 장애 감지에는 세션 타임아웃이 관여한다. 다만 45초를 모든 Kafka 환경의 고정 복구 시간으로 보면 안 된다. 실제 시간은 클라이언트, 그룹 프로토콜, 타임아웃과 리밸런싱 방식에 따라 달라진다. 이 기록만으로 A가 잠시 멈춘 원인을 하나로 확정할 수도 없다. Kafka Consumer Configs의 세션 타임아웃
처음에는 중복이 나올 것이라고 예상했다. 그런데 기록상 B가 마지막으로 완료한 메시지는 order-48, A가 이어받은 첫 메시지는 order-50이었고, 중복 처리가 관찰되지 않았다.
당시 흐름은 처리 후 커밋이었다. B는 처리 과정의 sleep(1.0) 도중 종료됐고, 해당 작업의 완료 기록과 커밋은 아직 남기지 않은 상태였다. A가 그 작업을 처음부터 수행했으므로 완료된 작업이 중복되는 상황을 만들지 못한 것이다.
단, order-48 → order-50이라는 주문 ID만으로 전체 메시지의 무유실을 증명할 수는 없다. 다른 파티션에서 처리한 주문이 있을 수 있으므로, 정확한 검증에는 파티션·오프셋·주문 ID와 처리 완료 기록을 함께 비교해야 한다.
이 실험에서 필요한 장애 지점은 단순히 “처리 도중”이 아니라 처리는 끝났지만 커밋은 하지 않은 순간이었다.
Python 실험 2: 처리와 커밋 사이를 벌리니 중복이 생겼다
두 번째 실험에서는 처리 결과를 기록한 뒤, Kafka 오프셋을 커밋하기 전까지의 대기 시간을 늘렸다. DB에는 저장했지만 아직 Kafka에는 처리 위치를 알리지 못한 상황을 가정한 것이다.
아래는 실험의 단계를 설명하기 위한 의사 코드다. 실제 Python 클라이언트의 커밋 API를 옮긴 코드는 아니다.
메시지 읽기
업무 처리 및 결과 기록
대기 ← 이 구간에서 강제 종료
해당 파티션의 다음 오프셋 커밋이번에는 order-10의 중복 실행이 발생했다.
- B가
order-10을 처리하고 결과를 기록했다. - 커밋 전 대기 구간에서 B가 종료됐다.
- 그룹의 커밋 오프셋은 해당 레코드를 지나가지 못했다.
- 파티션을 넘겨받은 A가
order-10을 다시 읽고 처리했다.
Kafka 입장에서는 외부 작업이 이미 성공했는지 알 수 없다. 저장된 오프셋을 기준으로 다시 전달했을 뿐이다. 여기서 order-10은 주문 ID이고, CURRENT-OFFSET에 주문 문자열이 저장되는 것은 아니다.
처리 후 커밋하는 방식은 실패 시 재처리를 허용한다. 흔히 말하는 at-least-once 처리에서 중복을 고려해야 하는 이유다. Kafka 메시지 전달 의미
중복 전달을 중복 결과로 만들지 않으려면
주문 저장이나 결제 요청이 두 번 실행되면 안 되는 경우에는 멱등성을 설계해야 한다.
- 같은 작업을 식별할 이벤트 ID나 주문 ID를 사용한다.
- DB에서는 중복 방지용 유니크 제약과 충돌 처리를 둔다.
- 처리 완료 기록과 실제 DB 변경은 가능한 한 같은 트랜잭션으로 묶는다.
- 외부 API가 멱등성 키를 지원한다면 재시도에도 같은 키를 사용한다.
단순히 “ID가 있는지 조회한 뒤 저장”만 하면 동시 실행 사이에 틈이 생길 수 있다. DB 제약이 실제 중복을 막아야 한다. 하나의 주문에 여러 종류의 이벤트가 있다면 주문 ID만으로 정상적인 후속 이벤트까지 제거하지 않도록 작업의 식별 단위도 정해야 한다.
처리 완료 표시만 먼저 저장하고 실제 작업 전에 죽는 경우도 피해야 한다. 다시 온 메시지를 완료된 것으로 오인해 건너뛸 수 있기 때문이다.
Kafka의 exactly-once가 외부 작업까지 해결해주지는 않는다
Kafka 트랜잭션은 Kafka에서 읽은 오프셋과 Kafka에 쓰는 결과를 함께 커밋하는 데 사용할 수 있다. 적절한 트랜잭션 설정과 소비자의 read_committed 설정 등을 갖춘 처리 흐름에서 exactly-once 의미를 구성한다.
하지만 DB 변경이나 외부 API 호출이 Kafka 트랜잭션에 자동으로 포함되는 것은 아니다. 외부 시스템까지 같은 보장을 얻으려면 그 시스템과의 협력이 필요하며, 멱등성이나 결과와 처리 위치를 함께 저장하는 설계 등을 별도로 고려해야 한다. Kafka의 exactly-once와 외부 시스템 연동
Python 실험 3: 커밋을 먼저 하면 유실될까?
세 번째는 순서를 바꿨다. Before 모드로 커밋 먼저, 처리 나중을 시도했다.
이번 실행에서는 B가 커밋과 실제 처리를 마친 뒤쪽 대기 구간에서 종료됐다. 따라서 유실은 관찰되지 않았다. 커밋을 먼저 하는 방식이 안전하다는 뜻이 아니라, 위험한 구간에서 종료하지 못한 것이다.
유실을 재현하려면 아래 구간에서 죽어야 한다.
메시지 읽기
해당 파티션의 다음 오프셋 커밋
대기 ← 업무 처리가 완료되기 전에 강제 종료
업무 처리 및 결과 기록숫자를 구분하기 위해, 어떤 파티션의 오프셋 7에 order-18이 있다고 가정해보자. 아래는 실제 관찰 로그가 아닌 설명용 예시다.
[B] offset=7, order-18을 읽음
[B] 다음 위치인 offset=8을 커밋
[B] order-18의 처리를 완료하기 전에 종료
[A] 커밋된 offset=8부터 재개
→ offset=7의 order-18은 이 그룹의 정상 재개 경로에서 건너뜀메시지가 Kafka 로그에서 즉시 사라진다는 뜻은 아니다. 그룹의 처리 위치가 먼저 이동했기 때문에 필요한 업무 처리가 누락되는 것이다. 데이터가 남아 있다면 오프셋을 되돌려 재처리할 수 있지만, 자동으로 이어받는 경로에서는 건너뛴다.
커밋 후 처리 방식은 이런 유실 가능성이 있는 at-most-once 방식으로 이해할 수 있다. Kafka 메시지 전달 의미
실험을 통해 정리한 것
| 방식 | 장애가 발생한 지점 | 가능한 결과 |
|---|---|---|
| 처리 후 커밋 | 처리 완료 후, 커밋 전 | 같은 메시지를 다시 처리할 수 있음 |
| 커밋 후 처리 | 커밋 완료 후, 처리 완료 전 | 필요한 작업을 건너뛸 수 있음 |
| 처리 후 커밋 + 멱등성 | 처리 완료 후, 커밋 전 | 재전달되더라도 중복 결과를 막도록 설계 가능 |
처음에는 컨슈머를 껐다 켜도 메시지가 이어서 나오는 것만 확인했다. 이후 LAG와 커밋 위치를 함께 보니, 화면에 메시지가 보이는 시점과 Kafka가 기억하는 처리 위치가 다르다는 것을 알 수 있었다.
컨슈머를 늘리면 파티션을 나눠 맡고, 하나가 종료되면 리밸런싱으로 다른 컨슈머가 이어받는다. 하지만 이 구조만으로 실제 작업의 중복이나 누락까지 해결되는 것은 아니다.
이번 실험에서 가장 크게 남은 기준은 “어디까지 읽었나”와 “어디까지 처리했고, 어디까지 커밋했나”를 나눠서 보는 것이다. 다음에 컨슈머 코드를 작성할 때는 정상 실행뿐 아니라, 처리와 커밋 사이에서 프로세스가 종료되는 경우까지 함께 확인하려고 한다.