release: 2026-03-10 (4건 커밋) #100

병합
htlee develop 에서 main 로 4 commits 를 머지했습니다 2026-03-10 09:21:06 +09:00
11개의 변경된 파일622개의 추가작업 그리고 78개의 파일을 삭제
Showing only changes of commit a0f24d5757 - Show all commits

파일 보기

@ -6,6 +6,9 @@ import type {
HaeguStat,
MetricsSummary,
ProcessingDelay,
QueryMetricsPage,
QueryMetricsParams,
QueryMetricsSummary,
ThroughputMetrics,
} from './types.ts'
@ -45,4 +48,22 @@ export const monitorApi = {
getHaeguStats(): Promise<Record<string, unknown>[]> {
return fetchJson('/admin/haegu/stats')
},
getQueryMetricsHistory(params: QueryMetricsParams): Promise<QueryMetricsPage> {
const qs = new URLSearchParams()
if (params.queryType) qs.set('queryType', params.queryType)
if (params.dataPath) qs.set('dataPath', params.dataPath)
if (params.status) qs.set('status', params.status)
if (params.elapsedMsMin != null) qs.set('elapsedMsMin', String(params.elapsedMsMin))
if (params.elapsedMsMax != null) qs.set('elapsedMsMax', String(params.elapsedMsMax))
qs.set('page', String(params.page ?? 0))
qs.set('size', String(params.size ?? 20))
qs.set('sortBy', params.sortBy ?? 'created_at')
qs.set('sortDir', params.sortDir ?? 'desc')
return fetchJson(`/api/monitoring/query-metrics/history?${qs}`)
},
getQueryMetricsSummary(hours = 24): Promise<QueryMetricsSummary> {
return fetchJson(`/api/monitoring/query-metrics/summary?hours=${hours}`)
},
}

파일 보기

