TL; DR
- Kafka 트랜잭션은 같은 transactional.id에 단 하나의 producer만 허용한다.
비동기 처리나 재시도로 producer가 하나 더 생성되는 순간, 기존 트랜잭션은 fencing으로 즉시 죽는다.
1. 장애 상황
아침에 출근하고, 얼마 전 배포한 새벽 배치 결과를 확인했다.
십여 개의 배치 중 몇 개가 실패했다.
전부가 아니라 "일부만" 실패했다는 점이 이상했다.
스케줄러는 순차적으로 배치를 실행하고 있었고,
개발 환경에서도 문제 없었고, 며칠간 운영에서도 정상이었다.
에러는 두 가지였다
메타 테이블을 확인해보니 두 가지 예외가 있었다.
- 60초 commit timeout
- ProducerFencedException
처음엔 이해가 안 갔다.
작업은 5초면 끝나는데, 왜 60초 timeout이 나지?
처음에 했던 착각
처음엔 이렇게 생각했다.
- 배치 스레드가 먼저 끝나서 commit이 제대로 안 된 것 아닐까?
- 다음 배치가 빨리 시작돼서 충돌난 것 아닐까?
- 멀티 스레드 문제 아닐까?
결론부터 말하면 전부 틀렸다.
코드 구조는 단순했다
for (var keywordEvent : keywordEvents) {
kafkaTemplate.send(event);
}
트랜잭션은 전혀 신경 쓰지 않았고,
그냥 메시지를 비동기로 발행하는 코드였다.
그런데 왜 트랜잭션이 걸렸을까
KafkaTemplate이 트랜잭션 설정을 가지고 있었다.
이 설정이 있으면:
- 단건 send라도 내부적으로 beginTransaction → commitTransaction 수행된다
즉, 나는 트랜잭션을 쓰지 않는다고 생각했지만
이미 트랜잭션 안에서 실행되고 있었다.
이 시스템에서 벌어진 진짜 일
핵심은 단 하나다.
같은 transactional.id를 가진 producer가 두 개 존재했다
Kafka 트랜잭션의 절대 규칙
Kafka는 이렇게 동작한다.
transactional.id 당 producer는 하나만 살아있을 수 있다
두 번째 producer가 나타나는 순간:
- 기존 producer는 fencing 된다
- 트랜잭션은 더 이상 유효하지 않다
문제의 구조
이 시스템에는 다음 요소가 있었다.
- Kafka 트랜잭션 활성화
- 비동기 send
- retry 로직 (sendAsyncWithRetry)
- transactional.id 고정
이 조합은 구조적으로 충돌을 만든다.
실제 타임라인
1) Job A 시작
- producer A 생성 (tx1)
- 트랜잭션 시작
- 비동기 send 쌓임
2) retry 발생 (핵심)
- send 실패 또는 지연
- retry 로직 진입
- 다른 스레드 또는 트랜잭션 밖에서 실행
이 순간:
새로운 producer B 생성 (같은 transactional.id)
3) Kafka 판단
같은 transactional.id인데 producer가 바뀌었다
→ producer A 즉시 fencing
4) 결과
- A → commit 대기 중 실패 → timeout
- B → send 시점 → ProducerFencedException
왜 멀티 스레드처럼 보였나
겉으로 보면:
- Job A
- Job B
- 여러 스레드
문제처럼 보인다.
하지만 본질은 다르다.
스레드 문제가 아니라 트랜잭션 경계가 깨진 문제다
중요한 포인트 하나
Spring 트랜잭션은 ThreadLocal 기반이다.
즉:
- 같은 스레드 → 같은 트랜잭션
- 다른 스레드 → 트랜잭션 없음
retry가 다른 스레드에서 실행되면:
- 트랜잭션이 끊기고
- KafkaTemplate이 새 트랜잭션을 시작하고
- 새로운 producer가 생성된다
Timeout의 진짜 의미
많이 헷갈리는 부분이다.
timeout은 “느려서” 발생한 게 아니다
이미 트랜잭션이 fencing으로 깨진 상태에서
commit을 기다리다가 실패한 결과다.
내가 몰랐던 것들
1. KafkaTemplate은 자동으로 트랜잭션에 참여한다
@Transactional이 없어도 참여한다.
2. 트랜잭션 프로듀서는 항상 트랜잭션을 사용한다
단건 send도 예외 없다.
3. 트랜잭션은 스레드에 묶인다
다른 스레드 = 다른 트랜잭션
4. 가장 중요한 것
transactional.id는 공유 자원이 아니라 충돌 자원이다
해결 방법
단기
비동기 send 결과를 모두 기다린다.
느리지만 안전하다.
중기
retry 구조를 바꾼다.
- 트랜잭션 밖 retry 금지
- 비동기 retry 금지
장기
구조를 분리한다.
- transactional KafkaTemplate
- non-transactional KafkaTemplate
또는
- Outbox 패턴 적용
결론
Kafka 트랜잭션은 강력하다.
하지만 잘못 쓰면 조용히 터진다.
'Spring > 문제 해결 사례' 카테고리의 다른 글
| Spring Batch는 왜 내 MariaDB에 데드락을 뿌렸나 — SERIALIZABLE 기본값과 동시 실행의 함정 (0) | 2026.04.26 |
|---|
댓글