Kafka 실무 — 토픽 설계와 메시지 흐름
Kafka 실무 — 토픽 설계와 메시지 흐름
Kafka 를 "쓸지 말지" 는 다른 노트에서 다뤘습니다. 이번 노트는 그 다음 — 토픽을 어떻게 이름 짓고, 파티션·키를 어떻게 잡고, Producer·Consumer 코드를 어떻게 쓰는가입니다. 개념은 한 번 잡으면 끝나지만, 토픽 설계는 한 번 잘못 잡으면 운영 내내 따라옵니다.
1. 토픽 명명 규칙
토픽 이름은 사실상 API 입니다. 한 번 발행이 시작되면 컨슈머가 의존하기 때문에 바꾸기 어렵습니다. 처음부터 규칙을 정해 둡니다.
<domain>.<entity>.<event>패턴 — 예:orders.order.created,billing.invoice.paid. 도메인·엔티티·이벤트 세 축이면 검색·권한·정리가 쉽습니다.- 구분자 컨벤션 통일 — dot (
.) 또는 kebab (-) 중 하나로 일관되게. 섞으면 (orders.order-created) 정렬·필터가 어긋납니다. dot 로 계층을, kebab 으로 단어를 표현하는 절충 (orders.order-line.created) 도 흔합니다. - 환경 분리 —
dev.orders.created처럼 prefix 를 붙이거나, 아예 환경별로 클러스터를 분리합니다. 같은 클러스터에 환경을 섞으면 dev 컨슈머가 prod 토픽을 읽는 사고가 납니다. 클러스터 분리가 더 안전합니다. - 토픽명은 설정으로 주입 — 코드에
"orders.order.created"를 하드코딩하지 말고 설정값으로 받습니다. 환경마다 prefix 가 다르고, 이름 변경 시 코드 전체를 grep 할 필요가 없어집니다.
# application.yml — 토픽명을 설정으로
app:
topics:
order-created: ${ORDER_CREATED_TOPIC:orders.order.created}
2. 파티션·키 설계
파티션은 Kafka 성능 설계의 핵심 손잡이입니다.
- 파티션 수 = 병렬도 상한 — 한 컨슈머 그룹에서 동시에 일하는 컨슈머 수는 파티션 수를 넘지 못합니다. 파티션 8 개면 컨슈머 8 개까지만 의미가 있고, 9 번째는 놀게 됩니다.
- 같은 키 → 같은 파티션 → 순서 보장 — 메시지 키의 해시로 파티션이 결정됩니다. 한 주문 id 를 키로 쓰면 그 주문의 모든 이벤트가 한 파티션에 모여 발행 순서가 유지됩니다. 키가
null이면 라운드로빈으로 흩어져 순서 보장이 사라집니다. - 파티션 수를 늘리면 매핑이 바뀐다 —
hash(key) % partitionCount라서 파티션 수가 바뀌면 같은 키가 다른 파티션으로 갑니다. 이전 파티션에 쌓인 메시지와 새 파티션 메시지 사이의 순서 가정이 깨집니다. 그래서 처음 설계가 중요합니다.
처음에는 예상 처리량의 2~3 배 정도로 약간 넉넉히 잡는 편이 안전합니다. 단 파티션이 과하게 많으면 메타데이터·파일 핸들 부담이 커지므로 무한정 늘리는 것도 답이 아닙니다.
// 같은 키 → 같은 파티션 → 주문별 순서 유지
producer.send(new ProducerRecord<>(
topic,
order.getId(), // key — 순서 보장의 단위
toJson(event)
));
3. Producer 에러 처리·재시도
발행은 "보냈다" 가 아니라 "브로커가 받았다" 까지 확인해야 신뢰할 수 있습니다.
acks—0(확인 안 함, 유실 가능),1(leader 만 확인),all(ISR 전체 확인). 데이터를 잃으면 안 되는 토픽은all입니다.enable.idempotence=true— Producer 재시도로 같은 메시지가 두 번 쓰이는 것을 브로커가 막아 줍니다.acks=all과 함께 켜는 것이 사실상 기본값입니다.- 재시도 — 일시적 네트워크 오류에 대비해
retries와delivery.timeout.ms를 설정합니다. 멱등성을 켜면 재시도가 안전합니다. - 동기 블로킹 회피 —
send().get()으로 매 메시지를 기다리면 처리량이 무너집니다. 콜백으로 결과를 받고, 실패는 콜백 안에서 처리합니다.
producer.send(record, (metadata, ex) -> {
if (ex != null) {
log.error("발행 실패 topic={}", record.topic(), ex);
// 실패 메시지를 별도 저장 (outbox / 재시도 큐)
}
});
- DB 커밋 전 발행 금지 — DB 트랜잭션 안에서 메시지를 먼저 보내면, 트랜잭션이 롤백돼도 메시지는 이미 나갑니다 (없는 주문에 대한 이벤트). outbox 패턴 — 같은 트랜잭션 안에서
outbox테이블에 INSERT 하고, 별도 발행기가 커밋된 행만 읽어 Kafka 로 보냅니다. 또는 트랜잭션 commit 직후(after-commit) 훅에서 발행합니다.
4. Consumer 그룹·오프셋 관리
컨슈머는 "어디까지 읽었는가" (오프셋) 를 스스로 관리합니다.
group.id— 같은 그룹의 컨슈머들이 파티션을 나눠 가집니다. 그룹을 분리하면 같은 토픽을 독립적으로 다시 읽습니다.- 오프셋 커밋 — auto vs manual — auto-commit 은 일정 주기로 자동 커밋해 간편하지만, 메시지를 받고 처리 전에 커밋되면 장애 시 유실됩니다. 처리 완료 후 직접 커밋하는 manual commit 이 at-least-once 와 맞물립니다.
auto.offset.reset— 저장된 오프셋이 없을 때(새 그룹 등)의 동작.earliest는 토픽 처음부터,latest는 지금부터. 새 컨슈머가 과거 데이터를 놓치지 않으려면earliest가 권장입니다.latest는 배포 사이 메시지를 조용히 건너뜁니다.- 컨슈머 수 ≤ 파티션 수 — §2 와 같습니다. 파티션보다 많은 컨슈머는 일을 받지 못합니다.
- 리밸런싱 — 컨슈머가 합류·이탈하면 파티션이 재할당됩니다. 그동안 처리가 잠시 멈춥니다. 컨슈머 안에서 오래 블로킹하면 그룹이 컨슈머를 죽은 것으로 보고 리밸런싱이 반복됩니다 (
max.poll.interval.ms초과).
// 처리 완료 후 수동 커밋 — at-least-once
records.forEach(record -> {
handle(record); // 처리
});
consumer.commitSync(); // 배치 처리 후 커밋
5. at-least-once 와 멱등 컨슈머
Kafka 의 기본 전달 보증은 at-least-once — 메시지는 최소 한 번 전달되며, 같은 메시지가 두 번 이상 올 수 있습니다. 재시도·리밸런싱·커밋 타이밍 때문에 중복은 정상입니다. 그러니 멱등성은 컨슈머의 책임입니다.
- 메시지에 고유 id — 발행 시 메시지마다 uuid (또는 도메인 키) 를 헤더·페이로드에 넣습니다.
- 처리완료 기록 — 컨슈머가 처리 전에
processed_messages(message_id)같은 테이블을 조회해, 이미 있으면 건너뜁니다. id 컬럼에 UNIQUE 제약을 두면 중복 INSERT 가 막혀 더 견고합니다. - DLQ (Dead Letter Queue) — 몇 번 재시도해도 실패하는 메시지(잘못된 형식, 영구적 오류)는 무한 재시도하지 말고 별도 DLQ 토픽으로 보냅니다. 메인 흐름이 막히지 않고, 실패 메시지는 나중에 따로 조사합니다.
- 재시도 + backoff — 일시적 오류(외부 API 5xx 등)는 즉시 재시도하지 말고 점점 간격을 늘려(backoff) 재시도합니다. 정해진 횟수를 넘으면 DLQ.
void handle(ConsumerRecord<String, String> record) {
String messageId = header(record, "message-id");
if (alreadyProcessed(messageId)) return; // 멱등 — 중복 스킵
try {
applyBusinessLogic(record);
markProcessed(messageId); // 처리완료 기록
} catch (TransientException e) {
throw e; // 재시도 대상
} catch (PermanentException e) {
sendToDlq(record, e); // 영구 실패 → DLQ
}
}
6. 스키마 진화
토픽의 메시지 형식은 시간이 지나면 바뀝니다. 필드가 추가되거나, 이름이 바뀌거나, 의미가 달라집니다. Producer 와 Consumer 가 따로 배포되므로 형식 변경은 깨지기 쉽습니다.
- Schema Registry — Avro · Protobuf · JSON Schema 스키마를 중앙에서 버전 관리합니다. Producer 는 스키마 id 와 함께 발행하고, Consumer 는 그 스키마로 역직렬화합니다. 형식 합의가 명시적이 됩니다.
- 호환성 규칙 — backward 호환은 새 스키마로 옛 메시지를 읽을 수 있음(컨슈머 먼저 배포), forward 호환은 옛 스키마로 새 메시지를 읽을 수 있음(프로듀서 먼저 배포). 보통 backward 를 기본으로 둡니다.
- 필드 추가 — 기본값이 있는(optional) 필드를 추가하는 것은 보통 안전합니다. 옛 컨슈머는 모르는 필드를 무시합니다.
- 필드 삭제·이름 변경·타입 변경 — 깨지는 변경입니다. 삭제는 한 단계 거쳐 (먼저 optional 로, 한참 후 제거), 이름 변경은 새 필드 추가 + 옛 필드 유지로 우회합니다.
Schema Registry 없이 JSON 을 그냥 쓸 수도 있지만, 그때는 형식 변경 규율을 코드 리뷰와 문서로 사람이 지켜야 합니다.
자주 걸리는 자리
동기 블로킹 send — send().get() 을 메시지마다 부르면 네트워크 왕복마다 멈춰 처리량이 한 자릿수로 떨어집니다. 콜백을 씁니다.
파티션 수 과소 설계 — 파티션 4 개로 시작했다가 컨슈머를 8 개로 늘리려는 순간 막힙니다. 늘리는 것은 가능하지만 키→파티션 매핑이 바뀌어 순서가 깨집니다.
컨슈머 안 장시간 블로킹 — 컨슈머 루프 안에서 Thread.sleep 이나 오래 걸리는 외부 호출을 하면 max.poll.interval.ms 를 넘겨 그룹이 컨슈머를 죽은 것으로 보고 리밸런싱이 반복됩니다.
auto.offset.reset=latest — 새 컨슈머 그룹이나 오프셋이 만료된 경우, latest 면 그 사이 쌓인 메시지를 조용히 건너뜁니다. 데이터 유실로 이어지므로 earliest 가 안전한 기본값입니다.
예외 전파로 무한 재시도 — 잘못된 형식의 메시지에서 예외를 그대로 던지면 오프셋이 커밋되지 않아 같은 메시지를 영원히 다시 받습니다. 영구적 실패는 DLQ 로 보내고 오프셋을 진행시킵니다.
DB 커밋 전 발행 — 트랜잭션 안에서 메시지를 먼저 보내면 롤백돼도 메시지는 나갑니다. 컨슈머는 존재하지 않는 데이터의 이벤트를 받습니다. outbox 또는 after-commit 으로 분리합니다.
하고픈 말
토픽 이름·파티션 수·키 전략은 한 번 정하면 운영 내내 따라옵니다. 코드는 리팩터로 고칠 수 있지만 토픽 설계는 마이그레이션이 필요합니다. 처음에 도메인 명명 규칙과 키 전략만 합의해 두면, 나머지(멱등 컨슈머·DLQ·스키마)는 점진적으로 더해 갈 수 있습니다.
Next
- kafka-when
- data-pipeline
Apache Kafka 공식 문서 · Producer 설정 · Consumer 설정 · Confluent Schema Registry · 스키마 호환성 규칙 을 참고합니다.