ClOr

ClOr

백엔드 실무 트러블슈팅과 AI 에이전트 구조 분석을 기록합니다.

Claude Code 해부학 (완결)

51만 줄 소스코드를 19편에 걸쳐 분석한 완결 시리즈

전체 시리즈 보기 →

백엔드 트러블슈팅

실무에서 겪은 장애와 해결 과정 기록

전체 시리즈 보기 →

최신 글

article thumbnail

Kafka 트랜잭션을 도입하고 나면 "이제 Exactly-Once니까 안심"이라고 생각하기 쉽다. 그런데 운영 환경에서는 설정 하나 빠졌을 뿐인데 트랜잭션이 통째로 abort되는 일이 반복된다. 이 글에서는 Kafka 3.x 기준으로 동기화 트랜잭션에서 실제로 자주 발생하는 장애 5가지를 정리한다.

목차

  • 결론부터 — 5가지 장애 한 줄 요약
  • 장애 1: 트랜잭션 타임아웃
    • 증상
    • 원인
    • 해결
  • 장애 2: Exactly-Once 설정 불일치
    • 증상
    • 원인
    • 해결
  • 장애 3: Consumer-Producer 패턴에서 오프셋 커밋 누락
    • 증상
    • 원인
    • 해결
  • 장애 4: 브로커 리밸런싱 중 트랜잭션 abort
    • 증상
    • 원인
    • 해결
  • 장애 5: Dead Letter Queue 미설계로 인한 무한 재시도
    • 증상
    • 원인
    • 해결
  • 체크리스트: 트랜잭션 설정 점검 항목
  • 요약

결론부터 — 5가지 장애 한 줄 요약

# 장애 핵심 원인
1 트랜잭션 타임아웃 transaction.timeout.ms보다 처리 시간이 긴 경우
2 Exactly-Once 설정 불일치 idempotence와 transactional.id 조합 오류
3 오프셋 커밋 누락 Consumer-Producer 패턴에서 sendOffsetsToTransaction 빠짐
4 리밸런싱 중 abort 브로커 파티션 재할당 타이밍과 트랜잭션 충돌
5 무한 재시도 DLQ 없이 실패 메시지가 계속 재처리됨

하나씩 증상, 원인, 해결 방법을 본다.


장애 1: 트랜잭션 타임아웃

증상

운영 로그에 이런 메시지가 뜬다.

org.apache.kafka.common.errors.TimeoutException:
  Timeout expired while initializing transactional state in 60000ms

트랜잭션이 시작은 되는데 커밋 전에 abort된다. 특히 새벽 배치 처리나 대량 동기화 작업에서 간헐적으로 발생한다.

원인

transaction.timeout.ms의 기본값은 60초다. 그런데 동기화 로직이 외부 API 호출이나 DB 쿼리를 포함하면 60초를 쉽게 넘긴다. 브로커 쪽 transaction.max.timeout.ms(기본 15분)보다 큰 값을 설정하면 프로듀서 생성 자체가 실패하는 것도 함정이다.

해결

# Producer 설정
transaction.timeout.ms=120000

# Broker 설정 (server.properties)
transaction.max.timeout.ms=900000

단, 타임아웃을 무작정 늘리는 건 해결이 아니다. 트랜잭션 내부 처리를 잘게 쪼개서 한 트랜잭션당 처리량을 줄이는 게 근본적인 방법이다. 배치 사이즈를 500 이하로 제한하고, 외부 호출은 트랜잭션 바깥으로 빼는 걸 권장한다.


장애 2: Exactly-Once 설정 불일치

증상

프로듀서 초기화 시점에 바로 예외가 터진다.

org.apache.kafka.common.errors.InvalidConfigurationException:
  Must set enable.idempotence to true when using transactional.id

또는 설정은 통과했는데 중복 메시지가 계속 발생한다.

원인

Exactly-Once를 위해서는 세 가지가 동시에 맞아야 한다.

