feat: 다중 폴리곤 영역 탐색 REST API + STRtree 공간 인덱스 (Phase 8)

캐시 기반 인메모리 다중 폴리곤 영역 내 선박 탐색 API 구현.
JTS STRtree + PreparedGeometry로 고속 공간 검색, ANY/ALL/SEQUENTIAL 3가지 모드 지원.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
HeungTak Lee 2026-02-07 08:38:18 +09:00
부모 e729316edf
커밋 b4221c36fd
6개의 변경된 파일844개의 추가작업 그리고 1개의 파일을 삭제

파일 보기

@ -193,6 +193,37 @@
---
## Phase 8 — 다중 폴리곤 영역 탐색 REST API + 공간 인덱스
- [x] **8.1** DailyTrackCacheManager에 STRtree 공간 인덱스 추가
- DailyTrackData에 STRtree spatialIndex 필드 추가 (하위 호환 유지)
- loadDay() 완료 후 자동 빌드 → build() 호출로 불변화 (동시성 안전)
- getDailyTrackData(date), getCachedDateList() public 메서드 추가
- 추가 메모리: 선박당 ~100B × 50K/일 = ~5MB/일, 7일 ~35MB
- 상태: **완료** (2026-02-07)
- [x] **8.2** AreaSearchRequest / AreaSearchResponse DTO 생성
- AreaSearchRequest: startTime/endTime + SearchMode(ANY/ALL/SEQUENTIAL) + List<SearchPolygon>
- AreaSearchResponse: tracks(CompactVesselTrack[]) + hitDetails + summary
- Swagger @Schema 상세 기입, 요청/응답 예시 포함
- 상태: **완료** (2026-02-07)
- [x] **8.3** AreaSearchService 핵심 비즈니스 로직 구현
- JTS PreparedGeometry + STRtree 기반 고속 영역 탐색
- 다일 데이터 병합 → 단일 STRtree → 폴리곤별 후보 추출 → 정밀 PIP
- ANY(합집합)/ALL(교집합)/SEQUENTIAL(순차 통과) 3가지 모드
- 캐시 미준비 시 CacheNotReadyException → 503 반환
- 상태: **완료** (2026-02-07)
- [x] **8.4** AreaSearchController REST 엔드포인트
- POST /api/v2/tracks/area-search
- @Tag("항적 조회 API V2") → 기존 GisControllerV2와 동일 Swagger 그룹
- 에러 핸들러: 400 (잘못된 폴리곤), 503 (캐시 미준비)
- Swagger: @Operation, @ExampleObject 상세 문서화
- 상태: **완료** (2026-02-07)
---
## 커밋 이력
| 날짜 | Phase | 커밋 메시지 | 해시 |

파일 보기

