실수 케이스 · 4
같은 주문의 상태가 뒤바뀌어 저장됐다
"배송중이던 주문이 갑자기 결제완료로 돌아갔다"는 CS 문의가 하루 200건 들어왔습니다.
코드에는 버그가 없었고, Kafka에도 에러가 하나도 없었습니다.
원인은 ProducerRecord에 키를 넣지 않았다는 것 하나였습니다.
Kafka의 순서 보장은 토픽이 아니라 파티션 단위이고, 키가 없으면 그 근거 자체가 없어집니다.
이 케이스에서 얻어 갈 것
- 순서 보장의 단위가 토픽-파티션이며, 키가 없으면 순서를 기대할 근거가 없다는 것을 설명할 수 있습니다.
- 4.x 기본 파티셔너가 키가 없을 때 어떻게 파티션을 고르는지 정확히 압니다.
- 파티션 내부에서도 순서가 깨질 수 있는 조건(
max.in.flight과 재시도)을 구분할 수 있습니다. - 키를 뒤늦게 도입할 때 생기는 과거 데이터와의 불일치 문제를 어떻게 다룰지 압니다.
상황
주문 상태 파이프라인입니다. order-status 토픽은
파티션 24개, 복제 계수 3, 브로커 3대에서 일 120만 건의 상태 전이 이벤트를 받습니다.
주문 하나는 평균 6번 상태가 바뀝니다.
CREATED → PAID → PREPARING → SHIPPED → DELIVERED
↘ CANCELLED
컨슈머는 이벤트를 받아 주문 조회용 읽기 모델(PostgreSQL)에 UPSERT합니다.
컨슈머 파드는 8개이고 각각 파티션 3개를 맡습니다.
주문량이 늘면 파드를 늘리는 방식으로 3년 동안 잘 운영됐습니다.
public void publish(OrderStatusChanged event) {
// 키를 지정하지 않았습니다.
// 같은 주문의 이벤트가 서로 다른 파티션으로 흩어집니다.
producer.send(new ProducerRecord<>("order-status", toJson(event)));
}
문제는 트래픽이 늘면서 드러났습니다. 파티션 수를 12에서 24로 늘리고 컨슈머 파드를 4개에서 8개로 올린 뒤,
같은 주문의 두 이벤트가 서로 다른 파티션에 들어가 서로 다른 파드에서 동시에 처리될 확률이 크게 올라갔습니다.
SHIPPED를 처리한 파드가 먼저 커밋하고, 몇십 밀리초 뒤 다른 파드가 PAID를 처리해
읽기 모델을 덮어썼습니다.
관측된 증상
메트릭이 어떻게 보였는가
- Kafka 지표 전부 정상. lag 0, 리밸런스 없음, 프로듀서 에러율 0, 컨슈머 에러율 0.
- CS 문의 건수: 파티션 증설 배포 다음 날부터 하루 40건 → 200건으로 증가.
- DB 감사 로그: 같은
order_id에 대해 상태가 역행한UPDATE가 하루 1,800건. 전체의 약 0.15%. - 이벤트
event_timevs DBupdated_at: 두 값의 순서가 어긋난 행이 존재. 이것이 결정적 단서였습니다.
DB에서 본 증거
-- 상태 전이 순서를 숫자로 정의해 두고 역행을 찾습니다
WITH ordered AS (
SELECT order_id, status, event_time, applied_at,
LAG(status) OVER (PARTITION BY order_id ORDER BY applied_at) AS prev_status,
LAG(event_time) OVER (PARTITION BY order_id ORDER BY applied_at) AS prev_event_time
FROM order_status_audit
)
SELECT order_id, prev_status, status, prev_event_time, event_time
FROM ordered
WHERE event_time < prev_event_time -- 나중에 적용된 이벤트가 더 과거에 발생함
ORDER BY applied_at DESC
LIMIT 20;
order_id | prev_status | status | prev_event_time | event_time
-----------+-------------+--------+-------------------------+-------------------------
ORD-91882 | SHIPPED | PAID | 2026-07-24 11:04:19.221 | 2026-07-24 11:04:18.907
ORD-91903 | DELIVERED | SHIPPED| 2026-07-24 11:04:22.010 | 2026-07-24 11:04:21.664
ORD-92014 | CANCELLED | PAID | 2026-07-24 11:05:02.338 | 2026-07-24 11:05:01.995
Kafka에서 본 증거
문제 주문 하나를 골라 토픽 전체를 뒤졌습니다.
kafka-console-consumer의 formatter 속성으로 파티션·오프셋·타임스탬프·키를 함께 출력하면
같은 주문이 여러 파티션에 흩어진 것이 바로 보입니다.
$ kafka-console-consumer.sh --bootstrap-server kafka-1:9092 \
--topic order-status --from-beginning --timeout-ms 60000 \
--property print.timestamp=true \
--property print.partition=true \
--property print.offset=true \
--property print.key=true \
| grep ORD-91882
CreateTime:1761296658907 Partition:17 Offset:2044119 null {"orderId":"ORD-91882","status":"PAID","eventTime":"2026-07-24T11:04:18.907"}
CreateTime:1761296659221 Partition:4 Offset:1980332 null {"orderId":"ORD-91882","status":"SHIPPED","eventTime":"2026-07-24T11:04:19.221"}
CreateTime:1761296657441 Partition:17 Offset:2044101 null {"orderId":"ORD-91882","status":"CREATED","eventTime":"2026-07-24T11:04:17.441"}
원인 분석
1단계 — 순서 보장의 범위를 정확히 안다
Kafka는 특정 토픽-파티션의 컨슈머가 그 파티션의 레코드를 기록된 순서 그대로 읽는 것을 보장합니다. 보장 범위는 여기까지입니다. 토픽 전체를 가로지르는 전역 순서는 존재하지 않고, 전역 오프셋도 없습니다.
| 범위 | 보장 | 깨지는 조건 |
|---|---|---|
| 같은 파티션 안 | 보장 | 멱등성을 끄고 max.in.flight.requests.per.connection > 1로 재시도가 일어난 경우 |
| 같은 키 (파티션 수 고정) | 보장 | 파티션 수를 늘리면 키의 배치가 바뀜 — 그 시점을 경계로 순서 근거가 끊김 |
| 서로 다른 파티션 사이 | 보장 없음 | 항상. 애초에 보장 대상이 아님 |
| 키 없는 레코드 사이 | 보장 없음 | 항상. 파티셔너가 파티션을 자유롭게 바꿈 |
| 컨슈머가 워커 풀로 넘긴 뒤 | 보장 없음 | 병렬 처리하는 순간 파티션 내 순서도 무의미해짐 |
2단계 — 기본 파티셔너가 키 없는 레코드를 어떻게 다루는가
partitioner.class의 기본값은 null이며,
이때 내장 기본 파티셔닝 로직이 쓰입니다. 공식 설명은 다음 세 갈래입니다.
| 입력 | 파티션 결정 방식 |
|---|---|
| 파티션을 명시했다 | 그 파티션을 그대로 사용 |
| 파티션은 없고 키가 있다 | 키의 해시로 파티션을 선택 → 같은 키는 항상 같은 파티션 |
| 파티션도 키도 없다 | sticky 파티션을 사용하며, 그 파티션에 batch.size 바이트 이상이 쌓이면 다른 파티션으로 바뀜 |
3단계 — 파티션 안에서도 순서가 깨질 수 있다
키를 넣어 같은 파티션에 보내더라도 프로듀서 설정에 따라 순서가 뒤집힐 수 있습니다. 공식 문서가 명시하는 조건입니다.
멱등 프로듀서와 시퀀스 번호의 동작은 4장 Producer 심화에서, 전달 보장 전체 그림은 6장 전달 보장과 트랜잭션에서 다룹니다.
4단계 — 파티션 증설이 순서 근거를 끊는다
이 사고에서 키를 도입하기로 결정한 뒤 두 번째 문제가 나왔습니다. 키가 있어도 파티션 수가 바뀌면 같은 키가 다른 파티션으로 갑니다. 공식 운영 문서가 이 부작용을 명시합니다.
재현 방법
단일 노드 KRaft 클러스터로 충분합니다. 아래 compose는 Apache Kafka의 공식 단일 노드 예제입니다. 3노드 구성은 예제 1 · 로컬 KRaft 클러스터를 쓰세요.
services:
broker:
image: apache/kafka:4.3.1
hostname: broker
container_name: broker
ports:
- '9092:9092'
environment:
KAFKA_NODE_ID: 1
KAFKA_PROCESS_ROLES: 'broker,controller'
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: 'CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT'
KAFKA_LISTENERS: 'CONTROLLER://:29093,PLAINTEXT://:19092,PLAINTEXT_HOST://:9092'
KAFKA_ADVERTISED_LISTENERS: 'PLAINTEXT://broker:19092,PLAINTEXT_HOST://localhost:9092'
KAFKA_CONTROLLER_QUORUM_VOTERS: '1@broker:29093'
KAFKA_CONTROLLER_LISTENER_NAMES: 'CONTROLLER'
KAFKA_INTER_BROKER_LISTENER_NAME: 'PLAINTEXT'
CLUSTER_ID: '4L6g3nShT-eMCtK--X86sw'
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0
KAFKA_LOG_DIRS: '/tmp/kraft-combined-logs'
docker compose up -d
K=/opt/kafka/bin
# 1. 파티션 6개 토픽
docker exec -it broker $K/kafka-topics.sh --create --topic order-status \
--partitions 6 --replication-factor 1 --bootstrap-server localhost:9092
# 2. 키 없이 같은 주문의 상태 전이 5건을 보낸다
# linger.ms=0, batch.size=1 로 두면 sticky 파티션이 매 건 바뀌어
# "흩어짐"이 즉시 재현됩니다
for s in CREATED PAID PREPARING SHIPPED DELIVERED; do
echo "{\"orderId\":\"ORD-1\",\"status\":\"$s\"}" | \
docker exec -i broker $K/kafka-console-producer.sh --topic order-status \
--bootstrap-server localhost:9092 \
--producer-property linger.ms=0 --producer-property batch.size=1
done
# 3. 파티션을 함께 출력해 본다 → 여러 파티션에 흩어져 있습니다
docker exec -it broker $K/kafka-console-consumer.sh --topic order-status \
--from-beginning --timeout-ms 8000 --bootstrap-server localhost:9092 \
--property print.partition=true --property print.offset=true \
--property print.key=true
# CreateTime 생략
# Partition:3 Offset:0 null {"orderId":"ORD-1","status":"CREATED"}
# Partition:0 Offset:0 null {"orderId":"ORD-1","status":"PAID"}
# Partition:5 Offset:0 null {"orderId":"ORD-1","status":"PREPARING"}
# Partition:1 Offset:0 null {"orderId":"ORD-1","status":"SHIPPED"}
# Partition:4 Offset:0 null {"orderId":"ORD-1","status":"DELIVERED"}
# → 5건이 5개 파티션. 순서를 보장할 방법이 없습니다.
# 4. 이제 키를 넣어 다시 보낸다
for s in CREATED PAID PREPARING SHIPPED DELIVERED; do
echo "ORD-2:{\"orderId\":\"ORD-2\",\"status\":\"$s\"}" | \
docker exec -i broker $K/kafka-console-producer.sh --topic order-status \
--bootstrap-server localhost:9092 \
--property parse.key=true --property key.separator=: \
--producer-property linger.ms=0 --producer-property batch.size=1
done
# 5. 다시 확인 → ORD-2 는 전부 같은 파티션, 오프셋이 순서대로입니다
docker exec -it broker $K/kafka-console-consumer.sh --topic order-status \
--from-beginning --timeout-ms 8000 --bootstrap-server localhost:9092 \
--property print.partition=true --property print.offset=true \
--property print.key=true | grep ORD-2
# Partition:2 Offset:0 ORD-2 {"orderId":"ORD-2","status":"CREATED"}
# Partition:2 Offset:1 ORD-2 {"orderId":"ORD-2","status":"PAID"}
# Partition:2 Offset:2 ORD-2 {"orderId":"ORD-2","status":"PREPARING"}
# Partition:2 Offset:3 ORD-2 {"orderId":"ORD-2","status":"SHIPPED"}
# Partition:2 Offset:4 ORD-2 {"orderId":"ORD-2","status":"DELIVERED"}
변형 — 파티션 증설로 키 배치가 바뀌는 것을 확인
# 증설 전 ORD-2 의 파티션을 기록한 뒤
docker exec -it broker $K/kafka-topics.sh --alter --topic order-status \
--partitions 12 --bootstrap-server localhost:9092
# 같은 키로 다시 보내고 파티션을 비교합니다
echo "ORD-2:{\"orderId\":\"ORD-2\",\"status\":\"REFUNDED\"}" | \
docker exec -i broker $K/kafka-console-producer.sh --topic order-status \
--bootstrap-server localhost:9092 \
--property parse.key=true --property key.separator=:
docker exec -it broker $K/kafka-console-consumer.sh --topic order-status \
--from-beginning --timeout-ms 8000 --bootstrap-server localhost:9092 \
--property print.partition=true --property print.key=true | grep ORD-2
# → REFUNDED 가 다른 파티션에 있을 수 있습니다.
# 기존 데이터는 이동하지 않으므로 한 키의 이력이 두 파티션에 걸칩니다.
해결
즉시 조치 — 애플리케이션에서 순서를 방어한다
키를 넣는 배포는 며칠이 걸립니다. 그동안 데이터 오염을 멈춰야 합니다. 읽기 모델 갱신을 이벤트 시각 기준 조건부 UPDATE로 바꿔 과거 이벤트가 최신 상태를 덮어쓰지 못하게 만듭니다.
-- 더 최신 이벤트가 이미 적용되어 있으면 무시합니다
INSERT INTO order_read_model (order_id, status, event_time, updated_at)
VALUES (:orderId, :status, :eventTime, now())
ON CONFLICT (order_id) DO UPDATE
SET status = EXCLUDED.status,
event_time = EXCLUDED.event_time,
updated_at = now()
WHERE order_read_model.event_time < EXCLUDED.event_time;
근본 해결 — 키를 넣는다
키가 없어 파티션이 매번 달라집니다.
enable.idempotence를 끈 설정까지 있으면 파티션 안에서도 순서가 위험합니다.
producer.send(new ProducerRecord<>("order-status", toJson(event)));
acks=1
enable.idempotence=false
max.in.flight.requests.per.connection=10
순서를 보장해야 하는 단위를 키로 씁니다. 여기서는 주문 ID입니다. 프로듀서 설정은 4.x 기본값을 그대로 씁니다.
producer.send(new ProducerRecord<>(
"order-status",
event.getOrderId(), // ← 순서 보장 단위를 키로
toJson(event)));
acks=all # 4.x 기본값
enable.idempotence=true # 4.x 기본값
max.in.flight.requests.per.connection=5 # 4.x 기본값 (멱등성 켜면 5 이하에서 순서 보장)
컨슈머 쪽에서 지켜야 할 것
프로듀서가 키를 넣어도 컨슈머가 병렬 처리하면 순서는 다시 깨집니다. 케이스 2에서 소개한 워커 풀 패턴을 쓴다면 같은 키는 반드시 같은 워커로 보내야 합니다.
// 파티션 단위 순차 처리를 유지하려면 파티션 하나당 워커 하나
int workerIndex = Math.abs(record.key().hashCode()) % workers.length;
workers[workerIndex].submit(() -> process(record));
키 도입 시의 마이그레이션 문제
키를 넣기 시작한 시점을 경계로 같은 주문의 이력이 여러 파티션에 걸칩니다. 기존 데이터는 이동하지 않기 때문입니다. 선택지는 세 가지입니다.
| 전략 | 방법 | 장점 / 단점 |
|---|---|---|
| 그냥 전환 | 키를 넣어 배포하고, 과거 데이터는 그대로 둔다 | 가장 간단. 전환 시점 전후에 걸친 주문의 순서는 여전히 보장되지 않음 |
| 새 토픽으로 전환 | order-status-v2를 파티션 수 확정 후 만들고 프로듀서를 옮긴다. 컨슈머는 두 토픽을 잠시 함께 구독 |
깨끗한 경계. 컨슈머가 두 토픽을 다루는 기간이 필요하고 운영 복잡도가 오름 |
| 재적재 | 과거 데이터를 키를 붙여 새 토픽으로 다시 넣는다 (Streams 또는 배치 잡) | 이력까지 정합. 재적재 비용이 크고 재처리 부작용을 통제해야 함 (케이스 7) |
어떤 전략을 택하든 파티션 수를 먼저 확정해야 합니다. 나중에 늘리면 같은 문제가 반복됩니다. 파티션 수를 크게 잡는 것도 답이 아닙니다 — 그 비용은 케이스 5에서 다룹니다.
예방 체크리스트
시험 포인트
이어서 볼 곳
- 4장 · Producer 심화 파티셔너, 멱등성, in-flight와 순서 보장의 관계.
- 2장 · 아키텍처와 핵심 개념 파티션·오프셋·순서 보장의 기본 모델.
- 케이스 5 · 파티션을 1000개로 늘렸더니 느려졌다 "파티션을 크게 잡아 두면 된다"가 왜 답이 아닌가.
- 케이스 2 · 무한 리밸런스 루프 워커 풀로 처리를 분리할 때의 순서 문제.
- 예제 2 · Spring Boot Producer/Consumer 키를 포함한 기본 발행·소비 코드.
-
CLI 치트시트
print.partition·parse.key등 console 도구 옵션.
공식 문서 출처
- Introduction — Topics and Partitions — 같은 키는 같은 파티션, 파티션 단위 순서 보장
- Producer Configs —
partitioner.class— 기본값null과 내장 파티셔닝 로직 3갈래 - Producer Configs —
max.in.flight.requests.per.connection— 재시도 시 순서가 뒤바뀌는 조건 - Producer Configs —
enable.idempotence— 기본값true, in-flight 제약 - Operations — Modifying topics — 파티션 증설의 4가지 부작용, 파티션 축소 불가, 내부 토픽 경고
- Design — Message Delivery Semantics — 순서와 전달 보장의 관계
- Apache Kafka 소스 (4.3) —
DefaultMessageFormatter의 출력 속성(print.partition,print.offset,print.key)