fos-blog/study
01 / 홈02 / 카테고리03 / 시리즈
01 / 홈02 / 카테고리03 / 시리즈

카테고리

  • AI 페이지로 이동
    • RAG 페이지로 이동
    • agent 페이지로 이동
    • claude-code 페이지로 이동
    • harness 페이지로 이동
    • llm 페이지로 이동
    • ops 페이지로 이동
    • practice 페이지로 이동
  • algorithm 페이지로 이동
    • Alias Method: 가중치 랜덤 선택을 전처리 O(n), 추출 O(1)로 바꾸기
    • Welford's Online Algorithm: 값을 저장하지 않고 평균과 분산 계산하기
  • architecture 페이지로 이동
    • distributed-systems 페이지로 이동
    • domain 페이지로 이동
    • evolution 페이지로 이동
    • patterns 페이지로 이동
  • database 페이지로 이동
    • milvus 페이지로 이동
    • mysql 페이지로 이동
    • opensearch 페이지로 이동
    • postgresql 페이지로 이동
    • qdrant 페이지로 이동
    • redis 페이지로 이동
    • vespa 페이지로 이동
    • DB Connection Pool Saturation과 Thread Pool 격리
    • 커넥션 풀 크기는 얼마나 조정해야 할까?
    • gaps and islands: SQL 창 함수로 이어진 구간 찾기
    • MyBatis 기본기 — XML Mapper, resultMap, 동적 SQL, 운영 패턴 정리
    • MyBatis와 JPA/Hibernate 트레이드오프 — 레거시 백엔드를 다루는 시니어 관점
    • 한국어 형태소 분석기 Nori vs Lindera — OpenSearch에서 Milvus로 갈 때 어휘 검색은 유지되나
    • 정규화와 역정규화: 무결성과 조회 비용 사이의 선택
    • 벡터 DB 5종, 아키텍처는 어떻게 다른가
    • 벡터 DB 5종을 실제로 벤치마크했다 — 같은 recall에서 QPS는 얼마나 갈리나
    • 벡터 DB 어떻게 고를까 — OpenSearch · Milvus · Qdrant · Vespa · pgvector 비교
    • 벡터 DB를 실제로 도입한 사례 — 빅테크 프로덕션
  • devops 페이지로 이동
    • docker 페이지로 이동
    • k8s 페이지로 이동
    • Envoy Proxy
    • Graceful Shutdown
    • 종료 신호는 어디서 멈추는가
  • http 페이지로 이동
    • HTTP Connection Pool
    • HTTPS와 TLS: 핸드셰이크, 인증서, 종료 지점
  • java 페이지로 이동
    • concurrency 페이지로 이동
    • jdbc 페이지로 이동
    • spring 페이지로 이동
    • spring-batch 페이지로 이동
    • testing 페이지로 이동
    • Alpine 이미지의 Java 에서 링크 바꿔치기(TOCTOU)를 막지 못하는 이유
    • Java 동시성 락 정리 — 커머스 메뉴/프로모션 정책 캐시 갱신 관점
    • JVM 튜닝 실전: 메모리 구조부터 Virtual Threads, GC 튜닝, 프로파일링까지
    • 로그에 traceId 남기기 — MDC 부터 OpenTelemetry 까지
    • Java StampedLock — 읽기 폭주에도 쓰기가 밀리지 않는 락
    • Virtual Thread와 Project Loom
  • javascript 페이지로 이동
    • typescript 페이지로 이동
    • AbortController
    • Async Iterator와 제너레이터
    • CommonJS와 ECMAScript Modules
    • 제너레이터(Generator)
    • Http Client
    • Node 백엔드 운영 패턴 — Streams 백프레셔, pipe/pipeline, 멱등성 vs 분산 락
    • Node.js
    • `setImmediate()`
  • kafka 페이지로 이동
    • Kafka 클러스터 아키텍처: Broker, KRaft Controller와 복제
    • Kafka 기본 개념: Topic, Partition, Offset과 Segment
    • Kafka 실전 설계: 파티션 전략, 컨슈머 그룹, 전달 보장, 재시도, 순서 보장 트레이드오프
    • Kafka 파티션·리밸런스·컨슈머 지연 운영
    • Kafka를 로그로 이해하기: 복제, 재처리, 상태와 데이터 통합
    • Spring Kafka 컨슈머 오프셋 커밋과 트랜잭션 정렬: AckMode, manual ack, 멱등 처리
  • linux 페이지로 이동
    • fsync — 리눅스 파일 동기화 시스템 콜
    • SSH forced command 로 배포 키가 실행할 수 있는 명령 제한하기
  • mlops 페이지로 이동
    • llm-serving 페이지로 이동
    • model-router 페이지로 이동
    • Python CUDA 버전 생태계 — nvidia-smi, nvcc, pip, conda가 다 다른 버전을 말하는 이유
    • GPU 컨테이너의 CUDA 버전 호환성 — nvidia-smi부터 이미지 다이어트까지
    • Kubernetes GPU 노드에서 /run tmpfs가 꽉 차서 Pod가 안 뜰 때
    • GPU·CUDA·MPS 기초 — 자바 백엔드 개발자가 처음 만나는 그림
    • Multi-process GPU 워크로드 — 자바 ThreadPool 사용자가 만나는 모델 차이
    • ML 서비스 성능 분석 워크플로 — 자바 백엔드 트러블슈팅과 다른 점
    • 한 GPU 를 여러 프로세스가 나눠 쓰기 — Time-Slicing 과 MPS
  • network 페이지로 이동
    • Connection reset by peer는 누가 보낸 걸까 — 리버스 프록시 홉마다 TCP 연결은 따로 논다
    • L2(스위치)와 L3(라우터)의 역할 차이
    • L4와 VIP(Virtual IP Address)
    • IP Subnet
  • observability 페이지로 이동
    • Observability 입문: 시니어 백엔드가 장애를 탐지하고 대응하는 방식
    • K8s 위 Spring Boot 앱의 메트릭 수집
  • python 페이지로 이동
    • Python async/await — CompletableFuture·Reactor 와 다른 점, 그리고 blocking I/O 함정
    • Python 의존성 관리 — Java Maven/Gradle 사용자가 만나는 첫 충격
    • FastAPI 기초 — Spring Boot 사용자가 빠르게 익히는 법
    • Java 개발자를 위한 Python 심화 — OOP·데코레이터·컨텍스트 매니저
    • PyTorch 기초 — 텐서, 디바이스, 그리고 모델 로딩이 무거운 이유
    • Java 개발자를 위한 Python 문법 핵심
    • ThreadLocal 에서 contextvars 로 — Python 의 요청 컨텍스트 전파
    • OCR 동작 원리 — Layout · Text · Post-process 3단계
    • Python 서버의 RSS가 줄지 않는 이유: CPython, gc.collect(), malloc_trim
  • rabbitmq 페이지로 이동
    • RabbitMQ Basics — 실전 백엔드 관점에서 정리하는 메시지 브로커 기본기
    • RabbitMQ vs Kafka — 백엔드 메시징 선택 기준과 실전 운영 관점
  • resume 페이지로 이동
    • 경험 및 경력기술서
    • 김병태 경력기술서
    • 김병태 포트폴리오
  • task 페이지로 이동
    • ai-service-team 페이지로 이동
    • nsc-slot 페이지로 이동
    • personal-projects 페이지로 이동
    • sb-dev-team 페이지로 이동
    • the-future-company 페이지로 이동
  • thinking 페이지로 이동
    • 자기주도 학습: 질문에서 시작해 글로 검증하기
    • 좋은 일을 넘어 중요한 일을 하는 법
