diff --git a/frontend/src/api/monitorApi.ts b/frontend/src/api/monitorApi.ts index 441ab6d..58d4bf1 100644 --- a/frontend/src/api/monitorApi.ts +++ b/frontend/src/api/monitorApi.ts @@ -68,7 +68,7 @@ export const monitorApi = { return fetchJson(`/api/monitoring/query-metrics/summary?hours=${hours}`) }, - getQueryMetricsTimeSeries(days = 7): Promise { - return fetchJson(`/api/monitoring/query-metrics/timeseries?days=${days}`) + getQueryMetricsTimeSeries(days = 7, groupBy: 'ip' | 'id' = 'ip'): Promise { + return fetchJson(`/api/monitoring/query-metrics/timeseries?days=${days}&groupBy=${groupBy}`) }, } diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index b8ca85a..42db6a9 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -32,6 +32,7 @@ export default function Dashboard() { const [running, setRunning] = useCachedState('dash.running', []) const [queryTs, setQueryTs] = useCachedState('dash.queryTs', null) const [days, setDays] = useState(7) + const [clientGroupBy, setClientGroupBy] = useState<'ip' | 'id'>('ip') const [isQueryChartsOpen, setIsQueryChartsOpen] = useState(() => localStorage.getItem('dashboard-query-charts') !== 'collapsed', ) @@ -51,8 +52,8 @@ 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]) + monitorApi.getQueryMetricsTimeSeries(days, clientGroupBy).then(setQueryTs).catch(() => {}) + }, POLL_INTERVAL, [days, clientGroupBy]) const memUsage = metrics ? Math.round((metrics.memory.used / metrics.memory.max) * 100) @@ -327,7 +328,21 @@ export default function Dashboard() { {/* Top Clients */} {queryTs.topClients.length > 0 && (
-
{t('dashboard.topClients')}
+
+ {t('dashboard.topClients')} +
+ + +
+
{queryTs.topClients.map((c, i) => { const maxCount = queryTs.topClients[0].query_count diff --git a/src/main/java/gc/mda/signal_batch/global/config/WebSocketStompConfig.java b/src/main/java/gc/mda/signal_batch/global/config/WebSocketStompConfig.java index e9f478d..e8f0c46 100644 --- a/src/main/java/gc/mda/signal_batch/global/config/WebSocketStompConfig.java +++ b/src/main/java/gc/mda/signal_batch/global/config/WebSocketStompConfig.java @@ -22,8 +22,10 @@ import org.springframework.http.server.ServletServerHttpRequest; import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import java.security.Principal; +import java.util.Base64; import java.util.List; import java.util.Map; import java.util.UUID; @@ -180,11 +182,18 @@ public class WebSocketStompConfig implements WebSocketMessageBrokerConfigurer { String clientIp = extractClientIp(request); attributes.put("CLIENT_IP", clientIp); - // User-Agent 추출 if (request instanceof ServletServerHttpRequest) { HttpServletRequest servletRequest = ((ServletServerHttpRequest) request).getServletRequest(); + + // User-Agent 추출 String userAgent = servletRequest.getHeader("User-Agent"); attributes.put("USER_AGENT", userAgent); + + // GC_SESSION 쿠키에서 JWT email 추출 (guide 서비스 인증) + String clientId = extractEmailFromJwtCookie(servletRequest); + if (clientId != null) { + attributes.put("CLIENT_ID", clientId); + } } return true; @@ -225,5 +234,43 @@ public class WebSocketStompConfig implements WebSocketMessageBrokerConfigurer { // ServletServerHttpRequest가 아닌 경우 기본값 return "unknown"; } + + /** + * GC_SESSION 쿠키에서 JWT payload의 email 클레임 추출. + * JWT 검증은 nginx auth_request에서 이미 완료 — 여기서는 payload 디코딩만 수행. + */ + private String extractEmailFromJwtCookie(HttpServletRequest request) { + Cookie[] cookies = request.getCookies(); + if (cookies == null) return null; + + String token = null; + for (Cookie cookie : cookies) { + if ("GC_SESSION".equals(cookie.getName())) { + token = cookie.getValue(); + break; + } + } + if (token == null || token.isEmpty()) return null; + + try { + // JWT: header.payload.signature — payload만 Base64URL 디코딩 + String[] parts = token.split("\\."); + if (parts.length < 2) return null; + + String payload = new String(Base64.getUrlDecoder().decode(parts[1])); + // 간단한 JSON 파싱 (Jackson 의존 없이): "email":"value" 추출 + int emailIdx = payload.indexOf("\"email\""); + if (emailIdx < 0) return null; + + int colonIdx = payload.indexOf(':', emailIdx); + int quoteStart = payload.indexOf('"', colonIdx + 1); + int quoteEnd = payload.indexOf('"', quoteStart + 1); + if (quoteStart < 0 || quoteEnd < 0) return null; + + return payload.substring(quoteStart + 1, quoteEnd); + } catch (Exception e) { + return null; + } + } } } \ No newline at end of file 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 37c9b1b..ff7f869 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,11 +71,17 @@ public class StompTrackController { } }; - // 세션 속성에서 CLIENT_IP 추출 + // 세션 속성에서 CLIENT_IP, CLIENT_ID 추출 String clientIp = null; + String clientId = null; Map sessionAttrs = headerAccessor.getSessionAttributes(); - if (sessionAttrs != null && sessionAttrs.containsKey("CLIENT_IP")) { - clientIp = (String) sessionAttrs.get("CLIENT_IP"); + if (sessionAttrs != null) { + if (sessionAttrs.containsKey("CLIENT_IP")) { + clientIp = (String) sessionAttrs.get("CLIENT_IP"); + } + if (sessionAttrs.containsKey("CLIENT_ID")) { + clientId = (String) sessionAttrs.get("CLIENT_ID"); + } } // 비동기 스트리밍 시작 - 청크 모드 체크 @@ -86,7 +92,8 @@ public class StompTrackController { sessionId, chunk -> sendChunkedDataToUser(userId, chunk), statusCallback, - clientIp + clientIp, + clientId ); } else { trackStreamingService.streamTracks( 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 c3b09de..5afd5fc 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 @@ -160,7 +160,8 @@ public class QueryMetricsController { @GetMapping("/timeseries") @Operation(summary = "쿼리 메트릭 시계열", description = "시간별/일별 버킷 집계 + Top 10 클라이언트") public Map getTimeSeries( - @Parameter(description = "조회 기간 (일)") @RequestParam(defaultValue = "7") int days) { + @Parameter(description = "조회 기간 (일)") @RequestParam(defaultValue = "7") int days, + @Parameter(description = "Top 클라이언트 그룹 기준 (ip | id)") @RequestParam(defaultValue = "ip") String groupBy) { days = Math.min(days, 90); String granularity = days <= 7 ? "HOURLY" : "DAILY"; @@ -184,15 +185,17 @@ public class QueryMetricsController { List> buckets = queryJdbcTemplate.queryForList(bucketSql); + boolean groupById = "id".equalsIgnoreCase(groupBy); + String clientColumn = groupById ? "client_id" : "client_ip"; String topClientsSql = """ - SELECT client_ip, COUNT(*) AS query_count, + SELECT %s AS client, 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 + AND %s IS NOT NULL + GROUP BY %s ORDER BY query_count DESC LIMIT 10 - """.formatted(days); + """.formatted(clientColumn, days, clientColumn, clientColumn); List> topClients = queryJdbcTemplate.queryForList(topClientsSql); @@ -200,6 +203,7 @@ public class QueryMetricsController { result.put("buckets", buckets); result.put("topClients", topClients); result.put("granularity", granularity); + result.put("groupBy", groupById ? "id" : "ip"); 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 73fb889..0e7b0c0 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 @@ -32,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, client_ip + status, client_ip, client_id ) VALUES ( ?, ?, ?, now(), ?, ?, ?, ?, ?, @@ -40,7 +40,7 @@ public class QueryMetricsBufferService { ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, - ?, ? + ?, ?, ? ) """; @@ -73,6 +73,27 @@ public class QueryMetricsBufferService { } } + @PostConstruct + void ensureClientIdColumn() { + 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_id' + ) THEN + ALTER TABLE signal.t_query_metrics ADD COLUMN client_id VARCHAR(100); + CREATE INDEX IF NOT EXISTS idx_query_metrics_client_id ON signal.t_query_metrics(client_id, created_at); + END IF; + END $$ + """); + log.info("t_query_metrics client_id column ensured"); + } catch (Exception e) { + log.warn("Failed to ensure client_id column: {}", e.getMessage()); + } + } + /** * 메트릭 레코드를 버퍼에 추가 (lock-free) */ @@ -119,7 +140,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.getClientIp() + m.getStatus(), m.getClientIp(), m.getClientId() }; } 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 8ec4b6f..d7a3ca8 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 @@ -131,5 +131,6 @@ public class QueryMetricsService { private final int backpressureEvents; private final String status; private final String clientIp; + private final String clientId; } }