# 이 세 가지가 세트다
enable.idempotence=true
transactional.id=my-sync-producer-001
acks=all

흔한 실수 패턴:

  • acks=1로 두고 transactional.id만 설정 — 트랜잭션은 동작하지만 중복 방지가 안 됨
  • 여러 인스턴스가 동일한 transactional.id를 사용 — 먼저 등록한 프로듀서가 펜싱(fencing)되어 죽음
  • 스케일아웃 시 transactional.id를 동적 생성하면서 좀비 프로듀서 발생

해결

// transactional.id는 인스턴스별로 고유하게
String txId = "sync-producer-" + partitionId;
props.put(ProducerConfig.TRANSACTIONAL_ID_CONFIG, txId);
props.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true);
props.put(ProducerConfig.ACKS_CONFIG, "all");

인스턴스 수가 유동적이라면 파티션 번호 기반으로 transactional.id를 할당하는 게 가장 안전하다. Kubernetes 환경이면 StatefulSet의 pod ordinal을 활용한다.


장애 3: Consumer-Producer 패턴에서 오프셋 커밋 누락

증상

동기화 처리가 정상 완료된 것 같은데, 애플리케이션 재시작 후 같은 메시지를 또 처리한다. 로그에는 에러가 없다. 데이터가 중복으로 쌓이면서 뒤늦게 발견된다.

원인

Consume-Transform-Produce 패턴에서 sendOffsetsToTransaction을 빠뜨리면, 메시지 발행은 트랜잭션으로 보호되지만 오프셋 커밋은 트랜잭션 밖에서 처리된다. 트랜잭션이 abort되면 메시지는 롤백되는데 오프셋은 이미 커밋된 상태가 된다. 반대로 오프셋 커밋 자체가 누락되면 재처리가 발생한다.

해결

producer.beginTransaction();
try {
    // 메시지 발행
    producer.send(new ProducerRecord<>(outputTopic, key, value));

    // 반드시 트랜잭션 안에서 오프셋 커밋
    Map<TopicPartition, OffsetAndMetadata> offsets = new HashMap<>();
    offsets.put(
        new TopicPartition(inputTopic, partition),
        new OffsetAndMetadata(offset + 1)
    );
    producer.sendOffsetsToTransaction(offsets, consumerGroupId);

    producer.commitTransaction();
} catch (Exception e) {
    producer.abortTransaction();
    throw e;
}

Consumer 쪽에서는 enable.auto.commit=false가 필수다. auto commit이 켜져 있으면 트랜잭션과 별개로 오프셋이 커밋되어 정합성이 깨진다.

# Consumer 설정
enable.auto.commit=false
isolation.level=read_committed

장애 4: 브로커 리밸런싱 중 트랜잭션 abort

증상

특정 시간대에 트랜잭션 abort 비율이 급증한다. 브로커 롤링 업데이트나 파티션 재할당 직후에 집중된다.

org.apache.kafka.common.errors.ProducerFencedException:
  Producer attempted an operation with an old epoch

원인

브로커가 리밸런싱되면 트랜잭션 코디네이터가 다른 브로커로 이동한다. 이 과정에서 진행 중이던 트랜잭션은 abort 처리된다. Kafka 3.x에서는 이 동작이 정상이지만, 재시도 로직이 없으면 데이터 유실로 이어진다.

또 다른 원인은 max.poll.interval.ms 초과다. Consumer가 처리에 시간을 너무 쓰면 그룹에서 제외되고, 파티션 재할당이 트리거되면서 연쇄적으로 트랜잭션이 abort된다.

해결

# Consumer 설정 — 처리 시간에 맞게 여유 확보
max.poll.interval.ms=600000
max.poll.records=100

# Producer 설정 — 재시도 활성화
retries=3
retry.backoff.ms=1000

재시도 로직은 애플리케이션 레벨에서도 감싸야 한다.

