MSS-Comparison-and-Asset-Recommendation-Engine.md에서 지적한 "단일 모달리티(ADS-B 하나뿐)" 한계에 대응해서, 두 번째 실시간 공개 데이터 소스(AIS, 선박 자동식별장치)를 붙인 기록. 연결 자체는 30분 넘게 원인을 못 찾다가, 결국 아주 단순한 데이터 형식 오해였다는 게 밝혀진 디버깅 과정이 이 문서의 절반을 차지한다.
무료로 접근 가능한 실시간 AIS API를 조사했다.
API 키는 클러스터의 target-tracking-secrets Secret에 ais-stream-api-key로 직접 추가(git에는 안 올림 — gemini-api-key/db-password와 동일 패턴).
AdsbFiPollingService(ADS-B, REST 폴링)와 같은 자리에 두 개의 새 클래스를 추가했다.
AisMessageParser — AIS 메시지 JSON → TargetEvent 순수 변환 함수. 네트워크 없이 단위 테스트 가능(AdsbFiClient/AdsbFiPollingService 분리 패턴을 그대로 따름). TrueHeading=511("값 없음" AIS 스펙 센티널)이면 Cog(대지 진행방향)로 대체.AisStreamService — WebSocket 연결·구독·재연결 관리, 파싱은 AisMessageParser에 위임.TargetEvent에 targetType="SHIP", altitude=0.0으로 발행 — 기존 Kafka 파이프라인(PostgreSQL 저장, WebSocket 브로드캐스트, AI 위협분석)을 그대로 재사용한다.
Python(threat-intel-ai-service)의 assess_threat_level에도 속도 규칙을 동일하게 미러링(MMSI 형식 검사는 그 함수 시그니처에 targetId가 없어서 Java 쪽에만 있음 — assess_threat_level이 이미 DRONE/MISSILE/AIRCRAFT 규칙을 Java와 중복 구현해온 것과 같은 이유).
warrantsAiAnalysis()가 원래 AIRCRAFT 전용으로 "군용/HIGH 이상만 AI 분석"하도록 막아뒀던 게(민항기 폭주로 Gemini 무료 쿼터 소진 사건, 과거 문서 참고) AIRCRAFT에만 적용되고 있었다. SHIP도 한국 연안 AIS 트래픽이면 똑같이 대량 유입될 게 뻔해서, 이 필터를 "DRONE/MISSILE(시뮬레이터, 개체 수 적음)만 무조건 분석, 나머지는 HIGH 이상만"으로 일반화했다.
java.net.http.WebSocket연결·구독 로그는 정상 출력됐지만(onOpen → sendText), 30분 넘게 단 하나의 메시지도 처리되지 않았다. API를 조회하니 SHIP 타입 0건, 에러 로그도 0건 — 침묵만 있었다.
같은 API 키·bounding box·구독 메시지로 Node.js 스크립트를 짜서 직접 접속해보니 1초 안에 실제 데이터가 왔다. 이걸로 API 키/구독 포맷/aisstream.io 서버 쪽은 전혀 문제가 없다는 게 확인됐다 — 문제는 100% Java 클라이언트 쪽.
JDK WebSocket의 onText가 왜 안 불리는지 로그를 더 추가해도 실마리가 안 보여서, 이 앱이 대시보드 STOMP 브로드캐스트로 이미 실전 검증된 **Spring StandardWebSocketClient**로 교체했다. 그러자 즉시 진짜 에러가 드러났다: