끄적끄적

Kafka 순서 보장 — 파티션 키 설계부터 재시도 순서 역전 해결까지 (트러블슈팅 포함) 본문

Back-end

Kafka 순서 보장 — 파티션 키 설계부터 재시도 순서 역전 해결까지 (트러블슈팅 포함)

mashko 2026. 2. 26. 21:20
반응형
⚡ Apache Kafka 순서 보장 실무 심화

Kafka 순서 보장

파티션 키 전략 · 재시도 시 순서 역전 · 글로벌 순서 보장 설계 · 트러블슈팅 정리

⚡ Kafka 3.x 기준 ☕ Spring Boot 3 + Java 17 ⏱ 읽기 약 18분 🔥 실무 설계 패턴 포함

📋 목차

  1. 들어가며 — 주문 이벤트가 역순으로 처리된 장애
  2. Kafka 순서 보장의 기본 원칙
  3. 파티션 키 설계 전략 — 실무 패턴 4가지
  4. 재시도(Retry)가 순서를 망가뜨리는 이유
  5. 멱등성 Producer로 순서 + 중복 동시 해결
  6. Consumer 레벨 순서 보장 설계
  7. 글로벌 순서가 꼭 필요한 경우 설계법
  8. 실무 트러블슈팅 BEST 4
  9. 순서 보장 설정값 완전 총정리
📖 Section 01

들어가며 — 주문 이벤트가 역순으로 처리된 장애

사용자가 주문을 생성하고 즉시 취소했는데, Consumer가 취소 이벤트를 먼저 처리하고 생성 이벤트를 나중에 처리하는 사고가 발생했습니다. 결과적으로 DB에는 "취소됐다가 다시 생성된 주문"이라는 이상한 상태가 남았고, 고객 CS가 폭주했습니다.

원인은 단순했습니다. 파티션 키를 설정하지 않아 주문 생성/취소 이벤트가 서로 다른 파티션으로 분산됐고, Consumer가 파티션을 병렬로 읽으면서 순서가 뒤섞인 것이었습니다. Kafka는 "파티션 내에서만" 순서를 보장한다는 사실을 실무에 제대로 적용하지 못한 결과였어요.

💡
이 글의 핵심 결론을 먼저 말하면:
① Kafka는 파티션 단위 순서만 보장, 파티션 간 순서는 보장 안 함
② 순서 보장의 핵심은 "어떤 기준으로 파티션 키를 잡느냐"
③ 재시도(Retry) 설정 잘못하면 순서 역전 가능
④ 글로벌 순서가 필요하면 파티션 1개 or 시퀀스 번호 기반 설계
· · ·
📐 Section 02

Kafka 순서 보장의 기본 원칙

파티션 내 순서 보장 vs 파티션 간 순서 미보장
❌ 파티션 키 없음 (순서 뒤섞임)
Producer 전송
①주문생성 → ②결제 → ③취소
①생성 → P0
②결제 → P1
③취소 → P2
Round-Robin 분산
Consumer: ③취소 먼저 도착 → 장애!
✅ 파티션 키 = orderId
Producer 전송
①주문생성 → ②결제 → ③취소
①생성 → P0
②결제 → P0
③취소 → P0
같은 orderId → 항상 P0
Consumer: ①②③ 순서 보장 ✅
같은 파티션 키 → 같은 파티션 → 순서 보장
범위 순서 보장 이유 실무 적용
🔴 토픽 전체 보장 안 됨 파티션이 여러 개 → 병렬 처리 글로벌 순서 필요 시 파티션 1개 (성능 포기)
🟢 파티션 내 보장됨 단일 파티션 = 단일 스레드 소비 파티션 키 기반 설계의 핵심
🟡 같은 키 메시지 보장됨 같은 키 → 같은 파티션 (해시 기반) orderId, userId 등을 키로 사용
🔵 재시도 발생 시 조건부 Retry 설정에 따라 순서 역전 가능 멱등성 Producer 필수
· · ·
🗝️ Section 03

