Merge pull request 'release: 2026-03-02.2 (105건 커밋)' (#90) from develop into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 2m29s

This commit is contained in:
htlee 2026-03-02 15:40:24 +09:00
커밋 7a21d5b8b0
15개의 변경된 파일675개의 추가작업 그리고 679개의 파일을 삭제

파일 보기

@ -4,6 +4,13 @@
## [Unreleased] ## [Unreleased]
## [2026-03-02.2]
### 변경
- SignalKindCode 매핑 규칙 개선 — aton/tug/tender→DEFAULT, shipName BUOY 검출 추가
- 응답 경로 signal_kind_code 치환 1회화 — 캐시 저장 시 치환, 응답 시 DB/캐시 값 직접 사용
- ChunkedTrackStreamingService 전수 최적화 — isQueryCancelled 버그수정, QueryContext 스레드 안전성, 쿼리 메트릭 DB 저장, 데드코드 400줄 삭제, VesselInfo N+1 해소
## [2026-03-02] ## [2026-03-02]
### 추가 ### 추가

파일 보기

@ -107,7 +107,7 @@ public class ChnPrmShipCacheWarmer implements ApplicationRunner {
entities.forEach(entity -> { entities.forEach(entity -> {
if (entity.getSignalKindCode() == null) { if (entity.getSignalKindCode() == null) {
SignalKindCode kindCode = SignalKindCode.resolve( SignalKindCode kindCode = SignalKindCode.resolve(
entity.getVesselType(), entity.getExtraInfo()); entity.getVesselType(), entity.getExtraInfo(), entity.getName());
entity.setSignalKindCode(kindCode.getCode()); entity.setSignalKindCode(kindCode.getCode());
} }
}); });

파일 보기

@ -35,9 +35,10 @@ public class AisTargetCacheWriter implements ItemWriter<AisTargetEntity> {
List<? extends AisTargetEntity> items = chunk.getItems(); List<? extends AisTargetEntity> items = chunk.getItems();
log.debug("AIS Target 캐시 업데이트 시작: {} 건", items.size()); log.debug("AIS Target 캐시 업데이트 시작: {} 건", items.size());
// 1. SignalKindCode 치환 // 1. SignalKindCode 치환 (vesselType + extraInfo + shipName 기반, 캐시 저장 1회만)
items.forEach(item -> { items.forEach(item -> {
SignalKindCode kindCode = SignalKindCode.resolve(item.getVesselType(), item.getExtraInfo()); SignalKindCode kindCode = SignalKindCode.resolve(
item.getVesselType(), item.getExtraInfo(), item.getName());
item.setSignalKindCode(kindCode.getCode()); item.setSignalKindCode(kindCode.getCode());
}); });

파일 보기

@ -5,7 +5,6 @@ import gc.mda.signal_batch.domain.vessel.dto.TrackResponse;
import gc.mda.signal_batch.domain.vessel.dto.VesselStatsResponse; import gc.mda.signal_batch.domain.vessel.dto.VesselStatsResponse;
import gc.mda.signal_batch.domain.vessel.dto.VesselTracksRequest; import gc.mda.signal_batch.domain.vessel.dto.VesselTracksRequest;
import gc.mda.signal_batch.domain.vessel.dto.CompactVesselTrack; import gc.mda.signal_batch.domain.vessel.dto.CompactVesselTrack;
import gc.mda.signal_batch.global.util.SignalKindCode;
import gc.mda.signal_batch.global.util.TrackSimplificationUtils; import gc.mda.signal_batch.global.util.TrackSimplificationUtils;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Qualifier;
@ -604,9 +603,11 @@ public class GisService {
Map<String, String> vesselInfo = getVesselInfo(mmsi); Map<String, String> vesselInfo = getVesselInfo(mmsi);
String shipName = vesselInfo.get("ship_name"); String shipName = vesselInfo.get("ship_name");
String shipType = vesselInfo.get("ship_type"); String shipType = vesselInfo.get("ship_type");
String signalKindCode = vesselInfo.get("signal_kind_code");
String nationalCode = (mmsi != null && mmsi.length() >= 3) ? mmsi.substring(0, 3) : null; String nationalCode = (mmsi != null && mmsi.length() >= 3) ? mmsi.substring(0, 3) : null;
String shipKindCode = SignalKindCode.resolve(shipType, null).getCode(); String shipKindCode = (signalKindCode != null && !signalKindCode.isEmpty())
? signalKindCode : "000027";
return CompactVesselTrack.builder() return CompactVesselTrack.builder()
.vesselId(mmsi) .vesselId(mmsi)
@ -628,7 +629,7 @@ public class GisService {
JdbcTemplate jdbcTemplate = new JdbcTemplate(queryDataSource); JdbcTemplate jdbcTemplate = new JdbcTemplate(queryDataSource);
try { try {
String sql = """ String sql = """
SELECT ship_nm as ship_name, vessel_type as ship_type SELECT ship_nm as ship_name, vessel_type as ship_type, signal_kind_code
FROM signal.t_ais_position FROM signal.t_ais_position
WHERE mmsi = ? WHERE mmsi = ?
LIMIT 1 LIMIT 1

파일 보기

@ -3,7 +3,6 @@ package gc.mda.signal_batch.domain.vessel.service;
import gc.mda.signal_batch.domain.ship.service.ShipImageService; import gc.mda.signal_batch.domain.ship.service.ShipImageService;
import gc.mda.signal_batch.domain.ship.service.ShipImageService.ShipImageSummary; import gc.mda.signal_batch.domain.ship.service.ShipImageService.ShipImageSummary;
import gc.mda.signal_batch.domain.vessel.dto.RecentVesselPositionDto; import gc.mda.signal_batch.domain.vessel.dto.RecentVesselPositionDto;
import gc.mda.signal_batch.global.util.SignalKindCode;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@ -124,6 +123,7 @@ public class VesselPositionService {
cog, cog,
name as ship_nm, name as ship_nm,
vessel_type as ship_ty, vessel_type as ship_ty,
signal_kind_code,
last_update last_update
FROM signal.t_ais_position FROM signal.t_ais_position
WHERE last_update >= NOW() - INTERVAL '%d minutes' WHERE last_update >= NOW() - INTERVAL '%d minutes'
@ -145,8 +145,9 @@ public class VesselPositionService {
String mmsi = rs.getString("mmsi"); String mmsi = rs.getString("mmsi");
String shipTy = rs.getString("ship_ty"); String shipTy = rs.getString("ship_ty");
// shipKindCode 계산 (vesselType 기반, extraInfo 없음) // shipKindCode: DB에 저장된 치환값 사용
String shipKindCode = SignalKindCode.resolve(shipTy, null).getCode(); String signalKindCode = rs.getString("signal_kind_code");
String shipKindCode = signalKindCode != null ? signalKindCode : "000027";
// nationalCode 계산 (MMSI 3자리 = MID) // nationalCode 계산 (MMSI 3자리 = MID)
String nationalCode = mmsi != null && mmsi.length() >= 3 String nationalCode = mmsi != null && mmsi.length() >= 3

파일 보기

@ -6,10 +6,11 @@ import lombok.RequiredArgsConstructor;
/** /**
* MDA 선종 범례코드 * MDA 선종 범례코드
* *
* S&P Global AIS API의 vesselType + extraInfo 기반으로 * S&P Global AIS API의 vesselType + extraInfo + shipName을 기반으로
* MDA 범례코드(signalKindCode) 치환한다. * MDA 범례코드(signalKindCode) 치환한다.
* *
* ShipKindCodeConverter를 대체하며, SNP-Batch-1의 치환 로직을 이식. * 치환은 캐시 저장 (AisTargetCacheWriter) 1회만 수행하며,
* API 응답 시에는 캐시 또는 DB의 signal_kind_code를 직접 사용한다.
*/ */
@Getter @Getter
@RequiredArgsConstructor @RequiredArgsConstructor
@ -28,18 +29,32 @@ public enum SignalKindCode {
private final String koreanName; private final String koreanName;
/** /**
* vesselType + extraInfo MDA 범례코드 치환 * vesselType + extraInfo MDA 범례코드 치환 (하위 호환용)
* * shipName 기반 BUOY 검출 불가 캐시 저장 시에는 3-파라미터 버전 사용 권장.
* 치환 우선순위:
* 1. vesselType 단독 매칭 (Cargo, Tanker, Passenger, AtoN )
* 2. vesselType + extraInfo 조합 매칭 (Vessel + Fishing )
* 3. fallback DEFAULT (000027)
*/ */
public static SignalKindCode resolve(String vesselType, String extraInfo) { public static SignalKindCode resolve(String vesselType, String extraInfo) {
return resolve(vesselType, extraInfo, null);
}
/**
* vesselType + extraInfo + shipName MDA 범례코드 치환
*
* 치환 우선순위:
* 1. shipName 기반 BUOY 검출 ('.' '_' 문자가 2개 이상 부이/항로표지)
* 2. vesselType 단독 매칭 (Cargo, Tanker, Passenger )
* 3. vesselType + extraInfo 조합 매칭 (Vessel + Fishing )
* 4. fallback DEFAULT (000027)
*/
public static SignalKindCode resolve(String vesselType, String extraInfo, String shipName) {
// 1. shipName 기반 BUOY 검출: '.' 또는 '_' 문자가 2개 이상
if (hasBuoyNamePattern(shipName)) {
return BUOY;
}
String vt = normalizeOrEmpty(vesselType); String vt = normalizeOrEmpty(vesselType);
String ei = normalizeOrEmpty(extraInfo); String ei = normalizeOrEmpty(extraInfo);
// 1. vesselType 단독 매칭 // 2. vesselType 단독 매칭
switch (vt) { switch (vt) {
case "cargo": case "cargo":
return CARGO; return CARGO;
@ -48,7 +63,7 @@ public enum SignalKindCode {
case "passenger": case "passenger":
return FERRY; return FERRY;
case "aton": case "aton":
return BUOY; return DEFAULT;
case "law enforcement": case "law enforcement":
return GOV; return GOV;
case "search and rescue": case "search and rescue":
@ -60,19 +75,19 @@ public enum SignalKindCode {
} }
// vesselType 그룹 매칭 // vesselType 그룹 매칭
if (matchesAny(vt, "tug", "pilot boat", "tender", "anti pollution", "medical transport")) { if (matchesAny(vt, "pilot boat", "anti pollution", "medical transport")) {
return GOV; return GOV;
} }
if (matchesAny(vt, "high speed craft", "wing in ground-effect")) { if (matchesAny(vt, "high speed craft", "wing in ground-effect")) {
return FERRY; return FERRY;
} }
// 2. "Vessel" + extraInfo 조합 // 3. "Vessel" + extraInfo 조합
if ("vessel".equals(vt)) { if ("vessel".equals(vt)) {
return resolveVesselExtraInfo(ei); return resolveVesselExtraInfo(ei);
} }
// 3. "N/A" + extraInfo 조합 // 4. "N/A" + extraInfo 조합
if ("n/a".equals(vt)) { if ("n/a".equals(vt)) {
if (ei.startsWith("hazardous cat")) { if (ei.startsWith("hazardous cat")) {
return CARGO; return CARGO;
@ -80,7 +95,7 @@ public enum SignalKindCode {
return DEFAULT; return DEFAULT;
} }
// 4. fallback // 5. fallback
return DEFAULT; return DEFAULT;
} }
@ -91,18 +106,32 @@ public enum SignalKindCode {
if ("military operations".equals(extraInfo)) { if ("military operations".equals(extraInfo)) {
return GOV; return GOV;
} }
if (matchesAny(extraInfo, "towing", "towing (large)", "dredging/underwater ops", "diving operations")) {
return GOV;
}
if (matchesAny(extraInfo, "pleasure craft", "sailing", "n/a")) {
return FISHING;
}
if (extraInfo.startsWith("hazardous cat")) { if (extraInfo.startsWith("hazardous cat")) {
return CARGO; return CARGO;
} }
return DEFAULT; return DEFAULT;
} }
/**
* shipName에 '.' 또는 '_' 문자가 2개 이상 포함되면 부이/항로표지로 판정
*/
static boolean hasBuoyNamePattern(String shipName) {
if (shipName == null || shipName.isBlank()) {
return false;
}
int count = 0;
for (int i = 0; i < shipName.length(); i++) {
char c = shipName.charAt(i);
if (c == '.' || c == '_') {
count++;
if (count >= 2) {
return true;
}
}
}
return false;
}
private static boolean matchesAny(String value, String... candidates) { private static boolean matchesAny(String value, String... candidates) {
for (String candidate : candidates) { for (String candidate : candidates) {
if (candidate.equals(value)) { if (candidate.equals(value)) {

파일 보기

@ -122,16 +122,18 @@ public class VesselTrackToCompactConverter {
int pointCount = geometry.size(); int pointCount = geometry.size();
double avgSpeed = pointCount > 0 ? totalDistance / Math.max(1, pointCount) * 60 : 0; double avgSpeed = pointCount > 0 ? totalDistance / Math.max(1, pointCount) * 60 : 0;
// 선박 정보 설정 // 선박 정보 설정 (캐시에 이미 치환된 signalKindCode 사용)
String shipName = null; String shipName = null;
String shipType = null; String shipType = null;
String shipKindCode = null; String shipKindCode = null;
if (vesselInfo != null) { if (vesselInfo != null) {
shipName = vesselInfo.getName(); shipName = vesselInfo.getName();
shipType = vesselInfo.getVesselType(); shipType = vesselInfo.getVesselType();
shipKindCode = SignalKindCode.resolve(vesselInfo.getVesselType(), vesselInfo.getExtraInfo()).getCode(); shipKindCode = vesselInfo.getSignalKindCode() != null
? vesselInfo.getSignalKindCode()
: SignalKindCode.DEFAULT.getCode();
} else { } else {
shipKindCode = SignalKindCode.resolve(null, null).getCode(); shipKindCode = SignalKindCode.DEFAULT.getCode();
} }
String nationalCode = mmsi.length() >= 3 ? mmsi.substring(0, 3) : mmsi; String nationalCode = mmsi.length() >= 3 ? mmsi.substring(0, 3) : mmsi;

파일 보기

@ -316,4 +316,11 @@ public class ActiveQueryManager {
public int getMaxConcurrentGlobal() { public int getMaxConcurrentGlobal() {
return maxConcurrentGlobal; return maxConcurrentGlobal;
} }
/**
* 대기열 타임아웃 ()
*/
public int getQueueTimeoutSeconds() {
return queueTimeoutSeconds;
}
} }

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. Load Diff

파일 보기

@ -310,8 +310,9 @@ public class DailyTrackCacheManager {
double avgSpeed = acc.pointCount > 0 ? acc.totalDistance / Math.max(1, acc.pointCount) * 60 : 0; double avgSpeed = acc.pointCount > 0 ? acc.totalDistance / Math.max(1, acc.pointCount) * 60 : 0;
// shipKindCode 계산 // shipKindCode: 캐시 저장 치환된 사용 (DB fallback 포함)
String shipKindCode = SignalKindCode.resolve(acc.shipType, null).getCode(); String shipKindCode = acc.signalKindCode != null
? acc.signalKindCode : SignalKindCode.DEFAULT.getCode();
// nationalCode 계산 (MMSI 3자리 = MID) // nationalCode 계산 (MMSI 3자리 = MID)
String nationalCode = acc.mmsi.length() >= 3 ? acc.mmsi.substring(0, 3) : acc.mmsi; String nationalCode = acc.mmsi.length() >= 3 ? acc.mmsi.substring(0, 3) : acc.mmsi;
@ -723,7 +724,7 @@ public class DailyTrackCacheManager {
try (Connection conn = queryDataSource.getConnection()) { try (Connection conn = queryDataSource.getConnection()) {
String placeholders = batch.stream().map(id -> "?").collect(Collectors.joining(",")); String placeholders = batch.stream().map(id -> "?").collect(Collectors.joining(","));
String sql = "SELECT mmsi, name as ship_nm, vessel_type as ship_ty " + String sql = "SELECT mmsi, name as ship_nm, vessel_type as ship_ty, signal_kind_code " +
"FROM signal.t_ais_position " + "FROM signal.t_ais_position " +
"WHERE mmsi IN (" + placeholders + ")"; "WHERE mmsi IN (" + placeholders + ")";
@ -739,6 +740,7 @@ public class DailyTrackCacheManager {
if (acc != null) { if (acc != null) {
acc.shipName = rs.getString("ship_nm"); acc.shipName = rs.getString("ship_nm");
acc.shipType = rs.getString("ship_ty"); acc.shipType = rs.getString("ship_ty");
acc.signalKindCode = rs.getString("signal_kind_code");
enriched++; enriched++;
} }
} }
@ -782,6 +784,7 @@ public class DailyTrackCacheManager {
String mmsi; String mmsi;
String shipName; String shipName;
String shipType; String shipType;
String signalKindCode;
List<double[]> geometry = new ArrayList<>(500); List<double[]> geometry = new ArrayList<>(500);
List<String> timestamps = new ArrayList<>(500); List<String> timestamps = new ArrayList<>(500);
List<Double> speeds = new ArrayList<>(500); List<Double> speeds = new ArrayList<>(500);

파일 보기

@ -0,0 +1,40 @@
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.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/**
* 쿼리 메트릭 조회 API
*
* WebSocket/REST 쿼리 실행 이력 성능 통계를 제공한다.
* ApiMetrics 프론트엔드 페이지의 데이터 소스.
*/
@RestController
@RequestMapping("/api/monitoring/query-metrics")
@RequiredArgsConstructor
@Tag(name = "Query Metrics", description = "쿼리 실행 메트릭 조회 API")
public class QueryMetricsController {
private final QueryMetricsService queryMetricsService;
@GetMapping
@Operation(summary = "최근 쿼리 메트릭 조회", description = "최근 N건의 쿼리 실행 메트릭을 조회합니다")
public ResponseEntity<List<Map<String, Object>>> getRecentMetrics(
@RequestParam(defaultValue = "50") int limit) {
return ResponseEntity.ok(queryMetricsService.getRecentMetrics(Math.min(limit, 200)));
}
@GetMapping("/stats")
@Operation(summary = "쿼리 메트릭 통계", description = "기간별 쿼리 성능 통계 (평균 응답시간, 캐시 비율, 느린 쿼리 등)")
public ResponseEntity<Map<String, Object>> getStats(
@RequestParam(defaultValue = "7") int days) {
return ResponseEntity.ok(queryMetricsService.getStats(Math.min(days, 90)));
}
}

파일 보기

@ -0,0 +1,180 @@
package gc.mda.signal_batch.monitoring.service;
import lombok.Builder;
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 호출하여 응답 지연 없이 기록.
*/
@Slf4j
@Service
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());
}
}
/**
* 최근 쿼리 메트릭 조회
*/
public List<Map<String, Object>> getRecentMetrics(int limit) {
return queryJdbcTemplate.queryForList("""
SELECT query_id, session_id, query_type, created_at,
start_time, end_time, zoom_level, viewport_bounds,
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
FROM signal.t_query_metrics
ORDER BY created_at DESC
LIMIT ?
""", limit);
}
/**
* 기간별 쿼리 메트릭 통계
*/
public Map<String, Object> getStats(int days) {
Map<String, Object> stats = new LinkedHashMap<>();
// 전체 통계
Map<String, Object> summary = queryJdbcTemplate.queryForMap("""
SELECT
COUNT(*) AS total_queries,
ROUND(AVG(elapsed_ms)) AS avg_elapsed_ms,
MAX(elapsed_ms) AS max_elapsed_ms,
ROUND(AVG(unique_vessels)) AS avg_vessels,
ROUND(AVG(total_points)) AS avg_points,
SUM(CASE WHEN data_path = 'CACHE' THEN 1 ELSE 0 END) AS cache_only,
SUM(CASE WHEN data_path = 'HYBRID' THEN 1 ELSE 0 END) AS hybrid,
SUM(CASE WHEN data_path = 'DB' THEN 1 ELSE 0 END) AS db_only,
SUM(CASE WHEN status = 'COMPLETED' THEN 1 ELSE 0 END) AS completed,
SUM(CASE WHEN status = 'CANCELLED' THEN 1 ELSE 0 END) AS cancelled,
SUM(CASE WHEN status = 'ERROR' THEN 1 ELSE 0 END) AS errors,
SUM(CASE WHEN status = 'TIMEOUT' THEN 1 ELSE 0 END) AS timeouts
FROM signal.t_query_metrics
WHERE created_at >= now() - INTERVAL '%d days'
""".formatted(days));
stats.put("summary", summary);
// 일별 추이
List<Map<String, Object>> daily = queryJdbcTemplate.queryForList("""
SELECT
DATE(created_at) AS date,
COUNT(*) AS query_count,
ROUND(AVG(elapsed_ms)) AS avg_elapsed_ms,
ROUND(AVG(unique_vessels)) AS avg_vessels,
SUM(CASE WHEN status = 'COMPLETED' THEN 1 ELSE 0 END) AS completed,
SUM(CASE WHEN status != 'COMPLETED' THEN 1 ELSE 0 END) AS failed
FROM signal.t_query_metrics
WHERE created_at >= now() - INTERVAL '%d days'
GROUP BY DATE(created_at)
ORDER BY date DESC
""".formatted(days));
stats.put("dailyTrend", daily);
// 느린 쿼리 TOP 10
List<Map<String, Object>> slowQueries = queryJdbcTemplate.queryForList("""
SELECT query_id, created_at, elapsed_ms, unique_vessels, total_points,
data_path, db_conn_total, zoom_level, status
FROM signal.t_query_metrics
WHERE created_at >= now() - INTERVAL '%d days'
ORDER BY elapsed_ms DESC
LIMIT 10
""".formatted(days));
stats.put("slowQueries", slowQueries);
return stats;
}
/**
* 쿼리 메트릭 데이터 클래스
*/
@Getter
@Builder
public static class QueryMetric {
private final String queryId;
private final String sessionId;
private final String queryType;
private final LocalDateTime startTime;
private final LocalDateTime endTime;
private final Integer zoomLevel;
private final String viewportBounds;
private final int requestedMmsi;
private final String dataPath;
private final int cacheHitDays;
private final int dbQueryDays;
private final int dbConnTotal;
private final int uniqueVessels;
private final int totalTracks;
private final int totalPoints;
private final int pointsAfterSimplify;
private final int totalChunks;
private final long responseBytes;
private final long elapsedMs;
private final long dbQueryMs;
private final long simplifyMs;
private final int backpressureEvents;
private final String status;
}
}

파일 보기

@ -0,0 +1,42 @@
-- 쿼리 실행 메트릭 테이블
-- WebSocket/REST 쿼리의 성능 지표를 기록하여 ApiMetrics 페이지에서 조회
CREATE TABLE IF NOT EXISTS signal.t_query_metrics (
id BIGSERIAL PRIMARY KEY,
query_id VARCHAR(64) NOT NULL,
session_id VARCHAR(64),
query_type VARCHAR(20) NOT NULL, -- 'WEBSOCKET' | 'REST_V1' | 'REST_V2'
created_at TIMESTAMP NOT NULL DEFAULT now(),
-- 요청 파라미터
start_time TIMESTAMP,
end_time TIMESTAMP,
zoom_level INTEGER,
viewport_bounds VARCHAR(200), -- "minLon,minLat,maxLon,maxLat"
requested_mmsi INTEGER DEFAULT 0,
-- 처리 경로
data_path VARCHAR(10), -- 'CACHE' | 'DB' | 'HYBRID'
cache_hit_days INTEGER DEFAULT 0,
db_query_days INTEGER DEFAULT 0,
db_conn_total INTEGER DEFAULT 0,
-- 결과 통계
unique_vessels INTEGER DEFAULT 0,
total_tracks INTEGER DEFAULT 0,
total_points INTEGER DEFAULT 0,
points_after_simplify INTEGER DEFAULT 0,
total_chunks INTEGER DEFAULT 0,
response_bytes BIGINT DEFAULT 0,
-- 성능
elapsed_ms BIGINT DEFAULT 0,
db_query_ms BIGINT DEFAULT 0,
simplify_ms BIGINT DEFAULT 0,
backpressure_events INTEGER DEFAULT 0,
-- 결과 상태
status VARCHAR(20) DEFAULT 'COMPLETED' -- 'COMPLETED' | 'CANCELLED' | 'ERROR' | 'TIMEOUT'
);
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);

파일 보기

@ -12,6 +12,25 @@ import static org.junit.jupiter.api.Assertions.*;
class SignalKindCodeTest { class SignalKindCodeTest {
@Nested
@DisplayName("shipName 기반 BUOY 검출")
class ShipNameBuoy {
@Test
@DisplayName("'.' 또는 '_' 2개 이상 → BUOY (vesselType 무시)")
void resolve_buoyByName() {
assertEquals("000028", SignalKindCode.resolve("Cargo", null, "BUOY_01_23").getCode());
assertEquals("000028", SignalKindCode.resolve("Tanker", null, "AIS.BUOY.01").getCode());
}
@Test
@DisplayName("'.' 또는 '_' 1개 이하 → vesselType 기준")
void resolve_notBuoyByName() {
assertEquals("000023", SignalKindCode.resolve("Cargo", null, "M.V CARGO").getCode());
assertEquals("000024", SignalKindCode.resolve("Tanker", null, "OIL_TANKER").getCode());
}
}
@Nested @Nested
@DisplayName("vesselType 단독 매칭") @DisplayName("vesselType 단독 매칭")
class VesselTypeDirect { class VesselTypeDirect {
@ -21,7 +40,7 @@ class SignalKindCodeTest {
"Cargo, 000023", "Cargo, 000023",
"Tanker, 000024", "Tanker, 000024",
"Passenger, 000022", "Passenger, 000022",
"AtoN, 000028", "AtoN, 000027",
"Law Enforcement, 000025", "Law Enforcement, 000025",
"Search and Rescue, 000021", "Search and Rescue, 000021",
"Local Vessel, 000020" "Local Vessel, 000020"
@ -38,11 +57,11 @@ class SignalKindCodeTest {
@ParameterizedTest @ParameterizedTest
@CsvSource({ @CsvSource({
"Tug, 000025",
"Pilot Boat, 000025", "Pilot Boat, 000025",
"Tender, 000025",
"Anti Pollution, 000025", "Anti Pollution, 000025",
"Medical Transport, 000025", "Medical Transport, 000025",
"Tug, 000027",
"Tender, 000027",
"High Speed Craft, 000022", "High Speed Craft, 000022",
"Wing in Ground-effect, 000022" "Wing in Ground-effect, 000022"
}) })
@ -60,13 +79,13 @@ class SignalKindCodeTest {
@CsvSource({ @CsvSource({
"Vessel, Fishing, 000020", "Vessel, Fishing, 000020",
"Vessel, Military Operations, 000025", "Vessel, Military Operations, 000025",
"Vessel, Towing, 000025", "Vessel, Towing, 000027",
"Vessel, Towing (Large), 000025", "Vessel, Towing (Large), 000027",
"Vessel, Dredging/Underwater Ops, 000025", "Vessel, Dredging/Underwater Ops, 000027",
"Vessel, Diving Operations, 000025", "Vessel, Diving Operations, 000027",
"Vessel, Pleasure Craft, 000020", "Vessel, Pleasure Craft, 000027",
"Vessel, Sailing, 000020", "Vessel, Sailing, 000027",
"Vessel, N/A, 000020", "Vessel, N/A, 000027",
"Vessel, Hazardous Cat A, 000023", "Vessel, Hazardous Cat A, 000023",
"Vessel, Hazardous Cat B, 000023", "Vessel, Hazardous Cat B, 000023",
"Vessel, Unknown, 000027" "Vessel, Unknown, 000027"

파일 보기

@ -14,6 +14,34 @@ import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("SignalKindCode - MDA 선종 범례코드 치환") @DisplayName("SignalKindCode - MDA 선종 범례코드 치환")
class SignalKindCodeTest { class SignalKindCodeTest {
@Nested
@DisplayName("shipName 기반 BUOY 검출 (최우선)")
class ShipNameBuoy {
@ParameterizedTest(name = "shipName={0} → BUOY")
@ValueSource(strings = {"BUOY_01_23", "AIS.BUOY.01", "LIGHT__HOUSE", "A.B.C"})
@DisplayName("'.' 또는 '_' 2개 이상 → BUOY")
void resolve_buoyByName(String shipName) {
assertThat(SignalKindCode.resolve("Cargo", null, shipName))
.isEqualTo(SignalKindCode.BUOY);
}
@ParameterizedTest(name = "shipName={0} → vesselType 기준")
@ValueSource(strings = {"M.V CARGO", "SHIP_ONE", "NORMAL SHIP", "ABC"})
@DisplayName("'.' 또는 '_' 1개 이하 → shipName 무시, vesselType 기준")
void resolve_notBuoyByName(String shipName) {
assertThat(SignalKindCode.resolve("Cargo", null, shipName))
.isEqualTo(SignalKindCode.CARGO);
}
@Test
@DisplayName("shipName null → vesselType 기준")
void resolve_nullShipName() {
assertThat(SignalKindCode.resolve("Cargo", null, null))
.isEqualTo(SignalKindCode.CARGO);
}
}
@Nested @Nested
@DisplayName("vesselType 단독 매칭") @DisplayName("vesselType 단독 매칭")
class VesselTypeDirect { class VesselTypeDirect {
@ -23,7 +51,6 @@ class SignalKindCodeTest {
"cargo, CARGO", "cargo, CARGO",
"tanker, TANKER", "tanker, TANKER",
"passenger, FERRY", "passenger, FERRY",
"aton, BUOY",
"law enforcement, GOV", "law enforcement, GOV",
"search and rescue, KCGV", "search and rescue, KCGV",
"local vessel, FISHING" "local vessel, FISHING"
@ -33,6 +60,12 @@ class SignalKindCodeTest {
SignalKindCode result = SignalKindCode.resolve(vesselType, null); SignalKindCode result = SignalKindCode.resolve(vesselType, null);
assertThat(result.name()).isEqualTo(expectedName); assertThat(result.name()).isEqualTo(expectedName);
} }
@Test
@DisplayName("aton → DEFAULT (부이가 아닌 일반 장비)")
void resolve_aton() {
assertThat(SignalKindCode.resolve("aton", null)).isEqualTo(SignalKindCode.DEFAULT);
}
} }
@Nested @Nested
@ -40,12 +73,19 @@ class SignalKindCodeTest {
class VesselTypeGroup { class VesselTypeGroup {
@ParameterizedTest(name = "vesselType={0} → GOV") @ParameterizedTest(name = "vesselType={0} → GOV")
@ValueSource(strings = {"tug", "pilot boat", "tender", "anti pollution", "medical transport"}) @ValueSource(strings = {"pilot boat", "anti pollution", "medical transport"})
@DisplayName("GOV 그룹 매칭") @DisplayName("GOV 그룹 매칭")
void resolve_govGroup(String vesselType) { void resolve_govGroup(String vesselType) {
assertThat(SignalKindCode.resolve(vesselType, null)).isEqualTo(SignalKindCode.GOV); assertThat(SignalKindCode.resolve(vesselType, null)).isEqualTo(SignalKindCode.GOV);
} }
@ParameterizedTest(name = "vesselType={0} → DEFAULT")
@ValueSource(strings = {"tug", "tender"})
@DisplayName("tug, tender → DEFAULT")
void resolve_tugTenderDefault(String vesselType) {
assertThat(SignalKindCode.resolve(vesselType, null)).isEqualTo(SignalKindCode.DEFAULT);
}
@ParameterizedTest(name = "vesselType={0} → FERRY") @ParameterizedTest(name = "vesselType={0} → FERRY")
@ValueSource(strings = {"high speed craft", "wing in ground-effect"}) @ValueSource(strings = {"high speed craft", "wing in ground-effect"})
@DisplayName("FERRY 그룹 매칭") @DisplayName("FERRY 그룹 매칭")
@ -70,18 +110,18 @@ class SignalKindCodeTest {
assertThat(SignalKindCode.resolve("Vessel", "Military Operations")).isEqualTo(SignalKindCode.GOV); assertThat(SignalKindCode.resolve("Vessel", "Military Operations")).isEqualTo(SignalKindCode.GOV);
} }
@ParameterizedTest(name = "Vessel + {0} → GOV") @ParameterizedTest(name = "Vessel + {0} → DEFAULT")
@ValueSource(strings = {"towing", "towing (large)", "dredging/underwater ops", "diving operations"}) @ValueSource(strings = {"towing", "towing (large)", "dredging/underwater ops", "diving operations"})
@DisplayName("Vessel + 해양작업 → GOV") @DisplayName("Vessel + 해양작업 → DEFAULT")
void resolve_vesselMarineOps(String extraInfo) { void resolve_vesselMarineOps(String extraInfo) {
assertThat(SignalKindCode.resolve("Vessel", extraInfo)).isEqualTo(SignalKindCode.GOV); assertThat(SignalKindCode.resolve("Vessel", extraInfo)).isEqualTo(SignalKindCode.DEFAULT);
} }
@ParameterizedTest(name = "Vessel + {0} → FISHING") @ParameterizedTest(name = "Vessel + {0} → DEFAULT")
@ValueSource(strings = {"pleasure craft", "sailing", "n/a"}) @ValueSource(strings = {"pleasure craft", "sailing", "n/a"})
@DisplayName("Vessel + 레저/기타 → FISHING") @DisplayName("Vessel + 레저/기타 → DEFAULT")
void resolve_vesselLeisure(String extraInfo) { void resolve_vesselLeisure(String extraInfo) {
assertThat(SignalKindCode.resolve("Vessel", extraInfo)).isEqualTo(SignalKindCode.FISHING); assertThat(SignalKindCode.resolve("Vessel", extraInfo)).isEqualTo(SignalKindCode.DEFAULT);
} }
@Test @Test
@ -164,4 +204,32 @@ class SignalKindCodeTest {
assertThat(SignalKindCode.BUOY.getCode()).isEqualTo("000028"); assertThat(SignalKindCode.BUOY.getCode()).isEqualTo("000028");
} }
} }
@Nested
@DisplayName("shipName BUOY 판정 (resolve 3-param 통합 검증)")
class BuoyNamePattern {
@ParameterizedTest(name = "{0} → BUOY")
@ValueSource(strings = {"A.B.C", "BUOY_01_02", "._", "A.B_C"})
@DisplayName("2개 이상 특수문자 → BUOY")
void resolve_buoyPattern(String name) {
// vesselType과 무관하게 BUOY로 치환
assertThat(SignalKindCode.resolve(null, null, name)).isEqualTo(SignalKindCode.BUOY);
}
@ParameterizedTest(name = "{0} → not BUOY")
@ValueSource(strings = {"ABC", "A.B", "A_B", "NORMAL"})
@DisplayName("1개 이하 특수문자 → shipName 무시")
void resolve_notBuoyPattern(String name) {
assertThat(SignalKindCode.resolve(null, null, name)).isEqualTo(SignalKindCode.DEFAULT);
}
@Test
@DisplayName("null/blank shipName → vesselType 기준")
void resolve_nullBlankName() {
assertThat(SignalKindCode.resolve("Cargo", null, null)).isEqualTo(SignalKindCode.CARGO);
assertThat(SignalKindCode.resolve("Cargo", null, "")).isEqualTo(SignalKindCode.CARGO);
assertThat(SignalKindCode.resolve("Cargo", null, " ")).isEqualTo(SignalKindCode.CARGO);
}
}
} }