@ -0,0 +1,123 @@
package gc.mda.signal_batch.domain.gis.controller;
import gc.mda.signal_batch.domain.gis.dto.AreaSearchRequest;
import gc.mda.signal_batch.domain.gis.dto.AreaSearchResponse;
import gc.mda.signal_batch.domain.gis.service.AreaSearchService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.ExampleObject;
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.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@Slf4j
@RestController
@RequestMapping("/api/v2/tracks")
@RequiredArgsConstructor
@Tag(name = "항적 조회 API V2", description = "해구/영역별 선박 항적 조회 API (WebSocket 호환 CompactVesselTrack 응답)")
public class AreaSearchController {
private final AreaSearchService areaSearchService;
@PostMapping("/area-search")
@Operation(
summary = "다중 폴리곤 영역 내 선박 탐색",
description = """
인메모리 캐시(D-1~D-7) 활용하여 사용자 지정 다중 폴리곤 영역 선박을 탐색합니다.
**검색 모드 (폴리곤 2개 이상 ):**
- **ANY**: 합집합 - 어느 영역이라도 통과한 선박
- **ALL**: 교집합 - 모든 영역을 통과한 선박
- **SEQUENTIAL**: 순차 통과 - 모든 영역을 지정된 순서대로 통과한 선박
**폴리곤 1개일 :** mode는 무시되며, 해당 영역 히트 선박 + 전체 트랙 반환
**응답 구조:**
- `tracks`: 기존 V2 API와 동일한 CompactVesselTrack 배열 (프론트엔드 렌더링 호환)
- `hitDetails`: 선박별 영역 히트 메타데이터 (진입/진출 시간, 히트 포인트 )
- `summary`: 검색 요약 (선박 , 포인트 , 처리 시간 )
**제약사항:**
- 캐시된 날짜 범위만 조회 가능 (D-1 ~ D-7, 오늘 제외)
- 폴리곤 최대 10개
- 폴리곤 좌표는 닫힌 형태 (첫점 == 끝점)
- 캐시 미준비 503 반환
"""
)
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "탐색 성공",
content = @Content(
mediaType = "application/json",
schema = @Schema(implementation = AreaSearchResponse.class)
)),
@ApiResponse(responseCode = "400", description = "잘못된 요청 (날짜 범위 초과, 잘못된 폴리곤 등)",
content = @Content(
mediaType = "application/json",
examples = @ExampleObject(value = "{\"error\": \"폴리곤 'zone_A'이 유효하지 않습니다 (자기 교차 등)\"}")
)),
@ApiResponse(responseCode = "503", description = "캐시 미준비 (LOADING 상태)",
content = @Content(
mediaType = "application/json",
examples = @ExampleObject(value = "{\"error\": \"캐시가 아직 준비되지 않았습니다 (상태: LOADING)\"}")
))
})
public ResponseEntity<AreaSearchResponse> searchArea(
@io.swagger.v3.oas.annotations.parameters.RequestBody(
description = "다중 폴리곤 영역 탐색 요청",
required = true,
content = @Content(
schema = @Schema(implementation = AreaSearchRequest.class),
examples = @ExampleObject(
name = "순차 통과 예시",
value = """
{
"startTime": "2026-02-01T00:00:00",
"endTime": "2026-02-07T23:59:59",
"mode": "SEQUENTIAL",
"polygons": [
{
"id": "zone_A", "name": "대한해협 서수도",
"coordinates": [[128.5,34.0],[129.5,34.0],[129.5,35.0],[128.5,35.0],[128.5,34.0]]
},
{
"id": "zone_B", "name": "제주해협",
"coordinates": [[126.0,33.0],[127.0,33.0],[127.0,34.0],[126.0,34.0],[126.0,33.0]]
}
]
}
"""
)
)
)
@Valid @RequestBody AreaSearchRequest request) {
log.info("Area search request: mode={}, polygons={}, timeRange={} ~ {}",
request.getMode(), request.getPolygons().size(),
request.getStartTime(), request.getEndTime());
return ResponseEntity.ok(areaSearchService.search(request));
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<Map<String, String>> handleBadRequest(IllegalArgumentException e) {
log.warn("Area search bad request: {}", e.getMessage());
return ResponseEntity.badRequest()
.body(Map.of("error", e.getMessage()));
}
@ExceptionHandler(AreaSearchService.CacheNotReadyException.class)
public ResponseEntity<Map<String, String>> handleCacheNotReady(AreaSearchService.CacheNotReadyException e) {
log.warn("Area search cache not ready: {}", e.getMessage());
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.body(Map.of("error", e.getMessage()));
}
}

파일 보기