int maxRetries = 3;
for (int attempt = 0; attempt < maxRetries; attempt++) {
    try {
        producer.beginTransaction();
        // ... 처리 로직
        producer.commitTransaction();
        break;
    } catch (ProducerFencedException e) {
        // 펜싱된 프로듀서는 재사용 불가 — 새로 생성
        producer.close();
        producer = createNewProducer();
    } catch (KafkaException e) {
        producer.abortTransaction();
        if (attempt == maxRetries - 1) throw e;
    }
}

ProducerFencedException이 뜨면 해당 프로듀서 인스턴스는 재사용할 수 없다. 반드시 새로 생성해야 한다.


장애 5: Dead Letter Queue 미설계로 인한 무한 재시도

증상

특정 메시지에서 계속 예외가 발생하고, 해당 파티션의 처리가 완전히 멈춘다. Consumer lag이 끝없이 증가한다. 로그에는 같은 에러가 수천 번 반복된다.

원인

역직렬화 실패, 스키마 불일치, 비즈니스 로직 예외 등 재시도해도 절대 성공할 수 없는 메시지가 있다. DLQ(Dead Letter Queue)가 없으면 이 메시지가 파티션을 영구적으로 블로킹한다.

해결

트랜잭션 내에서 처리 불가능한 메시지는 DLQ 토픽으로 보내고 넘어간다.

producer.beginTransaction();
try {
    ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(1000));
    for (ConsumerRecord<String, String> record : records) {
        try {
            String result = processRecord(record);
            producer.send(new ProducerRecord<>(outputTopic, record.key(), result));
        } catch (NonRetryableException e) {
            // DLQ로 전송 — 원본 메시지 + 에러 정보
            Headers headers = new RecordHeaders();
            headers.add("error-reason", e.getMessage().getBytes());
            headers.add("original-topic", record.topic().getBytes());
            producer.send(new ProducerRecord<>(
                "sync-dlq", null, record.key(), record.value(), headers
            ));
        }
    }
    producer.sendOffsetsToTransaction(getOffsets(records), consumerGroupId);
    producer.commitTransaction();
} catch (Exception e) {
    producer.abortTransaction();
}

DLQ 토픽은 retention을 넉넉하게(7일 이상) 잡고, 별도 모니터링 컨슈머로 알림을 붙여야 한다. DLQ에 메시지가 쌓이는데 아무도 모르면 의미가 없다.


체크리스트: 트랜잭션 설정 점검 항목

배포 전에 아래 항목을 점검한다.

Producer

  • enable.idempotence=true 설정됨
  • transactional.id가 인스턴스별로 고유함
  • acks=all 설정됨
  • transaction.timeout.ms가 실제 처리 시간보다 넉넉함

Consumer

  • enable.auto.commit=false 설정됨
  • isolation.level=read_committed 설정됨
  • max.poll.interval.ms가 최대 처리 시간보다 큼
  • max.poll.records로 한 번에 가져오는 양 제한됨

아키텍처

  • sendOffsetsToTransaction 호출이 트랜잭션 안에 포함됨
  • DLQ 토픽이 생성되어 있고, 모니터링이 붙어 있음
  • ProducerFencedException 발생 시 프로듀서 재생성 로직 존재
  • 브로커 롤링 업데이트 시 트랜잭션 abort 재시도 로직 존재

요약

  • 트랜잭션 타임아웃은 배치 사이즈를 줄이고, 외부 호출은 트랜잭션 바깥으로 분리한다
  • Exactly-Once는 idempotence + transactional.id + acks=all 세트가 맞아야 동작한다
  • Consume-Transform-Produce 패턴에서 sendOffsetsToTransaction 누락은 중복 처리의 주범이다
  • 리밸런싱 중 abort는 정상 동작이므로, 애플리케이션 레벨 재시도 로직이 필수다
  • DLQ 없는 트랜잭션은 하나의 poison pill 메시지로 전체 파이프라인이 멈출 수 있다

관련글:

profile

ClOr

@ClOr

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!

ClOr · 백엔드 트러블슈팅과 AI 에이전트 구조 분석을 기록합니다.