FOS-BLOG · FOOTERall systems normal·v0.1 · 2026.04.27·seoul, kr
Ffos-blog/study

개발 학습 기록을 정리하는 블로그입니다. 공부하면서 기록하고, 기록하면서 다시 배웁니다.

visitors
01site
  • Home↗
  • Posts↗
  • Categories↗
  • Glossary↗
  • About↗
02policy
  • 소개/about
  • 개인정보처리방침/privacy
  • 연락처/contact
03categories
  • AI↗
  • Algorithm↗
  • DB↗
  • DevOps↗
  • Java/Spring↗
  • JS/TS↗
  • React↗
  • Next.js↗
  • System↗
04connect
  • GitHub@jon890↗
  • Source repositoryjon890/fos-study↗
  • RSS feed/rss.xml↗
  • Newsletter매주 1 회 · 한 편의 글→
© 2026 FOS Study. All posts MIT-licensed.
built with·Next.js·Tailwind v4·Geist·Pretendard·oklch
fos-blog/kafka/Spring Kafka 컨슈머 오프셋 커밋과…
kafka

Spring Kafka 컨슈머 오프셋 커밋과 트랜잭션 정렬: AckMode, manual ack, 멱등 처리

이 문서의 결론을 먼저 적으면 하나다. "DB 커밋"과 "Kafka 오프셋 커밋"은 원자적으로 묶을 수 없다. 그래서 순서를 DB 커밋 → 오프셋 커밋으로 고정해 at-least-once로 만들고, 중복 재처리는 컨슈머 멱등성으로 흡수한다. 이 한 줄을 코드와 실패 시나리오로 풀어내는 것이 목표다. 다루는 질문은 다음과 같다. 리스너가 정상 종료하면 오프셋...

