Release: Dashboard API 연동 오류 수정 + Phase 1 안정화 #13

병합
htlee develop 에서 main 로 11 commits 를 머지했습니다 2026-02-19 17:35:32 +09:00
7개의 변경된 파일202개의 추가작업 그리고 122개의 파일을 삭제

파일 보기

@ -1,26 +1,56 @@
import { lazy } from 'react'
import { lazy, Suspense, Component, type ReactNode } from 'react'
import { BrowserRouter, Routes, Route } from 'react-router-dom'
import { ThemeProvider } from './contexts/ThemeContext.tsx'
import { I18nProvider } from './i18n/I18nContext.tsx'
import AppLayout from './components/layout/AppLayout.tsx'
import LoadingSpinner from './components/common/LoadingSpinner.tsx'
const Dashboard = lazy(() => import('./pages/Dashboard.tsx'))
const JobMonitor = lazy(() => import('./pages/JobMonitor.tsx'))
const BASE_URL = import.meta.env.VITE_BASE_URL || '/signal-batch'
/* React Error Boundary — 페이지 렌더링 에러 시 전체 앱 crash 방지 */
class ErrorBoundary extends Component<{ children: ReactNode }, { error: Error | null }> {
state: { error: Error | null } = { error: null }
static getDerivedStateFromError(error: Error) {
return { error }
}
render() {
if (this.state.error) {
return (
<div className="flex flex-col items-center justify-center gap-4 py-20">
<h2 className="text-xl font-bold text-danger">Rendering Error</h2>
<p className="text-sm text-muted">{this.state.error.message}</p>
<button
className="rounded bg-primary px-4 py-2 text-sm text-white"
onClick={() => this.setState({ error: null })}
>
Retry
</button>
</div>
)
}
return this.props.children
}
}
export default function App() {
return (
<I18nProvider>
<ThemeProvider>
<BrowserRouter basename={BASE_URL}>
<Routes>
<Route element={<AppLayout />}>
<Route index element={<Dashboard />} />
<Route path="jobs" element={<JobMonitor />} />
{/* Phase 2+ 페이지 추가 예정 */}
</Route>
</Routes>
<ErrorBoundary>
<Routes>
<Route element={<AppLayout />}>
<Route index element={<Suspense fallback={<LoadingSpinner />}><Dashboard /></Suspense>} />
<Route path="jobs" element={<Suspense fallback={<LoadingSpinner />}><JobMonitor /></Suspense>} />
{/* Phase 2+ 페이지 추가 예정 */}
</Route>
</Routes>
</ErrorBoundary>
</BrowserRouter>
</ThemeProvider>
</I18nProvider>

파일 보기

@ -107,9 +107,9 @@ export default function Dashboard() {
{delay ? (
<div className="space-y-3">
<div className="flex items-baseline gap-2">
<span className="text-3xl font-bold">{delay.delayMinutes}</span>
<span className="text-3xl font-bold">{delay.delayMinutes ?? 0}</span>
<span className="text-muted">{t('dashboard.delayMin')}</span>
<StatusBadge status={delay.status} />
<StatusBadge status={delay.status ?? 'NORMAL'} />
</div>
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
@ -166,7 +166,7 @@ export default function Dashboard() {
<div className="text-xs text-muted">{t('dashboard.dbConn')}</div>
</div>
<div>
<div className="text-lg font-bold">{metrics.processing.recordsPerSecond.toFixed(0)}</div>
<div className="text-lg font-bold">{(metrics.processing?.recordsPerSecond ?? 0).toFixed(0)}</div>
<div className="text-xs text-muted">{t('dashboard.recordsSec')}</div>
</div>
</div>

파일 보기

@ -1,5 +1,6 @@
/** 숫자를 천 단위 콤마 포맷 */
export function formatNumber(n: number): string {
export function formatNumber(n: number | null | undefined): string {
if (n == null) return '0'
return n.toLocaleString('ko-KR')
}

파일 보기

@ -123,6 +123,17 @@ public class FiveMinTrackCache {
stats.missCount());
}
public Map<String, Object> getStatsMap() {
var stats = cache.stats();
return Map.of(
"size", cache.estimatedSize(),
"maxSize", (long) maxSize,
"hitCount", stats.hitCount(),
"missCount", stats.missCount(),
"hitRate", stats.hitRate() * 100
);
}
private String buildKey(String mmsi, LocalDateTime timeBucket) {
return mmsi + "::" + timeBucket.format(KEY_FORMATTER);
}

파일 보기

@ -122,6 +122,17 @@ public class HourlyTrackCache {
stats.missCount());
}
public Map<String, Object> getStatsMap() {
var stats = cache.stats();
return Map.of(
"size", cache.estimatedSize(),
"maxSize", (long) maxSize,
"hitCount", stats.hitCount(),
"missCount", stats.missCount(),
"hitRate", stats.hitRate() * 100
);
}
private String buildKey(String mmsi, LocalDateTime timeBucket) {
return mmsi + "::" + timeBucket.format(KEY_FORMATTER);
}

파일 보기

@ -1,85 +1,128 @@
package gc.mda.signal_batch.monitoring.controller;
import gc.mda.signal_batch.batch.reader.AisTargetCacheManager;
import gc.mda.signal_batch.batch.reader.FiveMinTrackCache;
import gc.mda.signal_batch.batch.reader.HourlyTrackCache;
import gc.mda.signal_batch.domain.vessel.service.VesselLatestPositionCache;
import gc.mda.signal_batch.global.websocket.service.DailyTrackCacheManager;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
import java.util.*;
/**
* 캐시 모니터링 컨트롤러
* 다계층 캐시 모니터링 컨트롤러
*
* L1(5분) + L2(시간) + L3(일일) + AIS Target + Latest Position 전체 캐시 통합 통계
*
* 엔드포인트:
* - GET /api/monitoring/cache/stats : 캐시 통계 조회
* - POST /api/monitoring/cache/clear : 캐시 강제 초기화
* - GET /api/monitoring/cache/stats : 전체 캐시 집계 통계 (Dashboard용)
* - GET /api/monitoring/cache/details : 계층별 상세 통계
* - GET /api/monitoring/cache/health : 캐시 헬스체크
*/
@Slf4j
@RestController
@RequestMapping("/api/monitoring/cache")
@RequiredArgsConstructor
@Tag(name = "Cache Monitoring", description = "캐시 모니터링 API")
@ConditionalOnProperty(name = "vessel.cache.latest-position.enabled", havingValue = "true")
@Tag(name = "Cache Monitoring", description = "다계층 캐시 모니터링 API")
public class CacheMonitoringController {
@Autowired(required = false)
private VesselLatestPositionCache cache;
private FiveMinTrackCache fiveMinTrackCache;
@Autowired(required = false)
private HourlyTrackCache hourlyTrackCache;
@Autowired(required = false)
private DailyTrackCacheManager dailyTrackCacheManager;
@Autowired(required = false)
private AisTargetCacheManager aisTargetCacheManager;
@Autowired(required = false)
private VesselLatestPositionCache latestPositionCache;
/**
* 캐시 통계 조회
* 캐시 통계 조회 (Dashboard 표시용 전체 캐시 집계)
*/
@GetMapping("/stats")
@Operation(summary = "캐시 통계 조회", description = "Vessel Latest Position 캐시의 통계 정보를 조회합니다.")
@Operation(summary = "캐시 통계 조회", description = "L1/L2/L3 + AIS Target + Latest Position 전체 캐시의 집계 통계를 조회합니다")
public ResponseEntity<Map<String, Object>> getCacheStats() {
if (cache == null) {
return ResponseEntity.ok(Map.of(
"enabled", false,
"message", "Cache is not enabled"
));
}
try {
VesselLatestPositionCache.CacheStats stats = cache.getStats();
long totalSize = 0;
long totalHits = 0;
long totalMisses = 0;
Map<String, Object> response = new HashMap<>();
response.put("enabled", true);
response.put("currentSize", stats.currentSize());
response.put("estimatedSize", stats.estimatedSize());
response.put("hitRate", String.format("%.2f%%", stats.hitRate()));
response.put("missRate", String.format("%.2f%%", stats.missRate()));
response.put("hitCount", stats.hitCount());
response.put("missCount", stats.missCount());
response.put("totalRequests", stats.hitCount() + stats.missCount());
// 효율성 평가
double efficiency;
long totalRequests = stats.hitCount() + stats.missCount();
if (totalRequests > 0) {
efficiency = (double) stats.hitCount() / totalRequests * 100;
} else {
efficiency = 0.0;
// L1 FiveMinTrackCache
if (fiveMinTrackCache != null) {
Map<String, Object> l1 = fiveMinTrackCache.getStatsMap();
totalSize += ((Number) l1.get("size")).longValue();
totalHits += ((Number) l1.get("hitCount")).longValue();
totalMisses += ((Number) l1.get("missCount")).longValue();
}
response.put("efficiency", String.format("%.2f%%", efficiency));
// 상태 평가
// L2 HourlyTrackCache
if (hourlyTrackCache != null) {
Map<String, Object> l2 = hourlyTrackCache.getStatsMap();
totalSize += ((Number) l2.get("size")).longValue();
totalHits += ((Number) l2.get("hitCount")).longValue();
totalMisses += ((Number) l2.get("missCount")).longValue();
}
// L3 DailyTrackCacheManager
if (dailyTrackCacheManager != null) {
Map<String, Object> l3 = dailyTrackCacheManager.getCacheStatus();
Object totalVessels = l3.get("totalVessels");
if (totalVessels instanceof Number) {
totalSize += ((Number) totalVessels).longValue();
}
}
// AIS Target
if (aisTargetCacheManager != null) {
Map<String, Object> ais = aisTargetCacheManager.getStats();
totalSize += ((Number) ais.getOrDefault("estimatedSize", 0L)).longValue();
totalHits += ((Number) ais.getOrDefault("hitCount", 0L)).longValue();
totalMisses += ((Number) ais.getOrDefault("missCount", 0L)).longValue();
}
// Latest Position
if (latestPositionCache != null) {
VesselLatestPositionCache.CacheStats lp = latestPositionCache.getStats();
totalSize += lp.currentSize();
totalHits += lp.hitCount();
totalMisses += lp.missCount();
}
long totalRequests = totalHits + totalMisses;
double hitRateValue = totalRequests > 0 ? (double) totalHits / totalRequests * 100 : 0.0;
String status;
if (stats.currentSize() == 0) {
if (totalSize == 0) {
status = "EMPTY";
} else if (efficiency > 90) {
} else if (hitRateValue > 90) {
status = "EXCELLENT";
} else if (efficiency > 70) {
} else if (hitRateValue > 70) {
status = "GOOD";
} else if (efficiency > 50) {
} else if (hitRateValue > 50) {
status = "FAIR";
} else {
status = "POOR";
}
Map<String, Object> response = new LinkedHashMap<>();
response.put("enabled", true);
response.put("currentSize", totalSize);
response.put("estimatedSize", totalSize);
response.put("hitRate", String.format("%.2f%%", hitRateValue));
response.put("missRate", String.format("%.2f%%", 100 - hitRateValue));
response.put("hitCount", totalHits);
response.put("missCount", totalMisses);
response.put("totalRequests", totalRequests);
response.put("efficiency", String.format("%.2f%%", hitRateValue));
response.put("status", status);
return ResponseEntity.ok(response);
@ -92,82 +135,58 @@ public class CacheMonitoringController {
}
/**
* 캐시 강제 초기화 (주의: 운영 환경에서 신중하게 사용)
* 캐시 계층별 상세 통계
*/
@PostMapping("/clear")
@Operation(
summary = "캐시 강제 초기화",
description = "모든 캐시 데이터를 삭제합니다. 운영 환경에서는 신중하게 사용하세요."
)
public ResponseEntity<Map<String, Object>> clearCache() {
if (cache == null) {
return ResponseEntity.ok(Map.of(
"enabled", false,
"message", "Cache is not enabled"
));
}
@GetMapping("/details")
@Operation(summary = "캐시 계층별 상세", description = "L1/L2/L3/AIS/LatestPosition 각 캐시의 개별 통계를 조회합니다")
public ResponseEntity<Map<String, Object>> getCacheDetails() {
try {
VesselLatestPositionCache.CacheStats statsBefore = cache.getStats();
cache.clear();
VesselLatestPositionCache.CacheStats statsAfter = cache.getStats();
Map<String, Object> details = new LinkedHashMap<>();
log.warn("Cache manually cleared - before size: {}, after size: {}",
statsBefore.currentSize(), statsAfter.currentSize());
if (fiveMinTrackCache != null) {
details.put("l1_fiveMin", fiveMinTrackCache.getStatsMap());
}
if (hourlyTrackCache != null) {
details.put("l2_hourly", hourlyTrackCache.getStatsMap());
}
if (dailyTrackCacheManager != null) {
details.put("l3_daily", dailyTrackCacheManager.getCacheStatus());
}
if (aisTargetCacheManager != null) {
details.put("aisTarget", aisTargetCacheManager.getStats());
}
if (latestPositionCache != null) {
VesselLatestPositionCache.CacheStats lp = latestPositionCache.getStats();
details.put("latestPosition", Map.of(
"size", lp.currentSize(),
"hitRate", lp.hitRate(),
"hitCount", lp.hitCount(),
"missCount", lp.missCount()
));
}
return ResponseEntity.ok(Map.of(
"success", true,
"message", "Cache cleared successfully",
"clearedEntries", statsBefore.currentSize()
));
return ResponseEntity.ok(details);
} catch (Exception e) {
log.error("Failed to clear cache", e);
log.error("Failed to get cache details", e);
return ResponseEntity.internalServerError()
.body(Map.of("error", "Failed to clear cache: " + e.getMessage()));
.body(Map.of("error", e.getMessage()));
}
}
/**
* 캐시 상태 체크 (헬스체크용)
* 캐시 헬스체크
*/
@GetMapping("/health")
@Operation(summary = "캐시 헬스체크", description = "캐시가 정상 작동 중인지 확인합니다.")
@Operation(summary = "캐시 헬스체크", description = "캐시 시스템이 정상 작동 중인지 확인합니다")
public ResponseEntity<Map<String, Object>> checkCacheHealth() {
Map<String, Object> health = new HashMap<>();
if (cache == null) {
health.put("status", "DISABLED");
health.put("message", "Cache is not enabled");
return ResponseEntity.ok(health);
}
try {
VesselLatestPositionCache.CacheStats stats = cache.getStats();
boolean isHealthy = stats.currentSize() >= 0; // 기본 체크
health.put("status", isHealthy ? "UP" : "DOWN");
health.put("cacheSize", stats.currentSize());
health.put("healthy", isHealthy);
// 경고 조건 체크
if (stats.currentSize() == 0) {
health.put("warning", "Cache is empty");
}
long totalRequests = stats.hitCount() + stats.missCount();
if (totalRequests > 100 && stats.hitRate() < 50.0) {
health.put("warning", "Low cache hit rate: " + String.format("%.2f%%", stats.hitRate()));
}
return ResponseEntity.ok(health);
} catch (Exception e) {
log.error("Cache health check failed", e);
health.put("status", "ERROR");
health.put("error", e.getMessage());
return ResponseEntity.status(503).body(health);
}
Map<String, Object> health = new LinkedHashMap<>();
health.put("status", "UP");
health.put("l1", fiveMinTrackCache != null ? "UP" : "DISABLED");
health.put("l2", hourlyTrackCache != null ? "UP" : "DISABLED");
health.put("l3", dailyTrackCacheManager != null ? dailyTrackCacheManager.getStatus().name() : "DISABLED");
health.put("aisTarget", aisTargetCacheManager != null ? "UP" : "DISABLED");
health.put("latestPosition", latestPositionCache != null ? "UP" : "DISABLED");
return ResponseEntity.ok(health);
}
}

파일 보기

@ -55,12 +55,20 @@ public class MonitoringController {
LocalDateTime aisTime = (LocalDateTime) aisLatest.get("latest_update_time");
LocalDateTime queryTime = (LocalDateTime) queryLatest.get("latest_processed_time");
long delayMinutes = 0;
String delayStatus = "NORMAL";
if (aisTime != null && queryTime != null) {
long delayMinutes = java.time.Duration.between(queryTime, aisTime).toMinutes();
result.put("delayMinutes", delayMinutes);
result.put("status", delayMinutes < 10 ? "NORMAL" : delayMinutes < 30 ? "WARNING" : "CRITICAL");
delayMinutes = java.time.Duration.between(queryTime, aisTime).toMinutes();
delayStatus = delayMinutes < 10 ? "NORMAL" : delayMinutes < 30 ? "WARNING" : "CRITICAL";
} else if (aisTime == null && queryTime == null) {
delayStatus = "NORMAL"; // 데이터 없음 (수집 정상 상태)
} else {
delayStatus = "WARNING";
}
result.put("delayMinutes", delayMinutes);
result.put("status", delayStatus);
result.put("aisLatestTime", aisTime);
result.put("queryLatestTime", queryTime);
result.put("recentAisCount", aisLatest.get("recent_count"));