| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | 7 |
| 8 | 9 | 10 | 11 | 12 | 13 | 14 |
| 15 | 16 | 17 | 18 | 19 | 20 | 21 |
| 22 | 23 | 24 | 25 | 26 | 27 | 28 |
- PR꿀팁
- kafka
- 인생꿀팁
- 자바스크립트
- ECMAScript
- 고동시성
- 타입스크립트
- Flux
- 백엔드개발
- 이벤트드리븐
- HTTP란
- TCP/IP
- DI
- 의존성주입
- 실천방법
- 자기계발
- TypeScript
- 추천
- 카프카실무
- vue store
- webpack
- EKS네트워크
- 강남아파트
- springboot
- MSA
- frontend
- enum
- Javascript
- GC
- Vue+Typescript
- Today
- Total
끄적끄적
Kafka 순서 보장 — 파티션 키 설계부터 재시도 순서 역전 해결까지 (트러블슈팅 포함) 본문
Kafka 순서 보장
파티션 키 전략 · 재시도 시 순서 역전 · 글로벌 순서 보장 설계 · 트러블슈팅 정리
📋 목차
- 들어가며 — 주문 이벤트가 역순으로 처리된 장애
- Kafka 순서 보장의 기본 원칙
- 파티션 키 설계 전략 — 실무 패턴 4가지
- 재시도(Retry)가 순서를 망가뜨리는 이유
- 멱등성 Producer로 순서 + 중복 동시 해결
- Consumer 레벨 순서 보장 설계
- 글로벌 순서가 꼭 필요한 경우 설계법
- 실무 트러블슈팅 BEST 4
- 순서 보장 설정값 완전 총정리
들어가며 — 주문 이벤트가 역순으로 처리된 장애
사용자가 주문을 생성하고 즉시 취소했는데, Consumer가 취소 이벤트를 먼저 처리하고 생성 이벤트를 나중에 처리하는 사고가 발생했습니다. 결과적으로 DB에는 "취소됐다가 다시 생성된 주문"이라는 이상한 상태가 남았고, 고객 CS가 폭주했습니다.
원인은 단순했습니다. 파티션 키를 설정하지 않아 주문 생성/취소 이벤트가 서로 다른 파티션으로 분산됐고, Consumer가 파티션을 병렬로 읽으면서 순서가 뒤섞인 것이었습니다. Kafka는 "파티션 내에서만" 순서를 보장한다는 사실을 실무에 제대로 적용하지 못한 결과였어요.
① Kafka는 파티션 단위 순서만 보장, 파티션 간 순서는 보장 안 함
② 순서 보장의 핵심은 "어떤 기준으로 파티션 키를 잡느냐"
③ 재시도(Retry) 설정 잘못하면 순서 역전 가능
④ 글로벌 순서가 필요하면 파티션 1개 or 시퀀스 번호 기반 설계
Kafka 순서 보장의 기본 원칙
①주문생성 → ②결제 → ③취소
①주문생성 → ②결제 → ③취소
| 범위 | 순서 보장 | 이유 | 실무 적용 |
|---|---|---|---|
| 🔴 토픽 전체 | 보장 안 됨 | 파티션이 여러 개 → 병렬 처리 | 글로벌 순서 필요 시 파티션 1개 (성능 포기) |
| 🟢 파티션 내 | 보장됨 | 단일 파티션 = 단일 스레드 소비 | 파티션 키 기반 설계의 핵심 |
| 🟡 같은 키 메시지 | 보장됨 | 같은 키 → 같은 파티션 (해시 기반) | orderId, userId 등을 키로 사용 |
| 🔵 재시도 발생 시 | 조건부 | Retry 설정에 따라 순서 역전 가능 | 멱등성 Producer 필수 |
파티션 키 설계 전략 — 실무 패턴 4가지
패턴 1 — 도메인 Entity ID를 키로 (가장 일반적)
같은 Entity의 이벤트가 순서대로 처리돼야 할 때 사용합니다. orderId, userId, productId 등 비즈니스 단위 ID를 키로 잡으면 해당 Entity의 모든 이벤트가 같은 파티션으로 가서 순서가 보장됩니다.
@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 — 복합 키 (멀티 엔티티 순서 보장)
예를 들어 "특정 사용자의 특정 상품" 조합의 순서가 필요할 때 userId + productId 형태의 복합 키를 사용합니다. 단순 ID보다 파티션 분산이 더 고르게 되는 장점도 있습니다.
패턴 3 — 커스텀 파티셔너 (특수 분산 로직)
VIP 사용자의 이벤트는 별도 파티션으로, 일반 사용자 이벤트는 나머지 파티션으로 분산하고 싶을 때 커스텀 파티셔너를 구현합니다. 파티셔너는 Partitioner 인터페이스를 구현하면 됩니다.
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 — 키 없음 (순서 불필요한 경우)
로그 수집, 클릭 이벤트처럼 개별 메시지 순서가 중요하지 않고 처리량만 중요한 경우엔 키를 설정하지 않는 것이 오히려 최적입니다. Round-Robin 방식으로 모든 파티션에 균등 분산되어 처리량을 극대화합니다.
재시도(Retry)가 순서를 망가뜨리는 이유
2. 메시지 A 전송 실패 → 재시도 큐에 대기
3. 메시지 B 전송 성공 (B가 파티션에 먼저 기록됨)
4. 메시지 A 재시도 성공 (A가 B 다음에 기록됨)
5. 결과: Consumer가 B → A 순서로 소비 = 순서 역전!
retries > 0 + max.in.flight.requests.per.connection > 1
동시에 여러 요청을 날리고(in-flight), 그 중 하나가 실패해서 재시도되면 나중 메시지가 먼저 들어갈 수 있습니다.
retries=0이면 재시도 없어서 순서 유지되지만, 메시지 유실 위험!
멱등성 Producer로 순서 + 중복 동시 해결
Kafka의 멱등성 Producer(Idempotent Producer)는 enable.idempotence=true 설정 하나로 재시도 시 발생하는 순서 역전과 중복 전송을 동시에 해결합니다. Kafka 3.0+에서는 기본값이 true입니다.
각 Producer에 고유한 Producer ID(PID)를 부여하고, 메시지마다 시퀀스 번호를 붙입니다.
Broker는 시퀀스 번호를 보고 중복 메시지를 자동으로 걸러내고, 순서가 뒤집힌 메시지는 재정렬합니다.
이 덕분에 max.in.flight.requests.per.connection=5로 성능을 유지하면서도 순서를 보장할 수 있습니다.
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가 재시작되면 새 PID가 할당되어 이전 세션의 메시지와 순서 보장이 깨질 수 있습니다.
애플리케이션 재시작 후 순서 보장이 필요하면 → 트랜잭셔널 Producer 사용을 고려하세요.
Consumer 레벨 순서 보장 설계
파티션당 Consumer 1개 규칙 — 병렬 처리의 황금 원칙
Consumer Group 내에서 파티션 1개는 Consumer 1개만 담당합니다. 이 덕분에 파티션 내 순서 보장이 Consumer까지 자연스럽게 전달됩니다. Consumer를 파티션 수보다 많이 늘려봤자 초과분은 놀게 됩니다.
Consumer 내부 병렬 처리 시 순서 깨짐 주의
@KafkaListener에서 받은 메시지를 내부에서 ExecutorService나 CompletableFuture로 비동기 처리하면 순서가 깨집니다. Kafka가 파티션 단위로 순서를 보장해줘도, Consumer가 비동기로 처리하면 의미가 없어요.
@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();
}
}
글로벌 순서가 꼭 필요한 경우 설계법
파티션 키로 "같은 Entity" 내의 순서는 보장할 수 있지만, 서로 다른 Entity 간의 전체 순서 보장이 필요한 경우가 있습니다. (예: "A 주문 생성" 후 반드시 "B 재고 차감" 순서 보장)
❌ 파티션 수 = 1 (단순 해결책)
단점: 처리량이 파티션 1개의 한계로 제한됨. Consumer도 1개만 사용 가능. 대용량 트래픽 불가. 운영 중 파티션 증가 불가.
✅ 시퀀스 번호 기반 설계
장점: 멀티 파티션 성능 유지 + 순서 보장
단점: Consumer 로직 복잡, Redis 등 공유 시퀀스 저장소 필요
@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());
// 비즈니스 로직 처리
}
}
실무 트러블슈팅 BEST 4
Case 1 — 파티션 추가 후 기존 키의 파티션이 바뀜
해결책: 파티션 추가 후 이전 데이터 처리 완료까지 기다렸다가 새 파티션으로 전환하거나, 처음부터 충분한 파티션 수로 설계.
Case 2 — Rebalancing 중 순서 역전
group.instance.id를 각 Consumer에 고정 ID로 설정하면, Consumer 재시작 시 동일 파티션을 재할당받음. 리밸런싱 발생 횟수 대폭 감소.
Case 3 — 핫 파티션 (특정 파티션에 트래픽 집중)
✅ 해결 2: 해당 userId 이벤트를 별도 Topic으로 분리하여 독립 처리
✅ 해결 3: 순서 보장이 불필요한 이벤트면 키를 제거해서 전체 파티션으로 분산
Case 4 — 동일 이벤트 2번 처리로 결제 중복
이벤트 ID를 Redis 또는 DB에 저장하고, 처리 전 중복 체크. 멱등성이 보장되면 재처리가 되어도 결과가 동일하게 유지됨.
순서 보장 설정값 완전 총정리
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%가 해결됩니다 💪
비슷한 경험 있으시면 댓글로 공유해 주세요!
'Back-end' 카테고리의 다른 글
| Kafka Leader/Follower/ISR — Broker 1대가 죽었을 때 30초 장애를 겪고 배운 것들 (실무 설정값 포함) (0) | 2026.02.26 |
|---|