2026.06.13·9 min read·171 views

이 글에서 답하는 질문

이 문서의 결론을 먼저 적으면 하나다.

"DB 커밋"과 "Kafka 오프셋 커밋"은 원자적으로 묶을 수 없다. 그래서 순서를 DB 커밋 → 오프셋 커밋으로 고정해 at-least-once로 만들고, 중복 재처리는 컨슈머 멱등성으로 흡수한다.

이 한 줄을 코드와 실패 시나리오로 풀어내는 것이 목표다. 다루는 질문은 다음과 같다.

  • 리스너가 정상 종료하면 오프셋은 언제, 어떤 AckMode 규칙으로 커밋되는가
  • @Transactional DB 커밋과 오프셋 커밋의 순서는 무엇이고, 그 순서가 왜 중요한가
  • manual ack는 왜 위험한가
  • 재전송된 중복 메시지를 unique 제약 위반으로 어떻게 흡수하는가
  • Kafka 트랜잭션과 DB 트랜잭션을 묶으려는 시도의 원자성 한계는 어디인가
  • processed_event 테이블 기반 idempotent consumer는 어떻게 구성하는가

이 글은 메시지를 소비하는 쪽(consumer listener)의 오프셋 커밋 메커니즘에 집중한다. DB 커밋 이후 Kafka로 발행하는 쪽의 정렬(afterCommit / Outbox)은 별도 문서에서 다룬다 (마지막 관련 문서 참고).

먼저 잡아야 할 사실: 오프셋 커밋은 "어디까지 읽었다"는 약속

Kafka 컨슈머는 메시지를 지우지 않는다. 대신 "이 파티션에서 N번 오프셋까지 처리했다"를 브로커(__consumer_offsets 토픽)에 커밋한다. 컨슈머가 죽고 다시 떠서 리밸런싱되면, 마지막으로 커밋된 오프셋 다음부터 다시 읽는다.

여기서 정합성의 모든 논점이 갈린다.

  • 처리가 끝나기 전에 오프셋을 커밋하면 → 처리 도중 죽었을 때 그 메시지는 다시 안 온다 → 유실(at-most-once).
  • 처리가 끝난 후에 오프셋을 커밋하면 → 커밋 직전에 죽으면 같은 메시지가 다시 온다 → 중복(at-least-once).

실무 기본값은 후자다. 유실보다 중복이 다루기 쉽기 때문이다. 중복은 멱등성으로 흡수할 수 있지만, 유실된 메시지는 되살릴 방법이 없다.

Spring Kafka AckMode와 오프셋 커밋 시점

Spring Kafka 컨테이너는 enable.auto.commit=false로 두고 컨테이너가 직접 오프셋을 커밋한다. "언제 커밋하느냐"를 결정하는 것이 ContainerProperties.AckMode다.

AckMode커밋 시점비고
RECORD레코드 1건 리스너 처리가 끝날 때마다가장 안전, 처리량은 낮음
BATCH (기본값)poll()로 가져온 배치 전체 처리가 끝난 뒤Spring Kafka 컨테이너 기본값
TIMEackTime 경과 후시간 기반
COUNTackCount 건 처리 후건수 기반
COUNT_TIMEackCount 또는 ackTime 중 먼저 도달혼합
MANUALAcknowledgment.acknowledge() 호출분을 모았다가 다음 poll() 때 커밋큐잉 후 배치 커밋
MANUAL_IMMEDIATEacknowledge() 호출 즉시 컨슈머 스레드에서 커밋즉시 커밋

