diff --git a/src/main/java/gc/mda/signal_batch/global/websocket/service/CacheTrackSimplifier.java b/src/main/java/gc/mda/signal_batch/global/websocket/service/CacheTrackSimplifier.java index a481735..ec011fe 100644 --- a/src/main/java/gc/mda/signal_batch/global/websocket/service/CacheTrackSimplifier.java +++ b/src/main/java/gc/mda/signal_batch/global/websocket/service/CacheTrackSimplifier.java @@ -117,6 +117,9 @@ public class CacheTrackSimplifier { track.setPointCount(afterZoom); + // 간소화 후 속도 재계산 (포인트 간 거리/시간 기반) + recalculateSpeeds(track); + // 처음 5개 선박 상세 로그 (debug 레벨) if (simplifiedCount < 5) { log.debug("[CacheSimplify] vessel={} original={} -> DP={} -> distTime={} -> zoom={} (avg={} kn)", @@ -139,6 +142,43 @@ public class CacheTrackSimplifier { return tracks; } + // ── L3 캐시 저장용: DP-only 사전 간소화 ── + + /** + * DP(Douglas-Peucker)만 적용하는 사전 간소화 (L3 캐시 저장용). + * 방향 변화를 보존하여 어선 조업 패턴(원형, ㄹ자) 유지. + * 거리/시간 필터는 적용하지 않아 직선 구간만 제거. + */ + public void simplifyDpOnly(List tracks, double dpTolerance) { + if (tracks == null || tracks.isEmpty()) return; + + long startTime = System.currentTimeMillis(); + int totalOriginal = 0; + int totalAfter = 0; + int simplifiedCount = 0; + + for (CompactVesselTrack track : tracks) { + if (track.getGeometry() == null || track.getGeometry().size() <= 2) continue; + + int before = track.getGeometry().size(); + totalOriginal += before; + + applyDouglasPeucker(track, dpTolerance); + recalculateSpeeds(track); + track.setPointCount(track.getGeometry().size()); + + totalAfter += track.getGeometry().size(); + simplifiedCount++; + } + + long elapsed = System.currentTimeMillis() - startTime; + if (simplifiedCount > 0) { + double reduction = (1 - (double) totalAfter / totalOriginal) * 100; + log.info("[DpPreSimplify] {} tracks, {} -> {} pts ({}% 감소), {}ms", + simplifiedCount, totalOriginal, totalAfter, Math.round(reduction), elapsed); + } + } + // ── 1단계: Douglas-Peucker (ST_Simplify 대체) ── private void applyDouglasPeucker(CompactVesselTrack track, double tolerance) { @@ -412,6 +452,55 @@ public class CacheTrackSimplifier { if (sampledSpd != null) track.setSpeeds(sampledSpd); } + // ── 간소화 후 속도 재계산 ── + + /** + * 간소화된 포인트 간 속도 재계산. + * 간소화 후 남은 포인트에 대해 인접 좌표 간 Haversine 거리/시간차로 계산. + */ + private void recalculateSpeeds(CompactVesselTrack track) { + List geometry = track.getGeometry(); + List timestamps = track.getTimestamps(); + if (geometry == null || geometry.size() < 2 || + timestamps == null || timestamps.size() != geometry.size()) { + return; + } + + int size = geometry.size(); + List speeds = new ArrayList<>(size); + speeds.add(0.0); // 첫 포인트는 이전 포인트가 없으므로 0 + + for (int i = 1; i < size; i++) { + double[] prev = geometry.get(i - 1); + double[] curr = geometry.get(i); + try { + long prevTs = parseEpochSeconds(timestamps.get(i - 1)); + long currTs = parseEpochSeconds(timestamps.get(i)); + double timeDiffHours = (currTs - prevTs) / 3600.0; + if (timeDiffHours > 0) { + double distNm = calculateDistance(prev[1], prev[0], curr[1], curr[0]); + speeds.add(distNm / timeDiffHours); // knots + } else { + speeds.add(0.0); + } + } catch (Exception e) { + speeds.add(0.0); + } + } + + track.setSpeeds(speeds); + } + + private long parseEpochSeconds(String tsStr) { + if (tsStr == null) throw new IllegalArgumentException("null timestamp"); + if (tsStr.matches("\\d{10,}")) { + return Long.parseLong(tsStr); + } + return LocalDateTime.parse(tsStr, TIMESTAMP_FORMATTER) + .atZone(java.time.ZoneId.systemDefault()) + .toEpochSecond(); + } + // ── 거리 계산 (Haversine, 해리 단위) ── private double calculateDistance(double lat1, double lon1, double lat2, double lon2) { diff --git a/src/main/java/gc/mda/signal_batch/global/websocket/service/DailyTrackCacheManager.java b/src/main/java/gc/mda/signal_batch/global/websocket/service/DailyTrackCacheManager.java index db948a9..a80540b 100644 --- a/src/main/java/gc/mda/signal_batch/global/websocket/service/DailyTrackCacheManager.java +++ b/src/main/java/gc/mda/signal_batch/global/websocket/service/DailyTrackCacheManager.java @@ -40,9 +40,13 @@ public class DailyTrackCacheManager { NOT_STARTED, LOADING, PARTIAL, READY, DISABLED } + /** L3 사전 간소화 DP tolerance (~100m) — 항적 형상 유지하면서 직선 구간만 제거 */ + private static final double L3_DP_TOLERANCE = 0.001; + private final DataSource queryDataSource; private final DailyTrackCacheProperties cacheProperties; private final TrackMemoryBudgetManager memoryBudgetManager; + private final CacheTrackSimplifier cacheTrackSimplifier; // 날짜별 캐시 (D-1 ~ D-N) private final ConcurrentHashMap cache = new ConcurrentHashMap<>(); @@ -56,10 +60,12 @@ public class DailyTrackCacheManager { public DailyTrackCacheManager( @Qualifier("queryDataSource") DataSource queryDataSource, DailyTrackCacheProperties cacheProperties, - TrackMemoryBudgetManager memoryBudgetManager) { + TrackMemoryBudgetManager memoryBudgetManager, + CacheTrackSimplifier cacheTrackSimplifier) { this.queryDataSource = queryDataSource; this.cacheProperties = cacheProperties; this.memoryBudgetManager = memoryBudgetManager; + this.cacheTrackSimplifier = cacheTrackSimplifier; } /** @@ -337,6 +343,23 @@ public class DailyTrackCacheManager { estimatedMemory += tracks.size() * 200L; // 객체 오버헤드 + // DP 사전 간소화: 직선 구간만 제거, 방향 변화(어선 조업 패턴) 보존 + long memoryBeforeDp = estimatedMemory; + List trackList = new ArrayList<>(tracks.values()); + cacheTrackSimplifier.simplifyDpOnly(trackList, L3_DP_TOLERANCE); + + // 간소화 후 메모리 재추정 + estimatedMemory = trackList.stream() + .mapToLong(t -> t.getPointCount() * 40L) + .sum(); + estimatedMemory += tracks.size() * 200L; // 객체 오버헤드 + + if (memoryBeforeDp > 0) { + long reduction = memoryBeforeDp > 0 ? Math.round((1 - (double) estimatedMemory / memoryBeforeDp) * 100) : 0; + log.info("[DailyLoadDay] {} DP pre-simplification: {}MB -> {}MB ({}% reduction, tolerance={})", + date, memoryBeforeDp / (1024 * 1024), estimatedMemory / (1024 * 1024), reduction, L3_DP_TOLERANCE); + } + // STRtree 공간 인덱스 빌드 STRtree spatialIndex = buildSpatialIndex(tracks); estimatedMemory += tracks.size() * 100L; // 인덱스 오버헤드 diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index dda226f..404108a 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -48,7 +48,7 @@ spring: validation-timeout: 5000 leak-detection-threshold: 60000 # 커넥션 누수 감지 (60초) # PostGIS 함수를 위해 public 스키마를 search_path에 명시적으로 추가 - connection-init-sql: "SET TIME ZONE 'Asia/Seoul'; SET search_path TO signal, public, pg_catalog;" + connection-init-sql: "SET TIME ZONE 'Asia/Seoul'; SET search_path TO signal, public, pg_catalog; SET work_mem = '256MB'; SET synchronous_commit = 'off';" statement-cache-size: 250 data-source-properties: prepareThreshold: 3 @@ -68,7 +68,7 @@ spring: idle-timeout: 600000 max-lifetime: 1800000 leak-detection-threshold: 60000 # 커넥션 누수 감지 (60초) - connection-init-sql: "SET TIME ZONE 'Asia/Seoul'; SET search_path TO signal, public;" + connection-init-sql: "SET TIME ZONE 'Asia/Seoul'; SET search_path TO signal, public; SET synchronous_commit = 'off';" # Request 크기 설정 servlet: @@ -283,8 +283,8 @@ app: cache: daily-track: enabled: true - retention-days: 7 # D-1 ~ D-7 (오늘 제외) - max-memory-gb: 6 # 최대 6GB (일 평균 ~720MB × 7일 = ~5GB) + retention-days: 14 # D-1 ~ D-14 (2주, DP 간소화로 메모리 절감) + max-memory-gb: 10 # 최대 10GB (DP 간소화 후 일 ~400MB × 14일 ≈ 6GB + 여유) warmup-async: true # 비동기 워밍업 (서버 시작 차단 없음) # 항적 데이터 메모리 예산 (64GB JVM 기준)