From 7852f840e4a90881cc2496bab07b0527e1f48d18 Mon Sep 17 00:00:00 2001 From: htlee Date: Tue, 10 Mar 2026 11:15:00 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=EC=BF=BC=EB=A6=AC=20=EB=A9=94?= =?UTF-8?q?=ED=8A=B8=EB=A6=AD=20=EC=88=98=EC=A7=91=20=ED=99=95=EC=9E=A5=20?= =?UTF-8?q?+=20=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20=EC=84=B1=EB=8A=A5?= =?UTF-8?q?=20=EC=B0=A8=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - client IP 수집 (REST: X-Forwarded-For 체인, WS: 세션 속성) - 응답 크기 추정 (uniqueVessels*200 + points*40) - timeseries API (/api/monitoring/query-metrics/timeseries) - Dashboard 쿼리 성능 차트 5종 (응답시간, 볼륨, 캐시경로, 응답크기, Top 클라이언트) --- frontend/src/api/monitorApi.ts | 5 + frontend/src/api/types.ts | 28 ++++ frontend/src/components/charts/LineChart.tsx | 4 + frontend/src/i18n/en.ts | 10 ++ frontend/src/i18n/ko.ts | 10 ++ frontend/src/pages/Dashboard.tsx | 157 +++++++++++++++++- .../gis/controller/GisControllerV2.java | 15 +- .../domain/gis/service/GisServiceV2.java | 9 +- .../controller/StompTrackController.java | 10 +- .../service/ChunkedTrackStreamingService.java | 4 +- .../controller/QueryMetricsController.java | 50 +++++- .../service/QueryMetricsBufferService.java | 28 +++- .../service/QueryMetricsService.java | 1 + .../sql/create_query_metrics_table.sql | 12 ++ 14 files changed, 331 insertions(+), 12 deletions(-) diff --git a/frontend/src/api/monitorApi.ts b/frontend/src/api/monitorApi.ts index 579a8cd..441ab6d 100644 --- a/frontend/src/api/monitorApi.ts +++ b/frontend/src/api/monitorApi.ts @@ -9,6 +9,7 @@ import type { QueryMetricsPage, QueryMetricsParams, QueryMetricsSummary, + QueryMetricsTimeSeries, ThroughputMetrics, } from './types.ts' @@ -66,4 +67,8 @@ export const monitorApi = { getQueryMetricsSummary(hours = 24): Promise { return fetchJson(`/api/monitoring/query-metrics/summary?hours=${hours}`) }, + + getQueryMetricsTimeSeries(days = 7): Promise { + return fetchJson(`/api/monitoring/query-metrics/timeseries?days=${days}`) + }, } diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 239efe3..c434214 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -232,6 +232,34 @@ export interface QueryMetricsSummary { avg_vessels: number avg_points_before: number avg_points_after: number + avg_response_size_bytes: number +} + +/* Query Metrics TimeSeries */ + +export interface TimeSeriesBucket { + bucket: string + query_count: number + avg_elapsed_ms: number + max_elapsed_ms: number + avg_response_bytes: number + ws_count: number + rest_count: number + cache_count: number + db_count: number + hybrid_count: number +} + +export interface TopClient { + client_ip: string + query_count: number + avg_elapsed_ms: number +} + +export interface QueryMetricsTimeSeries { + buckets: TimeSeriesBucket[] + topClients: TopClient[] + granularity: 'HOURLY' | 'DAILY' } export interface QueryMetricsParams { diff --git a/frontend/src/components/charts/LineChart.tsx b/frontend/src/components/charts/LineChart.tsx index 759550d..9afa5c4 100644 --- a/frontend/src/components/charts/LineChart.tsx +++ b/frontend/src/components/charts/LineChart.tsx @@ -21,6 +21,7 @@ interface LineChartProps { xKey: string height?: number label?: string + yFormatter?: (value: number) => string } export default function LineChart({ @@ -29,6 +30,7 @@ export default function LineChart({ xKey, height = 240, label, + yFormatter, }: LineChartProps) { return (
@@ -46,6 +48,7 @@ export default function LineChart({ tick={{ fontSize: 12, fill: 'var(--sb-text-muted)' }} axisLine={false} tickLine={false} + tickFormatter={yFormatter} /> yFormatter(v) : undefined} /> {series.length > 1 && ( ('dash.delay', null) const [daily, setDaily] = useCachedState('dash.daily', null) const [running, setRunning] = useCachedState('dash.running', []) + const [queryTs, setQueryTs] = useCachedState('dash.queryTs', null) const [days, setDays] = useState(7) + const [isQueryChartsOpen, setIsQueryChartsOpen] = useState(() => + localStorage.getItem('dashboard-query-charts') !== 'collapsed', + ) + + const toggleQueryCharts = useCallback(() => { + setIsQueryChartsOpen(prev => { + const next = !prev + localStorage.setItem('dashboard-query-charts', next ? 'expanded' : 'collapsed') + return next + }) + }, []) usePoller(() => { batchApi.getStatistics(days).then(setStats).catch(() => {}) @@ -37,6 +51,7 @@ export default function Dashboard() { monitorApi.getDelay().then(setDelay).catch(() => {}) batchApi.getDailyStats().then(setDaily).catch(() => {}) batchApi.getRunningJobs().then(setRunning).catch(() => {}) + monitorApi.getQueryMetricsTimeSeries(days).then(setQueryTs).catch(() => {}) }, POLL_INTERVAL, [days]) const memUsage = metrics @@ -214,6 +229,146 @@ export default function Dashboard() { />
)} + + {/* Query Performance Charts */} +
+ + + {isQueryChartsOpen && ( +
+ {queryTs && queryTs.buckets.length > 0 ? ( + <> + {/* Row 1: Response Time + Query Volume */} +
+
+ ({ + time: formatBucket(b.bucket, queryTs.granularity), + avg: Math.round(b.avg_elapsed_ms), + max: Math.round(b.max_elapsed_ms), + }))} + series={[ + { dataKey: 'avg', color: 'var(--sb-primary)', name: t('dashboard.avgElapsed') }, + { dataKey: 'max', color: 'var(--sb-danger)', name: t('dashboard.maxElapsed') }, + ]} + xKey="time" + height={220} + yFormatter={v => `${v}ms`} + /> +
+
+ ({ + time: formatBucket(b.bucket, queryTs.granularity), + WS: b.ws_count, + REST: b.rest_count, + }))} + xKey="time" + height={220} + series={[ + { dataKey: 'WS', color: 'var(--sb-primary)', name: 'WebSocket', stackId: 'q' }, + { dataKey: 'REST', color: 'var(--sb-success)', name: 'REST', stackId: 'q' }, + ]} + /> +
+
+ + {/* Row 2: Cache Path + Response Size */} +
+
+ ({ + time: formatBucket(b.bucket, queryTs.granularity), + Cache: b.cache_count, + DB: b.db_count, + Hybrid: b.hybrid_count, + }))} + xKey="time" + height={220} + series={[ + { dataKey: 'Cache', color: 'var(--sb-success)', stackId: 'p' }, + { dataKey: 'DB', color: 'var(--sb-warning)', stackId: 'p' }, + { dataKey: 'Hybrid', color: 'var(--sb-primary)', stackId: 'p' }, + ]} + /> +
+
+ ({ + time: formatBucket(b.bucket, queryTs.granularity), + size: Math.round(b.avg_response_bytes / 1024), + }))} + series={[ + { dataKey: 'size', color: 'var(--sb-primary)', name: 'KB' }, + ]} + xKey="time" + height={220} + yFormatter={v => `${v}KB`} + /> +
+
+ + {/* Top Clients */} + {queryTs.topClients.length > 0 && ( +
+
{t('dashboard.topClients')}
+
+ {queryTs.topClients.map((c, i) => { + const maxCount = queryTs.topClients[0].query_count + const pct = maxCount > 0 ? (c.query_count / maxCount) * 100 : 0 + return ( +
+ {c.client_ip ?? '-'} +
+
+
+
+
+ + {c.query_count}{t('dashboard.queries')} · {Math.round(c.avg_elapsed_ms)}ms + +
+ ) + })} +
+
+ )} + + ) : ( +
{t('dashboard.noChartData')}
+ )} +
+ )} +
) } + +function formatBucket(bucket: string, granularity: 'HOURLY' | 'DAILY'): string { + if (granularity === 'HOURLY') { + // "2026-03-10T14:00:00" → "14:00" + const timePart = bucket.includes('T') ? bucket.split('T')[1] : bucket + return timePart.slice(0, 5) + } + // "2026-03-10" → "03-10" + return bucket.slice(5, 10) +} diff --git a/src/main/java/gc/mda/signal_batch/domain/gis/controller/GisControllerV2.java b/src/main/java/gc/mda/signal_batch/domain/gis/controller/GisControllerV2.java index 76ccdeb..495171a 100644 --- a/src/main/java/gc/mda/signal_batch/domain/gis/controller/GisControllerV2.java +++ b/src/main/java/gc/mda/signal_batch/domain/gis/controller/GisControllerV2.java @@ -18,6 +18,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.web.bind.annotation.*; @@ -188,8 +189,18 @@ public class GisControllerV2 { required = true, content = @Content(schema = @Schema(implementation = VesselTracksRequest.class)) ) - @RequestBody VesselTracksRequest request) { - return gisServiceV2.getVesselTracksV2(request); + @RequestBody VesselTracksRequest request, + HttpServletRequest httpRequest) { + return gisServiceV2.getVesselTracksV2(request, getClientIp(httpRequest)); + } + + private String getClientIp(HttpServletRequest request) { + String[] headers = {"X-Forwarded-For", "X-Original-Forwarded-For", "X-Real-IP"}; + for (String header : headers) { + String ip = request.getHeader(header); + if (ip != null && !ip.isBlank()) return ip.split(",")[0].trim(); + } + return request.getRemoteAddr(); } @GetMapping("/vessels/recent-positions") diff --git a/src/main/java/gc/mda/signal_batch/domain/gis/service/GisServiceV2.java b/src/main/java/gc/mda/signal_batch/domain/gis/service/GisServiceV2.java index cfff63a..42ec069 100644 --- a/src/main/java/gc/mda/signal_batch/domain/gis/service/GisServiceV2.java +++ b/src/main/java/gc/mda/signal_batch/domain/gis/service/GisServiceV2.java @@ -285,7 +285,7 @@ public class GisServiceV2 { /** * 선박별 항적 조회 V2 (캐시 + Semaphore + 간소화 + ChnPrmShip enrichment) */ - public List getVesselTracksV2(VesselTracksRequest request) { + public List getVesselTracksV2(VesselTracksRequest request, String clientIp) { String queryId = "rest-vessels-" + UUID.randomUUID().toString().substring(0, 8); long startMs = System.currentTimeMillis(); boolean slotAcquired = false; @@ -329,7 +329,7 @@ public class GisServiceV2 { result.size(), request.getVessels().size(), dailyTrackCacheManager.isEnabled(), request.isIncludeChnPrmShip()); - enqueueRestMetric(queryId, request, result, startMs); + enqueueRestMetric(queryId, request, result, startMs, clientIp); return result; @@ -347,9 +347,10 @@ public class GisServiceV2 { } private void enqueueRestMetric(String queryId, VesselTracksRequest request, - List result, long startMs) { + List result, long startMs, String clientIp) { try { int totalPoints = result.stream().mapToInt(CompactVesselTrack::getPointCount).sum(); + long responseBytes = (long) result.size() * 200 + (long) totalPoints * 40; queryMetricsBufferService.enqueue(QueryMetricsService.QueryMetric.builder() .queryId(queryId) .queryType("REST_V2") @@ -362,8 +363,10 @@ public class GisServiceV2 { .totalPoints(totalPoints) .pointsAfterSimplify(totalPoints) .totalChunks(1) + .responseBytes(responseBytes) .elapsedMs(System.currentTimeMillis() - startMs) .status("COMPLETED") + .clientIp(clientIp) .build()); } catch (Exception e) { log.debug("Failed to enqueue REST metric: {}", e.getMessage()); diff --git a/src/main/java/gc/mda/signal_batch/global/websocket/controller/StompTrackController.java b/src/main/java/gc/mda/signal_batch/global/websocket/controller/StompTrackController.java index 1b75ad8..37c9b1b 100644 --- a/src/main/java/gc/mda/signal_batch/global/websocket/controller/StompTrackController.java +++ b/src/main/java/gc/mda/signal_batch/global/websocket/controller/StompTrackController.java @@ -71,6 +71,13 @@ public class StompTrackController { } }; + // 세션 속성에서 CLIENT_IP 추출 + String clientIp = null; + Map sessionAttrs = headerAccessor.getSessionAttributes(); + if (sessionAttrs != null && sessionAttrs.containsKey("CLIENT_IP")) { + clientIp = (String) sessionAttrs.get("CLIENT_IP"); + } + // 비동기 스트리밍 시작 - 청크 모드 체크 if (request.isChunkedMode()) { chunkedTrackStreamingService.streamChunkedTracks( @@ -78,7 +85,8 @@ public class StompTrackController { queryId, sessionId, chunk -> sendChunkedDataToUser(userId, chunk), - statusCallback + statusCallback, + clientIp ); } else { trackStreamingService.streamTracks( diff --git a/src/main/java/gc/mda/signal_batch/global/websocket/service/ChunkedTrackStreamingService.java b/src/main/java/gc/mda/signal_batch/global/websocket/service/ChunkedTrackStreamingService.java index 493bded..613342b 100644 --- a/src/main/java/gc/mda/signal_batch/global/websocket/service/ChunkedTrackStreamingService.java +++ b/src/main/java/gc/mda/signal_batch/global/websocket/service/ChunkedTrackStreamingService.java @@ -724,7 +724,8 @@ public class ChunkedTrackStreamingService { String queryId, String sessionId, Consumer chunkConsumer, - Consumer statusConsumer) { + Consumer statusConsumer, + String clientIp) { boolean slotAcquired = false; QueryBenchmark benchmark = null; QueryContext ctx = null; @@ -1158,6 +1159,7 @@ public class ChunkedTrackStreamingService { .simplifyMs(benchmark.simplifyTimeMs) .backpressureEvents(bpMetrics != null ? bpMetrics.backpressureEvents.get() : 0) .status(queryStatus) + .clientIp(clientIp) .build()); } diff --git a/src/main/java/gc/mda/signal_batch/monitoring/controller/QueryMetricsController.java b/src/main/java/gc/mda/signal_batch/monitoring/controller/QueryMetricsController.java index a4d34c3..3418050 100644 --- a/src/main/java/gc/mda/signal_batch/monitoring/controller/QueryMetricsController.java +++ b/src/main/java/gc/mda/signal_batch/monitoring/controller/QueryMetricsController.java @@ -148,11 +148,59 @@ public class QueryMetricsController { 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 + COALESCE(AVG(points_after_simplify), 0) as avg_points_after, + COALESCE(AVG(response_bytes), 0) as avg_response_size_bytes FROM signal.t_query_metrics WHERE created_at >= NOW() - INTERVAL '%d hours' """.formatted(Math.min(hours, 720)); return queryJdbcTemplate.queryForMap(sql); } + + @GetMapping("/timeseries") + @Operation(summary = "쿼리 메트릭 시계열", description = "시간별/일별 버킷 집계 + Top 10 클라이언트") + public Map getTimeSeries( + @Parameter(description = "조회 기간 (일)") @RequestParam(defaultValue = "7") int days) { + + days = Math.min(days, 90); + String granularity = days <= 7 ? "HOURLY" : "DAILY"; + String bucketExpr = days <= 7 ? "DATE_TRUNC('hour', created_at)" : "DATE(created_at)"; + + String bucketSql = """ + SELECT %s AS bucket, + COUNT(*) AS query_count, + COALESCE(AVG(elapsed_ms), 0) AS avg_elapsed_ms, + COALESCE(MAX(elapsed_ms), 0) AS max_elapsed_ms, + COALESCE(AVG(response_bytes), 0) AS avg_response_bytes, + 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_count, + COUNT(CASE WHEN data_path = 'DB' THEN 1 END) AS db_count, + COUNT(CASE WHEN data_path = 'HYBRID' THEN 1 END) AS hybrid_count + FROM signal.t_query_metrics + WHERE created_at >= NOW() - INTERVAL '%d days' + GROUP BY bucket ORDER BY bucket + """.formatted(bucketExpr, days); + + List> buckets = queryJdbcTemplate.queryForList(bucketSql); + + String topClientsSql = """ + SELECT client_ip, COUNT(*) AS query_count, + COALESCE(AVG(elapsed_ms), 0) AS avg_elapsed_ms + FROM signal.t_query_metrics + WHERE created_at >= NOW() - INTERVAL '%d days' + AND client_ip IS NOT NULL + GROUP BY client_ip + ORDER BY query_count DESC LIMIT 10 + """.formatted(days); + + List> topClients = queryJdbcTemplate.queryForList(topClientsSql); + + Map result = new LinkedHashMap<>(); + result.put("buckets", buckets); + result.put("topClients", topClients); + result.put("granularity", granularity); + + return result; + } } diff --git a/src/main/java/gc/mda/signal_batch/monitoring/service/QueryMetricsBufferService.java b/src/main/java/gc/mda/signal_batch/monitoring/service/QueryMetricsBufferService.java index 97a5084..73fb889 100644 --- a/src/main/java/gc/mda/signal_batch/monitoring/service/QueryMetricsBufferService.java +++ b/src/main/java/gc/mda/signal_batch/monitoring/service/QueryMetricsBufferService.java @@ -1,5 +1,6 @@ package gc.mda.signal_batch.monitoring.service; +import jakarta.annotation.PostConstruct; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.jdbc.core.JdbcTemplate; @@ -31,7 +32,7 @@ public class QueryMetricsBufferService { unique_vessels, total_tracks, total_points, points_after_simplify, total_chunks, response_bytes, elapsed_ms, db_query_ms, simplify_ms, backpressure_events, - status + status, client_ip ) VALUES ( ?, ?, ?, now(), ?, ?, ?, ?, ?, @@ -39,7 +40,7 @@ public class QueryMetricsBufferService { ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, - ? + ?, ? ) """; @@ -51,6 +52,27 @@ public class QueryMetricsBufferService { this.queryJdbcTemplate = queryJdbcTemplate; } + @PostConstruct + void ensureClientIpColumn() { + try { + queryJdbcTemplate.execute(""" + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'signal' AND table_name = 't_query_metrics' AND column_name = 'client_ip' + ) THEN + ALTER TABLE signal.t_query_metrics ADD COLUMN client_ip VARCHAR(45); + CREATE INDEX IF NOT EXISTS idx_query_metrics_client_ip ON signal.t_query_metrics(client_ip, created_at); + END IF; + END $$ + """); + log.info("t_query_metrics client_ip column ensured"); + } catch (Exception e) { + log.warn("Failed to ensure client_ip column: {}", e.getMessage()); + } + } + /** * 메트릭 레코드를 버퍼에 추가 (lock-free) */ @@ -97,7 +119,7 @@ public class QueryMetricsBufferService { m.getUniqueVessels(), m.getTotalTracks(), m.getTotalPoints(), m.getPointsAfterSimplify(), m.getTotalChunks(), m.getResponseBytes(), m.getElapsedMs(), m.getDbQueryMs(), m.getSimplifyMs(), m.getBackpressureEvents(), - m.getStatus() + m.getStatus(), m.getClientIp() }; } diff --git a/src/main/java/gc/mda/signal_batch/monitoring/service/QueryMetricsService.java b/src/main/java/gc/mda/signal_batch/monitoring/service/QueryMetricsService.java index 3410210..8ec4b6f 100644 --- a/src/main/java/gc/mda/signal_batch/monitoring/service/QueryMetricsService.java +++ b/src/main/java/gc/mda/signal_batch/monitoring/service/QueryMetricsService.java @@ -130,5 +130,6 @@ public class QueryMetricsService { private final long simplifyMs; private final int backpressureEvents; private final String status; + private final String clientIp; } } diff --git a/src/main/resources/sql/create_query_metrics_table.sql b/src/main/resources/sql/create_query_metrics_table.sql index 3c4b404..26b146b 100644 --- a/src/main/resources/sql/create_query_metrics_table.sql +++ b/src/main/resources/sql/create_query_metrics_table.sql @@ -38,5 +38,17 @@ CREATE TABLE IF NOT EXISTS signal.t_query_metrics ( status VARCHAR(20) DEFAULT 'COMPLETED' -- 'COMPLETED' | 'CANCELLED' | 'ERROR' | 'TIMEOUT' ); +-- client_ip 컬럼 추가 (idempotent) +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'signal' AND table_name = 't_query_metrics' AND column_name = 'client_ip' + ) THEN + ALTER TABLE signal.t_query_metrics ADD COLUMN client_ip VARCHAR(45); + END IF; +END $$; + CREATE INDEX IF NOT EXISTS idx_query_metrics_created ON signal.t_query_metrics(created_at); CREATE INDEX IF NOT EXISTS idx_query_metrics_type ON signal.t_query_metrics(query_type, created_at); +CREATE INDEX IF NOT EXISTS idx_query_metrics_client_ip ON signal.t_query_metrics(client_ip, created_at); From bfaf190b8ca43d1f4bbf9d98ee30f7c4c72697f8 Mon Sep 17 00:00:00 2001 From: htlee Date: Tue, 10 Mar 2026 11:16:45 +0900 Subject: [PATCH 2/2] =?UTF-8?q?docs:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/RELEASE-NOTES.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 33d8845..3c956ef 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,9 @@ ## [Unreleased] +### 추가 +- 쿼리 메트릭 수집 확장 + 대시보드 성능 차트 — client IP 수집(REST/WS), 응답 크기 추정, timeseries API, 대시보드 쿼리 성능 차트 5종(응답시간·볼륨·캐시경로·응답크기·Top 클라이언트) + ## [2026-03-10] ### 추가