핵심 오해 하나를 먼저 정리한다.

  • AckMode는 "리스너가 성공적으로 끝났을 때 오프셋을 어느 단위로 커밋할지"를 정하는 것이지, 처리 성공 여부를 바꾸지 않는다.
  • 리스너가 예외를 던지면 AckMode와 무관하게 그 레코드의 오프셋은 커밋되지 않고, 에러 핸들러 정책(재시도 / DLQ / seek)에 따라 다시 처리된다.

BATCH가 기본값이라는 점이 실무에서 자주 발을 건다. 배치 중 3번째 레코드에서 죽으면, 같은 배치의 1~2번째가 이미 DB에 반영됐더라도 오프셋은 배치 단위로만 커밋되므로 배치 전체가 재전송된다. 그래서 멱등성이 없으면 1~2번째가 중복 처리된다.

@Transactional DB 커밋과 오프셋 커밋의 순서

가장 흔한 구성은 리스너 메서드에 DB 트랜잭션만 거는 형태다.

java
@Component
@RequiredArgsConstructor
public class OrderEventConsumer {
 
    private final OrderRepository orderRepository;
 
    @KafkaListener(topics = "order-created", groupId = "order-projection")
    @Transactional // DataSourceTransactionManager (DB 전용)
    public void consume(OrderCreatedEvent event) {
        orderRepository.save(OrderProjection.from(event));
        // 메서드가 정상 반환되는 시점에 DB 커밋
    }
}

이때 실제 실행 순서는 다음과 같다.

  1. 컨테이너가 poll()로 레코드를 가져와 리스너를 호출한다.
  2. @Transactional 프록시가 DB 트랜잭션을 연다.
  3. 비즈니스 로직(save)이 실행된다.
  4. 리스너 메서드가 정상 반환되면 프록시 경계에서 DB 커밋이 일어난다.
  5. 제어가 컨테이너로 돌아오고, 컨테이너가 AckMode 규칙에 따라 Kafka 오프셋을 커밋한다.

즉 DB 커밋(4) → 오프셋 커밋(5) 순서가 보장된다. 이 순서가 정합성의 핵심이다.

  • DB 커밋이 먼저이므로, 오프셋 커밋 직전에 죽으면 오프셋이 안 올라간 상태로 재시작한다.
  • 재시작하면 같은 메시지를 다시 읽고, DB에는 이미 반영돼 있으므로 중복 처리가 된다 → at-least-once.
  • 이 중복을 멱등성으로 막으면 결과적으로 "정확히 한 번 처리된 것과 같은 효과"를 얻는다.

반대 순서(오프셋 먼저, DB 나중)는 절대 피해야 한다. enable.auto.commit=true로 두거나 처리 시작 시점에 ack를 호출하면 이 함정에 빠진다. 오프셋만 올라가고 DB가 롤백되면 메시지가 영영 사라진다.

manual ack는 왜 위험한가

AckMode.MANUAL / MANUAL_IMMEDIATE를 쓰면 리스너 시그니처에 Acknowledgment를 받아 직접 커밋을 호출한다.

java
@KafkaListener(topics = "order-created", groupId = "order-projection")
public void consume(OrderCreatedEvent event, Acknowledgment ack) {
    orderRepository.save(OrderProjection.from(event));
    ack.acknowledge(); // 이 호출 위치가 위험의 원천
}

제어권을 손에 쥐는 만큼 실수 지점도 늘어난다.

ack를 먼저 호출하고 뒤에서 실패

java
public void consume(OrderCreatedEvent event, Acknowledgment ack) {
    ack.acknowledge();              // ⚠️ 오프셋 먼저 커밋
    orderRepository.save(...);      // 여기서 예외 → 오프셋은 이미 올라감 → 메시지 유실
}

MANUAL_IMMEDIATE에서 특히 치명적이다. consumer thread에서 ack가 즉시 처리되면 그 뒤 실패한 작업은 재전송으로 복구되지 않는다.

