LLM 기반 주식 분석 서비스에서 Kafka를 도입하여 분석 요청을 비동기로 처리하는 구조를 설계했다.
DeadLetterPublishingRecoverer와 DefaultErrorHandler를 통해 실패한 메시지를 DLT(Dead Letter Topic)로 라우팅하고, 스케줄러 기반 재시도 시스템을 구축했다.
그러나 운영 중 LLM API 또는 KIS API 호출이 실패해도 분석 요청이 자동으로 재처리되지 않는 현상이 발생했다.
DB를 확인하면 AnalysisJob의 상태가 FAILED로 남아있을 뿐, FailedAnalysisRequest 테이블에는 아무런 레코드도 쌓이지 않았다.
원인을 추적하자 두 가지 설계 문제가 발견됐다.
runFullAnalysis()는 @Async로 선언되어 호출 즉시 반환한다.
Consumer는 이 반환을 분석 완료로 착각하고 즉시 ack.acknowledge()를 호출했다.
서버가 분석 도중 재시작되면 Kafka는 해당 메시지를 이미 처리 완료로 간주하여 재배달하지 않았고, 분석 결과도 없이 메시지가 유실됐다.
// 🚨 문제 코드
analysisService.runFullAnalysis(analysisId); // @Async → 즉시 리턴
ack.acknowledge(); // 분석이 끝나지 않았는데 ACK
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를 동기화하는 방향으로 설계를 변경했다.
@Async 제거 — Consumer thread에서 직접 실행@Async를 제거하고 Consumer thread가 분석 완료까지 직접 대기하도록 변경했다.
내부의 10개 종목 병렬 LLM 호출은 여전히 CompletableFuture.supplyAsync(..., llmApiExecutor)로 처리되므로
병렬 처리 성능은 그대로 유지된다.
DefaultErrorHandler 활성화runFullAnalysis() catch 블록에서 FAILED 상태 저장 후 예외를 다시 던지도록 수정했다.
이로써 분석 실패가 Consumer 밖으로 전파되어 DefaultErrorHandler가 이를 감지하게 됐다.
// ✅ 수정 후
} catch (Exception e) {
jobStore.save(FAILED);
throw new RuntimeException("분석 실패: " + analysisId, e); // re-throw
}
동기 호출로 전환됨에 따라 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 사용 | 동일하게 유지 |
@Async 제거로 코드 구조가 단순해졌고, DefaultErrorHandler의 재시도·DLT 라우팅이 설계 의도대로 동작하게 됐다.llmApiExecutor)는 그대로 유지되어 처리 성능에는 변화가 없었다.