feat(metrics): 쿼리 메트릭 사용자 ID 수집 + 대시보드 IP/ID 토글

GC_SESSION JWT 쿠키에서 인증된 사용자 email을 추출하여 쿼리 메트릭에 기록.
대시보드 Top 클라이언트를 IP 기준 또는 사용자 ID 기준으로 전환 가능.

백엔드:
- WebSocket 핸드셰이크에서 GC_SESSION 쿠키 JWT payload → email 추출
- QueryMetric에 clientId 필드 추가, t_query_metrics에 client_id 컬럼 자동 생성
- timeseries API에 groupBy=ip|id 파라미터 추가

프론트엔드:
- Dashboard Top 클라이언트 섹션에 IP/ID 세그먼트 토글 추가
- 토글 전환 시 즉시 재조회

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
htlee 2026-03-27 06:27:43 +09:00
부모 8a97321a90
커밋 3333b2cec1
7개의 변경된 파일113개의 추가작업 그리고 18개의 파일을 삭제

파일 보기

@ -68,7 +68,7 @@ export const monitorApi = {
return fetchJson(`/api/monitoring/query-metrics/summary?hours=${hours}`) return fetchJson(`/api/monitoring/query-metrics/summary?hours=${hours}`)
}, },
getQueryMetricsTimeSeries(days = 7): Promise<QueryMetricsTimeSeries> { getQueryMetricsTimeSeries(days = 7, groupBy: 'ip' | 'id' = 'ip'): Promise<QueryMetricsTimeSeries> {
return fetchJson(`/api/monitoring/query-metrics/timeseries?days=${days}`) return fetchJson(`/api/monitoring/query-metrics/timeseries?days=${days}&groupBy=${groupBy}`)
}, },
} }

파일 보기

