이노펨 목업 수정

This commit is contained in:
2025-12-31 11:42:55 +09:00
parent ce87fbfc13
commit 3d3eedeec1

View File

@@ -1,24 +1,39 @@
package com.kamco.cd.kamcoback.Innopam.service; package com.kamco.cd.kamcoback.Innopam.service;
import com.fasterxml.jackson.core.JsonFactory; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.core.JsonToken;
import com.kamco.cd.kamcoback.Innopam.dto.DetectMastDto.Basic; 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.DetectMastReq;
import com.kamco.cd.kamcoback.Innopam.dto.DetectMastDto.DetectMastSearch; import com.kamco.cd.kamcoback.Innopam.dto.DetectMastDto.DetectMastSearch;
import com.kamco.cd.kamcoback.Innopam.dto.DetectMastDto.FeaturePnuDto; import com.kamco.cd.kamcoback.Innopam.dto.DetectMastDto.FeaturePnuDto;
import com.kamco.cd.kamcoback.Innopam.postgres.core.DetectMastCoreService; 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.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.Paths; import java.nio.file.Paths;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Queue; import java.util.Map;
import java.util.concurrent.ConcurrentLinkedQueue; import java.util.Objects;
import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Stream; import java.util.stream.Stream;
import lombok.RequiredArgsConstructor; 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.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -27,9 +42,20 @@ import org.springframework.transaction.annotation.Transactional;
@RequiredArgsConstructor @RequiredArgsConstructor
public class DetectMastService { public class DetectMastService {
private final JsonFactory factory = new JsonFactory();
private final DetectMastCoreService detectMastCoreService; 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<String, ShpIndex> shpIndexCache = new ConcurrentHashMap<>();
@Transactional @Transactional
public void saveDetectMast(DetectMastReq detectMast) { public void saveDetectMast(DetectMastReq detectMast) {
detectMastCoreService.saveDetectMast(detectMast); detectMastCoreService.saveDetectMast(detectMast);
@@ -44,28 +70,19 @@ public class DetectMastService {
} }
public List<FeaturePnuDto> findPnuData(DetectMastSearch detectMast) { public List<FeaturePnuDto> findPnuData(DetectMastSearch detectMast) {
String pathNm = detectMastCoreService.findPnuData(detectMast); String dirPath = detectMastCoreService.findPnuData(detectMast);
return this.extractFeaturePnusFast(pathNm); return extractFeaturePnusByIntersectionMaxCached(dirPath);
} }
public FeaturePnuDto findPnuDataDetail(DetectMastSearch detectMast) { public FeaturePnuDto findPnuDataDetail(DetectMastSearch detectMast) {
String pathNm = detectMastCoreService.findPnuData(detectMast); List<FeaturePnuDto> list = findPnuData(detectMast);
List<FeaturePnuDto> pnu = this.extractFeaturePnusFast(pathNm); return list.isEmpty() ? null : list.get(0);
return pnu.get(0);
} }
private String randomPnu() { // =========================
ThreadLocalRandom r = ThreadLocalRandom.current(); // ✅ 캐시 적용된 메인 로직
// =========================
String lawCode = String.valueOf(r.nextLong(1000000000L, 9999999999L)); // 10자리 public List<FeaturePnuDto> extractFeaturePnusByIntersectionMaxCached(String dirPath) {
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<FeaturePnuDto> extractFeaturePnusFast(String dirPath) {
Path basePath = Paths.get(dirPath); Path basePath = Paths.get(dirPath);
if (!Files.isDirectory(basePath)) { if (!Files.isDirectory(basePath)) {
@@ -73,35 +90,56 @@ public class DetectMastService {
return List.of(); return List.of();
} }
// 병렬로 모으기 위한 thread-safe 컬렉션 List<FeaturePnuDto> out = new ArrayList<>();
Queue<FeaturePnuDto> out = new ConcurrentLinkedQueue<>();
try (Stream<Path> stream = Files.walk(basePath)) { try (Stream<Path> stream = Files.walk(basePath)) {
stream stream
.filter(Files::isRegularFile) .filter(Files::isRegularFile)
.filter(p -> p.toString().toLowerCase().endsWith(".geojson")) .filter(p -> p.toString().toLowerCase().endsWith(".geojson"))
.parallel() // 병렬 // ✅ geojson 파싱/공간연산은 CPU+I/O 섞여서 병렬이 도움이 될 때가 많음
.parallel()
.forEach( .forEach(
p -> { p -> {
try (InputStream in = Files.newInputStream(p); try {
JsonParser parser = factory.createParser(in)) { JsonNode root = om.readTree(p.toFile());
JsonNode features = root.path("features");
if (!features.isArray()) {
return;
}
// "polygon_id" 키를 만나면 다음 토큰 값을 읽어서 저장 for (JsonNode feature : features) {
while (parser.nextToken() != null) { String polygonId = feature.path("properties").path("polygon_id").asText(null);
if (parser.currentToken() == JsonToken.FIELD_NAME if (polygonId == null || polygonId.isBlank()) {
&& "polygon_id".equals(parser.getCurrentName())) { continue;
}
JsonToken next = parser.nextToken(); // 값으로 이동 Geometry target = toJtsGeometry(feature.path("geometry"));
if (next == JsonToken.VALUE_STRING) { if (target == null || target.isEmpty()) {
String polygonId = parser.getValueAsString(); continue;
out.add(new FeaturePnuDto(polygonId, this.randomPnu())); }
// ✅ 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) { } catch (Exception e) {
// 파일별 에러 로그는 최소화 System.err.println("GeoJSON 처리 실패: " + p.getFileName() + " / " + e.getMessage());
System.err.println("파싱 실패: " + p.getFileName() + " / " + e.getMessage());
} }
}); });
@@ -110,6 +148,225 @@ public class DetectMastService {
return List.of(); 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<String, Object> 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<ShpRow> 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();
} }
} }