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

카테고리

  • AI 페이지로 이동
    • RAG 페이지로 이동
    • langgraph 페이지로 이동
    • agents.md
    • BMAD Method — AI 에이전트로 애자일 개발하는 방법론
    • Claude Code 메모리: CLAUDE.md와 .claude/rules를 규칙으로 쓰는 법
    • Claude Code의 Skill 시스템 - 개발자를 위한 AI 자동화의 새로운 차원
    • Claude Code를 5주 더 쓴 결과 — 스킬·CLAUDE.md를 키워가는 방식
    • Claude Code를 11일 동안 쓴 결과 — 데이터로 본 나의 사용 패턴
    • Claude Code 멀티 에이전트 — Teams
    • AI 에이전트와 디자인의 새 컨벤션 — DESIGN.md, Google Stitch, Claude Design
    • Docling — IBM Research 의 문서 파싱 toolkit 상세 정리
    • 하네스 엔지니어링 실전 — 4인 에이전트 팀으로 코딩 파이프라인 구축하기
    • 하네스 엔지니어링 — 오래 실행되는 AI 에이전트를 위한 설계
    • 멀티모달 LLM (Multimodal Large Language Model)
    • AI 에이전트와 함께 MVP 만들기 — dooray-cli 사례
    • OpenClaw는 context와 memory를 어떻게 관리하나 — 나만의 에이전트를 구성하는 법
    • OpenClaw vs Hermes Agent — 갈아탈까 고민하며 정리한 비교
    • 스킬 문서를 신경망처럼 학습시킨다 — Microsoft SkillOpt 분석
  • ai 페이지로 이동
    • agent 페이지로 이동
    • [초안] AI 제품 백엔드 안정성 — 지연·비용·권한·관측·도구 실패·폴백/재시도/사람 에스컬레이션
    • [초안] LLM 평가 프레임워크: 골든셋, 회귀 테스트, LLM-as-a-judge, 사람 피드백 루프
  • algorithm 페이지로 이동
    • live-coding 페이지로 이동
    • 분산 계산을 위한 알고리즘
  • apartment 페이지로 이동
    • 구리 럭키아파트 24평 인테리어 레퍼런스 모음
  • architecture 페이지로 이동
    • [초안] 시니어 백엔드를 위한 API 설계 실전 스터디 팩 — REST · 멱등성 · 페이지네이션 · 버전 전략
    • [초안] API Versioning과 Backward Compatibility: 시니어 백엔드 관점 정리
    • 캐시 설계 전략 총정리
    • [초안] 커머스 Spring 서비스에 Clean/Hexagonal Architecture를 실용적으로 적용하기
    • [초안] 커머스 도메인 모델링: 주문·재고·노출의 세 축을 분리해서 설계하기
    • 커머스 주문 상태와 데이터 정합성 기본기
    • [초안] 쿠폰/프로모션 동시성과 정합성 기본기 — 선착순·중복 사용 방지·발급/사용/복구
    • [초안] DDD와 도메인 모델링: 시니어 백엔드 관점의 전술/전략 패턴 실전 가이드
    • [초안] Decorator & Chain of Responsibility — 행동을 체인으로 조립하는 두 가지 방식
    • 디자인 패턴
    • [초안] 분산 아키텍처 완전 정복: Java 백엔드 시니어 인터뷰 대비 실전 가이드
    • [초안] 분산 트랜잭션과 Outbox 패턴 — 왜 2PC를 피하고 어떻게 대신할 것인가
    • 분산 트랜잭션
    • [초안] e-Commerce 주문·결제 도메인 모델링: 상태머신, 멱등성, Outbox/Saga 실전 정리
    • [초안] Event Sourcing과 CQRS — 상태가 아니라 변화를 저장한다는 발상
    • [초안] F&B 쿠폰·프로모션·멤버십·포인트 설계
    • [초안] F&B · e-Commerce 디지털 채널 도메인 한 장 정리
    • [초안] F&B 주문/매장/픽업 상태머신 설계
    • [초안] F&B 이커머스 결제·환불·정산 운영 가이드
    • [초안] Hexagonal / Clean Architecture를 Spring 백엔드에 적용하기
    • [초안] 대규모 커머스 트래픽 처리 패턴 — 대규모 회원과 메가 프로모션을 버티는 설계
    • [초안] 레거시 JSP/jQuery 화면과 신규 API가 공존하는 백엔드 운영 전략
    • [초안] MSA 서비스 간 통신: Redis [Cache-Aside](../database/redis/cache-aside.md) × Kafka 이벤트 하이브리드 설계
    • [초안] Observability 입문: 시니어 백엔드가 장애를 탐지하고 대응하는 방식
    • [초안] Outbox / Inbox Pattern 심화 — 분산 메시징의 정합성 문제를 DB 트랜잭션으로 풀어내기
    • [초안] 결제 도메인 멱등성과 트랜잭션 재시도 기본기
    • [초안] 시니어 백엔드를 위한 Resilience 패턴 실전 가이드 — Timeout, Retry, Circuit Breaker, Bulkhead, Backpressure
    • [초안] REST API 버저닝과 모바일 앱 하위 호환성 — 디지털 채널 백엔드 관점
    • [초안] Spring Batch vs Event-Driven — 같은 비동기처럼 보이지만 전혀 다른 두 패러다임
    • [초안] Strategy Pattern — 분기문을 없애는 설계, 시니어 백엔드 인터뷰 핵심 패턴
    • [초안] 시니어 백엔드를 위한 시스템 설계 입문 스터디 팩
    • [초안] 템플릿 메서드 패턴 - 백엔드 처리 골격을 강제하는 가장 오래되고 가장 위험한 패턴
    • [초안] 대규모 트래픽 중 무중단 마이그레이션 — Feature Flag + Shadow Mode 실전
  • database 페이지로 이동
    • mysql 페이지로 이동
    • opensearch 페이지로 이동
    • redis 페이지로 이동
    • 김영한의-실전-데이터베이스-설계 페이지로 이동
    • [초안] DB Connection Pool Saturation과 Thread Pool 격리
    • 커넥션 풀 크기는 얼마나 조정해야 할까?
    • 인덱스 - DB 성능 최적화의 핵심
    • [초안] JPA N+1과 커머스 조회 모델: 주문/메뉴/쿠폰 도메인에서 살아남기
    • [초안] MyBatis 기본기 — XML Mapper, resultMap, 동적 SQL, 운영 패턴 정리
    • [초안] MyBatis와 JPA/Hibernate 트레이드오프 — 레거시 백엔드를 다루는 시니어 관점
    • 역정규화 (Denormalization)
    • 데이터 베이스 정규화
  • devops 페이지로 이동
    • docker 페이지로 이동
    • k8s 페이지로 이동
    • k8s-in-action 페이지로 이동
    • observability 페이지로 이동
    • [초안] 커머스/F&B 채널 장애 첫 5분과 관측성 기본기
    • [초안] 운영 데이터 정합성 장애 대응 — 결제 취소 누락과 중복 적재 런북
    • Envoy Proxy
    • [초안] F&B / e-Commerce 운영 장애 대응과 모니터링 — 백엔드 관점 정리
    • Graceful Shutdown
    • [초안] 시니어 백엔드를 위한 SLO와 Error Budget 기반 장애 대응
  • finance 페이지로 이동
    • industry-cycle 페이지로 이동
    • investing 페이지로 이동
  • http 페이지로 이동
    • HTTP Connection Pool
    • HTTPS는 어떻게 안전한가 — TLS, 인증서, 그리고 termination
  • interview 페이지로 이동
    • [초안] AI 서비스 팀 경험 기반 시니어 백엔드 면접 질문 뱅크 — Spring Batch RAG / gRPC graceful shutdown / 전략 패턴 / 12일 AI 웹툰 MVP
    • Observability — 면접 답변 프레임
    • [초안] 시니어 Java 백엔드 면접 마스터 플레이북 — 김병태
    • [초안] NSC 슬롯팀 경험 기반 질문 은행 — 도메인 모델링·동시성·성능·AI 협업
  • java 페이지로 이동
    • concurrency 페이지로 이동
    • jdbc 페이지로 이동
    • opentelemetry 페이지로 이동
    • spring 페이지로 이동
    • spring-batch 페이지로 이동
    • 더_자바_코드를_조작하는_다양한_방법 페이지로 이동
    • [초안] Java 동시성 락 정리 — 커머스 메뉴/프로모션 정책 캐시 갱신 관점
    • [초안] JVM 튜닝 실전: 메모리 구조부터 Virtual Threads, GC 튜닝, 프로파일링까지
    • Java의 로깅 환경
    • MDC (Mapped Diagnostic Context)
    • Java StampedLock — 읽기 폭주에도 쓰기가 밀리지 않는 락
    • Virtual Thread와 Project Loom
  • javascript 페이지로 이동
    • typescript 페이지로 이동
    • AbortController
    • Async Iterator와 제너레이터
    • CommonJS와 ECMAScript Modules
    • 제너레이터(Generator)
    • Http Client
    • Node 백엔드 운영 패턴 — Streams 백프레셔, pipe/pipeline, 멱등성 vs 분산 락
    • Node.js
    • npm vs pnpm — 어떤 기준으로 선택했나
    • `setImmediate()`
  • kafka 페이지로 이동
    • [초안] Kafka 기본 개념 — 토픽, 파티션, 오프셋, 복제
    • Kafka를 사용하여 **데이터 정합성**은 어떻게 유지해야 할까?
    • [초안] Kafka 실전 설계: 파티션 전략, 컨슈머 그룹, 전달 보장, 재시도, 순서 보장 트레이드오프
    • 메시지 전송 신뢰성
    • [초안] Spring Kafka 컨슈머 오프셋 커밋과 트랜잭션 정렬: AckMode, manual ack, 멱등 처리
  • linux 페이지로 이동
    • fsync — 리눅스 파일 동기화 시스템 콜
    • tmux — Terminal Multiplexer
  • mlops 페이지로 이동
    • Python CUDA 버전 생태계 — nvidia-smi, nvcc, pip, conda가 다 다른 버전을 말하는 이유
    • GPU 컨테이너의 CUDA 버전 호환성 — nvidia-smi부터 이미지 다이어트까지
    • Kubernetes GPU 노드에서 /run tmpfs가 꽉 차서 Pod가 안 뜰 때
    • GPU·CUDA·MPS 기초 — 자바 백엔드 개발자가 처음 만나는 그림
    • Multi-process GPU 워크로드 — 자바 ThreadPool 사용자가 만나는 모델 차이
    • ML 서비스 성능 분석 워크플로 — 자바 백엔드 트러블슈팅과 다른 점
  • network 페이지로 이동
    • L2(스위치)와 L3(라우터)의 역할 차이
    • L4와 VIP(Virtual IP Address)
    • IP Subnet
  • python 페이지로 이동
    • Python async/await — CompletableFuture·Reactor 와 다른 점, 그리고 blocking I/O 함정
    • Python 의존성 관리 — Java Maven/Gradle 사용자가 만나는 첫 충격
    • FastAPI 기초 — Spring Boot 사용자가 빠르게 익히는 법
    • Java 개발자를 위한 Python 심화 — OOP·데코레이터·컨텍스트 매니저
    • PyTorch 기초 — 텐서, 디바이스, 그리고 모델 로딩이 무거운 이유
    • Java 개발자를 위한 Python 문법 핵심
    • OCR 동작 원리 — Layout · Text · Post-process 3단계
    • Python 서버의 RSS 가 안 줄어드는 이유 — gc.collect 의 한계와 malloc_trim
  • rabbitmq 페이지로 이동
    • [초안] RabbitMQ Basics — 실전 백엔드 관점에서 정리하는 메시지 브로커 기본기
    • [초안] RabbitMQ vs Kafka — 백엔드 메시징 선택 기준과 실전 운영 관점
  • security 페이지로 이동
    • [초안] 시니어 백엔드를 위한 보안 / 인증 스터디 팩 — Spring Security, JWT, OAuth2, OWASP Top 10
    • [초안] Spring Security 6.x OAuth2 + JWT 상용 인증 설계 — Grant 선택, Resource Server, Refresh Rotation, 로그아웃
  • task 페이지로 이동
    • ai-service-team 페이지로 이동
    • nsc-slot 페이지로 이동
    • sb-dev-team 페이지로 이동
    • the-future-company 페이지로 이동
  • testing 페이지로 이동
    • [초안] 시니어 Java 백엔드를 위한 테스트 전략 완전 정리 — 피라미드부터 TestContainers, 마이크로벤치, Contract까지
  • travel 페이지로 이동
    • 오사카 3박 4일 일정표: 우메다 쇼핑, USJ, 난바·도톤보리, 오사카성
  • web 페이지로 이동
    • [초안] HTTP / Cookie / Session / Token 인증 기본기 — 레거시 JSP와 모바일 API가 공존하는 백엔드 관점