파티션 키 설계 전략 — 실무 패턴 4가지

1

패턴 1 — 도메인 Entity ID를 키로 (가장 일반적)

같은 Entity의 이벤트가 순서대로 처리돼야 할 때 사용합니다. orderId, userId, productId 등 비즈니스 단위 ID를 키로 잡으면 해당 Entity의 모든 이벤트가 같은 파티션으로 가서 순서가 보장됩니다.

💡 적용 예시: 주문 생성→결제→배송→완료 이벤트 순서 보장, 사용자 행동 이벤트 순서 보장
 
 
 
OrderEventProducer.java — orderId를 파티션 키로
Java
@Service
@RequiredArgsConstructor
public class OrderEventProducer {

    private final KafkaTemplate<String, OrderEvent> kafkaTemplate;

    public void sendOrderEvent(OrderEvent event) {
        // ✅ orderId를 파티션 키로 → 같은 주문의 모든 이벤트는 같은 파티션
        var record = new ProducerRecord<>(
            "order-events",
            event.getOrderId(),    // ← 파티션 키
            event
        );
        kafkaTemplate.send(record);
    }

    // ✅ 어떤 파티션으로 가는지 확인용 (디버깅)
    private int getPartition(String key, int numPartitions) {
        // Kafka 기본 파티셔너: murmur2 해시 알고리즘
        return Math.abs(key.hashCode()) % numPartitions;
    }
}
2

패턴 2 — 복합 키 (멀티 엔티티 순서 보장)

예를 들어 "특정 사용자의 특정 상품" 조합의 순서가 필요할 때 userId + productId 형태의 복합 키를 사용합니다. 단순 ID보다 파티션 분산이 더 고르게 되는 장점도 있습니다.

💡 복합 키 예: userId + ":" + productId → "user123:product456"
3

패턴 3 — 커스텀 파티셔너 (특수 분산 로직)

VIP 사용자의 이벤트는 별도 파티션으로, 일반 사용자 이벤트는 나머지 파티션으로 분산하고 싶을 때 커스텀 파티셔너를 구현합니다. 파티셔너는 Partitioner 인터페이스를 구현하면 됩니다.

⚠️ 커스텀 파티셔너는 파티션 수가 변경될 때 분산 로직이 깨질 수 있습니다. 파티션 수 고정 후 사용 권장.
 
 
 
PriorityPartitioner.java — VIP 우선 파티셔너
Java
public class PriorityPartitioner implements Partitioner {

    @Override
    public int partition(String topic, Object key, byte[] keyBytes,
        Object value, byte[] valueBytes, Cluster cluster) {

        int numPartitions = cluster.partitionCountForTopic(topic);
        OrderEvent event = (OrderEvent) value;

        if (event.isVipUser()) {
            // VIP 이벤트 → 파티션 0번 전용 (Consumer도 P0만 처리하는 전용 인스턴스)
            return 0;
        }
        // 일반 사용자 → 1번 파티션부터 균등 분산
        return (Math.abs(key.hashCode()) % (numPartitions - 1)) + 1;
    }
}
4

패턴 4 — 키 없음 (순서 불필요한 경우)

로그 수집, 클릭 이벤트처럼 개별 메시지 순서가 중요하지 않고 처리량만 중요한 경우엔 키를 설정하지 않는 것이 오히려 최적입니다. Round-Robin 방식으로 모든 파티션에 균등 분산되어 처리량을 극대화합니다.

✅ Kafka 2.4+에서는 키 없을 때 Sticky Partitioner가 기본 적용됩니다 — 배치 내에서 같은 파티션으로 묶어 성능 향상.
· · ·
🔄 Section 04

재시도(Retry)가 순서를 망가뜨리는 이유

