release: 2026-03-10.2 (4건 커밋) #102

병합
htlee develop 에서 main 로 4 commits 를 머지했습니다 2026-03-10 11:20:20 +09:00
15개의 변경된 파일336개의 추가작업 그리고 12개의 파일을 삭제

파일 보기

@ -4,6 +4,11 @@
## [Unreleased]
## [2026-03-10.2]
### 추가
- 쿼리 메트릭 수집 확장 + 대시보드 성능 차트 — client IP 수집(REST/WS), 응답 크기 추정, timeseries API, 대시보드 쿼리 성능 차트 5종(응답시간·볼륨·캐시경로·응답크기·Top 클라이언트)
## [2026-03-10]
### 추가

파일 보기

@ -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<QueryMetricsSummary> {
return fetchJson(`/api/monitoring/query-metrics/summary?hours=${hours}`)
},
getQueryMetricsTimeSeries(days = 7): Promise<QueryMetricsTimeSeries> {
return fetchJson(`/api/monitoring/query-metrics/timeseries?days=${days}`)
},
}

파일 보기

@ -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 {

파일 보기

@ -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 (
<div>
@ -46,6 +48,7 @@ export default function LineChart({
tick={{ fontSize: 12, fill: 'var(--sb-text-muted)' }}
axisLine={false}
tickLine={false}
tickFormatter={yFormatter}
/>
<Tooltip
contentStyle={{
@ -54,6 +57,7 @@ export default function LineChart({
borderRadius: 'var(--sb-radius)',
fontSize: 12,
}}
formatter={yFormatter ? (v: number) => yFormatter(v) : undefined}
/>
{series.length > 1 && (
<Legend

파일 보기

@ -49,6 +49,16 @@ const en = {
'dashboard.hits': 'Hits',
'dashboard.misses': 'Misses',
'dashboard.dailyVolume': 'Daily Processing Volume',
'dashboard.queryPerformance': 'Query Performance',
'dashboard.responseTimeTrend': 'Response Time Trend',
'dashboard.queryVolume': 'Query Volume',
'dashboard.cachePathRatio': 'Cache Path Ratio',
'dashboard.responseSizeTrend': 'Response Size Trend',
'dashboard.topClients': 'Top Clients',
'dashboard.avgElapsed': 'Avg',
'dashboard.maxElapsed': 'Max',
'dashboard.queries': 'queries',
'dashboard.noChartData': 'No chart data available',
// Job Monitor
'jobs.title': 'Job Monitor',

파일 보기

@ -49,6 +49,16 @@ const ko = {
'dashboard.hits': '히트',
'dashboard.misses': '미스',
'dashboard.dailyVolume': '일별 처리량',
'dashboard.queryPerformance': '쿼리 성능',
'dashboard.responseTimeTrend': '응답시간 추이',
'dashboard.queryVolume': '쿼리 볼륨',
'dashboard.cachePathRatio': '캐시/경로 비율',
'dashboard.responseSizeTrend': '응답 크기 추이',
'dashboard.topClients': 'Top 클라이언트',
'dashboard.avgElapsed': '평균',
'dashboard.maxElapsed': '최대',
'dashboard.queries': '건',
'dashboard.noChartData': '차트 데이터가 없습니다',
// Job Monitor
'jobs.title': 'Job 모니터',

파일 보기

@ -1,4 +1,4 @@
import { useState } from 'react'
import { useState, useCallback } from 'react'
import { usePoller } from '../hooks/usePoller.ts'
import { useCachedState } from '../hooks/useCachedState.ts'
import { useI18n } from '../hooks/useI18n.ts'
@ -10,11 +10,13 @@ import type {
DailyStats,
MetricsSummary,
ProcessingDelay,
QueryMetricsTimeSeries,
RunningJob,
} from '../api/types.ts'
import MetricCard from '../components/charts/MetricCard.tsx'
import StatusBadge from '../components/common/StatusBadge.tsx'
import BarChart from '../components/charts/BarChart.tsx'
import LineChart from '../components/charts/LineChart.tsx'
import TimeRangeSelector from '../components/common/TimeRangeSelector.tsx'
import { formatDuration, formatNumber, formatDateTime, formatPercent } from '../utils/formatters.ts'
@ -28,7 +30,19 @@ export default function Dashboard() {
const [delay, setDelay] = useCachedState<ProcessingDelay | null>('dash.delay', null)
const [daily, setDaily] = useCachedState<DailyStats | null>('dash.daily', null)
const [running, setRunning] = useCachedState<RunningJob[]>('dash.running', [])
const [queryTs, setQueryTs] = useCachedState<QueryMetricsTimeSeries | null>('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() {
/>
</div>
)}
{/* Query Performance Charts */}
<div className="sb-card">
<button
type="button"
className="sb-card-header flex w-full items-center justify-between cursor-pointer"
onClick={toggleQueryCharts}
>
<span>{t('dashboard.queryPerformance')}</span>
<svg
className={`h-5 w-5 text-muted transition-transform ${isQueryChartsOpen ? 'rotate-180' : ''}`}
fill="none" viewBox="0 0 24 24" stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{isQueryChartsOpen && (
<div className="space-y-6 pt-2">
{queryTs && queryTs.buckets.length > 0 ? (
<>
{/* Row 1: Response Time + Query Volume */}
<div className="grid gap-4 lg:grid-cols-2">
<div>
<LineChart
label={t('dashboard.responseTimeTrend')}
data={queryTs.buckets.map(b => ({
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`}
/>
</div>
<div>
<BarChart
label={t('dashboard.queryVolume')}
data={queryTs.buckets.map(b => ({
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' },
]}
/>
</div>
</div>
{/* Row 2: Cache Path + Response Size */}
<div className="grid gap-4 lg:grid-cols-2">
<div>
<BarChart
label={t('dashboard.cachePathRatio')}
data={queryTs.buckets.map(b => ({
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' },
]}
/>
</div>
<div>
<LineChart
label={t('dashboard.responseSizeTrend')}
data={queryTs.buckets.map(b => ({
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`}
/>
</div>
</div>
{/* Top Clients */}
{queryTs.topClients.length > 0 && (
<div>
<div className="mb-2 text-sm font-medium text-muted">{t('dashboard.topClients')}</div>
<div className="space-y-2">
{queryTs.topClients.map((c, i) => {
const maxCount = queryTs.topClients[0].query_count
const pct = maxCount > 0 ? (c.query_count / maxCount) * 100 : 0
return (
<div key={c.client_ip ?? i} className="flex items-center gap-3 text-sm">
<span className="w-32 truncate font-mono text-xs">{c.client_ip ?? '-'}</span>
<div className="flex-1">
<div className="h-4 rounded bg-surface-hover">
<div
className="h-4 rounded bg-primary"
style={{ width: `${pct}%` }}
/>
</div>
</div>
<span className="w-20 text-right text-xs text-muted">
{c.query_count}{t('dashboard.queries')} · {Math.round(c.avg_elapsed_ms)}ms
</span>
</div>
)
})}
</div>
</div>
)}
</>
) : (
<div className="py-8 text-center text-sm text-muted">{t('dashboard.noChartData')}</div>
)}
</div>
)}
</div>
</div>
)
}
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)
}

파일 보기

@ -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")

파일 보기

@ -285,7 +285,7 @@ public class GisServiceV2 {
/**
* 선박별 항적 조회 V2 (캐시 + Semaphore + 간소화 + ChnPrmShip enrichment)
*/
public List<CompactVesselTrack> getVesselTracksV2(VesselTracksRequest request) {
public List<CompactVesselTrack> 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<CompactVesselTrack> result, long startMs) {
List<CompactVesselTrack> 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());

파일 보기

@ -71,6 +71,13 @@ public class StompTrackController {
}
};
// 세션 속성에서 CLIENT_IP 추출
String clientIp = null;
Map<String, Object> 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(

파일 보기

@ -724,7 +724,8 @@ public class ChunkedTrackStreamingService {
String queryId,
String sessionId,
Consumer<TrackChunkResponse> chunkConsumer,
Consumer<QueryStatusUpdate> statusConsumer) {
Consumer<QueryStatusUpdate> 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());
}

파일 보기

@ -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<String, Object> 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<Map<String, Object>> 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<Map<String, Object>> topClients = queryJdbcTemplate.queryForList(topClientsSql);
Map<String, Object> result = new LinkedHashMap<>();
result.put("buckets", buckets);
result.put("topClients", topClients);
result.put("granularity", granularity);
return result;
}
}

파일 보기

@ -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()
};
}

파일 보기

@ -130,5 +130,6 @@ public class QueryMetricsService {
private final long simplifyMs;
private final int backpressureEvents;
private final String status;
private final String clientIp;
}
}

파일 보기

@ -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);