From bf9c0bd34623e9a4d17ad883fbf1184013dd45f7 Mon Sep 17 00:00:00 2001 From: htlee Date: Thu, 19 Mar 2026 10:43:58 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20OpenSky=20OAuth2=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20+=20=EC=88=98=EC=A7=91=20=EC=A3=BC=EA=B8=B0=205?= =?UTF-8?q?=EB=B6=84=20=EC=A1=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OAuth2 Client Credentials 토큰 관리 (30분 유효, 자동 갱신) - 수집 주기 60초 → 300초 (일일 크레딧 11,520 → 2,304) - AppProperties: openSkyClientId/Secret/AuthUrl 설정 추가 - application-prod.yml: 환경변수 참조 (OPENSKY_CLIENT_ID/SECRET) - 미설정 시 익명 모드 폴백 유지 --- .../collector/aircraft/OpenSkyCollector.java | 77 ++++++++++++++++--- .../java/gc/mda/kcg/config/AppProperties.java | 3 + .../resources/application-local.yml.example | 3 + .../src/main/resources/application-prod.yml | 3 + 4 files changed, 76 insertions(+), 10 deletions(-) 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 index aa9a773..126b596 100644 --- a/backend/src/main/java/gc/mda/kcg/collector/aircraft/OpenSkyCollector.java +++ b/backend/src/main/java/gc/mda/kcg/collector/aircraft/OpenSkyCollector.java @@ -3,6 +3,7 @@ package gc.mda.kcg.collector.aircraft; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import gc.mda.kcg.collector.CollectorStatusTracker; +import gc.mda.kcg.config.AppProperties; import gc.mda.kcg.domain.aircraft.AircraftDto; import gc.mda.kcg.domain.aircraft.AircraftPosition; import gc.mda.kcg.domain.aircraft.AircraftPositionRepository; @@ -11,9 +12,15 @@ 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.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; import org.springframework.web.client.RestTemplate; import java.time.Instant; @@ -24,22 +31,23 @@ import java.util.List; @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 static final long TOKEN_REFRESH_MARGIN_SEC = 120; private final RestTemplate restTemplate; private final ObjectMapper objectMapper; private final AircraftCacheStore cacheStore; private final AircraftPositionRepository positionRepository; private final CollectorStatusTracker tracker; + private final AppProperties appProperties; private final GeometryFactory geometryFactory = new GeometryFactory(new PrecisionModel(), 4326); - @Scheduled(initialDelay = 30_000, fixedDelay = 60_000) + private String accessToken; + private Instant tokenExpiresAt = Instant.EPOCH; + + @Scheduled(initialDelay = 30_000, fixedDelay = 300_000) public void collectIran() { List aircraft = fetchStates(IRAN_PARAMS); if (!aircraft.isEmpty()) { @@ -48,12 +56,12 @@ public class OpenSkyCollector { persistAll(aircraft, "opensky", "iran"); tracker.recordSuccess("opensky-iran", "iran", aircraft.size()); } else { - tracker.recordFailure("opensky-iran", "iran", "빈 응답 또는 429"); + tracker.recordFailure("opensky-iran", "iran", "빈 응답 또는 인증 실패"); } log.debug("OpenSky 이란 수집 완료: {} 항공기", aircraft.size()); } - @Scheduled(initialDelay = 45_000, fixedDelay = 60_000) + @Scheduled(initialDelay = 180_000, fixedDelay = 300_000) public void collectKorea() { List aircraft = fetchStates(KOREA_PARAMS); if (!aircraft.isEmpty()) { @@ -62,15 +70,23 @@ public class OpenSkyCollector { persistAll(aircraft, "opensky", "korea"); tracker.recordSuccess("opensky-korea", "korea", aircraft.size()); } else { - tracker.recordFailure("opensky-korea", "korea", "빈 응답 또는 429"); + tracker.recordFailure("opensky-korea", "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); + String token = getAccessToken(); + String url = appProperties.getCollector().getOpenSkyBaseUrl() + "/states/all?" + bboxParams; + + HttpHeaders headers = new HttpHeaders(); + if (token != null) { + headers.setBearerAuth(token); + } + HttpEntity entity = new HttpEntity<>(headers); + + ResponseEntity response = restTemplate.exchange(url, HttpMethod.GET, entity, String.class); if (response.getStatusCode().value() == 429) { log.warn("OpenSky 429 rate limited, 스킵"); return List.of(); @@ -83,6 +99,47 @@ public class OpenSkyCollector { } } + private synchronized String getAccessToken() { + if (accessToken != null && Instant.now().isBefore(tokenExpiresAt.minusSeconds(TOKEN_REFRESH_MARGIN_SEC))) { + return accessToken; + } + + String clientId = appProperties.getCollector().getOpenSkyClientId(); + String clientSecret = appProperties.getCollector().getOpenSkyClientSecret(); + if (clientId == null || clientSecret == null) { + log.debug("OpenSky OAuth2 미설정, 익명 모드로 동작"); + return null; + } + + try { + String authUrl = appProperties.getCollector().getOpenSkyAuthUrl(); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("grant_type", "client_credentials"); + body.add("client_id", clientId); + body.add("client_secret", clientSecret); + + HttpEntity> request = new HttpEntity<>(body, headers); + ResponseEntity response = restTemplate.postForEntity(authUrl, request, String.class); + + JsonNode json = objectMapper.readTree(response.getBody()); + accessToken = json.get("access_token").asText(); + int expiresIn = json.get("expires_in").asInt(); + tokenExpiresAt = Instant.now().plusSeconds(expiresIn); + + log.info("OpenSky OAuth2 토큰 발급 완료 (만료: {}초)", expiresIn); + return accessToken; + } catch (Exception e) { + log.warn("OpenSky OAuth2 토큰 발급 실패: {}, 익명 모드로 폴백", e.getMessage()); + accessToken = null; + tokenExpiresAt = Instant.EPOCH; + return null; + } + } + private void persistAll(List aircraft, String source, String region) { try { Instant now = Instant.now(); 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 5fb83d8..2f144dc 100644 --- a/backend/src/main/java/gc/mda/kcg/config/AppProperties.java +++ b/backend/src/main/java/gc/mda/kcg/config/AppProperties.java @@ -40,6 +40,9 @@ public class AppProperties { public static class Collector { private String airplanesLiveBaseUrl = "https://api.airplanes.live/v2"; private String openSkyBaseUrl = "https://opensky-network.org/api"; + private String openSkyAuthUrl = "https://auth.opensky-network.org/auth/realms/opensky-network/protocol/openid-connect/token"; + private String openSkyClientId; + private String openSkyClientSecret; private String gdeltBaseUrl = "https://api.gdeltproject.org/api/v2/doc/doc"; private String googleNewsBaseUrl = "https://news.google.com/rss/search"; private String celestrakBaseUrl = "https://celestrak.org/NORAD/elements/gp.php"; diff --git a/backend/src/main/resources/application-local.yml.example b/backend/src/main/resources/application-local.yml.example index 0fa5862..70490de 100644 --- a/backend/src/main/resources/application-local.yml.example +++ b/backend/src/main/resources/application-local.yml.example @@ -11,3 +11,6 @@ app: client-id: YOUR_GOOGLE_CLIENT_ID auth: allowed-domain: gcsc.co.kr + collector: + open-sky-client-id: YOUR_OPENSKY_CLIENT_ID + open-sky-client-secret: YOUR_OPENSKY_CLIENT_SECRET diff --git a/backend/src/main/resources/application-prod.yml b/backend/src/main/resources/application-prod.yml index b62d921..71ae3c5 100644 --- a/backend/src/main/resources/application-prod.yml +++ b/backend/src/main/resources/application-prod.yml @@ -11,5 +11,8 @@ app: client-id: ${GOOGLE_CLIENT_ID} auth: allowed-domain: ${AUTH_ALLOWED_DOMAIN:gcsc.co.kr} + collector: + open-sky-client-id: ${OPENSKY_CLIENT_ID:} + open-sky-client-secret: ${OPENSKY_CLIENT_SECRET:} cors: allowed-origins: http://localhost:5173,https://kcg.gc-si.dev From 0b3775a251212178f1d9b94852531484dd958902 Mon Sep 17 00:00:00 2001 From: htlee Date: Thu, 19 Mar 2026 10:44:21 +0900 Subject: [PATCH 2/3] =?UTF-8?q?docs:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/RELEASE-NOTES.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 2c5f029..6fd1d11 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,12 @@ ## [Unreleased] +### 추가 +- OpenSky OAuth2 Client Credentials 인증 (토큰 자동 갱신) + +### 변경 +- OpenSky 수집 주기 60초 → 300초 (일일 크레딧 소비 11,520 → 2,304) + ## [2026-03-19] ### 변경 From dcff31002d4315078681dc113a2295713051b2c7 Mon Sep 17 00:00:00 2001 From: htlee Date: Thu, 19 Mar 2026 10:45:10 +0900 Subject: [PATCH 3/3] =?UTF-8?q?docs:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20=EC=A0=95=EB=A6=AC=20(2026-03-19.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/RELEASE-NOTES.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 6fd1d11..ecfa1ff 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,8 @@ ## [Unreleased] +## [2026-03-19.2] + ### 추가 - OpenSky OAuth2 Client Credentials 인증 (토큰 자동 갱신)