FOS-BLOG · FOOTERall systems normal·v0.1 · 2026.04.27·seoul, kr
Ffos-blog/study

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

visitors
01site
  • Home↗
  • Posts↗
  • Categories↗
  • 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 컨슈머 오프…
system

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

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

2026.06.13·10 min read·9 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에서 특히 치명적이다. ack가 즉시 커밋되므로, 그 뒤 실패한 작업은 재전송으로 복구되지 않는다. ack는 모든 부수 효과(특히 DB 커밋)가 끝난 뒤 호출해야 한다.

ack를 빠뜨림

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

다른 스레드에서 ack

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

manual ack를 쓸 가치가 있을 때

대부분은 RECORD 또는 BATCH로 충분하다. manual ack는 다음처럼 커밋 단위를 비즈니스 단위로 직접 통제해야 할 때만 쓴다.

  • 배치 안에서 일부만 처리하고 나머지는 의도적으로 나중에 처리(seek)할 때
  • 외부 시스템 응답을 받은 뒤에만 커밋해야 하는 비동기 파이프라인

이 경우에도 "성공 부수 효과 완료 → ack" 순서를 기계적으로 지킨다.

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

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

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

Kafka 트랜잭션(producer.beginTransaction() / sendOffsetsToTransaction() / commitTransaction())은 consume-process-produce 패턴에서 "입력 오프셋 커밋 + 출력 메시지 발행"을 원자적으로 묶는다. 즉 Kafka 안에서 읽고-처리하고-다시 Kafka로 쓰는 경로는 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 커밋은 성공했는데 그 직후 Kafka 커밋이 실패하면 — 두 자원의 상태가 어긋난다.
  • 즉 커밋과 커밋 사이의 실패 창은 여전히 남는다. 원자성이 아니다.

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

