diff --git a/src/main/java/com/kamco/cd/kamcoback/Innopam/service/DetectMastService.java b/src/main/java/com/kamco/cd/kamcoback/Innopam/service/DetectMastService.java index 1adf7774..ae6f8fa8 100644 --- a/src/main/java/com/kamco/cd/kamcoback/Innopam/service/DetectMastService.java +++ b/src/main/java/com/kamco/cd/kamcoback/Innopam/service/DetectMastService.java @@ -1,24 +1,39 @@ package com.kamco.cd.kamcoback.Innopam.service; -import com.fasterxml.jackson.core.JsonFactory; -import com.fasterxml.jackson.core.JsonParser; -import com.fasterxml.jackson.core.JsonToken; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import com.kamco.cd.kamcoback.Innopam.dto.DetectMastDto.Basic; import com.kamco.cd.kamcoback.Innopam.dto.DetectMastDto.DetectMastReq; import com.kamco.cd.kamcoback.Innopam.dto.DetectMastDto.DetectMastSearch; import com.kamco.cd.kamcoback.Innopam.dto.DetectMastDto.FeaturePnuDto; import com.kamco.cd.kamcoback.Innopam.postgres.core.DetectMastCoreService; -import java.io.InputStream; +import java.io.File; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; import java.util.List; -import java.util.Queue; -import java.util.concurrent.ConcurrentLinkedQueue; -import java.util.concurrent.ThreadLocalRandom; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Stream; import lombok.RequiredArgsConstructor; +import org.geotools.api.data.DataStore; +import org.geotools.api.data.DataStoreFinder; +import org.geotools.api.data.SimpleFeatureSource; +import org.geotools.api.feature.simple.SimpleFeature; +import org.geotools.data.simple.SimpleFeatureCollection; +import org.geotools.data.simple.SimpleFeatureIterator; +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.Envelope; +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.geom.LinearRing; +import org.locationtech.jts.geom.MultiPolygon; +import org.locationtech.jts.geom.Polygon; +import org.locationtech.jts.index.strtree.STRtree; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -27,9 +42,20 @@ import org.springframework.transaction.annotation.Transactional; @RequiredArgsConstructor public class DetectMastService { - private final JsonFactory factory = new JsonFactory(); private final DetectMastCoreService detectMastCoreService; + // ✅ GeoJSON 파싱용 + private final ObjectMapper om = new ObjectMapper(); + private final GeometryFactory gf = new GeometryFactory(); + + // ✅ (임시) SHP 루트/버전/필드명 + private static final String SHP_ROOT_DIR = "/app/tmp/shp"; // TODO 환경 맞게 변경 + private static final String SHP_YYYYMM = "202512"; // TODO yml 또는 DB에서 받기 + private static final String PNU_FIELD = "PNU"; // TODO 실제 컬럼명으로 변경 + + // ✅ 시도코드별 인덱스 캐시 + private final Map shpIndexCache = new ConcurrentHashMap<>(); + @Transactional public void saveDetectMast(DetectMastReq detectMast) { detectMastCoreService.saveDetectMast(detectMast); @@ -44,28 +70,19 @@ public class DetectMastService { } public List findPnuData(DetectMastSearch detectMast) { - String pathNm = detectMastCoreService.findPnuData(detectMast); - return this.extractFeaturePnusFast(pathNm); + String dirPath = detectMastCoreService.findPnuData(detectMast); + return extractFeaturePnusByIntersectionMaxCached(dirPath); } public FeaturePnuDto findPnuDataDetail(DetectMastSearch detectMast) { - String pathNm = detectMastCoreService.findPnuData(detectMast); - List pnu = this.extractFeaturePnusFast(pathNm); - return pnu.get(0); + List list = findPnuData(detectMast); + return list.isEmpty() ? null : list.get(0); } - private String randomPnu() { - ThreadLocalRandom r = ThreadLocalRandom.current(); - - String lawCode = String.valueOf(r.nextLong(1000000000L, 9999999999L)); // 10자리 - String sanFlag = r.nextBoolean() ? "1" : "2"; // 산/대지 - String bon = String.format("%04d", r.nextInt(1, 10000)); // 본번 - String bu = String.format("%04d", r.nextInt(0, 10000)); // 부번 - - return lawCode + sanFlag + bon + bu; - } - - public List extractFeaturePnusFast(String dirPath) { + // ========================= + // ✅ 캐시 적용된 메인 로직 + // ========================= + public List extractFeaturePnusByIntersectionMaxCached(String dirPath) { Path basePath = Paths.get(dirPath); if (!Files.isDirectory(basePath)) { @@ -73,35 +90,56 @@ public class DetectMastService { return List.of(); } - // 병렬로 모으기 위한 thread-safe 컬렉션 - Queue out = new ConcurrentLinkedQueue<>(); + List out = new ArrayList<>(); try (Stream stream = Files.walk(basePath)) { stream .filter(Files::isRegularFile) .filter(p -> p.toString().toLowerCase().endsWith(".geojson")) - .parallel() // 병렬 + // ✅ geojson 파싱/공간연산은 CPU+I/O 섞여서 병렬이 도움이 될 때가 많음 + .parallel() .forEach( p -> { - try (InputStream in = Files.newInputStream(p); - JsonParser parser = factory.createParser(in)) { + try { + JsonNode root = om.readTree(p.toFile()); + JsonNode features = root.path("features"); + if (!features.isArray()) { + return; + } - // "polygon_id" 키를 만나면 다음 토큰 값을 읽어서 저장 - while (parser.nextToken() != null) { - if (parser.currentToken() == JsonToken.FIELD_NAME - && "polygon_id".equals(parser.getCurrentName())) { + for (JsonNode feature : features) { + String polygonId = feature.path("properties").path("polygon_id").asText(null); + if (polygonId == null || polygonId.isBlank()) { + continue; + } - JsonToken next = parser.nextToken(); // 값으로 이동 - if (next == JsonToken.VALUE_STRING) { - String polygonId = parser.getValueAsString(); - out.add(new FeaturePnuDto(polygonId, this.randomPnu())); + Geometry target = toJtsGeometry(feature.path("geometry")); + if (target == null || target.isEmpty()) { + continue; + } + + // ✅ 1) target의 중심점으로 시도코드 판정 → 해당 시도 SHP만 사용 + // (시도코드 판정 방법은 프로젝트에 맞게 바꿀 수 있음) + String sidoCode = resolveSidoCodeByPoint(target.getCentroid().getCoordinate()); + if (sidoCode == null) { + continue; + } + + ShpIndex idx = getOrLoadShpIndex(sidoCode); + if (idx == null) { + continue; + } + + String pnu = pickPnuByIntersectionMax(idx.index, target); + if (pnu != null) { + synchronized (out) { + out.add(new FeaturePnuDto(polygonId, pnu)); } } } } catch (Exception e) { - // 파일별 에러 로그는 최소화 - System.err.println("파싱 실패: " + p.getFileName() + " / " + e.getMessage()); + System.err.println("GeoJSON 처리 실패: " + p.getFileName() + " / " + e.getMessage()); } }); @@ -110,6 +148,225 @@ public class DetectMastService { return List.of(); } - return new ArrayList<>(out); + return out; + } + + // ========================= + // ✅ SHP 인덱스 캐시 로딩 + // ========================= + + private static class ShpIndex { + + final String sidoCode; + final STRtree index; + + ShpIndex(String sidoCode, STRtree index) { + this.sidoCode = sidoCode; + this.index = index; + } + } + + private ShpIndex getOrLoadShpIndex(String sidoCode) { + // computeIfAbsent는 동시성 상황에서 안전하게 "1번만" 만들게 해줌 + return shpIndexCache.computeIfAbsent(sidoCode, this::loadShpIndex); + } + + private ShpIndex loadShpIndex(String sidoCode) { + String shpPath = resolveShpPathBySido(sidoCode); + + File shpFile = new File(shpPath); + if (!shpFile.exists()) { + System.err.println("SHP 파일 없음: " + shpPath); + return null; + } + + DataStore store = null; + + try { + Map params = new HashMap<>(); + params.put("url", shpFile.toURI().toURL()); + + store = DataStoreFinder.getDataStore(params); + if (store == null) { + System.err.println("SHP DataStore 생성 실패: " + shpPath); + return null; + } + + String typeName = store.getTypeNames()[0]; + SimpleFeatureSource source = store.getFeatureSource(typeName); + SimpleFeatureCollection collection = source.getFeatures(); + + STRtree index = new STRtree(10); + + try (SimpleFeatureIterator it = collection.features()) { + while (it.hasNext()) { + SimpleFeature f = it.next(); + + Object g = f.getDefaultGeometry(); + if (!(g instanceof Geometry)) { + continue; + } + + String pnu = Objects.toString(f.getAttribute(PNU_FIELD), null); + if (pnu == null || pnu.isBlank()) { + continue; + } + + Geometry geom = (Geometry) g; + index.insert(geom.getEnvelopeInternal(), new ShpRow(geom, pnu)); + } + } + + index.build(); + System.out.println("✅ SHP 인덱스 로딩 완료: sido=" + sidoCode + ", shp=" + shpFile.getName()); + return new ShpIndex(sidoCode, index); + + } catch (Exception e) { + System.err.println("SHP 인덱스 로딩 실패: sido=" + sidoCode + " / " + e.getMessage()); + return null; + } finally { + if (store != null) { + store.dispose(); + } + } + } + + private String resolveShpPathBySido(String sidoCode) { + // 파일명이 스크린샷처럼 동일 패턴이라고 가정 + // 예: /shp/LSMD_CONT_LDREG_11_202512.shp + return SHP_ROOT_DIR + "/LSMD_CONT_LDREG_" + sidoCode + "_" + SHP_YYYYMM + ".shp"; + } + + // ========================= + // ✅ intersection 면적 최대 + // ========================= + + private static class ShpRow { + + final Geometry geom; + final String pnu; + + ShpRow(Geometry geom, String pnu) { + this.geom = geom; + this.pnu = pnu; + } + } + + private String pickPnuByIntersectionMax(STRtree index, Geometry target) { + + Envelope env = target.getEnvelopeInternal(); + + @SuppressWarnings("unchecked") + List candidates = index.query(env); + if (candidates == null || candidates.isEmpty()) { + return null; + } + + double bestArea = 0.0; + String bestPnu = null; + + for (ShpRow row : candidates) { + Geometry parcel = row.geom; + + if (!parcel.intersects(target)) { + continue; + } + + Geometry inter = parcel.intersection(target); + double area = inter.getArea(); + + if (area > bestArea) { + bestArea = area; + bestPnu = row.pnu; + } + } + + return bestPnu; + } + + // ========================= + // ✅ GeoJSON geometry -> JTS + // ========================= + + private Geometry toJtsGeometry(JsonNode geomNode) { + String type = geomNode.path("type").asText(""); + JsonNode coords = geomNode.path("coordinates"); + + try { + if ("Polygon".equalsIgnoreCase(type)) { + return toPolygon(coords); + } + if ("MultiPolygon".equalsIgnoreCase(type)) { + return toMultiPolygon(coords); + } + return null; + } catch (Exception e) { + return null; + } + } + + private Polygon toPolygon(JsonNode coords) { + LinearRing shell = gf.createLinearRing(toCoordinateArray(coords.get(0))); + LinearRing[] holes = null; + + if (coords.size() > 1) { + holes = new LinearRing[coords.size() - 1]; + for (int i = 1; i < coords.size(); i++) { + holes[i - 1] = gf.createLinearRing(toCoordinateArray(coords.get(i))); + } + } + return gf.createPolygon(shell, holes); + } + + private MultiPolygon toMultiPolygon(JsonNode coords) { + Polygon[] polys = new Polygon[coords.size()]; + for (int i = 0; i < coords.size(); i++) { + polys[i] = toPolygon(coords.get(i)); + } + return gf.createMultiPolygon(polys); + } + + private Coordinate[] toCoordinateArray(JsonNode ring) { + int n = ring.size(); + Coordinate[] c = new Coordinate[n]; + + for (int i = 0; i < n; i++) { + double x = ring.get(i).get(0).asDouble(); + double y = ring.get(i).get(1).asDouble(); + c[i] = new Coordinate(x, y); + } + + // 링 닫힘 보장 + if (n >= 2 && !c[0].equals2D(c[n - 1])) { + Coordinate[] closed = Arrays.copyOf(c, n + 1); + closed[n] = new Coordinate(c[0]); + return closed; + } + return c; + } + + // ========================= + // ✅ 시도코드 판정 (임시) + // ========================= + private String resolveSidoCodeByPoint(Coordinate c) { + // ⚠️ 여기만 프로젝트 환경에 맞게 바꾸면 됩니다. + // 지금은 "임시로 하나 고정" 같은 방식 말고, + // 실제론 detectMastSearch나 map_id 기반으로 시도코드를 구하는 게 제일 안정적입니다. + + // 1) 만약 detectMastSearch에 sidoCode가 있다면 그걸 쓰는 걸 추천 + // 2) 또는 map_id에서 앞 2자리를 쓰는 방식 + + // 임시: 서울만 처리 (필요한 코드 추가하세요) + // return "11"; + + // TODO: 현재는 null 반환 -> 실제 로직으로 교체 필요 + return "11"; + } + + // ========================= + // ✅ 캐시 수동 초기화 (필요 시) + // ========================= + public void clearShpCache() { + shpIndexCache.clear(); } }