리스너 메서드에 @Transactional을 붙였다는 이유만으로 안전해지지도 않는다. DB transaction은 메서드가 반환된 뒤 interceptor에서 commit되므로, 메서드 안의 ack.acknowledge()는 DB commit보다 먼저 실행된다. DB 작업이 핵심 부수 효과라면 리스너가 정상 반환된 뒤 container가 offset을 처리하는 RECORD나 BATCH가 더 단순하다.

ack를 빠뜨림

조건 분기에서 어떤 경로는 ack를 호출하고 어떤 경로는 빠뜨리면, 그 파티션의 오프셋이 영영 안 올라간다. 다음 리밸런싱 때 마지막 커밋 지점부터 다시 읽으므로 같은 구간을 무한 재처리하거나 lag가 계속 쌓인다.

다른 스레드에서 ack

@Async나 별도 executor로 처리를 넘긴 뒤 그 안에서 ack를 호출하면, 컨테이너 스레드는 이미 다음 poll()로 넘어가 있다. 오프셋 순서가 꼬이고, 컨테이너의 단일 스레드 모델이 깨진다. ack는 컨슈머 스레드(리스너 본문) 안에서 호출한다.

manual ack를 쓸 가치가 있을 때

대부분은 RECORD 또는 BATCH로 충분하다. manual ack는 커밋 단위를 직접 통제해야 하고 DB transaction 경계를 별도로 증명할 수 있을 때만 쓴다.

  • 배치 안에서 일부만 처리하고 나머지는 의도적으로 나중에 처리(seek)할 때
  • container가 지원하는 비동기 반환과 out-of-order commit 동작을 이해한 상태에서 비동기 처리를 사용할 때

manual ack는 호출 위치만 보고 안전성을 판단하지 않는다. ack가 실제 offset commit으로 반영되는 시점과 DB transaction commit 시점을 실패 시나리오로 검증한다.

Kafka 트랜잭션과 DB 트랜잭션의 원자성 한계

"그럼 DB 커밋과 오프셋 커밋을 하나의 트랜잭션으로 묶으면 되지 않나?"가 자연스러운 다음 질문이다. 결론은 저렴하게는 못 묶는다이다.

Kafka 트랜잭션이 보장하는 범위

Kafka 트랜잭션(producer.beginTransaction() / sendOffsetsToTransaction() / commitTransaction())은 consume-process-produce 패턴에서 입력 오프셋 커밋과 출력 메시지 발행을 원자적으로 묶는다. Kafka 안에서 읽고, 처리하고, 다시 Kafka로 쓰는 경로는 transaction을 지원하는 producer와 read_committed consumer를 함께 사용할 때 exactly-once 범위를 구성할 수 있다.

java
// 개념 예시: 입력 오프셋과 출력 발행을 하나의 Kafka 트랜잭션으로
producer.beginTransaction();
producer.send(outputRecord);
producer.sendOffsetsToTransaction(offsets, consumerGroupMetadata);
producer.commitTransaction();

문제는 DB가 Kafka와 다른 자원이라는 점이다. Kafka 트랜잭션은 DB INSERT를 함께 커밋하지 못한다.

두 자원을 묶으려는 시도와 그 한계

Spring Kafka에는 과거 ChainedKafkaTransactionManager로 DB 트랜잭션 매니저와 Kafka 트랜잭션 매니저를 연결하는 방법이 있었다. 하지만 이건 진짜 2단계 커밋(2PC)이 아니라 커밋을 순서대로 호출하는 동기화일 뿐이다.

  • 안쪽(예: DB)이 먼저 커밋되고, 그 다음 바깥(Kafka)이 커밋된다.
  • DB commit은 성공했는데 그 직후 Kafka commit이 실패하면 두 자원의 상태가 어긋난다.
  • 즉 커밋과 커밋 사이의 실패 창은 여전히 남는다. 원자성이 아니다.

ChainedKafkaTransactionManager는 Spring Kafka 2.7부터 deprecated이며 이후 제거 방향이다. 새 코드에서 되살리지 말고, 두 자원의 비원자성을 설계로 인정하는 쪽으로 간다.