그래서 현실적인 선택지

진짜 분산 트랜잭션(XA/2PC)은 운영 비용과 성능 부담이 커서 대부분 피한다. 대신 둘 중 하나를 단일 진실원으로 택한다.

  • DB를 진실원으로 → Outbox 패턴. 비즈니스 데이터와 발행할 메시지를 같은 DB 트랜잭션에 INSERT하고, 별도 워커가 Kafka로 발행. (발행 측 정렬)
  • Kafka를 진실원으로 → idempotent consumer. at-least-once로 받고, DB 쪽에서 중복을 흡수. (소비 측 정렬, 이 글의 주제)

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) {
    try {
        processedEventRepository.saveAndFlush(
            new ProcessedEvent(event.eventId(), "order-projection"));
    } catch (DataIntegrityViolationException e) {
        // unique 제약 위반 = 이미 처리한 이벤트 → 비즈니스 로직 건너뜀
        log.info("중복 이벤트 스킵: {}", event.eventId());
        return;
    }
    // 여기까지 왔다는 건 이 이벤트를 처음 본다는 뜻
    orderRepository.save(OrderProjection.from(event));
}

processed_event INSERT와 OrderProjection 저장이 하나의 DB 트랜잭션이라, 둘 다 커밋되거나 둘 다 롤백된다. 재전송된 중복은 INSERT 단계에서 unique 위반으로 걸러지고, 비즈니스 로직은 실행되지 않는다.

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

