최근 인시던트에서, 우리 팀은 Amazon MSK 롤링 패치 도중 Kafka Producer에서 메시지 유실이 발생하는 것을 확인했습니다. 일상적인 업그레이드로 시작된 작업이 순식간에 Producer 설정에 숨겨진 취약점을 드러냈습니다.
문제를 파고들면서, Kafka Producer가 브로커 리더와 어떻게 상호작용하는지, 그리고 프로덕션 수준의 내결함성 Producer 파이프라인을 구축하려면 진정으로 무엇이 필요한지에 대해 더 명확한 그림을 그릴 수 있었습니다. 이 글에서는 그러한 인사이트를 정리합니다—메시지 전달 안정성에 영향을 미치는 핵심 설정 옵션과 그 이면의 메커니즘을 다룹니다.
먼저 롤링 패치 중 메시지 유실이 어떻게 발생할 수 있는지 살펴보고, 이후 Kafka 메시지가 위험에 처할 수 있는 다른 시나리오들로 시야를 넓혀 보겠습니다.
성공 시나리오: 롤링 패치가 정상적으로 동작하는 경우
Amazon MSK는 브로커를 하나씩 순차적으로 재시작하여 중단을 최소화하면서 업데이트를 적용하는 "롤링 패치"를 수행합니다.
잘 구성된 환경에서는 패치 프로세스가 일련의 내결함성 단계를 따르며, 메시지 전달이 중단 없이 유지됩니다:
1. 초기 상태:
- 모든 브로커(1, 2, 3)가 정상 운영 중입니다.
- 파티션 1의 리더는 브로커 1에 있으며, ISR(In-Sync Replicas)에는 {브로커 1, 2, 3}이 포함되어 있습니다.
- Producer는 높은
retries값,acks=all,enable.idempotence=true등의 설정으로 높은 복원력을 갖추고 있습니다.

2. 패치 시작 (대상: 브로커 1):
- MSK가 브로커 1의 제어된 종료를 시작합니다.
- Kafka 컨트롤러가 종료를 감지하고, 파티션 1의 리더십을 다른 ISR 멤버(예: 브로커 2)로 재할당합니다.
- 이 메타데이터 변경이 클러스터 전체에 전파됩니다.

3. Producer의 초기 반응:
- Producer는 여전히 브로커 1이 리더라고 인식할 수 있습니다.
- 브로커 1로의 전송 시도가 실패하며, 연결 오류 또는
NotLeaderOrFollowerException이 발생합니다.
4. 메타데이터 갱신 및 재시도 로직:
- 높은 재시도 횟수로 구성된 Producer는 계속 재시도합니다.
- 이러한 실패가 메타데이터 갱신을 트리거합니다(반응적으로 또는
metadata.max.age.ms를 통해). - Producer는 브로커 2가 새로운 리더라는 업데이트된 메타데이터를 수신하고 내부 라우팅을 갱신합니다.

5. 메시지 전달 성공:
- 메시지가 재시도되어 브로커 2로 전송됩니다.
- 브로커 2는 메시지를 로컬에 저장하고 브로커 3에 복제합니다(브로커 1은 오프라인).
- 모든 동기화 복제본(2, 3)으로부터 확인을 받고,
min.insync.replicas=2가 충족되면, 브로커 2가 최종 ACK로 응답합니다.