@ -187,6 +187,65 @@ export interface ThroughputMetrics {
partitionSizes: PartitionSize[]
}
/* Query Metrics (쿼리 이력) */
export interface QueryMetricRow {
query_id: string
query_type: string
created_at: string
data_path: string
status: string
zoom_level: number | null
requested_mmsi: number
unique_vessels: number
total_points: number
points_after_simplify: number
total_chunks: number
response_bytes: number
elapsed_ms: number
db_query_ms: number
simplify_ms: number
cache_hit_days: number
db_query_days: number
}
export interface QueryMetricsPage {
content: QueryMetricRow[]
totalElements: number
totalPages: number
currentPage: number
pageSize: number
}
export interface QueryMetricsSummary {
total_queries: number
avg_elapsed_ms: number
p95_elapsed_ms: number
max_elapsed_ms: number
ws_count: number
rest_count: number
cache_only_count: number
db_only_count: number
hybrid_count: number
completed_count: number
failed_count: number
avg_vessels: number
avg_points_before: number
avg_points_after: number
}
export interface QueryMetricsParams {
queryType?: string
dataPath?: string
status?: string
elapsedMsMin?: number
elapsedMsMax?: number
page?: number
size?: number
sortBy?: string
sortDir?: 'asc' | 'desc'
}
/* Monitor — Data Quality */
export interface DataQuality {

파일 보기

@ -16,6 +16,10 @@ interface DataTableProps<T> {
onRowClick?: (row: T) => void
emptyMessage?: string
pageSize?: number
// Server-side pagination (optional)
totalElements?: number
currentPage?: number
onPageChange?: (page: number) => void
}
export default function DataTable<T>({
@ -25,14 +29,19 @@ export default function DataTable<T>({
onRowClick,
emptyMessage,
pageSize = 20,
totalElements,
currentPage,
onPageChange,
}: DataTableProps<T>) {
const { t } = useI18n()
const [sortKey, setSortKey] = useState<string | null>(null)
const [sortAsc, setSortAsc] = useState(true)
const [page, setPage] = useState(0)
const isServerSide = totalElements != null && currentPage != null && onPageChange != null
const sorted = useMemo(() => {
if (!sortKey) return data
if (isServerSide || !sortKey) return data
return [...data].sort((a, b) => {
const av = (a as Record<string, unknown>)[sortKey]
const bv = (b as Record<string, unknown>)[sortKey]
@ -40,10 +49,12 @@ export default function DataTable<T>({
const cmp = av < bv ? -1 : av > bv ? 1 : 0
return sortAsc ? cmp : -cmp
})
}, [data, sortKey, sortAsc])
}, [data, sortKey, sortAsc, isServerSide])
const totalPages = Math.ceil(sorted.length / pageSize)
const paged = sorted.slice(page * pageSize, (page + 1) * pageSize)
const effectivePage = isServerSide ? currentPage! : page
const total = isServerSide ? totalElements! : sorted.length
const totalPages = Math.ceil(total / pageSize)
const paged = isServerSide ? sorted : sorted.slice(effectivePage * pageSize, (effectivePage + 1) * pageSize)
const handleSort = (key: string) => {
if (sortKey === key) {
@ -54,6 +65,14 @@ export default function DataTable<T>({
}
}
const handlePageChange = (newPage: number) => {
if (isServerSide) {
onPageChange!(newPage)
} else {
setPage(newPage)
}
}
return (
<div>
<div className="sb-table-wrapper">
@ -67,7 +86,7 @@ export default function DataTable<T>({
style={{ textAlign: col.align ?? 'left', cursor: col.sortable !== false ? 'pointer' : 'default' }}
>
{col.label}
{sortKey === col.key && (sortAsc ? ' \u25B2' : ' \u25BC')}
{sortKey === col.key && (sortAsc ? ' ▲' : ' ▼')}
</th>
))}
</tr>
@ -102,19 +121,19 @@ export default function DataTable<T>({
{totalPages > 1 && (
<div className="mt-3 flex items-center justify-between text-sm text-muted">
<span>
{sorted.length}{t('common.items')} {t('common.of')} {page * pageSize + 1}-{Math.min((page + 1) * pageSize, sorted.length)}
{total}{t('common.items')} {t('common.of')} {effectivePage * pageSize + 1}-{Math.min((effectivePage + 1) * pageSize, total)}
</span>
<div className="flex gap-1">
<button
onClick={() => setPage(p => Math.max(0, p - 1))}
disabled={page === 0}
onClick={() => handlePageChange(Math.max(0, effectivePage - 1))}
disabled={effectivePage === 0}
className="rounded border border-border px-2 py-1 disabled:opacity-40"
>
{t('common.prev')}
</button>
<button
onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1}
onClick={() => handlePageChange(Math.min(totalPages - 1, effectivePage + 1))}
disabled={effectivePage >= totalPages - 1}
className="rounded border border-border px-2 py-1 disabled:opacity-40"
>
{t('common.next')}

파일 보기

@ -170,8 +170,24 @@ const en = {
'metrics.cacheHitSummary': 'Cache Hit Summary',
'metrics.hits': 'Hits',
'metrics.misses': 'Misses',
'metrics.dbMetricsPlaceholder': 'API/WS History Metrics (Coming Soon)',
'metrics.dbMetricsDesc': 'REST/WebSocket request history, response sizes, latency DB storage + query',
'metrics.queryHistory': 'Query History',
'metrics.totalQueries': 'Total Queries',
'metrics.avgElapsed': 'Avg Response',
'metrics.p95Elapsed': 'P95 Response',
'metrics.cacheHitRate': 'Cache Hit Rate',
'metrics.queryType': 'Type',
'metrics.dataPath': 'Path',
'metrics.queryStatus': 'Status',
'metrics.queryTime': 'Time',
'metrics.vessels': 'Vessels',
'metrics.pointsBefore': 'Points(Before)',
'metrics.pointsAfter': 'Points(After)',
'metrics.simplification': 'Reduction',
'metrics.chunks': 'Chunks',
'metrics.elapsed': 'Elapsed',
'metrics.allTypes': 'All',
'metrics.allPaths': 'All',
'metrics.resetFilters': 'Reset Filters',
// Time Range
'range.1d': '1D',

파일 보기

@ -170,8 +170,24 @@ const ko = {
'metrics.cacheHitSummary': '캐시 히트 요약',
'metrics.hits': '히트',
'metrics.misses': '미스',
'metrics.dbMetricsPlaceholder': 'API/WS 이력 메트릭 (향후 구현)',
'metrics.dbMetricsDesc': 'REST/WebSocket 요청 이력, 응답 크기, 소요시간 DB 저장 + 조회',
'metrics.queryHistory': '쿼리 이력',
'metrics.totalQueries': '총 쿼리',
'metrics.avgElapsed': '평균 응답',
'metrics.p95Elapsed': 'P95 응답',
'metrics.cacheHitRate': '캐시 적중률',
'metrics.queryType': '유형',
'metrics.dataPath': '경로',
'metrics.queryStatus': '상태',
'metrics.queryTime': '시각',
'metrics.vessels': '선박',
'metrics.pointsBefore': '포인트(전)',
'metrics.pointsAfter': '포인트(후)',
'metrics.simplification': '간소화',
'metrics.chunks': '청크',
'metrics.elapsed': '응답시간',
'metrics.allTypes': '전체',
'metrics.allPaths': '전체',
'metrics.resetFilters': '필터 초기화',
// Time Range
'range.1d': '1일',

파일 보기

@ -1,12 +1,22 @@
import { useState, useCallback } from 'react'
import { usePoller } from '../hooks/usePoller.ts'
import { useCachedState } from '../hooks/useCachedState.ts'
import { useI18n } from '../hooks/useI18n.ts'
import { monitorApi } from '../api/monitorApi.ts'
import type { MetricsSummary, CacheStats, ProcessingDelay, CacheDetails } from '../api/types.ts'
import type { MetricsSummary, CacheStats, ProcessingDelay, CacheDetails, QueryMetricsPage, QueryMetricsSummary, QueryMetricsParams, QueryMetricRow } from '../api/types.ts'
import MetricCard from '../components/charts/MetricCard.tsx'
import DataTable, { type Column } from '../components/common/DataTable.tsx'
import { formatNumber } from '../utils/formatters.ts'
const POLL_INTERVAL = 10_000
const QUERY_POLL_INTERVAL = 30_000
const ELAPSED_RANGES = [
{ label: '< 1s', min: undefined, max: 999 },
{ label: '1-5s', min: 1000, max: 5000 },
{ label: '5-30s', min: 5000, max: 30000 },
{ label: '> 30s', min: 30000, max: undefined },
] as const
export default function ApiMetrics() {
const { t } = useI18n()
@ -15,6 +25,13 @@ export default function ApiMetrics() {
const [cacheDetails, setCacheDetails] = useCachedState<CacheDetails | null>('api.cacheDetail', null)
const [delay, setDelay] = useCachedState<ProcessingDelay | null>('api.delay', null)
// Query History state
const [filter, setFilter] = useState<QueryMetricsParams>({
page: 0, size: 20, sortBy: 'created_at', sortDir: 'desc',
})
const [historyData, setHistoryData] = useState<QueryMetricsPage | null>(null)
const [summaryData, setSummaryData] = useState<QueryMetricsSummary | null>(null)
usePoller(() => {
monitorApi.getMetricsSummary().then(setMetrics).catch(() => {})
monitorApi.getCacheStats().then(setCache).catch(() => {})
@ -22,10 +39,89 @@ export default function ApiMetrics() {
monitorApi.getDelay().then(setDelay).catch(() => {})
}, POLL_INTERVAL)
const fetchQueryData = useCallback(() => {
monitorApi.getQueryMetricsHistory(filter).then(setHistoryData).catch(() => {})
monitorApi.getQueryMetricsSummary(24).then(setSummaryData).catch(() => {})
}, [filter])
usePoller(fetchQueryData, QUERY_POLL_INTERVAL, [filter])
const updateFilter = (patch: Partial<QueryMetricsParams>) => {
setFilter(prev => ({ ...prev, page: 0, ...patch }))
}
const resetFilters = () => {
setFilter({ page: 0, size: 20, sortBy: 'created_at', sortDir: 'desc' })
}
const memUsed = metrics?.memory.used ?? 0
const memMax = metrics?.memory.max ?? 1
const memPct = Math.round((memUsed / memMax) * 100)
// Summary computed values
const totalQueries = summaryData?.total_queries ?? 0
const cacheHitRate = totalQueries > 0
? ((summaryData?.cache_only_count ?? 0) / totalQueries * 100).toFixed(1)
: '0.0'
const historyColumns: Column<QueryMetricRow>[] = [
{
key: 'created_at', label: t('metrics.queryTime'), sortable: false,
render: (row) => {
const ts = row.created_at ?? ''
return ts.length >= 19 ? ts.substring(5, 19) : ts
},
},
{
key: 'query_type', label: t('metrics.queryType'), sortable: false,
render: (row) => {
const isWs = row.query_type === 'WEBSOCKET'
return <span className={`inline-block rounded px-1.5 py-0.5 text-xs font-medium ${isWs ? 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300' : 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300'}`}>{isWs ? 'WS' : 'REST'}</span>
},
},
{
key: 'data_path', label: t('metrics.dataPath'), sortable: false,
render: (row) => {
const path = row.data_path ?? ''
const color = path === 'CACHE' ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300'
: path === 'DB' ? 'bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300'
: 'bg-violet-100 text-violet-700 dark:bg-violet-900 dark:text-violet-300'
return <span className={`inline-block rounded px-1.5 py-0.5 text-xs font-medium ${color}`}>{path}</span>
},
},
{
key: 'status', label: t('metrics.queryStatus'), sortable: false,
render: (row) => {
const ok = row.status === 'COMPLETED'
return <span className={`inline-block rounded px-1.5 py-0.5 text-xs font-medium ${ok ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300' : 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300'}`}>{row.status}</span>
},
},
{ key: 'unique_vessels', label: t('metrics.vessels'), align: 'right' as const, sortable: false,
render: (row) => formatNumber(row.unique_vessels) },
{ key: 'total_points', label: t('metrics.pointsBefore'), align: 'right' as const, sortable: false,
render: (row) => formatNumber(row.total_points) },
{ key: 'points_after_simplify', label: t('metrics.pointsAfter'), align: 'right' as const, sortable: false,
render: (row) => formatNumber(row.points_after_simplify) },
{
key: 'reduction', label: t('metrics.simplification'), align: 'right' as const, sortable: false,
render: (row) => {
const before = row.total_points || 0
const after = row.points_after_simplify || 0
if (before === 0) return '-'
return `${((1 - after / before) * 100).toFixed(0)}%`
},
},
{ key: 'total_chunks', label: t('metrics.chunks'), align: 'right' as const, sortable: false },
{
key: 'elapsed_ms', label: t('metrics.elapsed'), align: 'right' as const, sortable: false,
render: (row) => {
const ms = row.elapsed_ms || 0
const color = ms < 1000 ? 'text-success' : ms < 5000 ? 'text-warning' : 'text-danger'
return <span className={`font-mono font-medium ${color}`}>{ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`}</span>
},
},
]
return (
<div className="space-y-6 fade-in">
<h1 className="text-2xl font-bold">{t('metrics.title')}</h1>
@ -178,12 +274,114 @@ export default function ApiMetrics() {
</div>
</div>
{/* Placeholder for future DB-based metrics */}
<div className="sb-card border-dashed">
<div className="py-6 text-center text-sm text-muted">
<p>{t('metrics.dbMetricsPlaceholder')}</p>
<p className="mt-1 text-xs opacity-60">{t('metrics.dbMetricsDesc')}</p>
{/* Query History Section */}
<div className="sb-card">
<div className="sb-card-header">{t('metrics.queryHistory')}</div>
{/* Summary Cards */}
<div className="mb-4 grid grid-cols-2 gap-3 lg:grid-cols-4">
<MetricCard
title={t('metrics.totalQueries')}
value={summaryData ? formatNumber(totalQueries) : '-'}
subtitle={summaryData ? `WS:${summaryData.ws_count} / REST:${summaryData.rest_count}` : undefined}
/>
<MetricCard
title={t('metrics.avgElapsed')}
value={summaryData ? `${((summaryData.avg_elapsed_ms ?? 0) / 1000).toFixed(1)}s` : '-'}
/>
<MetricCard
title={t('metrics.p95Elapsed')}
value={summaryData ? `${((summaryData.p95_elapsed_ms ?? 0) / 1000).toFixed(1)}s` : '-'}
/>
<MetricCard
title={t('metrics.cacheHitRate')}
value={summaryData ? `${cacheHitRate}%` : '-'}
subtitle={summaryData ? `C:${summaryData.cache_only_count}/DB:${summaryData.db_only_count}/H:${summaryData.hybrid_count}` : undefined}
/>
</div>
{/* Filters */}
<div className="mb-4 flex flex-wrap items-center gap-3 text-sm">
{/* Query Type toggle */}
<div className="flex items-center gap-1">
<span className="text-muted mr-1">{t('metrics.queryType')}:</span>
{[undefined, 'WEBSOCKET', 'REST_V2'].map((val) => (
<button
type="button"
key={val ?? 'all'}
onClick={() => updateFilter({ queryType: val })}
className={`rounded px-2 py-1 text-xs font-medium transition ${
filter.queryType === val
? 'bg-primary text-white'
: 'bg-surface-secondary text-muted hover:bg-surface-tertiary'
}`}
>
{val == null ? t('metrics.allTypes') : val === 'WEBSOCKET' ? 'WS' : 'REST'}
</button>
))}
</div>
{/* Data Path toggle */}
<div className="flex items-center gap-1">
<span className="text-muted mr-1">{t('metrics.dataPath')}:</span>
{[undefined, 'CACHE', 'DB', 'HYBRID'].map((val) => (
<button
type="button"
key={val ?? 'all'}
onClick={() => updateFilter({ dataPath: val })}
className={`rounded px-2 py-1 text-xs font-medium transition ${
filter.dataPath === val
? 'bg-primary text-white'
: 'bg-surface-secondary text-muted hover:bg-surface-tertiary'
}`}
>
{val ?? t('metrics.allPaths')}
</button>
))}
</div>
{/* Elapsed Time select */}
<select
title={t('metrics.elapsed')}
value={filter.elapsedMsMin != null ? `${filter.elapsedMsMin}-${filter.elapsedMsMax ?? ''}` : ''}
onChange={(e) => {
if (!e.target.value) {
updateFilter({ elapsedMsMin: undefined, elapsedMsMax: undefined })
} else {
const range = ELAPSED_RANGES.find(r =>
`${r.min ?? ''}-${r.max ?? ''}` === e.target.value
)
if (range) updateFilter({ elapsedMsMin: range.min, elapsedMsMax: range.max })
}
}}
className="rounded border border-border bg-surface px-2 py-1 text-xs"
>
<option value="">{t('metrics.elapsed')}: {t('metrics.allTypes')}</option>
{ELAPSED_RANGES.map((r) => (
<option key={r.label} value={`${r.min ?? ''}-${r.max ?? ''}`}>{r.label}</option>
))}
</select>
{/* Reset */}
<button
type="button"
onClick={resetFilters}
className="rounded border border-border px-2 py-1 text-xs text-muted hover:bg-surface-secondary"
>
{t('metrics.resetFilters')}
</button>
</div>
{/* History Table */}
<DataTable<QueryMetricRow>
columns={historyColumns}
data={historyData?.content ?? []}
keyExtractor={(row) => row.query_id}
pageSize={filter.size ?? 20}
totalElements={historyData?.totalElements}
currentPage={historyData?.currentPage}
onPageChange={(p) => setFilter(prev => ({ ...prev, page: p }))}
/>
</div>
</div>
)

파일 보기

@ -18,6 +18,8 @@ import gc.mda.signal_batch.global.websocket.service.ActiveQueryManager;
import gc.mda.signal_batch.global.websocket.service.CacheTrackSimplifier;
import gc.mda.signal_batch.global.websocket.service.DailyTrackCacheManager;
import gc.mda.signal_batch.global.websocket.service.TrackMemoryBudgetManager;
import gc.mda.signal_batch.monitoring.service.QueryMetricsBufferService;
import gc.mda.signal_batch.monitoring.service.QueryMetricsService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
@ -56,6 +58,7 @@ public class GisServiceV2 {
private final ChnPrmShipCacheManager chnPrmShipCacheManager;
private final ChnPrmShipProperties chnPrmShipProperties;
private final TrackMemoryBudgetManager memoryBudgetManager;
private final QueryMetricsBufferService queryMetricsBufferService;
@Value("${rest.v2.query.timeout-seconds:30}")
private int restQueryTimeout;
@ -77,7 +80,8 @@ public class GisServiceV2 {
VesselTrackToCompactConverter vesselTrackToCompactConverter,
ChnPrmShipCacheManager chnPrmShipCacheManager,
ChnPrmShipProperties chnPrmShipProperties,
TrackMemoryBudgetManager memoryBudgetManager) {
TrackMemoryBudgetManager memoryBudgetManager,
QueryMetricsBufferService queryMetricsBufferService) {
this.queryDataSource = queryDataSource;
this.activeQueryManager = activeQueryManager;
this.dailyTrackCacheManager = dailyTrackCacheManager;
@ -89,6 +93,7 @@ public class GisServiceV2 {
this.chnPrmShipCacheManager = chnPrmShipCacheManager;
this.chnPrmShipProperties = chnPrmShipProperties;
this.memoryBudgetManager = memoryBudgetManager;
this.queryMetricsBufferService = queryMetricsBufferService;
}
/**
@ -282,6 +287,7 @@ public class GisServiceV2 {
*/
public List<CompactVesselTrack> getVesselTracksV2(VesselTracksRequest request) {
String queryId = "rest-vessels-" + UUID.randomUUID().toString().substring(0, 8);
long startMs = System.currentTimeMillis();
boolean slotAcquired = false;
boolean memoryReserved = false;
@ -323,6 +329,8 @@ public class GisServiceV2 {
result.size(), request.getVessels().size(),
dailyTrackCacheManager.isEnabled(), request.isIncludeChnPrmShip());
enqueueRestMetric(queryId, request, result, startMs);
return result;
} finally {
@ -338,6 +346,30 @@ public class GisServiceV2 {
}
}
private void enqueueRestMetric(String queryId, VesselTracksRequest request,
List<CompactVesselTrack> result, long startMs) {
try {
int totalPoints = result.stream().mapToInt(CompactVesselTrack::getPointCount).sum();
queryMetricsBufferService.enqueue(QueryMetricsService.QueryMetric.builder()
.queryId(queryId)
.queryType("REST_V2")
.startTime(request.getStartTime())
.endTime(request.getEndTime())
.requestedMmsi(request.getVessels().size())
.dataPath(dailyTrackCacheManager.isEnabled() ? "HYBRID" : "DB")
.uniqueVessels(result.size())
.totalTracks(result.size())
.totalPoints(totalPoints)
.pointsAfterSimplify(totalPoints)
.totalChunks(1)
.elapsedMs(System.currentTimeMillis() - startMs)
.status("COMPLETED")
.build());
} catch (Exception e) {
log.debug("Failed to enqueue REST metric: {}", e.getMessage());
}
}
// 캐시 조회 로직
private List<CompactVesselTrack> queryWithCache(VesselTracksRequest request) {

파일 보기

@ -29,6 +29,7 @@ import java.util.stream.Collectors;
import java.util.function.Consumer;
import gc.mda.signal_batch.global.websocket.dto.QueryStatusUpdate;
import gc.mda.signal_batch.global.websocket.dto.ViewportFilter;
import gc.mda.signal_batch.monitoring.service.QueryMetricsBufferService;
import gc.mda.signal_batch.monitoring.service.QueryMetricsService;
import org.springframework.scheduling.annotation.Async;
import java.util.concurrent.atomic.AtomicBoolean;
@ -53,7 +54,7 @@ public class ChunkedTrackStreamingService {
private final DailyTrackCacheManager dailyTrackCacheManager;
private final CacheTrackSimplifier cacheTrackSimplifier;
private final TrackMemoryBudgetManager memoryBudgetManager;
private final QueryMetricsService queryMetricsService;
private final QueryMetricsBufferService queryMetricsBufferService;
private static final ThreadLocal<WKTReader> wktReaderLocal = ThreadLocal.withInitial(WKTReader::new);
private static final DateTimeFormatter TIMESTAMP_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private static final int MAX_TRACKS_PER_CHUNK = 500000; // 청크당 최대 트랙 (10만 선박 지원)
@ -104,7 +105,7 @@ public class ChunkedTrackStreamingService {
DailyTrackCacheManager dailyTrackCacheManager,
CacheTrackSimplifier cacheTrackSimplifier,
TrackMemoryBudgetManager memoryBudgetManager,
QueryMetricsService queryMetricsService) {
QueryMetricsBufferService queryMetricsBufferService) {
this.queryJdbcTemplate = queryJdbcTemplate;
this.queryDataSource = queryDataSource;
this.activeQueryManager = activeQueryManager;
@ -112,7 +113,7 @@ public class ChunkedTrackStreamingService {
this.dailyTrackCacheManager = dailyTrackCacheManager;
this.cacheTrackSimplifier = cacheTrackSimplifier;
this.memoryBudgetManager = memoryBudgetManager;
this.queryMetricsService = queryMetricsService;
this.queryMetricsBufferService = queryMetricsBufferService;
}
/**
@ -1133,7 +1134,7 @@ public class ChunkedTrackStreamingService {
String vpBounds = (vp != null && vp.isValid())
? String.format("%.4f,%.4f,%.4f,%.4f", vp.getMinLon(), vp.getMinLat(), vp.getMaxLon(), vp.getMaxLat())
: null;
queryMetricsService.saveAsync(QueryMetricsService.QueryMetric.builder()
queryMetricsBufferService.enqueue(QueryMetricsService.QueryMetric.builder()
.queryId(queryId)
.sessionId(sessionId)
.queryType("WEBSOCKET")

파일 보기

@ -2,13 +2,14 @@ package gc.mda.signal_batch.monitoring.controller;
import gc.mda.signal_batch.monitoring.service.QueryMetricsService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.ResponseEntity;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
import java.util.*;
/**
* 쿼리 메트릭 조회 API
@ -18,11 +19,22 @@ import java.util.Map;
*/
@RestController
@RequestMapping("/api/monitoring/query-metrics")
@RequiredArgsConstructor
@Tag(name = "Query Metrics", description = "쿼리 실행 메트릭 조회 API")
public class QueryMetricsController {
private final QueryMetricsService queryMetricsService;
private final JdbcTemplate queryJdbcTemplate;
private static final Set<String> ALLOWED_SORT_COLUMNS = Set.of(
"created_at", "elapsed_ms", "unique_vessels", "total_points"
);
public QueryMetricsController(
QueryMetricsService queryMetricsService,
@Qualifier("queryJdbcTemplate") JdbcTemplate queryJdbcTemplate) {
this.queryMetricsService = queryMetricsService;
this.queryJdbcTemplate = queryJdbcTemplate;
}
@GetMapping
@Operation(summary = "최근 쿼리 메트릭 조회", description = "최근 N건의 쿼리 실행 메트릭을 조회합니다")
@ -37,4 +49,110 @@ public class QueryMetricsController {
@RequestParam(defaultValue = "7") int days) {
return ResponseEntity.ok(queryMetricsService.getStats(Math.min(days, 90)));
}
@GetMapping("/history")
@Operation(summary = "쿼리 이력 조회 (페이지네이션)", description = "필터 + 서버사이드 페이지네이션")
public Map<String, Object> getQueryHistory(
@Parameter(description = "쿼리 유형 (WEBSOCKET, REST_V2)") @RequestParam(required = false) String queryType,
@Parameter(description = "데이터 경로 (CACHE, DB, HYBRID)") @RequestParam(required = false) String dataPath,
@Parameter(description = "상태 (COMPLETED, CANCELLED, ERROR, TIMEOUT)") @RequestParam(required = false) String status,
@Parameter(description = "응답시간 최소 (ms)") @RequestParam(required = false) Integer elapsedMsMin,
@Parameter(description = "응답시간 최대 (ms)") @RequestParam(required = false) Integer elapsedMsMax,
@Parameter(description = "페이지 번호 (0부터)") @RequestParam(defaultValue = "0") int page,
@Parameter(description = "페이지 크기") @RequestParam(defaultValue = "20") int size,
@Parameter(description = "정렬 컬럼") @RequestParam(defaultValue = "created_at") String sortBy,
@Parameter(description = "정렬 방향 (asc, desc)") @RequestParam(defaultValue = "desc") String sortDir) {
if (!ALLOWED_SORT_COLUMNS.contains(sortBy)) {
sortBy = "created_at";
}
String direction = "asc".equalsIgnoreCase(sortDir) ? "ASC" : "DESC";
size = Math.min(size, 100);
StringBuilder where = new StringBuilder("WHERE 1=1");
List<Object> params = new ArrayList<>();
if (queryType != null && !queryType.isEmpty()) {
where.append(" AND query_type = ?");
params.add(queryType);
}
if (dataPath != null && !dataPath.isEmpty()) {
where.append(" AND data_path = ?");
params.add(dataPath);
}
if (status != null && !status.isEmpty()) {
where.append(" AND status = ?");
params.add(status);
}
if (elapsedMsMin != null) {
where.append(" AND elapsed_ms >= ?");
params.add(elapsedMsMin);
}
if (elapsedMsMax != null) {
where.append(" AND elapsed_ms <= ?");
params.add(elapsedMsMax);
}
String whereClause = where.toString();
// COUNT 쿼리
String countSql = "SELECT COUNT(*) FROM signal.t_query_metrics " + whereClause;
Integer totalElements = queryJdbcTemplate.queryForObject(countSql, Integer.class, params.toArray());
if (totalElements == null) totalElements = 0;
// 데이터 쿼리
String dataSql = """
SELECT id, query_id, query_type, created_at, data_path, status,
zoom_level, requested_mmsi, unique_vessels, total_tracks,
total_points, points_after_simplify, total_chunks,
response_bytes, elapsed_ms, db_query_ms, simplify_ms,
cache_hit_days, db_query_days
FROM signal.t_query_metrics
""" + whereClause +
" ORDER BY " + sortBy + " " + direction +
" LIMIT ? OFFSET ?";
List<Object> dataParams = new ArrayList<>(params);
dataParams.add(size);
dataParams.add(page * size);
List<Map<String, Object>> content = queryJdbcTemplate.queryForList(dataSql, dataParams.toArray());
Map<String, Object> result = new LinkedHashMap<>();
result.put("content", content);
result.put("totalElements", totalElements);
result.put("totalPages", (int) Math.ceil((double) totalElements / size));
result.put("currentPage", page);
result.put("pageSize", size);
return result;
}
@GetMapping("/summary")
@Operation(summary = "쿼리 메트릭 요약", description = "최근 N시간 요약 통계 (P95 포함)")
public Map<String, Object> getSummary(
@Parameter(description = "조회 기간 (시간)") @RequestParam(defaultValue = "24") int hours) {
String sql = """
SELECT
COUNT(*) as total_queries,
COALESCE(AVG(elapsed_ms), 0) as avg_elapsed_ms,
COALESCE(PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY elapsed_ms), 0) as p95_elapsed_ms,
COALESCE(MAX(elapsed_ms), 0) as max_elapsed_ms,
COUNT(CASE WHEN query_type = 'WEBSOCKET' THEN 1 END) as ws_count,
COUNT(CASE WHEN query_type LIKE 'REST%%' THEN 1 END) as rest_count,
COUNT(CASE WHEN data_path = 'CACHE' THEN 1 END) as cache_only_count,
COUNT(CASE WHEN data_path = 'DB' THEN 1 END) as db_only_count,
COUNT(CASE WHEN data_path = 'HYBRID' THEN 1 END) as hybrid_count,
COUNT(CASE WHEN status = 'COMPLETED' THEN 1 END) as completed_count,
COUNT(CASE WHEN status != 'COMPLETED' THEN 1 END) as failed_count,
COALESCE(AVG(unique_vessels), 0) as avg_vessels,
COALESCE(AVG(total_points), 0) as avg_points_before,
COALESCE(AVG(points_after_simplify), 0) as avg_points_after
FROM signal.t_query_metrics
WHERE created_at >= NOW() - INTERVAL '%d hours'
""".formatted(Math.min(hours, 720));
return queryJdbcTemplate.queryForMap(sql);
}
}

파일 보기

@ -0,0 +1,110 @@
package gc.mda.signal_batch.monitoring.service;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import java.sql.Timestamp;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ConcurrentLinkedQueue;
/**
* 쿼리 메트릭 벌크 INSERT 버퍼 서비스
*
* ConcurrentLinkedQueue로 lock-free 수집 10초 간격으로 batchUpdate.
* 1요청 = 1레코드 보장: WebSocket은 쿼리 완료 1회, REST는 호출당 1회 enqueue.
*/
@Slf4j
@Service
public class QueryMetricsBufferService {
private static final int MAX_FLUSH_SIZE = 500;
private static final String INSERT_SQL = """
INSERT INTO signal.t_query_metrics (
query_id, session_id, query_type, created_at,
start_time, end_time, zoom_level, viewport_bounds, requested_mmsi,
data_path, cache_hit_days, db_query_days, db_conn_total,
unique_vessels, total_tracks, total_points, points_after_simplify,
total_chunks, response_bytes,
elapsed_ms, db_query_ms, simplify_ms, backpressure_events,
status
) VALUES (
?, ?, ?, now(),
?, ?, ?, ?, ?,
?, ?, ?, ?,
?, ?, ?, ?,
?, ?,
?, ?, ?, ?,
?
)
""";
private final JdbcTemplate queryJdbcTemplate;
private final ConcurrentLinkedQueue<QueryMetricsService.QueryMetric> buffer = new ConcurrentLinkedQueue<>();
public QueryMetricsBufferService(
@Qualifier("queryJdbcTemplate") JdbcTemplate queryJdbcTemplate) {
this.queryJdbcTemplate = queryJdbcTemplate;
}
/**
* 메트릭 레코드를 버퍼에 추가 (lock-free)
*/
public void enqueue(QueryMetricsService.QueryMetric metric) {
if (metric == null) return;
buffer.offer(metric);
}
/**
* 10초 간격으로 버퍼 flush batchUpdate
*/
@Scheduled(fixedDelay = 10_000)
public void flush() {
if (buffer.isEmpty()) return;
List<QueryMetricsService.QueryMetric> batch = new ArrayList<>(MAX_FLUSH_SIZE);
QueryMetricsService.QueryMetric metric;
while (batch.size() < MAX_FLUSH_SIZE && (metric = buffer.poll()) != null) {
batch.add(metric);
}
if (batch.isEmpty()) return;
try {
List<Object[]> args = batch.stream()
.map(this::toArgs)
.toList();
queryJdbcTemplate.batchUpdate(INSERT_SQL, args);
log.debug("Flushed {} query metrics to DB (remaining: {})", batch.size(), buffer.size());
} catch (Exception e) {
log.warn("Failed to flush query metrics ({} records): {}", batch.size(), e.getMessage());
}
}
private Object[] toArgs(QueryMetricsService.QueryMetric m) {
return new Object[]{
m.getQueryId(), m.getSessionId(), m.getQueryType(),
m.getStartTime() != null ? Timestamp.valueOf(m.getStartTime()) : null,
m.getEndTime() != null ? Timestamp.valueOf(m.getEndTime()) : null,
m.getZoomLevel(), m.getViewportBounds(), m.getRequestedMmsi(),
m.getDataPath(), m.getCacheHitDays(), m.getDbQueryDays(), m.getDbConnTotal(),
m.getUniqueVessels(), m.getTotalTracks(), m.getTotalPoints(), m.getPointsAfterSimplify(),
m.getTotalChunks(), m.getResponseBytes(),
m.getElapsedMs(), m.getDbQueryMs(), m.getSimplifyMs(), m.getBackpressureEvents(),
m.getStatus()
};
}
/**
* 현재 버퍼 크기 (모니터링용)
*/
public int getBufferSize() {
return buffer.size();
}
}

파일 보기

@ -5,20 +5,18 @@ import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.sql.Timestamp;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 쿼리 실행 메트릭 DB 저장/조회 서비스
* 쿼리 실행 메트릭 조회 서비스
*
* WebSocket 리플레이 REST API 쿼리의 성능 메트릭을 signal.t_query_metrics에 저장.
* streamChunkedTracks() finally 블록에서 비동기 INSERT 호출하여 응답 지연 없이 기록.
* 적재는 QueryMetricsBufferService가 담당 (ConcurrentLinkedQueue + 10초 batch flush).
* 서비스는 조회 전용 + QueryMetric DTO 정의.
*/
@Slf4j
@Service
@ -26,54 +24,10 @@ public class QueryMetricsService {
private final JdbcTemplate queryJdbcTemplate;
private static final String INSERT_SQL = """
INSERT INTO signal.t_query_metrics (
query_id, session_id, query_type, created_at,
start_time, end_time, zoom_level, viewport_bounds, requested_mmsi,
data_path, cache_hit_days, db_query_days, db_conn_total,
unique_vessels, total_tracks, total_points, points_after_simplify,
total_chunks, response_bytes,
elapsed_ms, db_query_ms, simplify_ms, backpressure_events,
status
) VALUES (
?, ?, ?, now(),
?, ?, ?, ?, ?,
?, ?, ?, ?,
?, ?, ?, ?,
?, ?,
?, ?, ?, ?,
?
)
""";
public QueryMetricsService(@Qualifier("queryJdbcTemplate") JdbcTemplate queryJdbcTemplate) {
this.queryJdbcTemplate = queryJdbcTemplate;
}
/**
* 쿼리 메트릭 비동기 저장 쿼리 응답에 영향 없음
*/
@Async("trackStreamingExecutor")
public void saveAsync(QueryMetric metric) {
try {
queryJdbcTemplate.update(INSERT_SQL,
metric.queryId, metric.sessionId, metric.queryType,
metric.startTime != null ? Timestamp.valueOf(metric.startTime) : null,
metric.endTime != null ? Timestamp.valueOf(metric.endTime) : null,
metric.zoomLevel, metric.viewportBounds, metric.requestedMmsi,
metric.dataPath, metric.cacheHitDays, metric.dbQueryDays, metric.dbConnTotal,
metric.uniqueVessels, metric.totalTracks, metric.totalPoints, metric.pointsAfterSimplify,
metric.totalChunks, metric.responseBytes,
metric.elapsedMs, metric.dbQueryMs, metric.simplifyMs, metric.backpressureEvents,
metric.status
);
log.debug("Query metric saved: queryId={}, elapsed={}ms, status={}",
metric.queryId, metric.elapsedMs, metric.status);
} catch (Exception e) {
log.warn("Failed to save query metric: queryId={}, error={}", metric.queryId, e.getMessage());
}
}
/**
* 최근 쿼리 메트릭 조회
*/