unique 제약 위반을 catch할 때 주의할 함정이 둘 있다.

  • 트랜잭션 오염: 일부 DB(특히 PostgreSQL)는 제약 위반이 발생하면 현재 트랜잭션을 abort 상태로 만든다. 위반을 catch한 뒤 같은 트랜잭션에서 다른 쿼리를 이어가면 실패한다. 그래서 dedup INSERT는 트랜잭션의 가장 앞에 두고, 위반이면 곧장 return해 트랜잭션을 깨끗하게 종료시킨다.
  • saveAndFlush로 즉시 반영: JPA에서 그냥 save만 하면 flush가 커밋 시점까지 지연돼 위반을 그 자리에서 못 잡는다. saveAndFlush로 INSERT를 즉시 DB에 보내 위반을 리스너 본문에서 catch한다.

DB 종류에 따라 INSERT ... ON CONFLICT DO NOTHING(PostgreSQL)이나 INSERT IGNORE(MySQL)로 처리한 뒤 영향받은 행 수(0이면 중복)로 분기하는 방식도 깔끔하다. 트랜잭션 오염 문제를 피할 수 있어 선호되기도 한다.

Redis 1차 필터(선택)

매번 DB를 때리는 비용이 부담이면, DB 트랜잭션 진입 전에 Redis SETNX로 1차 필터링한 뒤 DB unique 제약을 최종 방어선으로 둔다. Redis는 캐시일 뿐이라 정합성의 최종 책임은 DB 제약에 있어야 한다.

