diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml
index 2787de1..2d3e787 100644
--- a/.gitea/workflows/deploy.yml
+++ b/.gitea/workflows/deploy.yml
@@ -31,10 +31,10 @@ jobs:
echo "Frontend deployed at $(date '+%Y-%m-%d %H:%M:%S')"
# ═══ Backend ═══
- - name: Install JDK 17 + Maven
+ - name: Install JDK 21 + Maven
run: |
apt-get update -qq
- apt-get install -y -qq openjdk-17-jdk-headless maven > /dev/null 2>&1
+ apt-get install -y -qq openjdk-21-jdk-headless maven > /dev/null 2>&1
java -version
mvn --version
diff --git a/backend/.sdkmanrc b/backend/.sdkmanrc
index a4417cd..8bea3c1 100644
--- a/backend/.sdkmanrc
+++ b/backend/.sdkmanrc
@@ -1 +1 @@
-java=17.0.18-amzn
+java=21.0.9-amzn
diff --git a/backend/pom.xml b/backend/pom.xml
index c99e425..48d4a08 100644
--- a/backend/pom.xml
+++ b/backend/pom.xml
@@ -19,7 +19,7 @@
KCG Monitoring Dashboard Backend
- 17
+ 21
0.12.6
@@ -45,6 +45,12 @@
runtime
+
+
+ org.hibernate.orm
+ hibernate-spatial
+
+
org.projectlombok
diff --git a/backend/src/main/java/gc/mda/kcg/collector/aircraft/AircraftCacheStore.java b/backend/src/main/java/gc/mda/kcg/collector/aircraft/AircraftCacheStore.java
new file mode 100644
index 0000000..965ea60
--- /dev/null
+++ b/backend/src/main/java/gc/mda/kcg/collector/aircraft/AircraftCacheStore.java
@@ -0,0 +1,106 @@
+package gc.mda.kcg.collector.aircraft;
+
+import gc.mda.kcg.domain.aircraft.AircraftDto;
+import org.springframework.stereotype.Component;
+
+import java.util.*;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * 항공기 데이터 인메모리 캐시.
+ * 소스별 원본을 유지하고, 병합된 최종 결과를 Controller에 제공.
+ */
+@Component
+public class AircraftCacheStore {
+
+ // 최종 병합 결과 (Controller가 읽는 데이터)
+ private final Map> regionCache = new ConcurrentHashMap<>();
+
+ // 소스별 원본 버퍼 (region → source → aircraft list)
+ private final Map>> sourceBuffers = new ConcurrentHashMap<>();
+
+ // 마지막 갱신 시각
+ private final Map lastUpdated = new ConcurrentHashMap<>();
+
+ public static final String SOURCE_LIVE = "live";
+ public static final String SOURCE_MIL = "mil";
+ public static final String SOURCE_OPENSKY = "opensky";
+
+ /**
+ * 특정 소스의 데이터 갱신.
+ */
+ public void updateSource(String region, String source, List aircraft) {
+ sourceBuffers
+ .computeIfAbsent(region, k -> new ConcurrentHashMap<>())
+ .put(source, Collections.unmodifiableList(new ArrayList<>(aircraft)));
+ }
+
+ /**
+ * 소스별 데이터를 병합하여 regionCache를 갱신.
+ * 병합 우선순위: live > mil > opensky (icao24 기준 중복제거)
+ */
+ public void mergeAndUpdate(String region) {
+ Map> sources = sourceBuffers.getOrDefault(region, Map.of());
+
+ List fromLive = sources.getOrDefault(SOURCE_LIVE, List.of());
+ List fromMil = sources.getOrDefault(SOURCE_MIL, List.of());
+ List fromOpenSky = sources.getOrDefault(SOURCE_OPENSKY, List.of());
+
+ Map merged = new LinkedHashMap<>();
+
+ // 1순위: live (point/radius — 가장 상세)
+ for (AircraftDto a : fromLive) {
+ merged.put(a.getIcao24(), a);
+ }
+
+ // 2순위: mil — 기존 항목은 category/typecode 보강, 없는 항목은 추가
+ for (AircraftDto m : fromMil) {
+ AircraftDto existing = merged.get(m.getIcao24());
+ if (existing != null) {
+ // mil 데이터로 category/typecode 보강
+ merged.put(m.getIcao24(), AircraftDto.builder()
+ .icao24(existing.getIcao24())
+ .callsign(existing.getCallsign())
+ .lat(existing.getLat())
+ .lng(existing.getLng())
+ .altitude(existing.getAltitude())
+ .velocity(existing.getVelocity())
+ .heading(existing.getHeading())
+ .verticalRate(existing.getVerticalRate())
+ .onGround(existing.isOnGround())
+ .category(m.getCategory())
+ .typecode(m.getTypecode() != null ? m.getTypecode() : existing.getTypecode())
+ .typeDesc(existing.getTypeDesc())
+ .registration(existing.getRegistration())
+ .operator(existing.getOperator())
+ .squawk(existing.getSquawk())
+ .lastSeen(existing.getLastSeen())
+ .build());
+ } else {
+ merged.put(m.getIcao24(), m);
+ }
+ }
+
+ // 3순위: opensky — 없는 항목만 추가
+ for (AircraftDto a : fromOpenSky) {
+ merged.putIfAbsent(a.getIcao24(), a);
+ }
+
+ regionCache.put(region, Collections.unmodifiableList(new ArrayList<>(merged.values())));
+ lastUpdated.put(region, System.currentTimeMillis());
+ }
+
+ /**
+ * 병합된 최종 결과 조회.
+ */
+ public List get(String region) {
+ return regionCache.getOrDefault(region, List.of());
+ }
+
+ /**
+ * 마지막 갱신 시각 조회.
+ */
+ public long getLastUpdated(String region) {
+ return lastUpdated.getOrDefault(region, 0L);
+ }
+}
diff --git a/backend/src/main/java/gc/mda/kcg/collector/aircraft/AircraftClassifier.java b/backend/src/main/java/gc/mda/kcg/collector/aircraft/AircraftClassifier.java
new file mode 100644
index 0000000..916c768
--- /dev/null
+++ b/backend/src/main/java/gc/mda/kcg/collector/aircraft/AircraftClassifier.java
@@ -0,0 +1,118 @@
+package gc.mda.kcg.collector.aircraft;
+
+import gc.mda.kcg.domain.aircraft.AircraftCategory;
+
+import java.util.Map;
+
+/**
+ * 항공기 카테고리 분류 유틸리티.
+ * 프론트엔드 airplaneslive.ts / opensky.ts의 분류 로직을 이식.
+ */
+public final class AircraftClassifier {
+
+ private AircraftClassifier() {}
+
+ // Airplanes.live 타입코드 기반 분류
+ private static final Map TYPE_CATEGORY_MAP = Map.ofEntries(
+ // Fighter
+ Map.entry("F16", AircraftCategory.FIGHTER),
+ Map.entry("F15", AircraftCategory.FIGHTER),
+ Map.entry("F15E", AircraftCategory.FIGHTER),
+ Map.entry("FA18", AircraftCategory.FIGHTER),
+ Map.entry("F22", AircraftCategory.FIGHTER),
+ Map.entry("F35", AircraftCategory.FIGHTER),
+ Map.entry("F14", AircraftCategory.FIGHTER),
+ Map.entry("EF2K", AircraftCategory.FIGHTER),
+ Map.entry("RFAL", AircraftCategory.FIGHTER),
+ Map.entry("SU27", AircraftCategory.FIGHTER),
+ Map.entry("SU30", AircraftCategory.FIGHTER),
+ Map.entry("SU35", AircraftCategory.FIGHTER),
+ // Tanker
+ Map.entry("KC10", AircraftCategory.TANKER),
+ Map.entry("KC30", AircraftCategory.TANKER),
+ Map.entry("KC46", AircraftCategory.TANKER),
+ Map.entry("K35R", AircraftCategory.TANKER),
+ Map.entry("KC35", AircraftCategory.TANKER),
+ Map.entry("A332", AircraftCategory.TANKER),
+ // Surveillance
+ Map.entry("RC135", AircraftCategory.SURVEILLANCE),
+ Map.entry("E3", AircraftCategory.SURVEILLANCE),
+ Map.entry("E8", AircraftCategory.SURVEILLANCE),
+ Map.entry("RQ4", AircraftCategory.SURVEILLANCE),
+ Map.entry("MQ9", AircraftCategory.SURVEILLANCE),
+ Map.entry("P8", AircraftCategory.SURVEILLANCE),
+ Map.entry("EP3", AircraftCategory.SURVEILLANCE),
+ Map.entry("E6", AircraftCategory.SURVEILLANCE),
+ Map.entry("U2", AircraftCategory.SURVEILLANCE),
+ // Cargo
+ Map.entry("C17", AircraftCategory.CARGO),
+ Map.entry("C5", AircraftCategory.CARGO),
+ Map.entry("C130", AircraftCategory.CARGO),
+ Map.entry("C2", AircraftCategory.CARGO)
+ );
+
+ // OpenSky 콜사인 prefix 기반 분류
+ private static final Map CALLSIGN_PREFIX_MAP = Map.ofEntries(
+ Map.entry("RCH", AircraftCategory.CARGO),
+ Map.entry("REACH", AircraftCategory.CARGO),
+ Map.entry("KING", AircraftCategory.TANKER),
+ Map.entry("ETHYL", AircraftCategory.TANKER),
+ Map.entry("STEEL", AircraftCategory.TANKER),
+ Map.entry("PACK", AircraftCategory.TANKER),
+ Map.entry("NCHO", AircraftCategory.TANKER),
+ Map.entry("JULEP", AircraftCategory.TANKER),
+ Map.entry("IRON", AircraftCategory.FIGHTER),
+ Map.entry("VIPER", AircraftCategory.FIGHTER),
+ Map.entry("RAGE", AircraftCategory.FIGHTER),
+ Map.entry("DEATH", AircraftCategory.FIGHTER),
+ Map.entry("TOXIN", AircraftCategory.SURVEILLANCE),
+ Map.entry("OLIVE", AircraftCategory.SURVEILLANCE),
+ Map.entry("COBRA", AircraftCategory.SURVEILLANCE),
+ Map.entry("FORTE", AircraftCategory.SURVEILLANCE),
+ Map.entry("HAWK", AircraftCategory.SURVEILLANCE),
+ Map.entry("GLOBAL", AircraftCategory.SURVEILLANCE),
+ Map.entry("SNTRY", AircraftCategory.SURVEILLANCE),
+ Map.entry("WIZARD", AircraftCategory.SURVEILLANCE),
+ Map.entry("REAPER", AircraftCategory.SURVEILLANCE),
+ Map.entry("DRAGON", AircraftCategory.SURVEILLANCE),
+ Map.entry("DOOM", AircraftCategory.MILITARY),
+ Map.entry("EVAC", AircraftCategory.MILITARY),
+ Map.entry("SAM", AircraftCategory.MILITARY),
+ Map.entry("EXEC", AircraftCategory.MILITARY),
+ Map.entry("NAVY", AircraftCategory.MILITARY),
+ Map.entry("TOPCT", AircraftCategory.MILITARY)
+ );
+
+ /**
+ * 타입코드 기반 분류 (Airplanes.live).
+ * 타입코드에 군용 코드가 포함되어 있으면 해당 카테고리 반환.
+ */
+ public static AircraftCategory classifyByTypecode(String typecode) {
+ if (typecode == null || typecode.isBlank()) return AircraftCategory.CIVILIAN;
+ String upper = typecode.toUpperCase();
+ for (var entry : TYPE_CATEGORY_MAP.entrySet()) {
+ if (upper.contains(entry.getKey())) return entry.getValue();
+ }
+ return AircraftCategory.CIVILIAN;
+ }
+
+ /**
+ * dbFlags 기반 군용기 판별 (Airplanes.live).
+ * dbFlags & 1 == 1이면 DB에서 군용기로 분류된 항공기.
+ */
+ public static boolean isMilitaryByDbFlags(int dbFlags) {
+ return (dbFlags & 1) == 1;
+ }
+
+ /**
+ * 콜사인 prefix 기반 분류 (OpenSky).
+ */
+ public static AircraftCategory classifyByCallsign(String callsign) {
+ if (callsign == null || callsign.isBlank()) return AircraftCategory.CIVILIAN;
+ String upper = callsign.toUpperCase().trim();
+ for (var entry : CALLSIGN_PREFIX_MAP.entrySet()) {
+ if (upper.startsWith(entry.getKey())) return entry.getValue();
+ }
+ return AircraftCategory.CIVILIAN;
+ }
+}
diff --git a/backend/src/main/java/gc/mda/kcg/collector/aircraft/AirplanesLiveCollector.java b/backend/src/main/java/gc/mda/kcg/collector/aircraft/AirplanesLiveCollector.java
new file mode 100644
index 0000000..2064798
--- /dev/null
+++ b/backend/src/main/java/gc/mda/kcg/collector/aircraft/AirplanesLiveCollector.java
@@ -0,0 +1,268 @@
+package gc.mda.kcg.collector.aircraft;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import gc.mda.kcg.domain.aircraft.AircraftDto;
+import gc.mda.kcg.domain.aircraft.AircraftPosition;
+import gc.mda.kcg.domain.aircraft.AircraftPositionRepository;
+import jakarta.annotation.PostConstruct;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.locationtech.jts.geom.Coordinate;
+import org.locationtech.jts.geom.GeometryFactory;
+import org.locationtech.jts.geom.PrecisionModel;
+import org.springframework.http.ResponseEntity;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+import org.springframework.web.client.RestTemplate;
+
+import java.time.Instant;
+import java.util.*;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicInteger;
+
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class AirplanesLiveCollector {
+
+ private static final String BASE_URL = "https://api.airplanes.live/v2";
+ private static final int DELAY_MS = 1500;
+ private static final int BACKOFF_MS = 5000;
+ private static final int POINTS_PER_CYCLE = 2;
+
+ private final RestTemplate restTemplate;
+ private final ObjectMapper objectMapper;
+ private final AircraftCacheStore cacheStore;
+ private final AircraftPositionRepository positionRepository;
+
+ private final GeometryFactory geometryFactory = new GeometryFactory(new PrecisionModel(), 4326);
+
+ // 이란/중동 쿼리 포인트 (프론트엔드 LIVE_QUERIES)
+ private static final List IRAN_QUERIES = List.of(
+ new RegionQuery(35.5, 51.5, 250),
+ new RegionQuery(30.0, 52.0, 250),
+ new RegionQuery(33.0, 57.0, 250),
+ new RegionQuery(33.5, 44.0, 250),
+ new RegionQuery(33.0, 36.0, 250),
+ new RegionQuery(38.0, 40.0, 250),
+ new RegionQuery(25.0, 55.0, 250),
+ new RegionQuery(26.0, 44.0, 250),
+ new RegionQuery(16.0, 44.0, 250),
+ new RegionQuery(22.0, 62.0, 250)
+ );
+
+ // 한국 쿼리 포인트 (프론트엔드 KR_QUERIES)
+ private static final List KOREA_QUERIES = List.of(
+ new RegionQuery(37.5, 127.0, 250),
+ new RegionQuery(35.0, 129.0, 250),
+ new RegionQuery(33.5, 126.5, 200),
+ new RegionQuery(36.0, 127.0, 250),
+ new RegionQuery(38.5, 128.0, 200),
+ new RegionQuery(35.5, 131.0, 250),
+ new RegionQuery(34.0, 124.0, 200),
+ new RegionQuery(40.0, 130.0, 250)
+ );
+
+ // 이란 mil 필터 bbox
+ private static final double IRAN_LAT_MIN = 12, IRAN_LAT_MAX = 42;
+ private static final double IRAN_LNG_MIN = 25, IRAN_LNG_MAX = 68;
+
+ // 한국 mil 필터 bbox
+ private static final double KOREA_LAT_MIN = 15, KOREA_LAT_MAX = 50;
+ private static final double KOREA_LNG_MIN = 110, KOREA_LNG_MAX = 150;
+
+ private final AtomicInteger iranQueryIdx = new AtomicInteger(0);
+ private final AtomicInteger koreaQueryIdx = new AtomicInteger(0);
+
+ // 지역별 서브캐시 (point/radius 결과를 지점 인덱스별로 보관)
+ private final Map> iranRegionBuffers = new ConcurrentHashMap<>();
+ private final Map> koreaRegionBuffers = new ConcurrentHashMap<>();
+
+ private volatile boolean iranInitDone = false;
+ private volatile boolean koreaInitDone = false;
+
+ @PostConstruct
+ public void init() {
+ Thread.ofVirtual().name("aircraft-init").start(() -> {
+ doInitialLoad("iran", IRAN_QUERIES, iranRegionBuffers);
+ iranInitDone = true;
+ mergePointResults("iran", iranRegionBuffers);
+ log.info("Airplanes.live 이란 초기 로드 완료");
+
+ doInitialLoad("korea", KOREA_QUERIES, koreaRegionBuffers);
+ koreaInitDone = true;
+ mergePointResults("korea", koreaRegionBuffers);
+ log.info("Airplanes.live 한국 초기 로드 완료");
+ });
+ }
+
+ private void doInitialLoad(String region, List queries, Map> buffers) {
+ log.info("Airplanes.live {} 초기 로드 시작 ({} 지점)", region, queries.size());
+ for (int i = 0; i < queries.size(); i++) {
+ if (i > 0) sleep(DELAY_MS);
+ List ac = fetchPoint(queries.get(i));
+ buffers.put(String.valueOf(i), ac);
+ log.debug(" {} 지점 {}: {} 항공기", region, i, ac.size());
+ }
+ }
+
+ /**
+ * 이란 지역 수집 — 60초마다 2개 지점 순환 갱신 + mil
+ */
+ @Scheduled(initialDelay = 60_000, fixedDelay = 60_000)
+ public void collectIran() {
+ if (!iranInitDone) return;
+
+ // 2개 지점 순환 갱신
+ int startIdx = iranQueryIdx.getAndUpdate(i -> (i + POINTS_PER_CYCLE) % IRAN_QUERIES.size());
+ for (int i = 0; i < POINTS_PER_CYCLE; i++) {
+ int idx = (startIdx + i) % IRAN_QUERIES.size();
+ if (i > 0) sleep(DELAY_MS);
+ List ac = fetchPoint(IRAN_QUERIES.get(idx));
+ iranRegionBuffers.put(String.valueOf(idx), ac);
+ }
+ mergePointResults("iran", iranRegionBuffers);
+
+ // mil 엔드포인트 (이란 bbox 필터)
+ sleep(DELAY_MS);
+ List mil = fetchMilitary(IRAN_LAT_MIN, IRAN_LAT_MAX, IRAN_LNG_MIN, IRAN_LNG_MAX);
+ cacheStore.updateSource("iran", AircraftCacheStore.SOURCE_MIL, mil);
+ cacheStore.mergeAndUpdate("iran");
+
+ // DB 적재
+ persistAll(mergeForPersistence(iranRegionBuffers, mil), "airplaneslive", "iran");
+ log.debug("Airplanes.live 이란 수집 완료 — 캐시: {}", cacheStore.get("iran").size());
+ }
+
+ /**
+ * 한국 지역 수집 — 60초마다 2개 지점 순환 갱신 + mil
+ */
+ @Scheduled(initialDelay = 65_000, fixedDelay = 60_000)
+ public void collectKorea() {
+ if (!koreaInitDone) return;
+
+ int startIdx = koreaQueryIdx.getAndUpdate(i -> (i + POINTS_PER_CYCLE) % KOREA_QUERIES.size());
+ for (int i = 0; i < POINTS_PER_CYCLE; i++) {
+ int idx = (startIdx + i) % KOREA_QUERIES.size();
+ if (i > 0) sleep(DELAY_MS);
+ List ac = fetchPoint(KOREA_QUERIES.get(idx));
+ koreaRegionBuffers.put(String.valueOf(idx), ac);
+ }
+ mergePointResults("korea", koreaRegionBuffers);
+
+ sleep(DELAY_MS);
+ List mil = fetchMilitary(KOREA_LAT_MIN, KOREA_LAT_MAX, KOREA_LNG_MIN, KOREA_LNG_MAX);
+ cacheStore.updateSource("korea", AircraftCacheStore.SOURCE_MIL, mil);
+ cacheStore.mergeAndUpdate("korea");
+
+ persistAll(mergeForPersistence(koreaRegionBuffers, mil), "airplaneslive", "korea");
+ log.debug("Airplanes.live 한국 수집 완료 — 캐시: {}", cacheStore.get("korea").size());
+ }
+
+ /**
+ * point/radius 결과를 dedup 후 캐시의 live 소스로 업데이트.
+ */
+ private void mergePointResults(String region, Map> buffers) {
+ Set seen = new HashSet<>();
+ List merged = new ArrayList<>();
+ for (List ac : buffers.values()) {
+ for (AircraftDto a : ac) {
+ if (seen.add(a.getIcao24())) merged.add(a);
+ }
+ }
+ cacheStore.updateSource(region, AircraftCacheStore.SOURCE_LIVE, merged);
+ cacheStore.mergeAndUpdate(region);
+ }
+
+ /**
+ * DB 적재용 병합 (point + mil dedup)
+ */
+ private List mergeForPersistence(Map> pointBuffers, List mil) {
+ Map map = new LinkedHashMap<>();
+ for (List ac : pointBuffers.values()) {
+ for (AircraftDto a : ac) map.putIfAbsent(a.getIcao24(), a);
+ }
+ for (AircraftDto m : mil) map.putIfAbsent(m.getIcao24(), m);
+ return new ArrayList<>(map.values());
+ }
+
+ private List fetchPoint(RegionQuery q) {
+ try {
+ String url = String.format("%s/point/%s/%s/%d", BASE_URL, q.lat(), q.lon(), q.radius());
+ ResponseEntity response = restTemplate.getForEntity(url, String.class);
+ if (response.getStatusCode().value() == 429) {
+ log.warn("Airplanes.live 429 rate limited, 백오프 {}ms", BACKOFF_MS);
+ sleep(BACKOFF_MS);
+ return List.of();
+ }
+ JsonNode root = objectMapper.readTree(response.getBody());
+ return AirplanesLiveParser.parse(root);
+ } catch (Exception e) {
+ log.warn("Airplanes.live point 요청 실패 ({},{}): {}", q.lat(), q.lon(), e.getMessage());
+ return List.of();
+ }
+ }
+
+ private List fetchMilitary(double latMin, double latMax, double lngMin, double lngMax) {
+ try {
+ String url = BASE_URL + "/mil";
+ ResponseEntity response = restTemplate.getForEntity(url, String.class);
+ if (response.getStatusCode().value() == 429) {
+ log.warn("Airplanes.live mil 429, 스킵");
+ return List.of();
+ }
+ JsonNode root = objectMapper.readTree(response.getBody());
+ return AirplanesLiveParser.parse(root).stream()
+ .filter(a -> a.getLat() >= latMin && a.getLat() <= latMax
+ && a.getLng() >= lngMin && a.getLng() <= lngMax)
+ .toList();
+ } catch (Exception e) {
+ log.warn("Airplanes.live mil 요청 실패: {}", e.getMessage());
+ return List.of();
+ }
+ }
+
+ private void persistAll(List aircraft, String source, String region) {
+ if (aircraft.isEmpty()) return;
+ try {
+ Instant now = Instant.now();
+ List entities = aircraft.stream()
+ .map(dto -> AircraftPosition.builder()
+ .icao24(dto.getIcao24())
+ .callsign(dto.getCallsign())
+ .position(geometryFactory.createPoint(new Coordinate(dto.getLng(), dto.getLat())))
+ .altitude(dto.getAltitude())
+ .velocity(dto.getVelocity())
+ .heading(dto.getHeading())
+ .verticalRate(dto.getVerticalRate())
+ .onGround(dto.isOnGround())
+ .category(dto.getCategory())
+ .typecode(dto.getTypecode())
+ .typeDesc(dto.getTypeDesc())
+ .registration(dto.getRegistration())
+ .operator(dto.getOperator())
+ .squawk(dto.getSquawk())
+ .source(source)
+ .region(region)
+ .collectedAt(now)
+ .lastSeen(Instant.ofEpochMilli(dto.getLastSeen()))
+ .build())
+ .toList();
+ positionRepository.saveAll(entities);
+ log.debug("DB 적재 완료: {} {} — {} 건", source, region, entities.size());
+ } catch (Exception e) {
+ log.error("DB 적재 실패 ({} {}): {}", source, region, e.getMessage());
+ }
+ }
+
+ private static void sleep(int ms) {
+ try {
+ Thread.sleep(ms);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }
+
+ record RegionQuery(double lat, double lon, int radius) {}
+}
diff --git a/backend/src/main/java/gc/mda/kcg/collector/aircraft/AirplanesLiveParser.java b/backend/src/main/java/gc/mda/kcg/collector/aircraft/AirplanesLiveParser.java
new file mode 100644
index 0000000..c35f5eb
--- /dev/null
+++ b/backend/src/main/java/gc/mda/kcg/collector/aircraft/AirplanesLiveParser.java
@@ -0,0 +1,110 @@
+package gc.mda.kcg.collector.aircraft;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import gc.mda.kcg.domain.aircraft.AircraftCategory;
+import gc.mda.kcg.domain.aircraft.AircraftDto;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Airplanes.live API 응답 JSON → AircraftDto 변환.
+ * 프론트엔드 airplaneslive.ts:parseAirplanesLive 이식.
+ */
+public final class AirplanesLiveParser {
+
+ private AirplanesLiveParser() {}
+
+ private static final double FT_TO_M = 0.3048;
+ private static final double KNOTS_TO_MS = 0.5144;
+ private static final double FPM_TO_MS = 0.00508;
+
+ /**
+ * Airplanes.live JSON 응답을 파싱하여 AircraftDto 리스트 반환.
+ * 응답 구조: { "ac": [ { hex, flight, lat, lon, alt_baro, gs, ... } ] }
+ */
+ public static List parse(JsonNode root) {
+ JsonNode acArray = root.path("ac");
+ if (acArray.isMissingNode() || !acArray.isArray()) {
+ return List.of();
+ }
+
+ List result = new ArrayList<>();
+ long now = System.currentTimeMillis();
+
+ for (JsonNode a : acArray) {
+ if (a.path("lat").isMissingNode() || a.path("lon").isMissingNode()) continue;
+ Double lat = getDouble(a, "lat");
+ Double lon = getDouble(a, "lon");
+ if (lat == null || lon == null) continue;
+
+ String typecode = getString(a, "t", "");
+ int dbFlags = getInt(a, "dbFlags", 0);
+
+ AircraftCategory category = AircraftClassifier.classifyByTypecode(typecode);
+ if (category == AircraftCategory.CIVILIAN && AircraftClassifier.isMilitaryByDbFlags(dbFlags)) {
+ category = AircraftCategory.MILITARY;
+ }
+
+ // alt_baro: 숫자(feet) 또는 문자열 "ground"
+ double altitude = 0;
+ boolean onGround = false;
+ JsonNode altNode = a.path("alt_baro");
+ if (altNode.isTextual() && "ground".equals(altNode.asText())) {
+ onGround = true;
+ } else if (altNode.isNumber()) {
+ altitude = altNode.asDouble() * FT_TO_M;
+ }
+
+ double heading = a.has("track") ? a.path("track").asDouble(0)
+ : a.path("nav_heading").asDouble(0);
+ int seen = getInt(a, "seen", 0);
+
+ result.add(AircraftDto.builder()
+ .icao24(getString(a, "hex", ""))
+ .callsign(getString(a, "flight", "").trim())
+ .lat(lat)
+ .lng(lon)
+ .altitude(altitude)
+ .velocity(getDouble(a, "gs", 0) * KNOTS_TO_MS)
+ .heading(heading)
+ .verticalRate(getDouble(a, "baro_rate", 0) * FPM_TO_MS)
+ .onGround(onGround)
+ .category(category)
+ .typecode(typecode.isEmpty() ? null : typecode)
+ .typeDesc(getStringOrNull(a, "desc"))
+ .registration(getStringOrNull(a, "r"))
+ .operator(getStringOrNull(a, "ownOp"))
+ .squawk(getStringOrNull(a, "squawk"))
+ .lastSeen(now - (long) seen * 1000)
+ .build());
+ }
+ return result;
+ }
+
+ private static String getString(JsonNode node, String field, String defaultVal) {
+ JsonNode v = node.path(field);
+ return v.isMissingNode() || v.isNull() ? defaultVal : v.asText(defaultVal);
+ }
+
+ private static String getStringOrNull(JsonNode node, String field) {
+ JsonNode v = node.path(field);
+ if (v.isMissingNode() || v.isNull() || v.asText().isBlank()) return null;
+ return v.asText();
+ }
+
+ private static Double getDouble(JsonNode node, String field) {
+ JsonNode v = node.path(field);
+ return v.isNumber() ? v.asDouble() : null;
+ }
+
+ private static double getDouble(JsonNode node, String field, double defaultVal) {
+ JsonNode v = node.path(field);
+ return v.isNumber() ? v.asDouble() : defaultVal;
+ }
+
+ private static int getInt(JsonNode node, String field, int defaultVal) {
+ JsonNode v = node.path(field);
+ return v.isNumber() ? v.asInt() : defaultVal;
+ }
+}
diff --git a/backend/src/main/java/gc/mda/kcg/collector/aircraft/OpenSkyCollector.java b/backend/src/main/java/gc/mda/kcg/collector/aircraft/OpenSkyCollector.java
new file mode 100644
index 0000000..5ddab10
--- /dev/null
+++ b/backend/src/main/java/gc/mda/kcg/collector/aircraft/OpenSkyCollector.java
@@ -0,0 +1,109 @@
+package gc.mda.kcg.collector.aircraft;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import gc.mda.kcg.domain.aircraft.AircraftDto;
+import gc.mda.kcg.domain.aircraft.AircraftPosition;
+import gc.mda.kcg.domain.aircraft.AircraftPositionRepository;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.locationtech.jts.geom.Coordinate;
+import org.locationtech.jts.geom.GeometryFactory;
+import org.locationtech.jts.geom.PrecisionModel;
+import org.springframework.http.ResponseEntity;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+import org.springframework.web.client.RestTemplate;
+
+import java.time.Instant;
+import java.util.List;
+
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class OpenSkyCollector {
+
+ private static final String BASE_URL = "https://opensky-network.org/api";
+
+ // 이란/중동 bbox
+ private static final String IRAN_PARAMS = "lamin=24&lomin=30&lamax=42&lomax=62";
+ // 한국/동아시아 bbox
+ private static final String KOREA_PARAMS = "lamin=20&lomin=115&lamax=45&lomax=145";
+
+ private final RestTemplate restTemplate;
+ private final ObjectMapper objectMapper;
+ private final AircraftCacheStore cacheStore;
+ private final AircraftPositionRepository positionRepository;
+
+ private final GeometryFactory geometryFactory = new GeometryFactory(new PrecisionModel(), 4326);
+
+ @Scheduled(initialDelay = 30_000, fixedDelay = 60_000)
+ public void collectIran() {
+ List aircraft = fetchStates(IRAN_PARAMS);
+ if (!aircraft.isEmpty()) {
+ cacheStore.updateSource("iran", AircraftCacheStore.SOURCE_OPENSKY, aircraft);
+ cacheStore.mergeAndUpdate("iran");
+ persistAll(aircraft, "opensky", "iran");
+ }
+ log.debug("OpenSky 이란 수집 완료: {} 항공기", aircraft.size());
+ }
+
+ @Scheduled(initialDelay = 45_000, fixedDelay = 60_000)
+ public void collectKorea() {
+ List aircraft = fetchStates(KOREA_PARAMS);
+ if (!aircraft.isEmpty()) {
+ cacheStore.updateSource("korea", AircraftCacheStore.SOURCE_OPENSKY, aircraft);
+ cacheStore.mergeAndUpdate("korea");
+ persistAll(aircraft, "opensky", "korea");
+ }
+ log.debug("OpenSky 한국 수집 완료: {} 항공기", aircraft.size());
+ }
+
+ private List fetchStates(String bboxParams) {
+ try {
+ String url = BASE_URL + "/states/all?" + bboxParams;
+ ResponseEntity response = restTemplate.getForEntity(url, String.class);
+ if (response.getStatusCode().value() == 429) {
+ log.warn("OpenSky 429 rate limited, 스킵");
+ return List.of();
+ }
+ JsonNode root = objectMapper.readTree(response.getBody());
+ return OpenSkyParser.parse(root);
+ } catch (Exception e) {
+ log.warn("OpenSky 요청 실패: {}", e.getMessage());
+ return List.of();
+ }
+ }
+
+ private void persistAll(List aircraft, String source, String region) {
+ try {
+ Instant now = Instant.now();
+ List entities = aircraft.stream()
+ .map(dto -> AircraftPosition.builder()
+ .icao24(dto.getIcao24())
+ .callsign(dto.getCallsign())
+ .position(geometryFactory.createPoint(new Coordinate(dto.getLng(), dto.getLat())))
+ .altitude(dto.getAltitude())
+ .velocity(dto.getVelocity())
+ .heading(dto.getHeading())
+ .verticalRate(dto.getVerticalRate())
+ .onGround(dto.isOnGround())
+ .category(dto.getCategory())
+ .typecode(dto.getTypecode())
+ .typeDesc(dto.getTypeDesc())
+ .registration(dto.getRegistration())
+ .operator(dto.getOperator())
+ .squawk(dto.getSquawk())
+ .source(source)
+ .region(region)
+ .collectedAt(now)
+ .lastSeen(Instant.ofEpochMilli(dto.getLastSeen()))
+ .build())
+ .toList();
+ positionRepository.saveAll(entities);
+ log.debug("DB 적재 완료: {} {} — {} 건", source, region, entities.size());
+ } catch (Exception e) {
+ log.error("DB 적재 실패 ({} {}): {}", source, region, e.getMessage());
+ }
+ }
+}
diff --git a/backend/src/main/java/gc/mda/kcg/collector/aircraft/OpenSkyParser.java b/backend/src/main/java/gc/mda/kcg/collector/aircraft/OpenSkyParser.java
new file mode 100644
index 0000000..0263ce4
--- /dev/null
+++ b/backend/src/main/java/gc/mda/kcg/collector/aircraft/OpenSkyParser.java
@@ -0,0 +1,55 @@
+package gc.mda.kcg.collector.aircraft;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import gc.mda.kcg.domain.aircraft.AircraftCategory;
+import gc.mda.kcg.domain.aircraft.AircraftDto;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * OpenSky Network API 응답 JSON → AircraftDto 변환.
+ * 프론트엔드 opensky.ts:parseOpenSkyResponse 이식.
+ *
+ * 응답 구조: { "time": 1234, "states": [ [icao24, callsign, ..., lat, lng, alt, ...], ... ] }
+ * 인덱스: 0=icao24, 1=callsign, 4=lastSeen, 5=lng, 6=lat, 7=altitude(m),
+ * 8=onGround, 9=velocity(m/s), 10=heading, 11=verticalRate
+ */
+public final class OpenSkyParser {
+
+ private OpenSkyParser() {}
+
+ public static List parse(JsonNode root) {
+ JsonNode states = root.path("states");
+ if (states.isMissingNode() || !states.isArray()) {
+ return List.of();
+ }
+
+ List result = new ArrayList<>();
+
+ for (JsonNode s : states) {
+ if (!s.isArray() || s.size() < 12) continue;
+
+ // lat(6), lng(5) 필수
+ if (s.get(6).isNull() || s.get(5).isNull()) continue;
+
+ String callsign = s.get(1).isNull() ? "" : s.get(1).asText("").trim();
+ AircraftCategory category = AircraftClassifier.classifyByCallsign(callsign);
+
+ result.add(AircraftDto.builder()
+ .icao24(s.get(0).asText(""))
+ .callsign(callsign)
+ .lat(s.get(6).asDouble())
+ .lng(s.get(5).asDouble())
+ .altitude(s.get(7).isNull() ? 0 : s.get(7).asDouble())
+ .velocity(s.get(9).isNull() ? 0 : s.get(9).asDouble())
+ .heading(s.get(10).isNull() ? 0 : s.get(10).asDouble())
+ .verticalRate(s.get(11).isNull() ? 0 : s.get(11).asDouble())
+ .onGround(s.get(8).isNull() ? false : s.get(8).asBoolean())
+ .category(category)
+ .lastSeen(s.get(4).isNull() ? System.currentTimeMillis() : s.get(4).asLong() * 1000)
+ .build());
+ }
+ return result;
+ }
+}
diff --git a/backend/src/main/java/gc/mda/kcg/config/AppProperties.java b/backend/src/main/java/gc/mda/kcg/config/AppProperties.java
index 686b67d..18acc07 100644
--- a/backend/src/main/java/gc/mda/kcg/config/AppProperties.java
+++ b/backend/src/main/java/gc/mda/kcg/config/AppProperties.java
@@ -14,6 +14,7 @@ public class AppProperties {
private Jwt jwt = new Jwt();
private Google google = new Google();
private Auth auth = new Auth();
+ private Collector collector = new Collector();
@Getter
@Setter
@@ -33,4 +34,13 @@ public class AppProperties {
public static class Auth {
private String allowedDomain;
}
+
+ @Getter
+ @Setter
+ public static class Collector {
+ private String airplanesLiveBaseUrl = "https://api.airplanes.live/v2";
+ private String openSkyBaseUrl = "https://opensky-network.org/api";
+ private int requestDelayMs = 1500;
+ private int backoffMs = 5000;
+ }
}
diff --git a/backend/src/main/java/gc/mda/kcg/config/RestTemplateConfig.java b/backend/src/main/java/gc/mda/kcg/config/RestTemplateConfig.java
new file mode 100644
index 0000000..ee3adee
--- /dev/null
+++ b/backend/src/main/java/gc/mda/kcg/config/RestTemplateConfig.java
@@ -0,0 +1,20 @@
+package gc.mda.kcg.config;
+
+import org.springframework.boot.web.client.RestTemplateBuilder;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.client.RestTemplate;
+
+import java.time.Duration;
+
+@Configuration
+public class RestTemplateConfig {
+
+ @Bean
+ public RestTemplate restTemplate(RestTemplateBuilder builder) {
+ return builder
+ .setConnectTimeout(Duration.ofSeconds(5))
+ .setReadTimeout(Duration.ofSeconds(10))
+ .build();
+ }
+}
diff --git a/backend/src/main/java/gc/mda/kcg/domain/aircraft/AircraftCategory.java b/backend/src/main/java/gc/mda/kcg/domain/aircraft/AircraftCategory.java
new file mode 100644
index 0000000..f6bfc04
--- /dev/null
+++ b/backend/src/main/java/gc/mda/kcg/domain/aircraft/AircraftCategory.java
@@ -0,0 +1,27 @@
+package gc.mda.kcg.domain.aircraft;
+
+import com.fasterxml.jackson.annotation.JsonValue;
+
+public enum AircraftCategory {
+ MILITARY,
+ TANKER,
+ SURVEILLANCE,
+ FIGHTER,
+ CARGO,
+ CIVILIAN,
+ UNKNOWN;
+
+ @JsonValue
+ public String toValue() {
+ return name().toLowerCase();
+ }
+
+ public static AircraftCategory fromString(String value) {
+ if (value == null) return UNKNOWN;
+ try {
+ return valueOf(value.toUpperCase());
+ } catch (IllegalArgumentException e) {
+ return UNKNOWN;
+ }
+ }
+}
diff --git a/backend/src/main/java/gc/mda/kcg/domain/aircraft/AircraftController.java b/backend/src/main/java/gc/mda/kcg/domain/aircraft/AircraftController.java
new file mode 100644
index 0000000..1846d1e
--- /dev/null
+++ b/backend/src/main/java/gc/mda/kcg/domain/aircraft/AircraftController.java
@@ -0,0 +1,43 @@
+package gc.mda.kcg.domain.aircraft;
+
+import gc.mda.kcg.collector.aircraft.AircraftCacheStore;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+@RestController
+@RequestMapping("/api/aircraft")
+@RequiredArgsConstructor
+public class AircraftController {
+
+ private static final Set VALID_REGIONS = Set.of("iran", "korea");
+
+ private final AircraftCacheStore cacheStore;
+
+ @GetMapping
+ public ResponseEntity