그래서 현실적인 선택지

분산 transaction인 XA와 2PC는 운영 비용과 성능 부담이 크다. 대신 DB나 Kafka 중 하나를 기준 데이터로 택하고 다른 저장소의 복구 절차를 설계한다.

  • DB가 기준 데이터인 경우: 비즈니스 데이터와 발행할 메시지를 같은 DB transaction에 INSERT하고 별도 worker가 Kafka로 발행하는 Outbox 패턴을 사용한다.
  • Kafka가 기준 데이터인 경우: at-least-once로 소비하고 DB unique constraint와 멱등한 상태 전이로 중복을 흡수한다.

idempotent consumer와 processed_event 테이블

소비 측 중복 흡수의 표준은 "이 이벤트를 이미 처리했는가"를 DB unique 제약으로 판정하는 것이다.

스키마

sql
CREATE TABLE processed_event (
    event_id   VARCHAR(64) NOT NULL,
    consumer   VARCHAR(64) NOT NULL,   -- 같은 이벤트를 여러 컨슈머가 처리하면 그룹별로 구분
    created_at DATETIME(6) NOT NULL,
    PRIMARY KEY (event_id, consumer)
) ENGINE=InnoDB;
  • event_id는 메시지에 실린 고유 키다. 주문번호나 발행 측이 심은 UUID처럼 재전송돼도 같은 값이어야 한다. Kafka의 offset은 재전송 시 같지만 토픽/파티션 재구성에 취약하므로 비즈니스 레벨 ID를 쓰는 편이 안전하다.
  • consumer 컬럼으로 컨슈머 그룹별 처리 여부를 분리한다. 같은 이벤트를 projection용과 알림용이 각자 한 번씩 처리해야 하기 때문이다.

dedup INSERT와 비즈니스 로직을 같은 트랜잭션에 묶는다

java
@KafkaListener(topics = "order-created", groupId = "order-projection")
@Transactional
public void consume(OrderCreatedEvent event) {
    int inserted = processedEventRepository.insertIfAbsent(
        event.eventId(), "order-projection");
 
    if (inserted == 0) {
        return;
    }
 
    orderRepository.save(OrderProjection.from(event));
}

processed_event INSERT와 OrderProjection 저장이 하나의 DB 트랜잭션이라, 둘 다 커밋되거나 둘 다 롤백된다. insertIfAbsent는 DB에 맞는 충돌 무시 INSERT를 실행하고 영향받은 행 수를 반환한다. 재전송된 중복이면 0을 반환하므로 비즈니스 로직을 실행하지 않는다.

unique violation 처리에서 자주 틀리는 지점

애플리케이션에서 unique constraint exception을 잡는 방식에는 두 문제가 있다.

  • PostgreSQL은 constraint violation 뒤 현재 transaction을 abort 상태로 만든다.
  • JPA provider나 transaction interceptor가 transaction을 rollback-only로 표시할 수 있어 exception을 catch하고 반환해도 commit에 실패할 수 있다.

PostgreSQL의 INSERT ... ON CONFLICT DO NOTHING이나 MySQL의 INSERT IGNORE처럼 constraint violation을 exception으로 만들지 않는 SQL을 사용하고 영향받은 행 수로 분기하면 이 문제를 피할 수 있다.

Redis 1차 필터(선택)

DB transaction 전에 Redis SETNX를 사용해 중복 후보를 줄일 수는 있다. 하지만 Redis 기록 뒤 DB commit 전에 consumer가 중단되면 재전송된 이벤트를 Redis가 처리 완료로 오인할 수 있다. Redis 결과만 보고 처리를 건너뛰지 말고 DB의 처리 완료 기록을 다시 확인해야 한다. 정확성이 중요하면 추가 복잡도와 실제 중복률을 비교해 Redis 필터를 생략하는 편이 낫다.

at-least-once와 멱등으로 만드는 effectively-once

지금까지를 한 흐름으로 잇는다.

  1. 컨테이너가 메시지를 가져와 리스너 호출.
  2. 리스너는 @Transactional 안에서 processed_event INSERT(dedup)와 비즈니스 로직을 묶어 실행.
  3. 메서드 반환 시점에 DB 커밋.
  4. 컨테이너가 그 뒤 오프셋 커밋.
  5. 3과 4 사이에서 중단되면 메시지를 다시 읽는다. 2의 insertIfAbsent가 0을 반환하므로 비즈니스 로직을 다시 실행하지 않는다.