Retry로 인한 순서 역전 시나리오
1. Producer가 메시지 A, B를 순서대로 전송
2. 메시지 A 전송 실패 → 재시도 큐에 대기
3. 메시지 B 전송 성공 (B가 파티션에 먼저 기록됨)
4. 메시지 A 재시도 성공 (A가 B 다음에 기록됨)
5. 결과: Consumer가 B → A 순서로 소비 = 순서 역전!
max.in.flight.requests.per.connection > 1 이면 이런 상황이 발생 가능
🚨
순서 역전이 발생하는 Producer 설정 조합:
retries > 0 + max.in.flight.requests.per.connection > 1
동시에 여러 요청을 날리고(in-flight), 그 중 하나가 실패해서 재시도되면 나중 메시지가 먼저 들어갈 수 있습니다.
retries=0이면 재시도 없어서 순서 유지되지만, 메시지 유실 위험!
· · ·
🛡️ Section 05

멱등성 Producer로 순서 + 중복 동시 해결

Kafka의 멱등성 Producer(Idempotent Producer)enable.idempotence=true 설정 하나로 재시도 시 발생하는 순서 역전과 중복 전송을 동시에 해결합니다. Kafka 3.0+에서는 기본값이 true입니다.

🔬
멱등성 Producer 동작 원리:
각 Producer에 고유한 Producer ID(PID)를 부여하고, 메시지마다 시퀀스 번호를 붙입니다.
Broker는 시퀀스 번호를 보고 중복 메시지를 자동으로 걸러내고, 순서가 뒤집힌 메시지는 재정렬합니다.
이 덕분에 max.in.flight.requests.per.connection=5로 성능을 유지하면서도 순서를 보장할 수 있습니다.
 
 
 
application.yml — 멱등성 Producer 완전체 설정
YAML
spring.kafka.producer:
  acks: all             # 멱등성 활성화 시 acks=all 필수
  retries: 3            # 재시도 허용
  properties:
    enable.idempotence: true   # ✅ 핵심! 중복 방지 + 순서 보장
    # 멱등성 활성화 시 자동으로 아래 설정 강제 적용됨:
    # - acks = all
    # - max.in.flight.requests.per.connection ≤ 5
    # - retries > 0
    max.in.flight.requests.per.connection: 5  # 성능 유지
    delivery.timeout.ms: 120000
    request.timeout.ms: 30000
⚠️
멱등성 Producer의 한계:
멱등성은 단일 Producer 세션 내에서만 보장됩니다.
Producer가 재시작되면 새 PID가 할당되어 이전 세션의 메시지와 순서 보장이 깨질 수 있습니다.
애플리케이션 재시작 후 순서 보장이 필요하면 → 트랜잭셔널 Producer 사용을 고려하세요.
· · ·
📥 Section 06

Consumer 레벨 순서 보장 설계

1

파티션당 Consumer 1개 규칙 — 병렬 처리의 황금 원칙

Consumer Group 내에서 파티션 1개는 Consumer 1개만 담당합니다. 이 덕분에 파티션 내 순서 보장이 Consumer까지 자연스럽게 전달됩니다. Consumer를 파티션 수보다 많이 늘려봤자 초과분은 놀게 됩니다.

💡 병렬 처리량을 늘리려면 Consumer 수가 아니라 파티션 수를 늘리세요!
2

Consumer 내부 병렬 처리 시 순서 깨짐 주의

@KafkaListener에서 받은 메시지를 내부에서 ExecutorService나 CompletableFuture로 비동기 처리하면 순서가 깨집니다. Kafka가 파티션 단위로 순서를 보장해줘도, Consumer가 비동기로 처리하면 의미가 없어요.

⚠️ 비동기 처리가 필요하면 동일 파티션 내 메시지는 반드시 순서 보장 큐(LinkedBlockingQueue)로 처리하세요!
 
 
 
OrderEventConsumer.java — 순서 보장 Consumer
Java
@Service
@RequiredArgsConstructor
public class OrderEventConsumer {

    private final OrderService orderService;