@ -0,0 +1,75 @@
package gc.mda.signal_batch.domain.gis.dto;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import gc.mda.signal_batch.global.config.FlexibleLocalDateTimeDeserializer;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.List;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "다중 폴리곤 영역 탐색 요청")
public class AreaSearchRequest {
@NotNull(message = "시작 시간은 필수입니다")
@JsonDeserialize(using = FlexibleLocalDateTimeDeserializer.class)
@Schema(description = "조회 시작 시간", example = "2026-02-01T00:00:00", requiredMode = Schema.RequiredMode.REQUIRED)
private LocalDateTime startTime;
@NotNull(message = "종료 시간은 필수입니다")
@JsonDeserialize(using = FlexibleLocalDateTimeDeserializer.class)
@Schema(description = "조회 종료 시간", example = "2026-02-07T23:59:59", requiredMode = Schema.RequiredMode.REQUIRED)
private LocalDateTime endTime;
@Schema(description = "검색 모드: ANY(합집합), ALL(교집합), SEQUENTIAL(순차 통과). 폴리곤 1개일 때는 무시됨",
example = "ANY", defaultValue = "ANY")
@Builder.Default
private SearchMode mode = SearchMode.ANY;
@NotNull(message = "폴리곤 목록은 필수입니다")
@Size(min = 1, max = 10, message = "폴리곤은 1~10개까지 지정 가능합니다")
@Valid
@Schema(description = "탐색 대상 폴리곤 영역 목록 (1~10개)", requiredMode = Schema.RequiredMode.REQUIRED)
private List<SearchPolygon> polygons;
@Schema(description = "검색 모드 (폴리곤이 2개 이상일 때 적용)")
public enum SearchMode {
@Schema(description = "합집합: 어느 한 영역이라도 통과한 선박")
ANY,
@Schema(description = "교집합: 모든 영역을 통과한 선박")
ALL,
@Schema(description = "순차 통과: 모든 영역을 지정된 순서대로 통과한 선박")
SEQUENTIAL
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "탐색 대상 폴리곤 영역")
public static class SearchPolygon {
@Schema(description = "클라이언트 지정 영역 식별자", example = "zone_A")
private String id;
@Schema(description = "영역 표시명", example = "대한해협 서수도")
private String name;
@NotNull(message = "폴리곤 좌표는 필수입니다")
@Size(min = 4, message = "폴리곤은 최소 4개 좌표 필요 (첫점==끝점)")
@Schema(description = "폴리곤 좌표 배열 [[lon,lat],...] 첫점과 끝점이 동일해야 함",
example = "[[128.5,34.0],[129.5,34.0],[129.5,35.0],[128.5,35.0],[128.5,34.0]]",
requiredMode = Schema.RequiredMode.REQUIRED)
private List<double[]> coordinates;
}
}

파일 보기

@ -0,0 +1,80 @@
package gc.mda.signal_batch.domain.gis.dto;
import gc.mda.signal_batch.domain.vessel.dto.CompactVesselTrack;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
import java.util.Map;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "다중 폴리곤 영역 탐색 응답")
public class AreaSearchResponse {
@Schema(description = "히트된 선박의 전체 기간 항적 (기존 V2 API와 동일한 CompactVesselTrack)")
private List<CompactVesselTrack> tracks;
@Schema(description = "선박별 영역 히트 메타데이터 (key: vesselId)")
private Map<String, List<PolygonHitDetail>> hitDetails;
@Schema(description = "검색 요약 정보")
private AreaSearchSummary summary;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "폴리곤별 히트 상세 정보")
public static class PolygonHitDetail {
@Schema(description = "폴리곤 식별자", example = "zone_A")
private String polygonId;
@Schema(description = "폴리곤 표시명", example = "대한해협 서수도")
private String polygonName;
@Schema(description = "영역 첫 진입 Unix timestamp (초)", example = "1738368000")
private Long entryTimestamp;
@Schema(description = "영역 마지막 진출 Unix timestamp (초)", example = "1738382400")
private Long exitTimestamp;
@Schema(description = "영역 내 포인트 수", example = "45")
private Integer hitPointCount;
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "영역 탐색 요약 정보")
public static class AreaSearchSummary {
@Schema(description = "히트된 선박 수", example = "12")
private Integer totalVessels;
@Schema(description = "히트된 전체 포인트 수", example = "4560")
private Long totalPoints;
@Schema(description = "적용된 검색 모드", example = "SEQUENTIAL")
private AreaSearchRequest.SearchMode mode;
@Schema(description = "검색 대상 폴리곤 ID 목록", example = "[\"zone_A\", \"zone_B\"]")
private List<String> polygonIds;
@Schema(description = "처리 소요 시간 (ms)", example = "1250")
private Long processingTimeMs;
@Schema(description = "조회에 사용된 캐시 날짜 목록", example = "[\"2026-02-01\", \"2026-02-02\"]")
private List<String> cachedDates;
@Schema(description = "캐시된 전체 선박 수", example = "285000")
private Integer totalCachedVessels;
}
}

파일 보기

