ai-analysis.md, threat-intel-ai-service의 /chat 모두 오래전에 구현은 끝나 있었지만, static/index.html 대시보드에는 연결된 적이 없었다 — WebSocket으로 표적 위치 보여주는 것과 시뮬레이터 버튼, 딸 두 가지만 붙어 있었다. 이 문서는 그 연결을 붙이면서 실제로 드러난 것들을 정리한다.

1. 두 가지를 붙였다

① AI 위협분석 모달 — 표적 카드마다 "🔍 AI 위협분석" 버튼을 추가해서 이 서비스 자체의 /api/v1/threat-analysis/analyze를 호출하고 결과를 모달로 띄운다. 자기 자신의 API를 부르는 거라 별다른 라우팅 이슈가 없다.

② AI 챗봇 패널 — threat-intel-ai-service(별도 Python 리포)의 /chat을 SSE로 스트리밍해서 채팅 UI로 보여준다. 다른 서비스를 부르는 거라 문제가 하나 있었다.

2. 다른 서비스를 브라우저에서 직접 부르는 문제 — CORS와 mixed content

threat-intel-ai-service는 별개 k8s Service(ClusterIP)라, 브라우저가 직접 절대 URL을 호출하게 하려면 두 벣에 부딛힌다:

  1. CORS: 다른 오리진이니 서버가 명시적으로 허용해야 한다.
  2. Mixed content: 이 대시보드는 Tailscale Funnel을 통해 HTTPS로 서빙되는데, AI 서비스를 평범한 HTTP NodePort로 노출했다면 브라우저가 HTTPS 페이지에서 HTTP로의 요청을 통째로 막아버린다.

두 문제를 한 번에 피하는 방법은 같은 오리진으로 만드는 것이었다. threat-intel-ai-service가 자기 라우터를 /ai prefix로 한 번 더 등록하고, 이 서비스가 이미 쓰고 있는 Traefik Ingress에 /ai 경로 규칙을 하나 추가해서, 브라우저 입장에서는 그냥 같은 사이트의 다른 경로(/ai/chat)를 부르는 것처럼 보이게 만들었다.

3. EventSource를 못 쓰는 이유 — SSE를 수동으로 파싱

브라우저의 표준 SSE 클라이언트인 EventSource는 GET 요청만 지원한다. /chat은 질문을 body로 보내는 POST라 EventSource를 쓸 수 없다. 대신 fetch + ReadableStream으로 직접 파싱했다:

const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';

while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    buffer += decoder.decode(value, { stream: true });

    const blocks = buffer.split('\n\n');
    buffer = blocks.pop();
    for (const block of blocks) handleSseBlock(block, assistantEl);
}

SSE 프레임은 빈 줄(nn)로 구분되므로, 청크가 올 때마다 버퍼에 쌓고 완성된 블록만 잘라서 처리한다.

4. 버튼을 연결하고서야 발견한 진짜 장애

위협분석 버튼을 실제로 눌러보니 500 에러가 났다:

com.google.genai.errors.ClientException: 404 . This model models/gemini-2.5-flash is no longer available to new users.

application.yaml이 여전히 gemini-2.5-flash를 가리키고 있었다 — threat-intel-ai-service(Python)에서 또같은 404를 격고 gemini-flash-lite-latest로 바꿔 게 며칠 전인데, 같은 계정의 Java 서비스에도 같은 문제가 있다는 걸 그때 확인 안 했다. curl로 /status만 확인하면 aiEnabled: true가 나오니(API 키 존재 여부만 확인) 정상처럼 보였고, /analyze를 실제로 호출해본 적이 최근 며칠간 없었다.

이게 이번 작업의 핵심 교훈: 기능이 코드로 구현돼 있고 상태 엔드포인트가 "정상"이라고 답해도, 그 기능을 실제로 실행하는 경로가 무언가에 연결되어 있지 않으면 회귀(regression)가 조용히 쌓인다.

5. 정리

<table header-row="true"><tr><td>붙인 것</td><td>부르는 대상</td><td>격은 문제</td><td>해결</td></tr><tr><td>AI 위협분석 모달</td><td>자신의 /analyze</td><td>gemini-2.5-flash 404</td><td>gemini-flash-lite-latest로 교체</td></tr><tr><td>AI 챗봇 패널</td><td>threat-intel-ai-service /chat</td><td>CORS + mixed content</td><td>Traefik /ai path same-origin화</td></tr><tr><td>챗봇 스트리밍</td><td>SSE(POST 필요)</td><td>EventSource POST 미지원</td><td>fetch+ReadableStream 수동 파싱</td></tr></table>