전체 그림 — at-least-once + 멱등으로 만드는 effectively-once

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

  1. 컨테이너가 메시지를 가져와 리스너 호출.
  2. 리스너는 @Transactional 안에서 processed_event INSERT(dedup) + 비즈니스 로직을 묶어 실행.
  3. 메서드 반환 시점에 DB 커밋.
  4. 컨테이너가 그 뒤 오프셋 커밋.
  5. 3과 4 사이에서 죽으면 메시지 재전송 → 2의 dedup INSERT가 unique 위반으로 걸러냄 → 비즈니스 로직 미실행.

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

면접 답변 프레임

Q. 리스너가 정상 종료하면 오프셋은 언제 커밋되나요?

Spring Kafka는 enable.auto.commit=false로 두고 컨테이너가 직접 커밋합니다. AckMode 기본값이 BATCH라 한 poll() 배치를 다 처리한 뒤 커밋되고, RECORD로 두면 레코드마다 커밋됩니다. 중요한 건 리스너가 예외 없이 반환된 뒤에 커밋된다는 점이고, 그래서 DB 작업이 함께 있으면 DB 커밋이 오프셋 커밋보다 먼저 일어납니다.

Q. DB 커밋과 오프셋 커밋 순서가 왜 중요한가요?

DB 커밋 → 오프셋 커밋 순서라야 at-least-once가 됩니다. 둘 사이에서 죽으면 오프셋이 안 올라가 메시지가 재전송되고, DB에는 이미 반영됐으니 중복 처리가 됩니다. 반대 순서면 오프셋만 올라가고 DB가 롤백돼 메시지를 잃습니다. 유실보다 중복이 다루기 쉬우니 전자를 택하고 중복은 멱등성으로 흡수합니다.

Q. Kafka 트랜잭션으로 DB까지 원자적으로 묶을 수 있나요?

Kafka 트랜잭션은 consume-process-produce, 즉 Kafka 안에서 읽고 다시 Kafka로 쓰는 경로의 오프셋 커밋과 발행을 묶어줍니다. 하지만 DB는 다른 자원이라 함께 못 묶습니다. ChainedKafkaTransactionManager로 순서대로 커밋할 수는 있었지만 2PC가 아니라 커밋 사이 실패 창이 남고, 지금은 deprecated입니다. 그래서 DB를 진실원으로 하는 Outbox나, Kafka를 진실원으로 하는 idempotent consumer 중 하나로 설계합니다.

Q. 중복 메시지는 어떻게 막나요?

processed_event 테이블에 이벤트 고유 ID를 unique 제약으로 두고, dedup INSERT와 비즈니스 로직을 같은 DB 트랜잭션에 넣습니다. 재전송된 중복은 INSERT에서 unique 위반으로 걸러지고 비즈니스 로직은 건너뜁니다. 위반 catch 시 트랜잭션 오염을 피하려고 dedup INSERT를 트랜잭션 맨 앞에 두거나, ON CONFLICT DO NOTHING 같은 구문으로 처리합니다.

