[Kafka 운영 ⑩] Kafka의 순서 보장 — 어디까지 믿을 수 있나
Kafka의 순서 보장은 토픽이 아니라 파티션 단위입니다. 키 기반 파티셔닝, 프로듀서 재정렬 함정(in-flight·idempotence), 컨슈머 처리 순서, 그리고 순서를 조용히 깨뜨리는 함정들을 정리하고, 'Kafka 운영 트러블슈팅' 10부작을 마무리합니다.
"Kafka는 메시지 순서를 보장한다"는 말은 절반만 맞습니다. 운영 현장에서 가장 흔하게 마주치는 장애 중 하나가 바로 "분명히 A를 먼저 보냈는데 컨슈머가 B를 먼저 처리했다"는 미스터리입니다. 결제 상태가 뒤집히고, 재고가 음수가 되고, 이벤트 소싱 애그리거트가 깨집니다. 원인을 추적해보면 십중팔구 "순서 보장의 범위"를 오해한 데서 출발합니다.
이 글은 'Kafka 운영 트러블슈팅' 시리즈의 마지막 10편입니다. Kafka가 정확히 어디까지 순서를 보장하는지, 그리고 그 보장을 조용히 무너뜨리는 함정들을 하나씩 짚어봅니다.
이 글에서 배우는 것
- Kafka 순서 보장의 핵심 진실: 파티션 단위 보장, 토픽 전역 보장 아님
- 프로듀서 재정렬 함정:
max.in.flight.requests.per.connection과 멱등성의 관계- 키 선택이 순서 범위를 결정하는 이유, 그리고 리파티셔닝이 깨뜨리는 것
- 컨슈머 측에서 순서가 보장되는 조건과 멀티스레딩의 함정
- DLQ·재처리·다중 프로듀서가 순서를 조용히 깨뜨리는 시나리오
- "전역 순서"가 정말 필요할 때의 트레이드오프
1. 핵심 진실 — 순서는 파티션 안에서만 보장된다
가장 먼저 머릿속에 새겨야 할 한 문장입니다.
Kafka는 단일 파티션 안에서만 메시지 순서를 보장한다. 토픽 전체에 걸친 순서는 보장하지 않는다.
토픽은 여러 개의 파티션으로 쪼개져 있고, 각 파티션은 독립적인 append-only 로그입니다. 한 파티션에 들어간 레코드는 오프셋(offset)이라는 단조 증가 번호를 부여받고, 컨슈머는 그 오프셋 순서대로 읽습니다. 그래서 같은 파티션 안에서는 쓴 순서 = 읽는 순서가 성립합니다.
문제는 토픽이 파티션 3개, 6개, 12개로 나뉘는 순간 시작됩니다. 파티션 0에 쓴 레코드와 파티션 1에 쓴 레코드 사이에는 어떤 순서 관계도 없습니다. 두 파티션은 서로 다른 브로커에 있을 수 있고, 서로 다른 속도로 처리되며, 컨슈머도 별개의 스레드/인스턴스가 병렬로 읽습니다.
| 범위 | 순서 보장 여부 | 이유 |
|---|---|---|
| 같은 파티션 내 | ✅ 보장 | append-only 로그 + 단조 증가 오프셋 |
| 같은 키(기본 파티셔너) | ✅ 사실상 보장 | 같은 키 → 같은 파티션으로 라우팅 |
| 토픽 전역(파티션 간) | ❌ 미보장 | 파티션은 독립 로그, 병렬 처리 |
같은 키는 같은 파티션으로 — per-key 순서
그렇다면 "주문 #1234에 대한 이벤트들은 순서대로 처리되어야 한다" 같은 요구는 어떻게 만족시킬까요? 답은 키(key) 입니다.
기본 파티셔너는 레코드 키의 해시를 파티션 수로 나눈 나머지로 파티션을 결정합니다(대략 hash(key) % numPartitions). 즉 같은 키를 가진 레코드는 항상 같은 파티션으로 갑니다. 따라서 "주문 ID를 키로 사용"하면, 같은 주문에 대한 모든 이벤트가 한 파티션에 모이고 → 그 파티션 안에서 순서가 보장됩니다. 이것이 Kafka가 제공하는 가장 강력하고 실용적인 보장, per-key ordering입니다.
// 같은 orderId → 같은 파티션 → 순서 보장
producer.send(new ProducerRecord<>("orders", orderId, "CREATED"));
producer.send(new ProducerRecord<>("orders", orderId, "PAID"));
producer.send(new ProducerRecord<>("orders", orderId, "SHIPPED"));
// 위 세 이벤트는 같은 파티션에 같은 순서로 적재된다.
// 단, orderId가 null이면 라운드로빈/스티키 분배 → 순서 보장 안 됨!키가 null이면 레코드는 파티션에 분산 배치되며(라운드로빈 또는 스티키 파티셔닝), 더 이상 per-key 순서가 성립하지 않습니다. 순서가 중요하다면 키를 반드시 지정하세요.
2. 프로듀서 측 재정렬 함정 — in-flight와 멱등성
파티션과 키를 올바르게 설정했는데도 순서가 뒤집히는 경우가 있습니다. 가장 악명 높은 함정이 프로듀서 재시도(retry) 시 발생하는 재정렬입니다.
왜 재정렬이 일어나는가
프로듀서는 처리량을 위해 여러 요청을 동시에 전송 중(in-flight) 으로 둘 수 있습니다. 이를 제어하는 설정이 max.in.flight.requests.per.connection(기본값 5)입니다. 여기에 retries > 0(기본적으로 활성)이 더해지면 다음 시나리오가 발생합니다.
시간 →
배치1(레코드 A,B) 전송 ──► 브로커: 일시적 오류로 거절(재시도 대기)
배치2(레코드 C,D) 전송 ──► 브로커: 성공! (먼저 로그에 적재)
배치1 재시도 ───────────► 브로커: 성공 (나중에 적재)
결과 파티션 로그: C, D, A, B ← A,B가 C,D보다 뒤로 밀림 (순서 깨짐!)먼저 보낸 배치1이 일시적 오류로 재시도되는 사이, 나중에 보낸 배치2가 먼저 성공해버립니다. 그 결과 나중 레코드가 앞 레코드보다 먼저 로그에 적재됩니다. enable.idempotence=false이고 max.in.flight > 1인 환경에서 이 재정렬이 발생할 수 있습니다.
해결책: 멱등성 프로듀서
해결책은 멱등성 프로듀서(idempotent producer) 입니다. enable.idempotence=true로 설정하면, 프로듀서는 각 레코드에 시퀀스 번호(sequence number)와 프로듀서 ID(PID)를 붙입니다. 브로커는 이 시퀀스 번호를 검사해서 순서를 벗어난 배치를 거부하고 재정렬을 방지합니다. 덕분에 최대 5개의 in-flight 요청 + 재시도가 있어도 순서가 보장됩니다(이것이 KIP-98이 도입한 핵심 기능입니다).
| 설정 | 순서 보장 | 처리량 | 권장도 |
|---|---|---|---|
enable.idempotence=true (in.flight ≤ 5) | ✅ 보장 | 높음 | ⭐ 권장 (기본값) |
enable.idempotence=false, max.in.flight=1 | ✅ 보장 | 낮음 (직렬화) | 차선책 |
enable.idempotence=false, max.in.flight>1, retries>0 | ❌ 깨질 수 있음 | 높음 | ⚠️ 위험 |
# 권장 설정 — 순서 + 처리량 둘 다 확보
enable.idempotence=true
acks=all
max.in.flight.requests.per.connection=5
retries=2147483647Kafka 3.0+ 기본값 주의: Kafka 3.0부터
enable.idempotence의 기본값이true입니다. 그래서 별도 설정 없이도 멱등성이 켜져 있는 경우가 많습니다. 다만acks를1이나0으로 낮추거나,max.in.flight를 5보다 크게 올리거나,retries=0으로 끄면 멱등성이 비활성화되거나 충돌할 수 있으니 함께 점검하세요.
3. 파티셔닝과 키 — 순서의 범위를 설계하기
순서 보장의 범위는 결국 파티셔닝 키를 어떻게 고르느냐로 결정됩니다.
키 선택이 곧 순서 단위
- 주문 처리: 키 =
orderId→ 한 주문의 이벤트들이 순서대로 - 사용자 활동 로그: 키 =
userId→ 한 사용자의 행동이 순서대로 - 계좌 거래: 키 =
accountId→ 한 계좌의 입출금이 순서대로 - IoT 센서: 키 =
deviceId→ 한 기기의 측정값이 순서대로
핵심은 "순서가 보장되어야 하는 단위"를 키로 잡는 것입니다. 단위가 너무 굵으면(예: 키 = 고정 상수 하나) 모든 데이터가 한 파티션에 몰려 병렬성이 사라지고, 너무 잘면(예: 키 = 매번 다른 UUID) per-key 순서의 의미가 없어집니다.
리파티셔닝이 깨뜨리는 것
여기에 운영상 가장 위험한 함정이 있습니다. 처리량이 늘어 파티션 수를 늘리면(예: 6개 → 12개), hash(key) % numPartitions의 분모가 바뀝니다. 그 결과:
기존에 파티션 3으로 가던 키가 이제 파티션 9로 갈 수 있습니다. 즉, 같은 키의 과거 레코드(파티션 3)와 신규 레코드(파티션 9)가 서로 다른 파티션에 흩어집니다.
이 시점부터 그 키에 대한 per-key 순서 보장이 깨집니다. 컨슈머는 파티션 3의 과거 이벤트와 파티션 9의 신규 이벤트 사이의 순서를 보장받지 못합니다.
파티션 6개일 때: hash("order-1234") % 6 = 3 → 파티션 3
파티션 12개로 증설: hash("order-1234") % 12 = 9 → 파티션 9
↑ 같은 키가 다른 파티션으로! 과거/신규 레코드 분리대응책:
- 파티션 수는 처음부터 넉넉하게 잡는다(줄이는 것은 불가능, 늘리는 것만 가능).
- 정말 늘려야 한다면, 키 기반 순서가 중요한 토픽은 새 토픽으로 마이그레이션하거나, 증설 시점에 해당 키의 in-flight 이벤트가 없는 정지 구간을 확보한다.
- 커스텀 파티셔너로 키→파티션 매핑을 고정(예: 명시적 매핑 테이블)하면 증설 영향을 통제할 수 있다.
4. 컨슈머 측 — 처리 순서를 깨뜨리지 않기
프로듀서가 순서를 잘 보존해도, 컨슈머가 그 순서를 깨뜨릴 수 있습니다.
단일 컨슈머는 파티션을 순서대로 처리한다
기본 원칙은 단순합니다. 하나의 컨슈머는 할당받은 파티션을 오프셋 순서대로 poll() 합니다. 그리고 컨슈머 그룹 내에서 하나의 파티션은 항상 단 하나의 컨슈머에게만 할당됩니다. 따라서 "파티션 → 컨슈머"가 1:1로 고정되어 있는 한, 그 파티션의 순서는 그대로 유지됩니다.
파티션 0 ─────► 컨슈머 A (파티션 0은 오직 A만 읽음)
파티션 1 ─────► 컨슈머 B
파티션 2 ─────► 컨슈머 B (한 컨슈머가 여러 파티션은 가능)
✅ 한 파티션을 두 컨슈머가 동시에 읽는 일은 (같은 그룹에선) 없다멀티스레딩이 순서를 깨뜨리는 지점
함정은 컨슈머가 받은 레코드를 여러 스레드로 나눠 처리할 때입니다. poll()은 순서대로 레코드를 주지만, 이를 스레드 풀에 던지면 처리 완료 순서는 보장되지 않습니다.
// ❌ 안티패턴 — 같은 파티션 레코드를 스레드 풀에 무작위 분배 → 순서 깨짐
for (ConsumerRecord<String, String> record : records) {
executor.submit(() -> process(record)); // C가 A보다 먼저 끝날 수 있음
}
// ✅ 키 기준으로 워커를 고정하면 per-key 순서 유지
// 같은 키는 항상 같은 워커 스레드 큐로 → 직렬 처리
int worker = Math.abs(record.key().hashCode()) % numWorkers;
workerQueues.get(worker).put(record);순서가 중요하다면, 같은 파티션(또는 같은 키)의 레코드는 반드시 단일 스레드에서 직렬로 처리하거나, 위처럼 키 기준으로 워커를 고정해야 합니다. 처리량을 위해 병렬화하고 싶다면 "키 단위 병렬, 키 내부는 직렬" 모델이 정답입니다.
5. 순서를 조용히 깨뜨리는 함정들
설정을 다 맞췄는데도 순서가 깨진다면, 아래 "보이지 않는" 시나리오들을 의심하세요. 이들은 에러를 내지 않고 조용히 순서를 무너뜨립니다.
| 함정 | 무엇이 깨지나 | 대응 |
|---|---|---|
| DLQ / 재시도 토픽 | 실패한 메시지를 retry 토픽으로 보내 나중에 재처리 → 원래 순서에서 이탈 | 키 단위 순서가 중요하면 DLQ로 우회 금지, 해당 키 전체를 정지/블로킹 재시도 |
| 재처리(reprocessing) | 오프셋을 되감아 재처리 시, 이미 처리된 후속 이벤트와 뒤섞임 | 멱등 컨슈머 설계, 재처리 구간 격리 |
| 같은 키에 다중 프로듀서 | 두 프로듀서가 같은 키를 동시에 보내면 둘 사이 순서는 비결정적 | 키별로 단일 프로듀서(파티션 소유권) 보장 |
| 비동기 컨슈머 핸드오프 | poll 후 별도 큐/액터/이벤트루프로 넘기면 처리 순서 역전 | 키 단위 직렬 큐, 순서 보존 핸드오프 |
| 토픽 간 이동 | A 토픽 → B 토픽 라우팅 시 두 토픽의 파티션 매핑이 달라 순서 분리 | 동일 키·동일 파티션 수 유지, 순서 의존 시 단일 토픽 |
DLQ 함정 자세히 보기
가장 흔하게 당하는 함정이 DLQ(Dead Letter Queue)입니다. "메시지 처리가 실패하면 DLQ로 보내고 다음 메시지를 처리한다"는 패턴은 처리량에는 좋지만, 순서를 명시적으로 포기하는 설계입니다.
파티션: [A실패] [B] [C]
A → DLQ로 우회, B·C는 정상 처리
나중에 A를 DLQ에서 재처리 → 이미 B,C 처리된 후
같은 키라면: A(생성) 실패 → B(수정),C(삭제) 먼저 처리됨 → 상태 붕괴!같은 키에 대해 순서가 비즈니스적으로 중요하다면, 실패 시 DLQ로 우회하지 말고 해당 메시지에서 멈추고 재시도(blocking retry) 해야 합니다. "전진하면서 건너뛰기"와 "멈추고 재시도" 사이의 트레이드오프를 키 단위 순서 요구사항에 맞춰 의식적으로 선택하세요.
6. "전역 순서"라는 신화
가끔 "토픽 전체에 걸친 완전한 전역 순서(total order)가 필요하다"는 요구가 들어옵니다. Kafka에서 이를 달성하는 방법은 단 하나 — 파티션을 1개만 사용하는 것입니다.
파티션이 1개면 모든 레코드가 하나의 로그에 직렬로 쌓이므로 완벽한 전역 순서가 보장됩니다. 하지만 대가가 큽니다.
| 항목 | 단일 파티션(전역 순서) | 다중 파티션(per-key 순서) |
|---|---|---|
| 순서 범위 | 토픽 전체 | 키 단위만 |
| 병렬성 | ❌ 없음 (컨슈머 1개만 유효) | ✅ 파티션 수만큼 |
| 처리량 | 낮음 (단일 브로커·단일 컨슈머 한계) | 높음 (수평 확장) |
| 확장성 | ❌ 늘릴 수 없음 | ✅ 파티션 추가로 확장 |
현실적인 조언: "전역 순서가 필요하다"는 요구는 대부분 "특정 엔티티 단위의 순서가 필요하다"로 재정의할 수 있습니다. 정말 토픽의 모든 이벤트가 한 줄로 정렬되어야 하는 경우는 드뭅니다. 단일 파티션을 택하기 전에, 키를 잘 골라 per-key 순서로 충분한지 먼저 따져보세요. 단일 파티션은 병렬성을 완전히 포기하는 선택이며, 처리량 상한이 곧 시스템의 한계가 됩니다.
7. 한눈에 보는 순서 보장 다이어그램
지금까지의 내용을 그림 하나로 정리합니다. 같은 키의 레코드는 같은 파티션으로 모여 순서가 보존되지만, 파티션 사이의 순서는 정의되지 않습니다.
- 파티션 0 내부:
a1 → a2 → a3순서 보존 - 파티션 1 내부: 키 A는 없고 B·C가 섞여 있지만, 각 키 내부(b1→b2, c1→c2→c3)는 순서 보존
- 파티션 0과 파티션 1 사이: a2와 c1 중 무엇이 먼저인지는 정의되지 않음
마치며 — 10부작을 마무리하며
순서 보장은 Kafka 운영에서 가장 자주 오해받고, 가장 조용히 깨지는 주제입니다. 핵심을 다시 한 번 정리합니다.
- 순서는 파티션 단위입니다. 토픽 전역 순서는 보장되지 않습니다.
- 같은 키 → 같은 파티션 → per-key 순서. 키를 "순서가 중요한 단위"로 고르세요.
- 프로듀서는
enable.idempotence=true로 재정렬을 막으세요(in-flight 5 + 재시도에도 안전). - 파티션 증설은 키→파티션 매핑을 바꿔 기존 키의 순서를 깨뜨립니다.
- 컨슈머는 키 단위 직렬 처리를 지키세요. 멀티스레딩과 비동기 핸드오프가 함정입니다.
- DLQ·재처리·다중 프로듀서는 순서를 조용히 무너뜨립니다. 의식적으로 트레이드오프를 선택하세요.
- 정말 전역 순서가 필요하면 단일 파티션뿐이며, 병렬성을 완전히 포기해야 합니다.
'Kafka 운영 트러블슈팅' 시리즈 전체 요약
| 편 | 주제 | 핵심 메시지 |
|---|---|---|
| ① | 컨슈머 랙(lag) 진단 | 랙은 증상이지 원인이 아니다 — 처리량/리밸런스/파티션 스큐를 분리 진단 |
| ② | 리밸런스 폭풍 | 세션 타임아웃·poll 간격·정적 멤버십으로 불필요한 리밸런스를 줄인다 |
| ③ | 프로듀서 처리량 튜닝 | batch.size·linger.ms·압축으로 처리량과 지연의 균형을 잡는다 |
| ④ | acks와 내구성 | acks=all + min.insync.replicas로 데이터 유실을 막는다 |
| ⑤ | ISR과 언더리플리케이션 | ISR 축소는 곧 내구성 위험 — 복제 지연의 근본 원인을 추적 |
| ⑥ | 디스크·리텐션 관리 | 리텐션·세그먼트·로그 압축으로 디스크 폭발을 예방 |
| ⑦ | 정확히 한 번(EOS) | 트랜잭션·멱등성으로 중복/유실 없는 처리를 구현 |
| ⑧ | 모니터링과 알람 | JMX 메트릭으로 장애를 사후가 아닌 사전에 탐지 |
| ⑨ | 스키마 진화와 호환성 | 스키마 레지스트리로 호환성을 깨지 않고 진화시킨다 |
| ⑩ | 순서 보장 | 순서는 파티션 단위 — 키 설계와 멱등성으로 지킨다 |
10편을 관통하는 운영 테마는 하나입니다. "Kafka의 기본 동작을 정확히 이해하고, 보장의 경계를 의식적으로 설계하라." 랙도, 리밸런스도, 순서도 모두 "Kafka가 알아서 해줄 것"이라는 막연한 기대에서 장애가 시작됩니다. 경계를 알면, 그 안에서 안전하게 설계할 수 있습니다.
다음 시리즈 예고 — "Kafka DR 구축"
운영 트러블슈팅 시리즈를 마치며, 다음은 한 단계 더 나아간 형제 시리즈 "Kafka DR(재해 복구) 구축" 을 예고합니다. MirrorMaker 2 기반 멀티 클러스터 복제, Active-Active vs Active-Passive 토폴로지, 컨슈머 오프셋 동기화, RPO/RTO 설계, 리전 장애 페일오버 훈련까지 — 단일 클러스터를 넘어 재해 상황에서도 살아남는 Kafka를 다룰 예정입니다. 다음 시리즈에서 만나요.
참고 자료
- Apache Kafka Documentation — Message Delivery Semantics: https://kafka.apache.org/documentation/#semantics
- Apache Kafka Documentation — Producer Configs (
enable.idempotence,max.in.flight.requests.per.connection,acks): https://kafka.apache.org/documentation/#producerconfigs- KIP-98 — Exactly Once Delivery and Transactional Messaging: https://cwiki.apache.org/confluence/display/KAFKA/KIP-98+-+Exactly+Once+Delivery+and+Transactional+Messaging
- Apache Kafka Documentation — Design (Partitioning & Ordering): https://kafka.apache.org/documentation/#design
— Data Dynamics 엔지니어링 팀