Merge pull request 'feat: OpenSky OAuth2 인증 + 수집 주기 5분 조정' (#73) from feat/opensky-oauth2-credits into develop

This commit is contained in:
htlee 2026-03-19 10:44:46 +09:00
커밋 b6456145d5
5개의 변경된 파일82개의 추가작업 그리고 10개의 파일을 삭제

파일 보기

@ -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<AircraftDto> 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<AircraftDto> 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<AircraftDto> fetchStates(String bboxParams) {
try {
String url = BASE_URL + "/states/all?" + bboxParams;
ResponseEntity<String> 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<Void> entity = new HttpEntity<>(headers);
ResponseEntity<String> 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<String, String> body = new LinkedMultiValueMap<>();
body.add("grant_type", "client_credentials");
body.add("client_id", clientId);
body.add("client_secret", clientSecret);
HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(body, headers);
ResponseEntity<String> 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<AircraftDto> aircraft, String source, String region) {
try {
Instant now = Instant.now();

파일 보기

@ -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";

파일 보기

@ -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

파일 보기

@ -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

파일 보기

@ -4,6 +4,12 @@
## [Unreleased]
### 추가
- OpenSky OAuth2 Client Credentials 인증 (토큰 자동 갱신)
### 변경
- OpenSky 수집 주기 60초 → 300초 (일일 크레딧 소비 11,520 → 2,304)
## [2026-03-19]
### 변경