@ -0,0 +1,481 @@
package gc.mda.signal_batch.domain.gis.service;
import gc.mda.signal_batch.domain.gis.dto.AreaSearchRequest;
import gc.mda.signal_batch.domain.gis.dto.AreaSearchRequest.SearchMode;
import gc.mda.signal_batch.domain.gis.dto.AreaSearchRequest.SearchPolygon;
import gc.mda.signal_batch.domain.gis.dto.AreaSearchResponse;
import gc.mda.signal_batch.domain.gis.dto.AreaSearchResponse.AreaSearchSummary;
import gc.mda.signal_batch.domain.gis.dto.AreaSearchResponse.PolygonHitDetail;
import gc.mda.signal_batch.domain.vessel.dto.CompactVesselTrack;
import gc.mda.signal_batch.global.websocket.service.DailyTrackCacheManager;
import gc.mda.signal_batch.global.websocket.service.DailyTrackCacheManager.DailyTrackData;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.locationtech.jts.geom.*;
import org.locationtech.jts.geom.prep.PreparedGeometry;
import org.locationtech.jts.geom.prep.PreparedGeometryFactory;
import org.locationtech.jts.index.strtree.STRtree;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.*;
import java.util.stream.Collectors;
@Slf4j
@Service
@RequiredArgsConstructor
public class AreaSearchService {
private final DailyTrackCacheManager cacheManager;
private static final GeometryFactory GEOMETRY_FACTORY = new GeometryFactory();
/**
* 다중 폴리곤 영역 탐색 메인 엔트리
*/
public AreaSearchResponse search(AreaSearchRequest request) {
long startMs = System.currentTimeMillis();
// 1. 입력 검증
validateRequest(request);
// 2. 캐시 데이터 수집 (날짜 범위)
List<LocalDate> targetDates = collectTargetDates(request.getStartTime(), request.getEndTime());
if (targetDates.isEmpty()) {
return buildEmptyResponse(request, startMs);
}
// 3. 다일 데이터 선박별 단일 트랙 병합
Map<String, CompactVesselTrack> mergedTracks = mergeMultipleDays(targetDates);
if (mergedTracks.isEmpty()) {
return buildEmptyResponse(request, startMs);
}
// 4. 좌표 JTS Polygon 변환
List<Polygon> jtsPolygons = convertToJtsPolygons(request.getPolygons());
// 5. 병합된 트랙으로 STRtree 빌드
STRtree spatialIndex = buildSpatialIndex(mergedTracks);
// 6. 폴리곤별 히트 선박 + 타임스탬프 수집
List<Map<String, PolygonHitDetail>> perPolygonHits = new ArrayList<>();
for (int i = 0; i < jtsPolygons.size(); i++) {
Polygon polygon = jtsPolygons.get(i);
SearchPolygon searchPolygon = request.getPolygons().get(i);
Map<String, PolygonHitDetail> hits = findHitsForPolygon(
polygon, searchPolygon, mergedTracks, spatialIndex);
perPolygonHits.add(hits);
}
// 7. 모드별 결과 합산
SearchMode mode = request.getPolygons().size() == 1 ? SearchMode.ANY : request.getMode();
Map<String, List<PolygonHitDetail>> resultHits;
switch (mode) {
case ALL:
resultHits = processAllMode(perPolygonHits);
break;
case SEQUENTIAL:
resultHits = processSequentialMode(perPolygonHits);
break;
default:
resultHits = processAnyMode(perPolygonHits);
break;
}
// 8. 결과 선박의 전체 기간 트랙 + 히트 메타 반환
List<CompactVesselTrack> resultTracks = resultHits.keySet().stream()
.map(mergedTracks::get)
.filter(Objects::nonNull)
.collect(Collectors.toList());
long totalPoints = resultHits.values().stream()
.flatMap(Collection::stream)
.mapToLong(h -> h.getHitPointCount() != null ? h.getHitPointCount() : 0)
.sum();
int totalCachedVessels = targetDates.stream()
.mapToInt(d -> {
DailyTrackData data = cacheManager.getDailyTrackData(d);
return data != null ? data.getVesselCount() : 0;
})
.sum();
long elapsedMs = System.currentTimeMillis() - startMs;
log.info("Area search completed: mode={}, polygons={}, hitVessels={}, totalPoints={}, elapsed={}ms",
mode, request.getPolygons().size(), resultHits.size(), totalPoints, elapsedMs);
return AreaSearchResponse.builder()
.tracks(resultTracks)
.hitDetails(resultHits)
.summary(AreaSearchSummary.builder()
.totalVessels(resultHits.size())
.totalPoints(totalPoints)
.mode(mode)
.polygonIds(request.getPolygons().stream()
.map(SearchPolygon::getId)
.collect(Collectors.toList()))
.processingTimeMs(elapsedMs)
.cachedDates(targetDates.stream()
.map(LocalDate::toString)
.collect(Collectors.toList()))
.totalCachedVessels(totalCachedVessels)
.build())
.build();
}
// 입력 검증
private void validateRequest(AreaSearchRequest request) {
if (request.getStartTime().isAfter(request.getEndTime())) {
throw new IllegalArgumentException("시작 시간이 종료 시간보다 나중입니다");
}
DailyTrackCacheManager.CacheStatus cacheStatus = cacheManager.getStatus();
if (cacheStatus == DailyTrackCacheManager.CacheStatus.LOADING
|| cacheStatus == DailyTrackCacheManager.CacheStatus.NOT_STARTED) {
throw new CacheNotReadyException("캐시가 아직 준비되지 않았습니다 (상태: " + cacheStatus + ")");
}
for (SearchPolygon polygon : request.getPolygons()) {
validatePolygon(polygon);
}
}
private void validatePolygon(SearchPolygon polygon) {
List<double[]> coords = polygon.getCoordinates();
if (coords == null || coords.size() < 4) {
throw new IllegalArgumentException(
"폴리곤 '" + polygon.getId() + "'은 최소 4개 좌표가 필요합니다 (첫점==끝점)");
}
double[] first = coords.get(0);
double[] last = coords.get(coords.size() - 1);
if (first[0] != last[0] || first[1] != last[1]) {
throw new IllegalArgumentException(
"폴리곤 '" + polygon.getId() + "'의 첫점과 끝점이 동일해야 합니다");
}
// JTS로 유효성 검사
try {
Polygon jtsPolygon = toJtsPolygon(coords);
if (!jtsPolygon.isValid()) {
throw new IllegalArgumentException(
"폴리곤 '" + polygon.getId() + "'이 유효하지 않습니다 (자기 교차 등)");
}
} catch (IllegalArgumentException e) {
throw e;
} catch (Exception e) {
throw new IllegalArgumentException(
"폴리곤 '" + polygon.getId() + "' 변환 실패: " + e.getMessage());
}
}
// 날짜 수집
private List<LocalDate> collectTargetDates(LocalDateTime startTime, LocalDateTime endTime) {
LocalDate today = LocalDate.now();
LocalDate startDate = startTime.toLocalDate();
LocalDate endDate = endTime.toLocalDate();
List<LocalDate> dates = new ArrayList<>();
for (LocalDate d = startDate; !d.isAfter(endDate); d = d.plusDays(1)) {
if (d.isAfter(today) || d.equals(today)) {
// 오늘/미래 데이터는 캐시에 없음 (경고 로그)
if (d.equals(today)) {
log.warn("Area search: 오늘({}) 데이터는 캐시에 없어 제외됩니다", today);
}
continue;
}
if (cacheManager.isCached(d)) {
dates.add(d);
} else {
log.debug("Area search: 날짜 {}가 캐시에 없어 제외됩니다", d);
}
}
return dates;
}
// JTS 변환
List<Polygon> convertToJtsPolygons(List<SearchPolygon> searchPolygons) {
return searchPolygons.stream()
.map(sp -> toJtsPolygon(sp.getCoordinates()))
.collect(Collectors.toList());
}
private Polygon toJtsPolygon(List<double[]> coordinates) {
Coordinate[] coords = new Coordinate[coordinates.size()];
for (int i = 0; i < coordinates.size(); i++) {
double[] c = coordinates.get(i);
coords[i] = new Coordinate(c[0], c[1]);
}
return GEOMETRY_FACTORY.createPolygon(coords);
}
// 다일 데이터 병합
Map<String, CompactVesselTrack> mergeMultipleDays(List<LocalDate> dates) {
Map<String, List<CompactVesselTrack>> byVessel = new HashMap<>();
for (LocalDate date : dates) {
DailyTrackData data = cacheManager.getDailyTrackData(date);
if (data == null) continue;
for (Map.Entry<String, CompactVesselTrack> entry : data.getTracks().entrySet()) {
byVessel.computeIfAbsent(entry.getKey(), k -> new ArrayList<>())
.add(entry.getValue());
}
}
Map<String, CompactVesselTrack> merged = new HashMap<>(byVessel.size());
for (Map.Entry<String, List<CompactVesselTrack>> entry : byVessel.entrySet()) {
List<CompactVesselTrack> trackList = entry.getValue();
if (trackList.size() == 1) {
merged.put(entry.getKey(), trackList.get(0));
continue;
}
// 여러 날짜 병합
CompactVesselTrack first = trackList.get(0);
List<double[]> geo = new ArrayList<>();
List<String> ts = new ArrayList<>();
List<Double> sp = new ArrayList<>();
double totalDist = 0;
double maxSpeed = 0;
int pointCount = 0;
for (CompactVesselTrack t : trackList) {
if (t.getGeometry() != null) geo.addAll(t.getGeometry());
if (t.getTimestamps() != null) ts.addAll(t.getTimestamps());
if (t.getSpeeds() != null) sp.addAll(t.getSpeeds());
if (t.getTotalDistance() != null) totalDist += t.getTotalDistance();
if (t.getMaxSpeed() != null) maxSpeed = Math.max(maxSpeed, t.getMaxSpeed());
if (t.getPointCount() != null) pointCount += t.getPointCount();
}
merged.put(entry.getKey(), CompactVesselTrack.builder()
.vesselId(first.getVesselId())
.sigSrcCd(first.getSigSrcCd())
.targetId(first.getTargetId())
.nationalCode(first.getNationalCode())
.shipName(first.getShipName())
.shipType(first.getShipType())
.shipKindCode(first.getShipKindCode())
.integrationTargetId(first.getIntegrationTargetId())
.geometry(geo)
.timestamps(ts)
.speeds(sp)
.totalDistance(totalDist)
.avgSpeed(pointCount > 0 ? totalDist / Math.max(1, pointCount) * 60 : 0)
.maxSpeed(maxSpeed)
.pointCount(pointCount)
.build());
}
return merged;
}
// STRtree 빌드
private STRtree buildSpatialIndex(Map<String, CompactVesselTrack> tracks) {
STRtree tree = new STRtree();
for (Map.Entry<String, CompactVesselTrack> entry : tracks.entrySet()) {
CompactVesselTrack track = entry.getValue();
if (track.getGeometry() == null || track.getGeometry().isEmpty()) continue;
double minLon = Double.MAX_VALUE, maxLon = -Double.MAX_VALUE;
double minLat = Double.MAX_VALUE, maxLat = -Double.MAX_VALUE;
for (double[] coord : track.getGeometry()) {
if (coord[0] < minLon) minLon = coord[0];
if (coord[0] > maxLon) maxLon = coord[0];
if (coord[1] < minLat) minLat = coord[1];
if (coord[1] > maxLat) maxLat = coord[1];
}
tree.insert(new Envelope(minLon, maxLon, minLat, maxLat), entry.getKey());
}
tree.build();
return tree;
}
// 폴리곤별 히트 검색
@SuppressWarnings("unchecked")
Map<String, PolygonHitDetail> findHitsForPolygon(
Polygon polygon, SearchPolygon searchPolygon,
Map<String, CompactVesselTrack> tracks, STRtree spatialIndex) {
PreparedGeometry prepared = PreparedGeometryFactory.prepare(polygon);
Envelope mbr = polygon.getEnvelopeInternal();
// STRtree 후보 추출
List<String> candidates = spatialIndex.query(mbr);
Map<String, PolygonHitDetail> hits = new HashMap<>();
for (String vesselId : candidates) {
CompactVesselTrack track = tracks.get(vesselId);
if (track == null) continue;
PolygonHitDetail hit = checkTrackAgainstPolygon(track, prepared, searchPolygon);
if (hit != null) {
hits.put(vesselId, hit);
}
}
return hits;
}
/**
* 정밀 point-in-polygon 검사: 트랙의 좌표를 폴리곤과 비교
*/
private PolygonHitDetail checkTrackAgainstPolygon(
CompactVesselTrack track, PreparedGeometry prepared, SearchPolygon searchPolygon) {
List<double[]> geometry = track.getGeometry();
List<String> timestamps = track.getTimestamps();
if (geometry == null || geometry.isEmpty()) return null;
Long entryTimestamp = null;
Long exitTimestamp = null;
int hitCount = 0;
for (int i = 0; i < geometry.size(); i++) {
double[] coord = geometry.get(i);
Point point = GEOMETRY_FACTORY.createPoint(new Coordinate(coord[0], coord[1]));
if (prepared.contains(point)) {
hitCount++;
long ts = parseTimestamp(timestamps, i);
if (entryTimestamp == null || ts < entryTimestamp) {
entryTimestamp = ts;
}
if (exitTimestamp == null || ts > exitTimestamp) {
exitTimestamp = ts;
}
}
}
if (hitCount == 0) return null;
return PolygonHitDetail.builder()
.polygonId(searchPolygon.getId())
.polygonName(searchPolygon.getName())
.entryTimestamp(entryTimestamp)
.exitTimestamp(exitTimestamp)
.hitPointCount(hitCount)
.build();
}
private long parseTimestamp(List<String> timestamps, int index) {
if (timestamps == null || index >= timestamps.size()) return 0L;
try {
return Long.parseLong(timestamps.get(index));
} catch (NumberFormatException e) {
return 0L;
}
}
// 모드별 결과 처리
/**
* ANY 모드: 합집합 (어느 영역이든 통과한 선박)
*/
Map<String, List<PolygonHitDetail>> processAnyMode(
List<Map<String, PolygonHitDetail>> perPolygonHits) {
Map<String, List<PolygonHitDetail>> result = new HashMap<>();
for (Map<String, PolygonHitDetail> polygonHits : perPolygonHits) {
for (Map.Entry<String, PolygonHitDetail> entry : polygonHits.entrySet()) {
result.computeIfAbsent(entry.getKey(), k -> new ArrayList<>())
.add(entry.getValue());
}
}
return result;
}
/**
* ALL 모드: 교집합 (모든 영역을 통과한 선박)
*/
Map<String, List<PolygonHitDetail>> processAllMode(
List<Map<String, PolygonHitDetail>> perPolygonHits) {
if (perPolygonHits.isEmpty()) return Collections.emptyMap();
// 모든 폴리곤에 공통으로 존재하는 vesselId 찾기
Set<String> commonVessels = new HashSet<>(perPolygonHits.get(0).keySet());
for (int i = 1; i < perPolygonHits.size(); i++) {
commonVessels.retainAll(perPolygonHits.get(i).keySet());
}
Map<String, List<PolygonHitDetail>> result = new HashMap<>();
for (String vesselId : commonVessels) {
List<PolygonHitDetail> hits = new ArrayList<>();
for (Map<String, PolygonHitDetail> polygonHits : perPolygonHits) {
hits.add(polygonHits.get(vesselId));
}
result.put(vesselId, hits);
}
return result;
}
/**
* SEQUENTIAL 모드: 교집합 + entryTimestamp 순서 검증
*/
Map<String, List<PolygonHitDetail>> processSequentialMode(
List<Map<String, PolygonHitDetail>> perPolygonHits) {
// 먼저 ALL 모드로 교집합 구함
Map<String, List<PolygonHitDetail>> allHits = processAllMode(perPolygonHits);
// 순서 검증: 선박에 대해 polygon 순서대로 entryTimestamp 증가 확인
Map<String, List<PolygonHitDetail>> result = new HashMap<>();
for (Map.Entry<String, List<PolygonHitDetail>> entry : allHits.entrySet()) {
List<PolygonHitDetail> hits = entry.getValue();
if (isSequentialOrder(hits)) {
result.put(entry.getKey(), hits);
}
}
return result;
}
private boolean isSequentialOrder(List<PolygonHitDetail> hits) {
for (int i = 1; i < hits.size(); i++) {
Long prevEntry = hits.get(i - 1).getEntryTimestamp();
Long currEntry = hits.get(i).getEntryTimestamp();
if (prevEntry == null || currEntry == null) return false;
if (currEntry <= prevEntry) return false;
}
return true;
}
// 응답
private AreaSearchResponse buildEmptyResponse(AreaSearchRequest request, long startMs) {
long elapsedMs = System.currentTimeMillis() - startMs;
SearchMode mode = request.getPolygons().size() == 1 ? SearchMode.ANY : request.getMode();
return AreaSearchResponse.builder()
.tracks(Collections.emptyList())
.hitDetails(Collections.emptyMap())
.summary(AreaSearchSummary.builder()
.totalVessels(0)
.totalPoints(0L)
.mode(mode)
.polygonIds(request.getPolygons().stream()
.map(SearchPolygon::getId)
.collect(Collectors.toList()))
.processingTimeMs(elapsedMs)
.cachedDates(Collections.emptyList())
.totalCachedVessels(0)
.build())
.build();
}
// 예외 클래스
public static class CacheNotReadyException extends RuntimeException {
public CacheNotReadyException(String message) {
super(message);
}
}
}

