Spring/문제 해결 사례

Spring Kafka 트랜잭션 + 비동기/재시도 = 반드시 터진다 (Fencing & Timeout 실무 장애 분석)

Chipmunks 2026. 4. 18.
728x90

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 결과를 모두 기다린다.

 
방법 1 : kafkaTemplate.send(...).get();
 
방법 2: for (...) { allFutures.add( kafkaTemplate.send(...) ); } allFutures.join();
 

느리지만 안전하다.

 

중기

retry 구조를 바꾼다.

  • 트랜잭션 밖 retry 금지
  • 비동기 retry 금지

 

장기

구조를 분리한다.

  • transactional KafkaTemplate
  • non-transactional KafkaTemplate

또는

  • Outbox 패턴 적용

 

결론

Kafka 트랜잭션은 강력하다.

하지만 잘못 쓰면 조용히 터진다.

 

댓글