결과적으로, 원래 리더가 오프라인이 되었음에도 메시지는 성공적으로 전달됩니다. Kafka의 장애 조치 메커니즘과 복원력 있는 Producer 설정이 결합되어 데이터 유실이 발생하지 않습니다.
그러나 우리의 경우, retries 설정이 5로 제한되어 있었고(Kafka 2.1 이전 버전의 기본값은 0), retry.backoff.ms는 기본값인 100ms로 설정되어 있었습니다. 이로 인해 총 재시도 시간이 1초도 채 되지 않았습니다.
리더 재선출과 메타데이터 전파가 이 좁은 시간 내에 완료되지 못했습니다. 결과적으로 Producer는 새로운 리더를 인식하기 전에 모든 재시도를 소진했습니다.
결국 Producer는 전송을 포기했습니다. 애플리케이션이 이 실패를 명시적으로 처리하지 않는 경우—예를 들어 Dead Letter Queue로 라우팅하는 등—메시지는 유실됩니다.
다른 브로커가 존재했음에도 불구하고, Producer는 제한된 재시도 기간 내에 올바른 리더에 도달하지 못했고, 이로 인해 되돌릴 수 없는 메시지 유실이 발생했습니다.
리더 재선출이 즉각적이지 않은 이유
파티션 리더가 사용 불가능해지면—예를 들어 롤링 패치 중—Kafka는 가용성과 일관성을 유지하기 위해 리더 재선출 프로세스를 시작합니다. 이 프로세스는 컨트롤러라고 불리는 특수 브로커가 조율합니다.
이 프로세스를 이해하려면 먼저 복제에서 브로커가 수행하는 역할을 살펴볼 필요가 있습니다. Kafka 토픽은 파티션으로 나뉘며, 각 파티션은 여러 브로커에 걸쳐 복제됩니다. 이 중 하나의 브로커가 리더로 선출되어 해당 파티션의 모든 읽기/쓰기 작업을 담당합니다. 모든 Producer와 Consumer는 리더하고만 상호작용합니다. 나머지 브로커는 팔로워로서 리더의 데이터를 복제하여 동기화 상태를 유지합니다.
컨트롤러 브로커의 역할
컨트롤러 브로커는 클러스터의 두뇌 역할을 합니다. ZooKeeper 또는 KRaft를 통해 브로커 상태를 모니터링하고, 장애를 감지하며, 리더 재선출 프로세스를 조율합니다. 중요한 점은 컨트롤러 자체도 고가용성으로 설계되어 있다는 것입니다.
리더 재선출 프로세스는 일반적으로 다음과 같이 진행됩니다:
-
장애 감지: 컨트롤러가 리더가 응답하지 않는 것을 감지합니다. 보통 누락된 하트비트나 만료된 세션을 통해 감지합니다.
-
파티션 식별: 장애가 발생한 브로커가 리더였던 모든 파티션을 식별합니다.
-
ISR 조회: 영향받는 각 파티션에 대해 ISR(In-Sync Replica) 목록을 조회하여 어떤 팔로워가 완전히 최신 상태인지 확인합니다.
-
안전한 리더 할당: ISR에서 새 리더가 선택됩니다(
unclean.leader.election.enable=false가정). 이를 통해 데이터 유실을 방지합니다. -
메타데이터 업데이트: 컨트롤러가 클러스터 메타데이터(ZooKeeper 또는 KRaft)에 리더십 변경을 기록합니다.
-
클러스터 전체 전파: 새로운 메타데이터가 모든 브로커에 브로드캐스트됩니다.
-
클라이언트 갱신: Kafka 클라이언트(Producer 등)는
NotLeaderOrFollowerException같은 오류를 감지하거나metadata.max.age.ms간격 이후 메타데이터를 갱신합니다. 이를 통해 새로운 리더의 정보를 파악하고 작업을 재개할 수 있습니다.
타이밍 문제
각 단계마다 약간의 지연이 발생합니다. 실제로 장애 감지부터 클라이언트가 메타데이터를 업데이트하기까지의 전체 프로세스는 클러스터 크기, 네트워크 상태, ZooKeeper 또는 KRaft 사용 여부에 따라 수 초에서 수십 초까지 소요될 수 있습니다.
이 지연이 바로 위험 구간입니다: Producer가 새로운 리더를 알기 전에 재시도를 모두 소진하면 메시지는 유실됩니다.
이 타이밍을 이해하는 것이 Producer를 적절히 구성하는 데 매우 중요하며—이것이 바로 우리 팀이 뼈저리게 배운 교훈입니다.
복원력 있는 Kafka Producer 구축: 핵심 설정
복원력 있는 Kafka Producer는 우연히 만들어지지 않습니다—브로커 다운타임이나 리더 재선출 같은 실제 장애 시나리오를 고려하여 신중하게 선택된 설정의 결과입니다.
아래는 Producer의 안정성을 크게 향상시키는 핵심 설정입니다:
acks=all
이 설정은 리더 브로커가 Producer에게 응답하기 전에 모든 동기화 복제본(ISR)으로부터 확인을 기다리도록 합니다. 가장 높은 수준의 내구성을 제공합니다.
- 장점: 리더가 기록 후 복제 전에 장애가 발생해도 데이터 유실을 방지합니다.
- 미설정 시 위험:
acks=1의 경우, 리더가 로컬에 기록한 후 바로 확인 응답을 보냅니다. 복제 전에 장애가 발생하면 메시지가 유실됩니다.
retries=Integer.MAX_VALUE
Producer가 실패한 전송을 무한으로 재시도할 수 있게 합니다(delivery.timeout.ms에 의해 제한됨). Kafka 2.1부터 이것이 기본값입니다.
-
장점: 리더 불가용이나 네트워크 일시적 장애 같은 일시적 오류를 처리합니다.
-
미설정 시 위험: 제한된 재시도 횟수로 인해 리더 재선출이나 메타데이터 갱신이 완료되기 전에 재시도가 소진될 수 있습니다(
delivery.timeout.ms에 의해 제한됨).
enable.idempotence=true
재시도 시 중복 메시지 전달을 방지하며, 단일 파티션 내에서 메시지 순서도 보존합니다.
-
동작 원리: 멱등성이 활성화되면, 각 Kafka Producer에 고유한 **Producer ID(PID)**가 할당됩니다. Producer가 쓰는 각 파티션에 대해 단조 증가하는 시퀀스 번호가 각 메시지에 첨부됩니다. 브로커는 각 PID/파티션 쌍에 대해 마지막으로 성공적으로 기록된 시퀀스 번호를 추적합니다.
브로커가 이미 확인한 시퀀스 번호의 메시지를 수신하거나 순서가 맞지 않는 메시지를 수신하면, 이를 중복으로 처리하고 조용히 폐기합니다.
-
순서 보장: 멱등성 Producer는 파티션 단위로 메시지 순서도 보존합니다. 이는 재시도 시 특히 중요합니다. 이 순서를 안전하게 유지하기 위해 Kafka는
max.in.flight.requests.per.connection이 5 이하로 설정되어야 합니다. 더 높은 값은 순서가 뒤바뀐 재시도를 유발할 수 있으며, Kafka는 이를 안정적으로 중복 제거할 수 없습니다. -
요구 사항:
- 복제 안전성을 보장하기 위해
acks=all이 활성화되어야 합니다. - 재전송을 허용하기 위해
retries가 0보다 커야 합니다. - 멱등성을 유지하기 위해
max.in.flight.requests.per.connection이 5 이하여야 합니다.
- 복제 안전성을 보장하기 위해
이러한 조건을 충족하면, Kafka는 단일 Producer 세션 내에서 파티션 단위 정확히 한 번(exactly-once) 시맨틱을 보장합니다—성능이나 메시지 무결성을 희생하지 않으면서 말입니다.
이 메커니즘은 미션 크리티컬 시스템에서 매우 중요합니다. 단 하나의 중복이나 순서 오류만으로도 하류 시스템에 일관성 없는 상태가 발생할 수 있기 때문입니다.
- 동작 원리: 메시지에 시퀀스 번호를 할당하고 Producer ID를 사용하여 중복을 감지하고 폐기합니다.
- 요구 사항:
acks=all및retries>0과 함께 사용해야 합니다.
추가 고려 설정
-
max.in.flight.requests.per.connection<=5: 확인 응답을 받지 않고 브로커로 보낼 수 있는 메시지 수를 제어합니다.enable.idempotence=true일 때, Kafka는 안전한 중복 제거를 위해 이 값이 5 이하여야 합니다. 더 높으면 상태 관리 복잡성을 피하기 위해 Kafka가 멱등성을 비활성화합니다. -
request.timeout.ms: Producer가 브로커의 응답을 기다리는 시간입니다. 일반적으로delivery.timeout.ms이하로 설정해야 합니다.
이러한 설정을 적절히 조정함으로써 Producer는 일시적 오류, 롤링 패치, 심지어 짧은 리더 장애에도 복원력을 갖추게 되어 메시지 유실 위험을 극적으로 줄일 수 있습니다.
재시도로도 부족할 때: DLQ가 여전히 필요한 이유
멱등성이 활성화되고 재시도가 최대로 설정되어 있더라도, Kafka Producer는 여전히 복구 불가능한 장애에 직면할 수 있습니다. 장시간의 브로커 장애, 지속적인 네트워크 파티션, 메시지 직렬화 오류, 또는 잘못된 설정(예: 메시지 크기 제한 초과) 등이 최종 전송 실패를 유발할 수 있습니다.
바로 여기서 **Dead Letter Queue(DLQ)**가 필요합니다.
DLQ란?
Dead Letter Queue는 반복적인 전달 시도가 실패한 후 메시지가 라우팅되는 보조 Kafka 토픽 또는 외부 시스템입니다.
DLQ가 여전히 필요한 이유
- 일시적 장애 vs 영구적 장애: Kafka의 재시도 메커니즘은 일시적 장애를 처리합니다. DLQ는 영구적 장애를 포착합니다.
- 전달 타임아웃:
retries=Integer.MAX_VALUE로 설정해도,delivery.timeout.ms를 초과하면 Kafka Producer는 결국 전송을 포기합니다. - 재시도 불가능한 오류: 스키마 유효성 검사 실패, 레코드 크기 위반, 인증 문제 등의 오류는 재시도로 해결되지 않습니다.
- 관측 가능성: DLQ는 팀에게 사후 분석이나 수동 재처리를 위한 실패 메시지에 대한 가시성을 제공합니다.
DLQ 설계 모범 사례
- 별도의 Kafka 토픽 사용: 실패한 메시지를 명확하게 명명된 토픽(예:
my-topic.DLQ)에 격리합니다. - 컨텍스트 메타데이터 포함: 오류 사유, 원본 토픽 및 파티션, 타임스탬프, 메시지 키 등을 포함합니다.
- 메인 흐름 차단 방지: DLQ 쓰기는 비동기 또는 분리되어야 메인 처리 경로를 느리게 하지 않습니다.
- 보안 및 모니터링: 적절한 ACL을 적용하고 DLQ 볼륨에 대한 알림/모니터링을 설정합니다.
DLQ를 구현하는 것은 실용적이고 필수적인 보호 계층입니다. 모든 것이 실패했을 때 데이터가 조용히 사라지지 않도록 보장하며, 예상치 못한 엣지 케이스로부터 복구하는 데 필요한 도구를 팀에게 제공합니다.
설정 검증: 장애 시나리오 시뮬레이션
문서를 읽고 설정을 조정하는 것은 방정식의 일부일 뿐입니다—장애 시뮬레이션을 통해 Kafka Producer 설정을 검증하는 것이 진정한 복원력을 보장하는 데 필수적입니다.
다음은 실제 환경에서 Producer를 스트레스 테스트하기 위한 단계별 가이드입니다:
1. 테스트 클러스터 구성
Docker Compose 또는 테스트 인프라를 사용하여 최소 3개의 브로커로 로컬 Kafka 클러스터를 구성합니다.
- 대상 토픽의 복제 팩터 = 3,
min.insync.replicas = 2를 설정합니다.
2. Producer 설정 준비
두 가지 설정을 준비합니다:
- 기준선: 낮은 재시도, 멱등성 미사용 (예:
retries=3,acks=1). - 복원력 설정: 권장 설정 (
acks=all,retries=Integer.MAX_VALUE,enable.idempotence=true, 적절한 백오프 및 타임아웃).
3. 롤링 브로커 재시작 시뮬레이션
메시지를 활발히 생산하는 동안:
- 브로커를 한 번에 하나씩 재시작하여 롤링 패치를 모방합니다.
- 제어된 종료를 도입하고 Producer 로그를 관찰합니다.
4. 관찰 및 비교
- 메시지가 유실되거나 중복되는가?
- 재시도가 예상대로 동작하는가?
- 복구 불가능한 장애에 대해 DLQ 폴백이 트리거되는가?
불리한 조건에서 Kafka 설정을 철저히 테스트함으로써, 설정이 문서상으로만 좋아 보이는 것이 아니라 실제 스트레스 상황에서도 견딜 수 있는지 검증할 수 있습니다. 이는 메시지 유실이 허용되지 않는 프로덕션 환경에서 안심을 보장합니다.
결론: 메시지 안정성을 엔드투엔드로 책임지기
Kafka의 내구성 보장은 강력하지만 완벽하지는 않습니다. 적절히 구성되지 않은 Producer에서는 일상적인 롤링 패치조차도 조용한 메시지 유실로 이어질 수 있습니다—어떤 팀도 사후에 발견하고 싶지 않은 상황입니다.
이번 실제 장애와 복구를 통해, 우리는 메시지 안전성을 보장하는 것이 Kafka와 애플리케이션 간의 공동 책임임을 배웠습니다. 이는 단순히 복제를 활성화하는 것 이상을 요구합니다—Producer 설정, 재시도 동작, 멱등성, DLQ 설계, 그리고 통제된 장애 테스트를 통한 검증에 세심한 주의가 필요합니다.
다음을 통해:
acks=all설정- 멱등성 활성화
- 의미 있는 백오프와 함께 재시도 최대화
- 복구 불가능한 경우를 위한 DLQ 사용
—예상치 못한 상황에서도 살아남는 시스템을 자신 있게 구축할 수 있습니다.
메시지 안전성은 기본값이 아닙니다. 설계 선택입니다. 그리고 올바른 선택을 통해, Kafka는 단순히 빠르고 확장 가능할 뿐만 아니라 진정으로 신뢰할 수 있는 시스템이 됩니다.