파일 보기

@ -8,7 +8,9 @@ import gc.mda.signal_batch.domain.vessel.dto.IntegrationVessel;
import gc.mda.signal_batch.global.util.IntegrationSignalConstants;
import lombok.extern.slf4j.Slf4j;
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.Envelope;
import org.locationtech.jts.geom.LineString;
import org.locationtech.jts.index.strtree.STRtree;
import org.locationtech.jts.io.ParseException;
import org.locationtech.jts.io.WKTReader;
import org.springframework.beans.factory.annotation.Qualifier;
@ -75,13 +77,19 @@ public class DailyTrackCacheManager {
private final long loadedAtMillis;
private final int vesselCount;
private final long memorySizeBytes;
private final STRtree spatialIndex;
public DailyTrackData(LocalDate date, Map<String, CompactVesselTrack> tracks, long memorySizeBytes) {
this(date, tracks, memorySizeBytes, null);
}
public DailyTrackData(LocalDate date, Map<String, CompactVesselTrack> tracks, long memorySizeBytes, STRtree spatialIndex) {
this.date = date;
this.tracks = tracks;
this.loadedAtMillis = System.currentTimeMillis();
this.vesselCount = tracks.size();
this.memorySizeBytes = memorySizeBytes;
this.spatialIndex = spatialIndex;
}
public LocalDate getDate() { return date; }
@ -89,6 +97,7 @@ public class DailyTrackCacheManager {
public long getLoadedAtMillis() { return loadedAtMillis; }
public int getVesselCount() { return vesselCount; }
public long getMemorySizeBytes() { return memorySizeBytes; }
public STRtree getSpatialIndex() { return spatialIndex; }
}
/**
@ -341,7 +350,12 @@ public class DailyTrackCacheManager {
}
estimatedMemory += tracks.size() * 200L; // 객체 오버헤드
return new DailyTrackData(date, tracks, estimatedMemory);
// STRtree 공간 인덱스 빌드
STRtree spatialIndex = buildSpatialIndex(tracks);
estimatedMemory += tracks.size() * 100L; // 인덱스 오버헤드
return new DailyTrackData(date, tracks, estimatedMemory, spatialIndex);
}
// 캐시 조회 API
@ -590,6 +604,22 @@ public class DailyTrackCacheManager {
return cacheProperties.isEnabled();
}
/**
* 특정 날짜의 DailyTrackData 직접 접근 (STRtree 포함)
*/
public DailyTrackData getDailyTrackData(LocalDate date) {
return cache.get(date);
}
/**
* 현재 캐시된 날짜 목록 반환
*/
public List<LocalDate> getCachedDateList() {
List<LocalDate> dates = new ArrayList<>(cache.keySet());
Collections.sort(dates);
return dates;
}
// 내부 유틸
private boolean isInViewport(CompactVesselTrack track, double minLon, double minLat, double maxLon, double maxLat) {
@ -602,6 +632,29 @@ public class DailyTrackCacheManager {
return false;
}
/**
* 항적 맵에서 STRtree 공간 인덱스 빌드
*/
private STRtree buildSpatialIndex(Map<String, CompactVesselTrack> tracks) {
STRtree tree = new STRtree();
for (Map.Entry<String, CompactVesselTrack> entry : tracks.entrySet()) {
CompactVesselTrack track = entry.getValue();
if (track.getGeometry() == null || track.getGeometry().isEmpty()) continue;
double minLon = Double.MAX_VALUE, maxLon = -Double.MAX_VALUE;
double minLat = Double.MAX_VALUE, maxLat = -Double.MAX_VALUE;
for (double[] coord : track.getGeometry()) {
if (coord[0] < minLon) minLon = coord[0];
if (coord[0] > maxLon) maxLon = coord[0];
if (coord[1] < minLat) minLat = coord[1];
if (coord[1] > maxLat) maxLat = coord[1];
}
tree.insert(new Envelope(minLon, maxLon, minLat, maxLat), entry.getKey());
}
tree.build();
return tree;
}
/**
* 선박 데이터 누적용 내부 클래스
*/