This commit is contained in:
2026-03-06 18:04:38 +09:00
parent 3521a5fd3d
commit 37f534abff

View File

@@ -17,7 +17,8 @@ import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
/** /**
* GeoJSON 파일의 "features[].properties.scene_id" 값들이 "요청한 도엽번호 목록(requestedMapSheetNums)"과 정확히 일치하는지 검증하는 유틸. * GeoJSON 파일의 "features[].properties.scene_id" 값들이 "요청한 도엽번호 목록(requestedMapSheetNums)"과 정확히 일치하는지
* 검증하는 유틸.
* *
* <p>핵심 목적: - 요청한 도엽번호를 기반으로 GeoJSON을 생성했는데, 실제 결과 파일에 누락/추가/중복/빈값(scene_id 없음) 등이 발생했는지 빠르게 잡아내기. * <p>핵심 목적: - 요청한 도엽번호를 기반으로 GeoJSON을 생성했는데, 실제 결과 파일에 누락/추가/중복/빈값(scene_id 없음) 등이 발생했는지 빠르게 잡아내기.
* *
@@ -26,22 +27,19 @@ import org.springframework.web.server.ResponseStatusException;
*/ */
public class GeoJsonValidator { public class GeoJsonValidator {
/** /** GeoJSON 파싱용 ObjectMapper (정적 1개로 재사용) */
* GeoJSON 파싱용 ObjectMapper (정적 1개로 재사용)
*/
private static final ObjectMapper om = new ObjectMapper(); private static final ObjectMapper om = new ObjectMapper();
/** /** 로그 출력용 */
* 로그 출력용
*/
private static final Logger log = LogManager.getLogger(GeoJsonValidator.class); private static final Logger log = LogManager.getLogger(GeoJsonValidator.class);
/** /**
* @param geojsonPath GeoJSON 파일 경로(문자열) * @param geojsonPath GeoJSON 파일 경로(문자열)
* @param requestedMapSheetNums "요청한 도엽번호" 리스트 (중복/공백/NULL 포함 가능) * @param requestedMapSheetNums "요청한 도엽번호" 리스트 (중복/공백/NULL 포함 가능)
* <p>동작 개요: 1) 파일 존재/크기 검증 2) 요청 도엽번호 목록 정리(Trim + 공백 제거 + 중복 제거) 3) GeoJSON 파싱 후 features 배열 * <p>동작 개요: 1) 파일 존재/크기 검증 2) 요청 도엽번호 목록 정리(Trim + 공백 제거 + 중복 제거) 3) GeoJSON 파싱 후 features 배열
* 확보 4) features에서 scene_id 추출하여 유니크 set 구성 5) requested vs found 비교: - missing: requested - found - extra : found - requested - duplicates: GeoJSON * 확보 4) features에서 scene_id 추출하여 유니크 set 구성 5) requested vs found 비교: - missing: requested -
* 내부에서 scene_id 중복 등장 - nullIdCount: scene_id가 null/blank 인 feature 개수 6) 이상 있으면 422로 실패 처리 * found - extra : found - requested - duplicates: GeoJSON 내부에서 scene_id 중복 등장 - nullIdCount:
* scene_id가 null/blank 인 feature 개수 6) 이상 있으면 422로 실패 처리
*/ */
public static void validateWithRequested(String geojsonPath, List<String> requestedMapSheetNums) { public static void validateWithRequested(String geojsonPath, List<String> requestedMapSheetNums) {
@@ -57,19 +55,19 @@ public class GeoJsonValidator {
// 파일 존재 여부 체크 (없으면 404) // 파일 존재 여부 체크 (없으면 404)
if (!Files.exists(path)) { if (!Files.exists(path)) {
throw new ResponseStatusException( throw new ResponseStatusException(
HttpStatus.NOT_FOUND, "GeoJSON 파일이 존재하지 않습니다: " + geojsonPath); HttpStatus.NOT_FOUND, "GeoJSON 파일이 존재하지 않습니다: " + geojsonPath);
} }
// 파일 사이즈 체크 (0 byte면 400) // 파일 사이즈 체크 (0 byte면 400)
if (Files.size(path) == 0) { if (Files.size(path) == 0) {
throw new ResponseStatusException( throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "GeoJSON 파일이 비어있습니다: " + geojsonPath); HttpStatus.BAD_REQUEST, "GeoJSON 파일이 비어있습니다: " + geojsonPath);
} }
} catch (IOException e) { } catch (IOException e) {
// 파일 사이즈/상태 확인 중 IO 오류면 서버오류로 처리 // 파일 사이즈/상태 확인 중 IO 오류면 서버오류로 처리
log.error("GeoJSON 파일 상태 확인 실패: path={}", path, e); log.error("GeoJSON 파일 상태 확인 실패: path={}", path, e);
throw new ResponseStatusException( throw new ResponseStatusException(
HttpStatus.INTERNAL_SERVER_ERROR, "GeoJSON 파일 상태 확인 실패: " + geojsonPath, e); HttpStatus.INTERNAL_SERVER_ERROR, "GeoJSON 파일 상태 확인 실패: " + geojsonPath, e);
} }
// ========================================================= // =========================================================
@@ -88,16 +86,16 @@ public class GeoJsonValidator {
// - LinkedHashSet 사용: "중복 제거 + 원래 입력 순서 유지" // - LinkedHashSet 사용: "중복 제거 + 원래 입력 순서 유지"
// ========================================================= // =========================================================
Set<String> requested = Set<String> requested =
requestedMapSheetNums.stream() requestedMapSheetNums.stream()
.filter(Objects::nonNull) // null 제거 .filter(Objects::nonNull) // null 제거
.map(String::trim) // 앞뒤 공백 제거 .map(String::trim) // 앞뒤 공백 제거
.filter(s -> !s.isEmpty()) // "" 제거 .filter(s -> !s.isEmpty()) // "" 제거
.collect(Collectors.toCollection(LinkedHashSet::new)); // 중복 제거 + 순서 유지 .collect(Collectors.toCollection(LinkedHashSet::new)); // 중복 제거 + 순서 유지
// 정리 결과가 비어있으면(전부 null/공백)이므로 400 // 정리 결과가 비어있으면(전부 null/공백)이므로 400
if (requested.isEmpty()) { if (requested.isEmpty()) {
throw new ResponseStatusException( throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "requestedMapSheetNums 가 공백/NULL만 포함합니다."); HttpStatus.BAD_REQUEST, "requestedMapSheetNums 가 공백/NULL만 포함합니다.");
} }
// ========================================================= // =========================================================
@@ -123,7 +121,7 @@ public class GeoJsonValidator {
// features가 없거나 배열이 아니면 GeoJSON 구조가 이상한 것 // features가 없거나 배열이 아니면 GeoJSON 구조가 이상한 것
if (features == null || !features.isArray()) { if (features == null || !features.isArray()) {
throw new ResponseStatusException( throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "유효하지 않은 GeoJSON: features가 없거나 배열이 아닙니다."); HttpStatus.BAD_REQUEST, "유효하지 않은 GeoJSON: features가 없거나 배열이 아닙니다.");
} }
} catch (ResponseStatusException e) { } catch (ResponseStatusException e) {
// 위에서 직접 던진 에러는 그대로 전달 // 위에서 직접 던진 에러는 그대로 전달
@@ -132,12 +130,12 @@ public class GeoJsonValidator {
// 읽기/파싱 과정에서 IO 문제가 터지면 서버오류 // 읽기/파싱 과정에서 IO 문제가 터지면 서버오류
log.error("GeoJSON 파일 읽기/파싱 실패: path={}", path, e); log.error("GeoJSON 파일 읽기/파싱 실패: path={}", path, e);
throw new ResponseStatusException( throw new ResponseStatusException(
HttpStatus.INTERNAL_SERVER_ERROR, "GeoJSON 파일 읽기/파싱 실패: " + geojsonPath, e); HttpStatus.INTERNAL_SERVER_ERROR, "GeoJSON 파일 읽기/파싱 실패: " + geojsonPath, e);
} catch (Exception e) { } catch (Exception e) {
// JSON 문법 오류/예상치 못한 파싱 오류는 클라이언트 입력/파일 자체 문제로 400 처리 // JSON 문법 오류/예상치 못한 파싱 오류는 클라이언트 입력/파일 자체 문제로 400 처리
log.error("GeoJSON 파싱 오류(비정상 JSON): path={}", path, e); log.error("GeoJSON 파싱 오류(비정상 JSON): path={}", path, e);
throw new ResponseStatusException( throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "GeoJSON 파싱 오류(비정상 JSON): " + geojsonPath, e); HttpStatus.BAD_REQUEST, "GeoJSON 파싱 오류(비정상 JSON): " + geojsonPath, e);
} }
// ========================================================= // =========================================================
@@ -176,7 +174,9 @@ public class GeoJsonValidator {
// properties가 있고 scene_id가 null이 아니면 텍스트로 읽음 // properties가 있고 scene_id가 null이 아니면 텍스트로 읽음
// 없으면 null 처리 // 없으면 null 처리
String sceneId = String sceneId =
(props != null && props.hasNonNull("scene_id")) ? props.get("scene_id").asText().trim() : null; (props != null && props.hasNonNull("scene_id"))
? props.get("scene_id").asText().trim()
: null;
// scene_id가 없거나 빈값이면 "정상적으로 도엽번호가 들어오지 않은 feature"로 카운트 // scene_id가 없거나 빈값이면 "정상적으로 도엽번호가 들어오지 않은 feature"로 카운트
if (sceneId == null || sceneId.isBlank()) { if (sceneId == null || sceneId.isBlank()) {
@@ -215,7 +215,7 @@ public class GeoJsonValidator {
// - sample 로그는 너무 길어질 수 있으므로 limit 걸어줌 // - sample 로그는 너무 길어질 수 있으므로 limit 걸어줌
// ========================================================= // =========================================================
log.info( log.info(
""" """
===== GeoJSON Validation ===== ===== GeoJSON Validation =====
file: {} file: {}
features(total): {} features(total): {}
@@ -227,14 +227,14 @@ public class GeoJsonValidator {
extra(found - requested): {} extra(found - requested): {}
============================== ==============================
""", """,
geojsonPath, geojsonPath,
featureCount, // 중복 포함한 전체 feature 수 featureCount, // 중복 포함한 전체 feature 수
requested.size(), // 요청 도엽 유니크 수 requested.size(), // 요청 도엽 유니크 수
foundUnique.size(), // GeoJSON에서 발견된 scene_id 유니크 수 foundUnique.size(), // GeoJSON에서 발견된 scene_id 유니크 수
nullIdCount, // scene_id가 비어있는 feature 수 nullIdCount, // scene_id가 비어있는 feature 수
0, // 중복 scene_id 종류 수 0, // 중복 scene_id 종류 수
missing.size(), // 요청했지만 빠진 도엽 수 missing.size(), // 요청했지만 빠진 도엽 수
extra.size()); // 요청하지 않았는데 들어온 도엽 수 extra.size()); // 요청하지 않았는데 들어온 도엽 수
// 중복/누락/추가 항목은 전체를 다 찍으면 로그 폭발하므로 샘플만 // 중복/누락/추가 항목은 전체를 다 찍으면 로그 폭발하므로 샘플만
// if (!duplicates.isEmpty()) // if (!duplicates.isEmpty())
@@ -263,10 +263,10 @@ public class GeoJsonValidator {
// ========================================================= // =========================================================
if (!missing.isEmpty() || !extra.isEmpty() || nullIdCount > 0) { if (!missing.isEmpty() || !extra.isEmpty() || nullIdCount > 0) {
throw new ResponseStatusException( throw new ResponseStatusException(
HttpStatus.UNPROCESSABLE_ENTITY, HttpStatus.UNPROCESSABLE_ENTITY,
String.format( String.format(
"GeoJSON validation failed: missing=%d, extra=%d, duplicates=%d, nullId=%d", "GeoJSON validation failed: missing=%d, extra=%d, duplicates=%d, nullId=%d",
missing.size(), extra.size(), 0, nullIdCount)); missing.size(), extra.size(), 0, nullIdCount));
} }
// 모든 조건을 통과하면 정상 // 모든 조건을 통과하면 정상