    @KafkaListener(
        topics = "order-events",
        groupId = "order-service-prod",
        // ✅ concurrency로 Consumer 스레드 수 설정 — 파티션 수와 맞추기
        concurrency = "3"  // 파티션 3개 → Consumer 스레드 3개
    )
    public void consume(
        ConsumerRecord<String, OrderEvent> record,
        Acknowledgment ack
    ) {
        OrderEvent event = record.value();

        log.info("처리 중 | orderId={} | type={} | partition={} | offset={}",
            event.getOrderId(),
            event.getEventType(),
            record.partition(),
            record.offset());

        // ✅ 동기 처리 — 순서 보장 유지
        orderService.processOrderEvent(event);

        // ✅ 처리 완료 후 수동 커밋
        ack.acknowledge();
    }
}
· · ·
🌍 Section 07

글로벌 순서가 꼭 필요한 경우 설계법

파티션 키로 "같은 Entity" 내의 순서는 보장할 수 있지만, 서로 다른 Entity 간의 전체 순서 보장이 필요한 경우가 있습니다. (예: "A 주문 생성" 후 반드시 "B 재고 차감" 순서 보장)

❌ 파티션 수 = 1 (단순 해결책)

모든 메시지 → 단일 파티션 → 글로벌 순서 보장
장점: 구현 단순, 완벽한 순서 보장
단점: 처리량이 파티션 1개의 한계로 제한됨. Consumer도 1개만 사용 가능. 대용량 트래픽 불가. 운영 중 파티션 증가 불가.

✅ 시퀀스 번호 기반 설계

멀티 파티션 + 메시지 내 시퀀스 번호로 재정렬
Producer가 메시지에 글로벌 시퀀스 번호를 부여. Consumer가 시퀀스 번호 기반으로 버퍼에서 재정렬 후 처리.
장점: 멀티 파티션 성능 유지 + 순서 보장
단점: Consumer 로직 복잡, Redis 등 공유 시퀀스 저장소 필요
 
 
 
SequenceOrderedConsumer.java — 시퀀스 기반 재정렬
Java
@Service
public class SequenceOrderedConsumer {

    // 시퀀스 순서를 맞추기 위한 버퍼 (TreeMap: 자동 정렬)
    private final TreeMap<Long, OrderEvent> buffer
        = new TreeMap<>();
    private long nextExpectedSeq = 1L;

    @KafkaListener(topics = "order-events")
    public synchronized void consume(OrderEvent event) {
        // 버퍼에 시퀀스 번호로 저장
        buffer.put(event.getSequenceNo(), event);

        // 연속된 시퀀스만 순서대로 처리
        while (buffer.containsKey(nextExpectedSeq)) {
            OrderEvent next = buffer.remove(nextExpectedSeq);
            processInOrder(next);
            nextExpectedSeq++;
        }
    }

    private void processInOrder(OrderEvent event) {
        log.info("순서 보장 처리 | seq={} | type={}",
            event.getSequenceNo(), event.getEventType());
        // 비즈니스 로직 처리
    }
}
· · ·
🚨 Section 08

실무 트러블슈팅 BEST 4

🔴

Case 1 — 파티션 추가 후 기존 키의 파티션이 바뀜

🚨 현상: 운영 중 파티션을 6개→12개로 늘렸더니, 기존에 P0으로 가던 userId=A의 메시지가 P7로 가기 시작. 기존 P0의 메시지와 새 P7의 메시지가 뒤섞임.
✅ 원인: 기본 파티셔너는 해시값 % 파티션수로 파티션 결정. 파티션 수가 바뀌면 해시 결과도 바뀜.
해결책: 파티션 추가 후 이전 데이터 처리 완료까지 기다렸다가 새 파티션으로 전환하거나, 처음부터 충분한 파티션 수로 설계.
🔴

Case 2 — Rebalancing 중 순서 역전