체크리스트

  • 리스너에 DB 작업이 있을 때 DB 커밋 → 오프셋 커밋 순서가 보장되는가 (enable.auto.commit=false 확인)
  • AckMode가 의도와 맞는가 (기본 BATCH의 배치 단위 재전송 영향을 이해했는가)
  • manual ack를 쓴다면 모든 부수 효과 완료 후에만 acknowledge()를 호출하는가
  • 분기 경로마다 ack가 빠짐없이 호출되는가 (오프셋 정체로 무한 재처리되지 않는가)
  • ack를 컨슈머 스레드 안에서만 호출하는가 (@Async/별도 스레드에서 호출하지 않는가)
  • 컨슈머가 멱등한가 (processed_event 또는 동등한 dedup이 있는가)
  • dedup 키가 재전송에도 동일한 비즈니스 레벨 ID인가 (offset 의존이 아닌가)
  • unique 위반 catch가 트랜잭션을 오염시키지 않는가 (dedup INSERT를 앞단에 두거나 ON CONFLICT 사용)
  • dedup INSERT와 비즈니스 로직이 같은 DB 트랜잭션에 묶여 있는가
  • Kafka 트랜잭션과 DB 트랜잭션을 진짜 원자적으로 묶었다고 오해하고 있지 않은가

관련 문서

  • 메시지 전달 신뢰성 — at-most/least/exactly-once 의미와 컨슈머 멱등 전략
  • Kafka 데이터 정합성 설계 — 멱등 프로듀서, exactly-once, min.insync.replicas
  • 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차 필터(선택)
  • 08전체 그림 — at-least-once + 멱등으로 만드는 effectively-once
  • 09면접 답변 프레임
  • 10체크리스트
  • 11관련 문서
  • 12참고 공식 문서

이런 글도

  • [초안] Kafka 실전 설계: 파티션 전략, 컨슈머 그룹, 전달 보장, 재시도, 순서 보장 트레이드오프
    Kafka를 "메시지 큐로 쓴다"는 말은 맞지만, 그것만으로는 시니어 면접을 통과할 수 없다. 면접관이 묻고 싶은 것은 "파티션을 몇 개로 잡았고 왜 그랬나", "컨슈머가 죽었을 때 리밸런싱은 어떻게 되나", "결제 이벤트인데 순서가 바뀌면 어떻게 처리했나", "메시지 유실은 허용 가능한 도메인인가" 같은 설계 판단이다. 이 문서는 Kafka의 내부 동작을...
    📨 system
    system
    2026.04.16
  • [초안] Kafka 기본 개념 — 토픽, 파티션, 오프셋, 복제
    Kafka 글을 여러 편 정리하다 보니 "기본 개념을 한 번 모아서 짚는 문서"가 빠져 있었다. 이 글은 토픽·파티션·오프셋·복제(Leader/Follower/ISR)에 한해서 입문 수준으로만 정리한다. 파티션 키 전략, 컨슈머 그룹 리밸런싱, 메시지 전달 보장(at-least-once 등) 같은 운영·설계 영역은 별도 문서에서 다룬다. - 파티션 수 결정...
    📨 system
    system
    2026.01.30
  • Kafka를 사용하여 **데이터 정합성**은 어떻게 유지해야 할까?
    - 메시징 시스템에서 '정확히 한 번 (Exactly-once)'을 보장하기 위한 전략들과 설정들을 살펴보자. 데이터 유실은 보통 Producer가 메시지를 보냈으나 Broker에 안전하게 저장되지 않았을 떄 발생한다. - acks=all (또는 -1) : 리더 파티션뿐만 아니라 min.insync.replicas에 설정된 모든 복제본이 메시지를 받았는지...
    📨 system
    system
    2026.01.30
  • 메시지 전송 신뢰성
    - 메시지 큐 시스템에서 가장 중요한 개념 중 하나인 전송 신뢰성에 대해 알아보자. - Kafka는 네트워크 오류나 서버 장애 등 다양한 변수 속에서 "메시지를 얼마나 확실하게 보낼 것인가"를 세 가지 단계로 정의한다. 메시지가 유실될 수는 있지만, 중복되지는 않는 방식 - 동작 원리 : 프로듀서가 메시지를 보낸 후 브로커의 응답(ack)를 기다리지 않거나...
    📨 system
    system
    2026.01.30

댓글 (0)