From a0f24d575724da7984fb1e8210dd9cf5e56ed392 Mon Sep 17 00:00:00 2001 From: htlee Date: Tue, 10 Mar 2026 08:41:56 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20API/WS=20=EC=BF=BC=EB=A6=AC=20?= =?UTF-8?q?=EB=A9=94=ED=8A=B8=EB=A6=AD=20=EC=9D=B4=EB=A0=A5=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - QueryMetricsBufferService: ConcurrentLinkedQueue + 10초 batch flush - GisServiceV2: REST API 메트릭 수집 추가 - ChunkedTrackStreamingService: saveAsync → buffer.enqueue 전환 - QueryMetricsController: /history (페이지네이션+필터), /summary (P95 포함) - ApiMetrics.tsx: 요약카드 + 버튼그룹 필터 + 서버사이드 DataTable + 30s 폴링 - DataTable: server-side pagination props 확장 (하위 호환) --- frontend/src/api/monitorApi.ts | 21 ++ frontend/src/api/types.ts | 59 +++++ frontend/src/components/common/DataTable.tsx | 39 +++- frontend/src/i18n/en.ts | 20 +- frontend/src/i18n/ko.ts | 20 +- frontend/src/pages/ApiMetrics.tsx | 210 +++++++++++++++++- .../domain/gis/service/GisServiceV2.java | 34 ++- .../service/ChunkedTrackStreamingService.java | 9 +- .../controller/QueryMetricsController.java | 126 ++++++++++- .../service/QueryMetricsBufferService.java | 110 +++++++++ .../service/QueryMetricsService.java | 52 +---- 11 files changed, 622 insertions(+), 78 deletions(-) create mode 100644 src/main/java/gc/mda/signal_batch/monitoring/service/QueryMetricsBufferService.java diff --git a/frontend/src/api/monitorApi.ts b/frontend/src/api/monitorApi.ts index e487b51..579a8cd 100644 --- a/frontend/src/api/monitorApi.ts +++ b/frontend/src/api/monitorApi.ts @@ -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[]> { return fetchJson('/admin/haegu/stats') }, + + getQueryMetricsHistory(params: QueryMetricsParams): Promise { + 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 { + return fetchJson(`/api/monitoring/query-metrics/summary?hours=${hours}`) + }, } diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 419fecb..239efe3 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -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 { diff --git a/frontend/src/components/common/DataTable.tsx b/frontend/src/components/common/DataTable.tsx index 049dce8..4176438 100644 --- a/frontend/src/components/common/DataTable.tsx +++ b/frontend/src/components/common/DataTable.tsx @@ -16,6 +16,10 @@ interface DataTableProps { onRowClick?: (row: T) => void emptyMessage?: string pageSize?: number + // Server-side pagination (optional) + totalElements?: number + currentPage?: number + onPageChange?: (page: number) => void } export default function DataTable({ @@ -25,14 +29,19 @@ export default function DataTable({ onRowClick, emptyMessage, pageSize = 20, + totalElements, + currentPage, + onPageChange, }: DataTableProps) { const { t } = useI18n() const [sortKey, setSortKey] = useState(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)[sortKey] const bv = (b as Record)[sortKey] @@ -40,10 +49,12 @@ export default function DataTable({ 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({ } } + const handlePageChange = (newPage: number) => { + if (isServerSide) { + onPageChange!(newPage) + } else { + setPage(newPage) + } + } + return (
@@ -67,7 +86,7 @@ export default function DataTable({ style={{ textAlign: col.align ?? 'left', cursor: col.sortable !== false ? 'pointer' : 'default' }} > {col.label} - {sortKey === col.key && (sortAsc ? ' \u25B2' : ' \u25BC')} + {sortKey === col.key && (sortAsc ? ' ▲' : ' ▼')} ))} @@ -102,19 +121,19 @@ export default function DataTable({ {totalPages > 1 && (
- {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)}
- {/* Placeholder for future DB-based metrics */} -
-
-

{t('metrics.dbMetricsPlaceholder')}

-

{t('metrics.dbMetricsDesc')}

+ {/* Query History Section */} +
+
{t('metrics.queryHistory')}
+ + {/* Summary Cards */} +
+ + + +
+ + {/* Filters */} +
+ {/* Query Type toggle */} +
+ {t('metrics.queryType')}: + {[undefined, 'WEBSOCKET', 'REST_V2'].map((val) => ( + + ))} +
+ + {/* Data Path toggle */} +
+ {t('metrics.dataPath')}: + {[undefined, 'CACHE', 'DB', 'HYBRID'].map((val) => ( + + ))} +
+ + {/* Elapsed Time select */} + + + {/* Reset */} + +
+ + {/* History Table */} + + 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 }))} + />
) 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 7f3dd29..cfff63a 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 @@ -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 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 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 queryWithCache(VesselTracksRequest request) { 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 445d0a0..493bded 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 @@ -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 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") 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 e7fb789..a4d34c3 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 @@ -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 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 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 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 dataParams = new ArrayList<>(params); + dataParams.add(size); + dataParams.add(page * size); + + List> content = queryJdbcTemplate.queryForList(dataSql, dataParams.toArray()); + + Map 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 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); + } } 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 new file mode 100644 index 0000000..97a5084 --- /dev/null +++ b/src/main/java/gc/mda/signal_batch/monitoring/service/QueryMetricsBufferService.java @@ -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 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 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 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(); + } +} 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 9dd696c..3410210 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 @@ -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()); - } - } - /** * 최근 쿼리 메트릭 조회 */ From 1a0d52911faeaf7a737056551422d3a959d2ffa3 Mon Sep 17 00:00:00 2001 From: htlee Date: Tue, 10 Mar 2026 08:46:03 +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 e0cb6a3..2355d0a 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,9 @@ ## [Unreleased] +### 추가 +- API/WS 쿼리 메트릭 이력 조회 기능 — BufferService(batch flush) + /history, /summary API + 프론트엔드 요약카드·필터·페이지네이션 + ## [2026-03-09.2] ### 수정