🚨 현상: Consumer 재시작 시 리밸런싱이 발생하고, 처리 중이던 메시지가 다른 Consumer에게 할당됐다가 다시 돌아오면서 순서 역전.
✅ 해결: Static Group Membership 적용으로 불필요한 리밸런싱 최소화.
group.instance.id를 각 Consumer에 고정 ID로 설정하면, Consumer 재시작 시 동일 파티션을 재할당받음. 리밸런싱 발생 횟수 대폭 감소.
🔴

Case 3 — 핫 파티션 (특정 파티션에 트래픽 집중)

🚨 현상: 특정 userId(예: 대형 쇼핑몰 계정)가 초당 수천 건의 이벤트를 발생시켜 해당 파티션만 Consumer Lag 폭증. 다른 파티션은 여유로움.
✅ 해결 1: 핫 키에 서픽스 추가로 파티션 분산: userId + "_" + (eventNo % N)
✅ 해결 2: 해당 userId 이벤트를 별도 Topic으로 분리하여 독립 처리
✅ 해결 3: 순서 보장이 불필요한 이벤트면 키를 제거해서 전체 파티션으로 분산
🔴

Case 4 — 동일 이벤트 2번 처리로 결제 중복

🚨 현상: Consumer가 처리 완료 후 offset commit 전에 죽어서, 재시작 시 같은 메시지를 다시 처리. 결제가 2번 발생.
✅ 해결: 순서 보장과 별개로 멱등성 Consumer 패턴 필수.
이벤트 ID를 Redis 또는 DB에 저장하고, 처리 전 중복 체크. 멱등성이 보장되면 재처리가 되어도 결과가 동일하게 유지됨.
· · ·
⚙️ Section 09

순서 보장 설정값 완전 총정리

 
 
 
순서 보장 완전체 설정 — application.yml
YAML
spring.kafka:
  bootstrap-servers: kafka1:9092,kafka2:9092,kafka3:9092

  producer:
    acks: all
    retries: 3
    properties:
      # ✅ 순서 보장 핵심 3총사
      enable.idempotence: true             # 중복 방지 + 순서 보장
      max.in.flight.requests.per.connection: 5  # 멱등성 활성화 시 최대 5
      delivery.timeout.ms: 120000
      retry.backoff.ms: 1000

  consumer:
    enable-auto-commit: false          # ✅ 수동 커밋 — 처리 완료 후 커밋
    auto-offset-reset: earliest
    max-poll-records: 100              # 한 번에 읽을 메시지 수 (순서 영향 없음)
    properties:
      max.poll.interval.ms: 300000     # 처리 시간 초과 시 리밸런싱 방지
      # ✅ Static Group Membership — 불필요한 리밸런싱 방지
      group.instance.id: ${HOSTNAME}-consumer

# Broker 설정 (server.properties)
# min.insync.replicas=2       → 데이터 무결성
# unclean.leader.election.enable=false → 데이터 유실 방지
설정 권장값 순서 보장 영향
enable.idempotence true 재시도 시 순서 역전 방지
max.in.flight.requests 5 이하 멱등성과 함께 순서 보장
acks all 메시지 유실 방지
enable-auto-commit false 처리 실패 시 재처리 보장
group.instance.id 고정 ID 리밸런싱 최소화
concurrency 파티션 수와 동일 Consumer 병렬 처리 최적화

⚡ "Kafka 순서 보장 = 파티션 키 설계 × 멱등성 Producer × 동기 Consumer"

이 세 가지가 맞물려야 진짜 순서 보장이 됩니다.
파티션 키만 잘 잡아도 80%는 해결되고,
멱등성 Producer로 재시도 문제를 잡고,
Consumer를 동기 처리로 유지하면 나머지 20%가 해결됩니다 💪

비슷한 경험 있으시면 댓글로 공유해 주세요!

#Kafka순서보장 #파티션키 #KafkaOrdering #멱등성Producer #카프카실무 #아파치카프카 #이벤트드리븐 #백엔드개발 #데이터엔지니어 #MSA #개발블로그 #카프카설계
반응형
Comments