Kafka DLT 기반 장애 처리 설계 트러블슈팅

Simple·2026년 3월 30일

트러블슈팅

목록 보기
14/15

🔴 문제 상황

LLM 기반 주식 분석 서비스에서 Kafka를 도입하여 분석 요청을 비동기로 처리하는 구조를 설계했다.
DeadLetterPublishingRecovererDefaultErrorHandler를 통해 실패한 메시지를 DLT(Dead Letter Topic)로 라우팅하고, 스케줄러 기반 재시도 시스템을 구축했다.

그러나 운영 중 LLM API 또는 KIS API 호출이 실패해도 분석 요청이 자동으로 재처리되지 않는 현상이 발생했다.
DB를 확인하면 AnalysisJob의 상태가 FAILED로 남아있을 뿐, FailedAnalysisRequest 테이블에는 아무런 레코드도 쌓이지 않았다.


🔍 원인 분석

원인을 추적하자 두 가지 설계 문제가 발견됐다.

문제 1. ACK 시점 오류 — 분석 완료 전 선(先) ACK

runFullAnalysis()@Async로 선언되어 호출 즉시 반환한다.
Consumer는 이 반환을 분석 완료로 착각하고 즉시 ack.acknowledge()를 호출했다.
서버가 분석 도중 재시작되면 Kafka는 해당 메시지를 이미 처리 완료로 간주하여 재배달하지 않았고, 분석 결과도 없이 메시지가 유실됐다.

// 🚨 문제 코드
analysisService.runFullAnalysis(analysisId); // @Async → 즉시 리턴
ack.acknowledge();                            // 분석이 끝나지 않았는데 ACK

문제 2. 예외 삼킴 — DLT 파이프라인 미작동

DefaultErrorHandler@KafkaListener 메서드 밖으로 예외가 전파될 때만 동작한다.
그런데 runFullAnalysis() 내부에서 모든 예외를 catch하여 FAILED 상태만 저장하고 예외를 삼켰다.
Kafka 입장에서는 분석 실패 여부와 무관하게 정상 처리된 메시지로 인식됐고,
DefaultErrorHandler → DLT → DltRetryService로 이어지는 재시도 파이프라인이 전혀 작동하지 않았다.

// 🚨 문제 코드
} catch (Exception e) {
    jobStore.save(FAILED);
    // 예외를 삼킴 → Kafka는 정상 처리로 인식 → DLT 미작동
}
return CompletableFuture.completedFuture(null);

결과적으로 JSON 파싱 오류나 saveJob() 실패처럼 동기 영역의 예외만 DLT를 탔고,
핵심 장애 시나리오인 LLM·KIS API 실패는 DLT 파이프라인을 우회하고 있었다.


✅ 해결

두 문제의 근본 원인은 @Async로 인한 Consumer thread와 분석 작업의 생명주기 분리였다.
이를 해소하기 위해 Consumer를 동기화하는 방향으로 설계를 변경했다.

1. @Async 제거 — Consumer thread에서 직접 실행

@Async를 제거하고 Consumer thread가 분석 완료까지 직접 대기하도록 변경했다.
내부의 10개 종목 병렬 LLM 호출은 여전히 CompletableFuture.supplyAsync(..., llmApiExecutor)로 처리되므로
병렬 처리 성능은 그대로 유지된다.

2. 예외 re-throw — DefaultErrorHandler 활성화

runFullAnalysis() catch 블록에서 FAILED 상태 저장 후 예외를 다시 던지도록 수정했다.
이로써 분석 실패가 Consumer 밖으로 전파되어 DefaultErrorHandler가 이를 감지하게 됐다.

// ✅ 수정 후
} catch (Exception e) {
    jobStore.save(FAILED);
    throw new RuntimeException("분석 실패: " + analysisId, e); // re-throw
}

3. ACK 위치 이동 — 분석 완료 후 ACK

동기 호출로 전환됨에 따라 whenComplete 콜백을 제거하고,
runFullAnalysis() 정상 반환 후 ACK를 호출하도록 단순화했다.
실패 시에는 예외가 throw되어 ACK에 도달하지 않는다.

// ✅ 수정 후
analysisService.runFullAnalysis(analysisId); // 완료까지 대기, 실패 시 throw
ack.acknowledge();                            // 정상 완료 시에만 도달

전체 장애 처리 흐름

분석 실패 → RuntimeException throw
  → DefaultErrorHandler: 1초 간격 3회 재시도
  → 3회 모두 실패 → DeadLetterPublishingRecoverer → analysis-requests.DLT
  → DltMessageProcessor → FailedAnalysisRequest DB 저장
  → DltRetryService (5분 주기, 최대 3회) → analysis-requests 재발송

📊 결과

항목변경 전변경 후
LLM·KIS API 실패 처리DLT 파이프라인 우회DLT 통해 자동 재처리
서버 재시작 시 메시지유실 가능Kafka 재배달로 유실 없음
코드 구조@Async + ACK 분리로 복잡동기 흐름으로 단순화
분석 내부 병렬 처리llmApiExecutor 사용동일하게 유지
  • 모든 분석 장애가 동일한 DLT 파이프라인을 통해 자동 재처리되도록 통일됐다.
  • 서버 재시작 시 처리 중이던 메시지가 ACK되지 않아 Kafka가 재배달하므로 메시지 유실이 없어졌다.
  • @Async 제거로 코드 구조가 단순해졌고, DefaultErrorHandler의 재시도·DLT 라우팅이 설계 의도대로 동작하게 됐다.
  • 분석 내부 병렬 처리(llmApiExecutor)는 그대로 유지되어 처리 성능에는 변화가 없었다.
profile
몰입하는 개발자

0개의 댓글