이 구조에서 정확히 한 번 "전달"은 보장하지 못해도, 정확히 한 번 "처리"한 것과 같은 효과(effectively-once)를 얻는다. 이것이 실무에서 Kafka 정합성을 다루는 기본 접근이다. Exactly-once delivery를 좇기보다 at-least-once와 멱등 설계로 간다.

체크리스트

  • 리스너에 DB 작업이 있을 때 DB 커밋 → 오프셋 커밋 순서가 보장되는가 (enable.auto.commit=false 확인)
  • AckMode가 의도와 맞는가 (기본 BATCH의 배치 단위 재전송 영향을 이해했는가)
  • @Transactional 리스너 안의 manual ack가 DB commit보다 먼저 실행될 수 있음을 검증했는가
  • 분기 경로마다 ack가 빠짐없이 호출되는가 (오프셋 정체로 무한 재처리되지 않는가)
  • ack를 컨슈머 스레드 안에서만 호출하는가 (@Async/별도 스레드에서 호출하지 않는가)
  • 컨슈머가 멱등한가 (processed_event 또는 동등한 dedup이 있는가)
  • dedup 키가 재전송에도 동일한 비즈니스 레벨 ID인가 (offset 의존이 아닌가)
  • 중복 INSERT가 exception으로 transaction을 rollback-only 상태로 만들지 않는가
  • dedup INSERT와 비즈니스 로직이 같은 DB 트랜잭션에 묶여 있는가
  • Kafka 트랜잭션과 DB 트랜잭션을 진짜 원자적으로 묶었다고 오해하고 있지 않은가

관련 문서

  • Kafka 실전 설계 — 파티션, 컨슈머 그룹, 전달 보장과 복제 설정
  • Spring 트랜잭션 전파·격리수준·AFTER_COMMIT 실전 — DB 커밋 이후 Kafka로 발행하는 쪽의 정렬
  • TransactionSynchronization 실전 — afterCommit 훅 커스터마이징
  • 분산 트랜잭션과 Outbox 패턴 — DB를 기준 데이터로 삼는 발행 원자성

참고 공식 문서

  • Spring Kafka — Message Listener Containers
  • Spring Kafka — Transactions
on this page
  • 01이 글에서 답하는 질문
  • 02먼저 잡아야 할 사실: 오프셋 커밋은 "어디까지 읽었다"는 약속
  • 03Spring Kafka AckMode와 오프셋 커밋 시점
  • 04`@Transactional` DB 커밋과 오프셋 커밋의 순서
  • 05manual ack는 왜 위험한가
  • ack를 먼저 호출하고 뒤에서 실패
  • ack를 빠뜨림
  • 다른 스레드에서 ack
  • manual ack를 쓸 가치가 있을 때
  • 06Kafka 트랜잭션과 DB 트랜잭션의 원자성 한계
  • Kafka 트랜잭션이 보장하는 범위
  • 두 자원을 묶으려는 시도와 그 한계
  • 그래서 현실적인 선택지
  • 07idempotent consumer와 `processed_event` 테이블
  • 스키마
  • dedup INSERT와 비즈니스 로직을 같은 트랜잭션에 묶는다
  • unique violation 처리에서 자주 틀리는 지점
  • Redis 1차 필터(선택)
  • 08at-least-once와 멱등으로 만드는 effectively-once
  • 09체크리스트
  • 10관련 문서
  • 11참고 공식 문서
tags
#study

이런 글도

  • kafka

    Kafka 클러스터 아키텍처: Broker, KRaft Controller와 복제

    2026.09.14
  • kafka

    Kafka를 로그로 이해하기: 복제, 재처리, 상태와 데이터 통합

    2026.09.14
  • kafka

    Kafka 파티션·리밸런스·컨슈머 지연 운영

    2026.07.21
  • kafka

    Kafka 실전 설계: 파티션 전략, 컨슈머 그룹, 전달 보장, 재시도, 순서 보장 트레이드오프

    2026.04.16

댓글 (0)