@ -32,6 +32,7 @@ export default function Dashboard() {
const [running, setRunning] = useCachedState<RunningJob[]>('dash.running', []) const [running, setRunning] = useCachedState<RunningJob[]>('dash.running', [])
const [queryTs, setQueryTs] = useCachedState<QueryMetricsTimeSeries | null>('dash.queryTs', null) const [queryTs, setQueryTs] = useCachedState<QueryMetricsTimeSeries | null>('dash.queryTs', null)
const [days, setDays] = useState(7) const [days, setDays] = useState(7)
const [clientGroupBy, setClientGroupBy] = useState<'ip' | 'id'>('ip')
const [isQueryChartsOpen, setIsQueryChartsOpen] = useState(() => const [isQueryChartsOpen, setIsQueryChartsOpen] = useState(() =>
localStorage.getItem('dashboard-query-charts') !== 'collapsed', localStorage.getItem('dashboard-query-charts') !== 'collapsed',
) )
@ -51,8 +52,8 @@ export default function Dashboard() {
monitorApi.getDelay().then(setDelay).catch(() => {}) monitorApi.getDelay().then(setDelay).catch(() => {})
batchApi.getDailyStats().then(setDaily).catch(() => {}) batchApi.getDailyStats().then(setDaily).catch(() => {})
batchApi.getRunningJobs().then(setRunning).catch(() => {}) batchApi.getRunningJobs().then(setRunning).catch(() => {})
monitorApi.getQueryMetricsTimeSeries(days).then(setQueryTs).catch(() => {}) monitorApi.getQueryMetricsTimeSeries(days, clientGroupBy).then(setQueryTs).catch(() => {})
}, POLL_INTERVAL, [days]) }, POLL_INTERVAL, [days, clientGroupBy])
const memUsage = metrics const memUsage = metrics
? Math.round((metrics.memory.used / metrics.memory.max) * 100) ? Math.round((metrics.memory.used / metrics.memory.max) * 100)
@ -327,7 +328,21 @@ export default function Dashboard() {
{/* Top Clients */} {/* Top Clients */}
{queryTs.topClients.length > 0 && ( {queryTs.topClients.length > 0 && (
<div> <div>
<div className="mb-2 text-sm font-medium text-muted">{t('dashboard.topClients')}</div> <div className="mb-2 flex items-center gap-2">
<span className="text-sm font-medium text-muted">{t('dashboard.topClients')}</span>
<div className="flex overflow-hidden rounded-md border border-[var(--border-primary)] text-xs">
<button
type="button"
className={`px-2 py-0.5 ${clientGroupBy === 'ip' ? 'bg-[var(--accent-primary)] text-white' : 'text-[var(--text-secondary)]'}`}
onClick={() => setClientGroupBy('ip')}
>IP</button>
<button
type="button"
className={`px-2 py-0.5 ${clientGroupBy === 'id' ? 'bg-[var(--accent-primary)] text-white' : 'text-[var(--text-secondary)]'}`}
onClick={() => setClientGroupBy('id')}
>ID</button>
</div>
</div>
<div className="space-y-2"> <div className="space-y-2">
{queryTs.topClients.map((c, i) => { {queryTs.topClients.map((c, i) => {
const maxCount = queryTs.topClients[0].query_count const maxCount = queryTs.topClients[0].query_count

파일 보기

@ -22,8 +22,10 @@ import org.springframework.http.server.ServletServerHttpRequest;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import java.security.Principal; import java.security.Principal;
import java.util.Base64;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.UUID; import java.util.UUID;
@ -180,11 +182,18 @@ public class WebSocketStompConfig implements WebSocketMessageBrokerConfigurer {
String clientIp = extractClientIp(request); String clientIp = extractClientIp(request);
attributes.put("CLIENT_IP", clientIp); attributes.put("CLIENT_IP", clientIp);
// User-Agent 추출
if (request instanceof ServletServerHttpRequest) { if (request instanceof ServletServerHttpRequest) {
HttpServletRequest servletRequest = ((ServletServerHttpRequest) request).getServletRequest(); HttpServletRequest servletRequest = ((ServletServerHttpRequest) request).getServletRequest();
// User-Agent 추출
String userAgent = servletRequest.getHeader("User-Agent"); String userAgent = servletRequest.getHeader("User-Agent");
attributes.put("USER_AGENT", userAgent); attributes.put("USER_AGENT", userAgent);
// GC_SESSION 쿠키에서 JWT email 추출 (guide 서비스 인증)
String clientId = extractEmailFromJwtCookie(servletRequest);
if (clientId != null) {
attributes.put("CLIENT_ID", clientId);
}
} }
return true; return true;
@ -225,5 +234,43 @@ public class WebSocketStompConfig implements WebSocketMessageBrokerConfigurer {
// ServletServerHttpRequest가 아닌 경우 기본값 // ServletServerHttpRequest가 아닌 경우 기본값
return "unknown"; 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;
}
}
} }
} }

파일 보기

@ -71,11 +71,17 @@ public class StompTrackController {
} }
}; };
// 세션 속성에서 CLIENT_IP 추출 // 세션 속성에서 CLIENT_IP, CLIENT_ID 추출
String clientIp = null; String clientIp = null;
String clientId = null;
Map<String, Object> sessionAttrs = headerAccessor.getSessionAttributes(); Map<String, Object> sessionAttrs = headerAccessor.getSessionAttributes();
if (sessionAttrs != null && sessionAttrs.containsKey("CLIENT_IP")) { if (sessionAttrs != null) {
clientIp = (String) sessionAttrs.get("CLIENT_IP"); 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, sessionId,
chunk -> sendChunkedDataToUser(userId, chunk), chunk -> sendChunkedDataToUser(userId, chunk),
statusCallback, statusCallback,
clientIp clientIp,
clientId
); );
} else { } else {
trackStreamingService.streamTracks( trackStreamingService.streamTracks(

파일 보기

@ -160,7 +160,8 @@ public class QueryMetricsController {
@GetMapping("/timeseries") @GetMapping("/timeseries")
@Operation(summary = "쿼리 메트릭 시계열", description = "시간별/일별 버킷 집계 + Top 10 클라이언트") @Operation(summary = "쿼리 메트릭 시계열", description = "시간별/일별 버킷 집계 + Top 10 클라이언트")
public Map<String, Object> getTimeSeries( public Map<String, Object> 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); days = Math.min(days, 90);
String granularity = days <= 7 ? "HOURLY" : "DAILY"; String granularity = days <= 7 ? "HOURLY" : "DAILY";
@ -184,15 +185,17 @@ public class QueryMetricsController {
List<Map<String, Object>> buckets = queryJdbcTemplate.queryForList(bucketSql); List<Map<String, Object>> buckets = queryJdbcTemplate.queryForList(bucketSql);
boolean groupById = "id".equalsIgnoreCase(groupBy);
String clientColumn = groupById ? "client_id" : "client_ip";
String topClientsSql = """ 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 COALESCE(AVG(elapsed_ms), 0) AS avg_elapsed_ms
FROM signal.t_query_metrics FROM signal.t_query_metrics
WHERE created_at >= NOW() - INTERVAL '%d days' WHERE created_at >= NOW() - INTERVAL '%d days'
AND client_ip IS NOT NULL AND %s IS NOT NULL
GROUP BY client_ip GROUP BY %s
ORDER BY query_count DESC LIMIT 10 ORDER BY query_count DESC LIMIT 10
""".formatted(days); """.formatted(clientColumn, days, clientColumn, clientColumn);
List<Map<String, Object>> topClients = queryJdbcTemplate.queryForList(topClientsSql); List<Map<String, Object>> topClients = queryJdbcTemplate.queryForList(topClientsSql);
@ -200,6 +203,7 @@ public class QueryMetricsController {
result.put("buckets", buckets); result.put("buckets", buckets);
result.put("topClients", topClients); result.put("topClients", topClients);
result.put("granularity", granularity); result.put("granularity", granularity);
result.put("groupBy", groupById ? "id" : "ip");
return result; return result;
} }

파일 보기

@ -32,7 +32,7 @@ public class QueryMetricsBufferService {
unique_vessels, total_tracks, total_points, points_after_simplify, unique_vessels, total_tracks, total_points, points_after_simplify,
total_chunks, response_bytes, total_chunks, response_bytes,
elapsed_ms, db_query_ms, simplify_ms, backpressure_events, elapsed_ms, db_query_ms, simplify_ms, backpressure_events,
status, client_ip status, client_ip, client_id
) VALUES ( ) VALUES (
?, ?, ?, now(), ?, ?, ?, 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) * 메트릭 레코드를 버퍼에 추가 (lock-free)
*/ */
@ -119,7 +140,7 @@ public class QueryMetricsBufferService {
m.getUniqueVessels(), m.getTotalTracks(), m.getTotalPoints(), m.getPointsAfterSimplify(), m.getUniqueVessels(), m.getTotalTracks(), m.getTotalPoints(), m.getPointsAfterSimplify(),
m.getTotalChunks(), m.getResponseBytes(), m.getTotalChunks(), m.getResponseBytes(),
m.getElapsedMs(), m.getDbQueryMs(), m.getSimplifyMs(), m.getBackpressureEvents(), m.getElapsedMs(), m.getDbQueryMs(), m.getSimplifyMs(), m.getBackpressureEvents(),
m.getStatus(), m.getClientIp() m.getStatus(), m.getClientIp(), m.getClientId()
}; };
} }

파일 보기

@ -131,5 +131,6 @@ public class QueryMetricsService {
private final int backpressureEvents; private final int backpressureEvents;
private final String status; private final String status;
private final String clientIp; private final String clientIp;
private final String clientId;
} }
} }