대시보드에서 아무 표적이나 눌러도 "AI 위협 분석" 모달이 HTTP 500을 뱉는 걸 발견하고 원인을 추적한 기록. 결론적으로 배포 문제가 아니라 Gemini 무료 tier 쿼타를 실제로 다 써버린 거였고, 그 원인을 파고들어 보니 사용자가 버튼을 안 눌러도 서버가 백그라운드에서 조용히 Gemini를 계속 호출하고 있었다.

1. 증상 — 아무 표적이나 눌러도 500

{"timestamp":"...","status":500,"error":"Internal Server Error","message":"서버 내부 오류가 발생했습니다."}

pod 로그:

com.google.genai.errors.ClientException: 429 . You exceeded your current quota...
* Quota exceeded for metric: generativelanguage.googleapis.com/embed_content_free_tier_requests, limit: 1000

ThreatAnalysisService.analyze()가 RAG 검색(vectorStore.similaritySearch)을 위해 Gemini 임베딩 API를 부르는데, 이게 무료 tier 일일 한도(1000건)를 넘겨서 429가 났고, 그걸 그대로 예외로 던지고 있었으니 사용자 입장에선 "그냥 안 되는 기능"으로 보였다.

2. Google AI Studio 비율 제한 페이지로 실측 확인

<table header-row="true"><tr><td>모델</td><td>RPD(일일 요청)</td></tr><tr><td>Gemini Embedding 1</td><td>1.01K/1K — 초과</td></tr><tr><td>Gemini 2.5 Flash</td><td>32/20 — 초과(예전에 갈아탄 모델)</td></tr><tr><td>Gemini 3.6 Flash</td><td>24/20 — 초과</td></tr><tr><td>Gemini 3.5 Flash Lite(현재 사용 모델)</td><td>149/500 — 여유 있음</td></tr></table>

임베딩 모델만 한도를 넘겨고, 실제 채팅/SITREP 생성에 쓰는 모델(gemini-flash-lite-latest)은 아직 여유가 있었다. 즉 병목은 "AI가 응답을 못 만든 것"이 아니라 "RAG 검색 단계의 임베딩 호출"이었다.

결제 계정이 없으면 물리적으로 과금될 방법이 없고, 한도를 넘으면 그냥 429로 막히는 걸로 끝난다 — 지출 $0임도 확인됨.

3. 응급 조치 — 500 대신 우아하게 실패하도록

} catch (Exception e) {
    log.warn("[ThreatAI] LLM 호출 실패(쿼타/네트워크), 규칙 기반 등급으로 대체: ...");
    return ThreatAnalysisDto.Response.builder()
        .threatLevel(ruleBasedLevel)
        .sitrep("⚠️ AI 분석 일시 실패 — 규칙 기반 등급만 표시됩니다...")
        .aiEnabled(true)
        .build();
}

4. 진짜 원인 — 버튼을 안 눌러도 자동으로 소진되고 있었다

@KafkaListener(topics = "target-tracking", groupId = "target-tracking-group")
public void consume(TargetEvent event) {
    ...
    threatAnalysisService.analyzeAsync(event);   // 모든 Kafka 메시지마다 무조건 호출
}

Kafka로 들어오는 모든 표적 이벤트마다 예외 없이 analyzeAsync()가 불린다. 실제 Gemini 호출 여부는 세 조건으로 걸러지는데:

private boolean warrantsAiAnalysis(TargetEvent event) {
    if (!"AIRCRAFT".equals(event.getTargetType())) return true;
    if ("MILITARY".equals(event.getStatus())) return true;
    String level = calculateRuleBasedThreatLevel(event);
    return "HIGH".equals(level) || "CRITICAL".equals(level);
}

문제는 군용기(status=MILITARY)가 반경 안에 계속 떠 있는 한, 쉟다운(당시 10분)마다 사용자 개입 없이 자동 재분석된다는 점이었다. "군용기만" 필터에 상시 4대 정도 잡혀 있었으니, 하루 48회 이상(24h ÷ 10min × 4대) 백그라운드에서 조용히 Gemini를 불렀다는 계산이 나온다.

5. 근본 조치 — 자동분석을 기본 OFF로, 설정 플래그로 감싸기

완전 삭제 대신 설정 플래그로 감싸서 기본값 OFF를 채택했다 — 기능은 코드에 그대로 남아있고, 환경변수 하나로 언제든 복원 가능하다.

ai:
  auto-analysis:
    enabled: ${AI_AUTO_ANALYSIS_ENABLED:false}