이 케이스에서 얻어 갈 것

상황

주문 상태 파이프라인입니다. order-status 토픽은 파티션 24개, 복제 계수 3, 브로커 3대에서 일 120만 건의 상태 전이 이벤트를 받습니다. 주문 하나는 평균 6번 상태가 바뀝니다.

상태 전이 — 순서가 뒤집히면 의미가 완전히 달라집니다
CREATED → PAID → PREPARING → SHIPPED → DELIVERED
                     ↘ CANCELLED

컨슈머는 이벤트를 받아 주문 조회용 읽기 모델(PostgreSQL)에 UPSERT합니다. 컨슈머 파드는 8개이고 각각 파티션 3개를 맡습니다. 주문량이 늘면 파드를 늘리는 방식으로 3년 동안 잘 운영됐습니다.

OrderStatusProducer.java — 이 한 줄이 사고의 전부입니다
public void publish(OrderStatusChanged event) {
    // 키를 지정하지 않았습니다.
    // 같은 주문의 이벤트가 서로 다른 파티션으로 흩어집니다.
    producer.send(new ProducerRecord<>("order-status", toJson(event)));
}

문제는 트래픽이 늘면서 드러났습니다. 파티션 수를 12에서 24로 늘리고 컨슈머 파드를 4개에서 8개로 올린 뒤, 같은 주문의 두 이벤트가 서로 다른 파티션에 들어가 서로 다른 파드에서 동시에 처리될 확률이 크게 올라갔습니다. SHIPPED를 처리한 파드가 먼저 커밋하고, 몇십 밀리초 뒤 다른 파드가 PAID를 처리해 읽기 모델을 덮어썼습니다.

case04 — 키 없이 프로듀싱해 같은 엔티티의 순서 보장이 깨지는 흐름 정상 흐름에서는 주문 아이디를 키로 주므로 파티셔너가 murmur2 해시를 파티션 수로 나눈 나머지를 써서 같은 키를 항상 같은 파티션으로 보냅니다. 순서 보장은 파티션 안에서만 성립하므로 이때 한 주문의 이벤트 순서가 지켜집니다. 어긋나는 지점에서 키를 null 로 두면 파티셔너가 sticky 방식으로 배치 단위로 파티션을 바꾸므로 같은 주문의 생성, 수정, 취소 이벤트가 서로 다른 파티션에 흩어집니다. 결과적으로 컨슈머 세 명이 각 파티션을 병렬로 처리하면서 취소가 생성보다 먼저 처리될 수 있고 최종 상태가 어긋납니다. 처방은 순서가 중요한 이벤트에 엔티티 키를 반드시 지정하는 것입니다. 1. 정상 흐름 같은 키는 같은 파티션 key=order-42 murmur2(key) % 파티션수 항상 파티션 1 파티션 안에서 순서 보장 기본 파티셔너는 키가 있으면 murmur2(key) 를 파티션 수로 나눈 나머지로 정합니다 (결정적). Kafka 는 파티션 안에서만 순서를 보장합니다. 토픽 전체 순서라는 개념은 없습니다. 2. 어긋나는 지점 key=null 이면 배치마다 파티션이 바뀝니다 key=null sticky 배치 파티셔닝 CREATE → P0 CANCEL → P2 키가 없으면 파티셔너는 배치가 찰 때까지 한 파티션에 몰아 쓰고 그다음 배치는 다른 파티션을 고릅니다 (sticky). 레코드마다 번갈아 도는 순수 라운드로빈이 아니라 배치 단위로 옮겨 가는 방식입니다. 3. 결과 취소가 생성보다 먼저 처리됩니다 컨슈머 3명 병렬 처리 P2 가 먼저 소비 CANCEL 먼저 적용 최종 상태 불일치 파티션이 다르면 처리 순서를 보장할 방법이 없습니다 — 컨슈머 수를 줄여도 해결되지 않습니다. 이미 흩어진 데이터는 사후에 순서를 복원할 수 없습니다. 처방 순서가 중요한 이벤트에는 엔티티 식별자를 키로 지정합니다 (주문 ID · 계정 ID 등). 파티션 수를 늘리면 키 → 파티션 매핑이 바뀌므로 순서가 필요한 토픽의 파티션 증설은 신중하게 합니다. 전역 순서가 꼭 필요하면 파티션 1개를 쓰되 처리량 상한을 감수합니다.
키가 없을 때 순서 보장이 사라지는 경로 — 같은 주문의 이벤트가 여러 파티션으로 분산되고, 파티션별로 다른 컨슈머가 병렬 처리하면서 적용 순서가 역전됩니다

관측된 증상

메트릭이 어떻게 보였는가

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이며, 이때 내장 기본 파티셔닝 로직이 쓰입니다. 공식 설명은 다음 세 갈래입니다.

기본 파티셔닝 로직 (partitioner.class 미지정 시)
입력파티션 결정 방식
파티션을 명시했다 그 파티션을 그대로 사용
파티션은 없고 키가 있다 키의 해시로 파티션을 선택 → 같은 키는 항상 같은 파티션
파티션도 키도 없다 sticky 파티션을 사용하며, 그 파티션에 batch.size 바이트 이상이 쌓이면 다른 파티션으로 바뀜

3단계 — 파티션 안에서도 순서가 깨질 수 있다

키를 넣어 같은 파티션에 보내더라도 프로듀서 설정에 따라 순서가 뒤집힐 수 있습니다. 공식 문서가 명시하는 조건입니다.

멱등 프로듀서와 시퀀스 번호의 동작은 4장 Producer 심화에서, 전달 보장 전체 그림은 6장 전달 보장과 트랜잭션에서 다룹니다.

4단계 — 파티션 증설이 순서 근거를 끊는다

이 사고에서 키를 도입하기로 결정한 뒤 두 번째 문제가 나왔습니다. 키가 있어도 파티션 수가 바뀌면 같은 키가 다른 파티션으로 갑니다. 공식 운영 문서가 이 부작용을 명시합니다.

재현 방법

단일 노드 KRaft 클러스터로 충분합니다. 아래 compose는 Apache Kafka의 공식 단일 노드 예제입니다. 3노드 구성은 예제 1 · 로컬 KRaft 클러스터를 쓰세요.

docker-compose.yml — 단일 노드 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로 바꿔 과거 이벤트가 최신 상태를 덮어쓰지 못하게 만듭니다.

즉시 조치 — 역행 UPDATE 를 DB 레벨에서 차단
-- 더 최신 이벤트가 이미 적용되어 있으면 무시합니다
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)));
producer.properties (변경 전)
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)));
producer.properties (변경 후)
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에서 다룹니다.

예방 체크리스트

시험 포인트

공식 문서 출처