Merge pull request 'fix: MonitoringController 레거시 타일 쿼리 전환 + 해구 통계 수정' (#19) from develop into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 2m45s

This commit is contained in:
htlee 2026-02-19 18:30:55 +09:00
커밋 065f14ede4
6개의 변경된 파일105개의 추가작업 그리고 129개의 파일을 삭제

파일 보기

@ -75,7 +75,7 @@ export interface ProcessingDelay {
aisLatestTime: string
queryLatestTime: string
recentAisCount: number
processedTiles: number
processedVessels: number
}
export interface MetricsSummary {
@ -159,10 +159,9 @@ export interface CacheDetails {
export interface HaeguStat {
haegu_no: number
haegu_name: string
active_tiles: number
current_vessels: number
avg_speed: number
avg_density: number
max_tile_vessels: number
last_update: string
center_lon: number | null
center_lat: number | null
@ -187,7 +186,7 @@ export interface ThroughputMetrics {
export interface DataQuality {
duplicateRecords: number
missingTiles: number
stalePositions: number
qualityScore: 'GOOD' | 'NEEDS_ATTENTION' | 'ERROR'
checkedAt: string
}

파일 보기

@ -37,7 +37,7 @@ const en = {
'dashboard.aisLatest': 'AIS Latest',
'dashboard.processLatest': 'Process Latest',
'dashboard.aisReceived': 'AIS Received',
'dashboard.tileProcessed': 'Tiles Processed',
'dashboard.vesselsProcessed': 'Vessels Processed',
'dashboard.systemMetrics': 'System Metrics',
'dashboard.memory': 'Memory',
'dashboard.threads': 'Threads',
@ -105,18 +105,17 @@ const en = {
'area.haeguStats': 'Area Status',
'area.haeguNo': 'Area No.',
'area.haeguName': 'Area Name',
'area.activeTiles': 'Active Tiles',
'area.currentVessels': 'Vessels',
'area.avgSpeed': 'Avg Speed',
'area.avgDensityCol': 'Avg Density',
'area.maxTileVessels': 'Max Tile Vessels',
'area.lastUpdate': 'Last Update',
'area.throughput': 'Throughput',
'area.vesselsPerMin': 'vessels/min',
'area.vesselsPerHour': 'vessels/hour',
'area.partitions': 'Partition Sizes',
'area.tableSizes': 'Table Sizes',
'area.dataQualityTitle': 'Data Quality Check',
'area.duplicates': 'Duplicates',
'area.missingTiles': 'Missing Tiles',
'area.duplicates': 'Duplicate Tracks',
'area.stalePositions': 'Stale Positions',
'area.checkedAt': 'Checked at',
// Time Range

파일 보기

@ -37,7 +37,7 @@ const ko = {
'dashboard.aisLatest': 'AIS 최신',
'dashboard.processLatest': '처리 최신',
'dashboard.aisReceived': 'AIS 수신',
'dashboard.tileProcessed': '타일 처리',
'dashboard.vesselsProcessed': '선박 집계',
'dashboard.systemMetrics': '시스템 메트릭',
'dashboard.memory': '메모리',
'dashboard.threads': '스레드',
@ -105,18 +105,17 @@ const ko = {
'area.haeguStats': '대해구별 현황',
'area.haeguNo': '해구번호',
'area.haeguName': '해구명',
'area.activeTiles': '활성 타일',
'area.currentVessels': '현재 선박',
'area.avgSpeed': '평균 속력',
'area.avgDensityCol': '평균 밀도',
'area.maxTileVessels': '최대 타일 선박',
'area.lastUpdate': '최종 갱신',
'area.throughput': '처리량',
'area.vesselsPerMin': '선박/분',
'area.vesselsPerHour': '선박/시간',
'area.partitions': '파티션 크기',
'area.tableSizes': '테이블 크기',
'area.dataQualityTitle': '데이터 품질 검증',
'area.duplicates': '중복 레코드',
'area.missingTiles': '누락 타일',
'area.duplicates': '중복 항적',
'area.stalePositions': '갱신 지연 위치',
'area.checkedAt': '검증 시각',
// Time Range

파일 보기

@ -28,7 +28,6 @@ export default function AreaStats() {
return (
<div className="space-y-6 fade-in">
{/* Header */}
<h1 className="text-2xl font-bold">{t('area.title')}</h1>
{/* Summary Cards */}
@ -67,10 +66,9 @@ export default function AreaStats() {
<tr>
<th>{t('area.haeguNo')}</th>
<th>{t('area.haeguName')}</th>
<th className="text-right">{t('area.activeTiles')}</th>
<th className="text-right">{t('area.currentVessels')}</th>
<th className="text-right">{t('area.avgSpeed')}</th>
<th className="text-right">{t('area.avgDensityCol')}</th>
<th className="text-right">{t('area.maxTileVessels')}</th>
<th>{t('area.lastUpdate')}</th>
</tr>
</thead>
@ -79,10 +77,9 @@ export default function AreaStats() {
<tr key={h.haegu_no}>
<td className="font-mono">{h.haegu_no}</td>
<td>{h.haegu_name}</td>
<td className="text-right">{formatNumber(h.active_tiles)}</td>
<td className="text-right font-bold">{formatNumber(h.current_vessels)}</td>
<td className="text-right">{(h.avg_density ?? 0).toFixed(2)}</td>
<td className="text-right">{formatNumber(h.max_tile_vessels)}</td>
<td className="text-right">{(h.avg_speed ?? 0).toFixed(1)} kn</td>
<td className="text-right">{(h.avg_density ?? 0).toFixed(4)}</td>
<td className="text-xs text-muted">{formatDateTime(h.last_update)}</td>
</tr>
))}
@ -111,10 +108,9 @@ export default function AreaStats() {
</div>
</div>
)}
{/* Partition Sizes */}
{throughput.partitionSizes && throughput.partitionSizes.length > 0 && (
<div>
<div className="mb-2 text-xs font-medium text-muted">{t('area.partitions')}</div>
<div className="mb-2 text-xs font-medium text-muted">{t('area.tableSizes')}</div>
<div className="space-y-1">
{throughput.partitionSizes.map((p, i) => (
<div key={i} className="flex items-center justify-between rounded bg-surface-hover px-3 py-1.5 text-sm">
@ -149,8 +145,8 @@ export default function AreaStats() {
<div className="text-lg font-bold">{formatNumber(quality.duplicateRecords)}</div>
</div>
<div className="rounded-lg bg-surface-hover p-3">
<div className="text-xs text-muted">{t('area.missingTiles')}</div>
<div className="text-lg font-bold">{formatNumber(quality.missingTiles)}</div>
<div className="text-xs text-muted">{t('area.stalePositions')}</div>
<div className="text-lg font-bold">{formatNumber(quality.stalePositions)}</div>
</div>
</div>
<div className="text-xs text-muted">

파일 보기

@ -125,8 +125,8 @@ export default function Dashboard() {
<div>{formatNumber(delay.recentAisCount)}{t('common.items')}</div>
</div>
<div>
<span className="text-muted">{t('dashboard.tileProcessed')}</span>
<div>{formatNumber(delay.processedTiles)}{t('common.items')}</div>
<span className="text-muted">{t('dashboard.vesselsProcessed')}</span>
<div>{formatNumber(delay.processedVessels)}{t('common.items')}</div>
</div>
</div>
</div>

파일 보기

@ -22,15 +22,14 @@ public class MonitoringController {
private final JdbcTemplate queryJdbcTemplate;
/**
* 데이터 처리 지연 상태 확인
* 데이터 처리 지연 상태 확인 (AIS 수집 vs 5분 집계 지연)
*/
@GetMapping("/delay")
@Operation(summary = "데이터 처리 지연 상태", description = "수집DB와 조회DB 간의 데이터 처리 지연 시간을 확인합니다")
@Operation(summary = "데이터 처리 지연 상태", description = "AIS 수집 시점과 5분 집계 시점 간의 처리 지연을 확인합니다")
public Map<String, Object> getProcessingDelay() {
Map<String, Object> result = new HashMap<>();
try {
// AIS 최신 위치 데이터 (캐시 스냅샷)
Map<String, Object> aisLatest = queryJdbcTemplate.queryForMap(
"""
SELECT
@ -41,18 +40,23 @@ public class MonitoringController {
"""
);
// 집계 데이터의 최신 처리 시간
Map<String, Object> queryLatest = queryJdbcTemplate.queryForMap(
"""
SELECT
MAX(time_bucket) as latest_processed_time,
COUNT(DISTINCT tile_id) as processed_tiles
FROM signal.t_tile_summary
COUNT(DISTINCT mmsi) as processed_vessels
FROM signal.t_vessel_tracks_5min
WHERE time_bucket > NOW() - INTERVAL '10 minutes'
"""
);
LocalDateTime aisTime = (LocalDateTime) aisLatest.get("latest_update_time");
Object aisRaw = aisLatest.get("latest_update_time");
LocalDateTime aisTime = null;
if (aisRaw instanceof java.time.OffsetDateTime odt) {
aisTime = odt.toLocalDateTime();
} else if (aisRaw instanceof LocalDateTime ldt) {
aisTime = ldt;
}
LocalDateTime queryTime = (LocalDateTime) queryLatest.get("latest_processed_time");
long delayMinutes = 0;
@ -62,7 +66,7 @@ public class MonitoringController {
delayMinutes = java.time.Duration.between(queryTime, aisTime).toMinutes();
delayStatus = delayMinutes < 10 ? "NORMAL" : delayMinutes < 30 ? "WARNING" : "CRITICAL";
} else if (aisTime == null && queryTime == null) {
delayStatus = "NORMAL"; // 데이터 없음 (수집 정상 상태)
delayStatus = "NORMAL";
} else {
delayStatus = "WARNING";
}
@ -72,57 +76,48 @@ public class MonitoringController {
result.put("aisLatestTime", aisTime);
result.put("queryLatestTime", queryTime);
result.put("recentAisCount", aisLatest.get("recent_count"));
result.put("processedTiles", queryLatest.get("processed_tiles"));
result.put("processedVessels", queryLatest.get("processed_vessels"));
} catch (Exception e) {
log.error("Failed to get processing delay", e);
result.put("error", e.getMessage());
result.put("status", "ERROR");
}
return result;
}
/**
* 대해구별 실시간 처리 현황
* 대해구별 실시간 선박 현황 (t_ais_position + t_haegu_definitions 공간 조인)
*/
@GetMapping("/haegu/realtime")
@Operation(summary = "대해구별 실시간 현황", description = "최신 타일 데이터 기준 대해구별 선박 통계를 조회합니다")
@Operation(summary = "대해구별 실시간 현황", description = "최신 AIS 위치 데이터 기준 대해구별 선박 통계를 조회합니다")
public List<Map<String, Object>> getRealtimeHaeguStatus() {
String sql = """
WITH recent_data AS (
SELECT
g.haegu_no,
t.tile_id,
t.tile_level,
t.vessel_count,
t.vessel_density,
t.time_bucket,
public.ST_X(public.ST_Centroid(g.tile_geom))::numeric(10,6) as center_lon,
public.ST_Y(public.ST_Centroid(g.tile_geom))::numeric(10,6) as center_lat
FROM signal.t_tile_summary t
JOIN signal.t_grid_tiles g ON t.tile_id = g.tile_id AND t.tile_level = g.tile_level
WHERE t.time_bucket = (SELECT MAX(time_bucket) FROM signal.t_tile_summary)
)
SELECT
haegu_no,
CONCAT('대해구 ', haegu_no) as haegu_name,
COUNT(DISTINCT tile_id) as active_tiles,
COALESCE(SUM(vessel_count), 0) as current_vessels,
COALESCE(AVG(vessel_density), 0) as avg_density,
COALESCE(MAX(vessel_count), 0) as max_tile_vessels,
MAX(time_bucket) as last_update,
-- 대해구 중심점 (tile_level=0인 경우만)
MAX(CASE WHEN tile_level = 0 THEN center_lon END) as center_lon,
MAX(CASE WHEN tile_level = 0 THEN center_lat END) as center_lat
FROM recent_data
WHERE tile_level = 0
GROUP BY haegu_no
HAVING SUM(vessel_count) > 0
SELECT
h.haegu_no,
CONCAT('대해구 ', h.haegu_no) as haegu_name,
COUNT(DISTINCT a.mmsi) as current_vessels,
ROUND(AVG(a.sog)::numeric, 1) as avg_speed,
ROUND(COUNT(DISTINCT a.mmsi)::numeric /
GREATEST((h.max_lat - h.min_lat) * (h.max_lon - h.min_lon) * 12321, 0.01),
4) as avg_density,
MAX(a.last_update) as last_update,
h.center_lon,
h.center_lat
FROM signal.t_haegu_definitions h
JOIN signal.t_ais_position a
ON a.lat BETWEEN h.min_lat AND h.max_lat
AND a.lon BETWEEN h.min_lon AND h.max_lon
AND public.ST_Contains(h.geom, a.geom)
WHERE a.last_update > NOW() - INTERVAL '30 minutes'
GROUP BY h.haegu_no, h.min_lat, h.min_lon, h.max_lat, h.max_lon,
h.center_lon, h.center_lat, h.geom
HAVING COUNT(DISTINCT a.mmsi) > 0
ORDER BY current_vessels DESC
LIMIT 50
""";
try {
return queryJdbcTemplate.queryForList(sql);
} catch (Exception e) {
@ -132,122 +127,110 @@ public class MonitoringController {
}
/**
* 시스템 처리량 메트릭
* 시스템 처리량 메트릭 (5분 항적 기반)
*/
@GetMapping("/throughput")
@Operation(summary = "시스템 처리량 메트릭", description = "최근 1시간 처리량 및 파티션 크기 정보를 조회합니다")
@Operation(summary = "시스템 처리량 메트릭", description = "최근 1시간 5분 집계 처리량 및 테이블 크기 정보를 조회합니다")
public Map<String, Object> getThroughputMetrics() {
Map<String, Object> metrics = new HashMap<>();
try {
// 최근 1시간 처리량
List<Map<String, Object>> hourlyStats = queryJdbcTemplate.queryForList(
"""
SELECT
DATE_TRUNC('minute', time_bucket) as minute,
COUNT(DISTINCT tile_id) as tiles_processed,
SUM(vessel_count) as vessels_processed
FROM signal.t_tile_summary
SELECT
time_bucket,
COUNT(DISTINCT mmsi) as vessels_processed,
COUNT(*) as tracks_count,
COALESCE(SUM(point_count), 0) as points_processed
FROM signal.t_vessel_tracks_5min
WHERE time_bucket > NOW() - INTERVAL '1 hour'
GROUP BY DATE_TRUNC('minute', time_bucket)
ORDER BY minute DESC
LIMIT 60
GROUP BY time_bucket
ORDER BY time_bucket DESC
"""
);
// 평균 처리량 계산
if (!hourlyStats.isEmpty()) {
long totalVessels = hourlyStats.stream()
.mapToLong(m -> ((Number) m.get("vessels_processed")).longValue())
.sum();
double avgVesselsPerMinute = totalVessels / (double) hourlyStats.size();
metrics.put("avgVesselsPerMinute", avgVesselsPerMinute);
metrics.put("avgVesselsPerHour", avgVesselsPerMinute * 60);
double avgVesselsPerBucket = totalVessels / (double) hourlyStats.size();
metrics.put("avgVesselsPerMinute", avgVesselsPerBucket / 5.0);
metrics.put("avgVesselsPerHour", avgVesselsPerBucket * 12);
metrics.put("hourlyDetails", hourlyStats);
}
// 파티션별 크기
List<Map<String, Object>> partitionSizes = queryJdbcTemplate.queryForList(
"""
SELECT
SELECT
tablename,
pg_size_pretty(pg_total_relation_size('signal.' || tablename)) as size,
pg_total_relation_size('signal.' || tablename) as size_bytes
FROM pg_tables
WHERE schemaname = 'signal'
AND tablename LIKE 't_tile_summary_%'
AND tablename ~ '\\\\d{6}$'
ORDER BY tablename DESC
LIMIT 7
AND tablename LIKE 't_vessel_tracks_%'
ORDER BY tablename
"""
);
metrics.put("partitionSizes", partitionSizes);
} catch (Exception e) {
log.error("Failed to get throughput metrics", e);
metrics.put("error", e.getMessage());
}
return metrics;
}
/**
* 데이터 품질 검증
* 데이터 품질 검증 (5분 항적 중복 + AIS 위치 갱신 누락)
*/
@SuppressWarnings("null")
@GetMapping("/quality")
@Operation(summary = "데이터 품질 검증", description = "중복 데이터 및 누락 타일을 확인하여 데이터 품질을 검증합니다")
@Operation(summary = "데이터 품질 검증", description = "5분 항적 중복 및 AIS 위치 갱신 누락을 확인하여 데이터 품질을 검증합니다")
public Map<String, Object> checkDataQuality() {
Map<String, Object> quality = new HashMap<>();
try {
// 중복 데이터 확인
Integer duplicates = queryJdbcTemplate.queryForObject(
"""
SELECT COUNT(*)
SELECT COUNT(*)
FROM (
SELECT tile_id, time_bucket, COUNT(*) as cnt
FROM signal.t_tile_summary
SELECT mmsi, time_bucket, COUNT(*) as cnt
FROM signal.t_vessel_tracks_5min
WHERE time_bucket > NOW() - INTERVAL '1 hour'
GROUP BY tile_id, time_bucket
GROUP BY mmsi, time_bucket
HAVING COUNT(*) > 1
) dup
""",
Integer.class
);
// 누락된 타일 확인
Integer missingTiles = queryJdbcTemplate.queryForObject(
Integer stalePositions = queryJdbcTemplate.queryForObject(
"""
WITH expected_tiles AS (
SELECT DISTINCT tile_id FROM signal.t_grid_tiles
),
recent_tiles AS (
SELECT DISTINCT tile_id
FROM signal.t_tile_summary
WHERE time_bucket > NOW() - INTERVAL '10 minutes'
)
SELECT COUNT(*)
FROM expected_tiles e
LEFT JOIN recent_tiles r ON e.tile_id = r.tile_id
WHERE r.tile_id IS NULL
FROM signal.t_ais_position
WHERE last_update < NOW() - INTERVAL '30 minutes'
""",
Integer.class
);
quality.put("duplicateRecords", duplicates != null ? duplicates : 0);
quality.put("missingTiles", missingTiles != null ? missingTiles : 0);
quality.put("qualityScore", duplicates == 0 && missingTiles < 100 ? "GOOD" : "NEEDS_ATTENTION");
int dupCount = duplicates != null ? duplicates : 0;
int staleCount = stalePositions != null ? stalePositions : 0;
quality.put("duplicateRecords", dupCount);
quality.put("stalePositions", staleCount);
quality.put("qualityScore",
dupCount == 0 && staleCount < 1000 ? "GOOD" : "NEEDS_ATTENTION");
quality.put("checkedAt", LocalDateTime.now());
} catch (Exception e) {
log.error("Failed to check data quality", e);
quality.put("error", e.getMessage());
quality.put("qualityScore", "ERROR");
}
return quality;
}
}
}