feat: 지진파(USGS) + 기압(Open-Meteo) 수집기 + DB 테이블 설계

Backend:
- SeismicCollector: USGS FDSN API, 이란 bbox, 5분 주기, M2+
- PressureCollector: Open-Meteo API, 이란 5개 관측점, 10분 주기
- Entity: seismic_events, pressure_readings (DB 마이그레이션 004)
- REST: GET /api/sensor/seismic, GET /api/sensor/pressure
- Caffeine 캐시: seismic, pressure (TTL 2일)

Frontend:
- SensorChart 그래프 순서: 지진파 → 기압 → 소음 → 방사선
- 소음/방사선 차트에 (DEMO) 라벨 표시

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
htlee 2026-03-18 08:05:11 +09:00
부모 0fd32081b0
커밋 e6680f6e03
12개의 변경된 파일479개의 추가작업 그리고 15개의 파일을 삭제

파일 보기

@ -0,0 +1,113 @@
package gc.mda.kcg.collector.sensor;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import gc.mda.kcg.collector.CollectorStatusTracker;
import gc.mda.kcg.domain.sensor.PressureReading;
import gc.mda.kcg.domain.sensor.PressureReadingRepository;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.time.format.DateTimeParseException;
@Slf4j
@Component
@RequiredArgsConstructor
public class PressureCollector {
private static final String[] STATION_NAMES = {"tehran", "isfahan", "bandar-abbas", "shiraz", "tabriz"};
private static final double[] LATS = {35.69, 32.65, 27.19, 29.59, 38.08};
private static final double[] LNGS = {51.39, 51.68, 56.27, 52.58, 46.29};
private static final String OPEN_METEO_URL =
"https://api.open-meteo.com/v1/forecast?" +
"latitude=35.69,32.65,27.19,29.59,38.08" +
"&longitude=51.39,51.68,56.27,52.58,46.29" +
"&hourly=surface_pressure&past_hours=24&forecast_hours=0&timezone=UTC";
private final RestTemplate restTemplate;
private final ObjectMapper objectMapper;
private final PressureReadingRepository repository;
private final CollectorStatusTracker tracker;
@PostConstruct
public void init() {
Thread.ofVirtual().name("pressure-init").start(() -> {
log.info("Open-Meteo 기압 데이터 초기 로드");
collect();
});
}
@Scheduled(initialDelay = 45_000, fixedDelay = 600_000)
public void collectScheduled() {
collect();
}
private void collect() {
try {
String body = restTemplate.getForObject(OPEN_METEO_URL, String.class);
if (body == null || body.isBlank()) return;
JsonNode root = objectMapper.readTree(body);
int saved = 0;
Instant now = Instant.now();
// Open-Meteo 멀티 위치: 배열 형태 응답
if (root.isArray()) {
for (int i = 0; i < root.size() && i < STATION_NAMES.length; i++) {
saved += parseStation(root.get(i), STATION_NAMES[i], LATS[i], LNGS[i], now);
}
} else {
// 단일 위치 응답 (파라미터 1개일 )
saved += parseStation(root, STATION_NAMES[0], LATS[0], LNGS[0], now);
}
tracker.recordSuccess("pressure", "iran", saved);
log.debug("Open-Meteo 기압 수집 완료: {}건 저장", saved);
} catch (Exception e) {
tracker.recordFailure("pressure", "iran", e.getMessage());
log.warn("Open-Meteo 기압 수집 실패: {}", e.getMessage());
}
}
private int parseStation(JsonNode stationData, String station, double lat, double lng, Instant now) {
JsonNode hourly = stationData.path("hourly");
JsonNode times = hourly.path("time");
JsonNode pressures = hourly.path("surface_pressure");
if (!times.isArray() || !pressures.isArray()) return 0;
int saved = 0;
for (int j = 0; j < times.size() && j < pressures.size(); j++) {
String timeStr = times.get(j).asText();
double pressure = pressures.get(j).asDouble(Double.NaN);
if (Double.isNaN(pressure)) continue;
try {
// Open-Meteo 시간 형식: "2026-03-18T07:00" (Z 없음) Z 추가
Instant readingTime = OffsetDateTime.parse(timeStr + "Z").toInstant();
try {
repository.save(PressureReading.builder()
.station(station)
.lat(lat)
.lng(lng)
.pressureHpa(pressure)
.readingTime(readingTime)
.collectedAt(now)
.build());
saved++;
} catch (Exception ignored) {
// unique constraint violation = 이미 존재, 무시
}
} catch (DateTimeParseException e) {
log.debug("기압 시간 파싱 실패: {}", timeStr);
}
}
return saved;
}
}

파일 보기

@ -0,0 +1,90 @@
package gc.mda.kcg.collector.sensor;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import gc.mda.kcg.collector.CollectorStatusTracker;
import gc.mda.kcg.domain.sensor.SeismicEvent;
import gc.mda.kcg.domain.sensor.SeismicEventRepository;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
import java.time.Instant;
@Slf4j
@Component
@RequiredArgsConstructor
public class SeismicCollector {
private static final String USGS_URL =
"https://earthquake.usgs.gov/fdsnws/event/1/query?format=geojson" +
"&minlatitude=25&maxlatitude=40&minlongitude=44&maxlongitude=63" +
"&orderby=time&limit=50&minmagnitude=2";
private final RestTemplate restTemplate;
private final ObjectMapper objectMapper;
private final SeismicEventRepository repository;
private final CollectorStatusTracker tracker;
@PostConstruct
public void init() {
Thread.ofVirtual().name("seismic-init").start(() -> {
log.info("USGS 지진 데이터 초기 로드");
collect();
});
}
@Scheduled(initialDelay = 60_000, fixedDelay = 300_000)
public void collectScheduled() {
collect();
}
private void collect() {
try {
String body = restTemplate.getForObject(USGS_URL, String.class);
if (body == null || body.isBlank()) return;
JsonNode root = objectMapper.readTree(body);
JsonNode features = root.path("features");
if (!features.isArray()) return;
int saved = 0;
Instant now = Instant.now();
for (JsonNode f : features) {
JsonNode props = f.path("properties");
JsonNode coords = f.path("geometry").path("coordinates");
String usgsId = f.path("id").asText(null);
if (usgsId == null) continue;
if (repository.existsByUsgsId(usgsId)) continue;
double mag = props.path("mag").asDouble(0);
String place = props.path("place").asText(null);
long timeMs = props.path("time").asLong(0);
double lng = coords.get(0).asDouble();
double lat = coords.get(1).asDouble();
Double depth = coords.has(2) ? coords.get(2).asDouble() : null;
repository.save(SeismicEvent.builder()
.usgsId(usgsId)
.magnitude(mag)
.depth(depth)
.lat(lat)
.lng(lng)
.place(place)
.eventTime(Instant.ofEpochMilli(timeMs))
.collectedAt(now)
.build());
saved++;
}
tracker.recordSuccess("seismic", "iran", saved);
log.debug("USGS 지진 수집 완료: {}건 저장", saved);
} catch (Exception e) {
tracker.recordFailure("seismic", "iran", e.getMessage());
log.warn("USGS 지진 수집 실패: {}", e.getMessage());
}
}
}

파일 보기

@ -18,13 +18,16 @@ public class CacheConfig {
public static final String OSINT_IRAN = "osint-iran";
public static final String OSINT_KOREA = "osint-korea";
public static final String SATELLITES = "satellites";
public static final String SEISMIC = "seismic";
public static final String PRESSURE = "pressure";
@Bean
public CacheManager cacheManager() {
CaffeineCacheManager manager = new CaffeineCacheManager(
AIRCRAFT_IRAN, AIRCRAFT_KOREA,
OSINT_IRAN, OSINT_KOREA,
SATELLITES
SATELLITES,
SEISMIC, PRESSURE
);
manager.setCaffeine(Caffeine.newBuilder()
.expireAfterWrite(2, TimeUnit.DAYS)

파일 보기

@ -0,0 +1,49 @@
package gc.mda.kcg.domain.sensor;
import jakarta.persistence.*;
import lombok.*;
import java.time.Instant;
@Entity
@Table(
name = "pressure_readings",
schema = "kcg",
uniqueConstraints = @UniqueConstraint(columnNames = {"station", "reading_time"})
)
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class PressureReading {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 50)
private String station;
@Column(nullable = false)
private double lat;
@Column(nullable = false)
private double lng;
@Column(nullable = false)
private double pressureHpa;
@Column(nullable = false)
private Instant readingTime;
@Column(nullable = false)
private Instant collectedAt;
@PrePersist
protected void onCreate() {
if (collectedAt == null) {
collectedAt = Instant.now();
}
}
}

파일 보기

@ -0,0 +1,11 @@
package gc.mda.kcg.domain.sensor;
import org.springframework.data.jpa.repository.JpaRepository;
import java.time.Instant;
import java.util.List;
public interface PressureReadingRepository extends JpaRepository<PressureReading, Long> {
List<PressureReading> findByStationAndReadingTimeAfterOrderByReadingTimeAsc(String station, Instant since);
List<PressureReading> findByReadingTimeAfterOrderByReadingTimeAsc(Instant since);
}

파일 보기

@ -0,0 +1,50 @@
package gc.mda.kcg.domain.sensor;
import jakarta.persistence.*;
import lombok.*;
import java.time.Instant;
@Entity
@Table(name = "seismic_events", schema = "kcg")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class SeismicEvent {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false, length = 30)
private String usgsId;
@Column(nullable = false)
private double magnitude;
private Double depth;
@Column(nullable = false)
private double lat;
@Column(nullable = false)
private double lng;
@Column(length = 255)
private String place;
@Column(nullable = false)
private Instant eventTime;
@Column(nullable = false)
private Instant collectedAt;
@PrePersist
protected void onCreate() {
if (collectedAt == null) {
collectedAt = Instant.now();
}
}
}

파일 보기

@ -0,0 +1,11 @@
package gc.mda.kcg.domain.sensor;
import org.springframework.data.jpa.repository.JpaRepository;
import java.time.Instant;
import java.util.List;
public interface SeismicEventRepository extends JpaRepository<SeismicEvent, Long> {
boolean existsByUsgsId(String usgsId);
List<SeismicEvent> findByEventTimeAfterOrderByEventTimeDesc(Instant since);
}

파일 보기

@ -0,0 +1,44 @@
package gc.mda.kcg.domain.sensor;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/sensor")
@RequiredArgsConstructor
public class SensorController {
private final SeismicEventRepository seismicRepo;
private final PressureReadingRepository pressureRepo;
/**
* 지진 이벤트 조회 (USGS 수집 데이터)
*/
@GetMapping("/seismic")
public Map<String, Object> getSeismic(
@RequestParam(defaultValue = "24") int hours) {
Instant since = Instant.now().minus(hours, ChronoUnit.HOURS);
List<SensorDto.SeismicDto> data = seismicRepo
.findByEventTimeAfterOrderByEventTimeDesc(since)
.stream().map(SensorDto.SeismicDto::from).toList();
return Map.of("count", data.size(), "data", data);
}
/**
* 기압 데이터 조회 (Open-Meteo 수집 데이터)
*/
@GetMapping("/pressure")
public Map<String, Object> getPressure(
@RequestParam(defaultValue = "24") int hours) {
Instant since = Instant.now().minus(hours, ChronoUnit.HOURS);
List<SensorDto.PressureDto> data = pressureRepo
.findByReadingTimeAfterOrderByReadingTimeAsc(since)
.stream().map(SensorDto.PressureDto::from).toList();
return Map.of("count", data.size(), "data", data);
}
}

파일 보기

@ -0,0 +1,51 @@
package gc.mda.kcg.domain.sensor;
import lombok.Builder;
import lombok.Getter;
public class SensorDto {
@Getter
@Builder
public static class SeismicDto {
private String usgsId;
private double magnitude;
private Double depth;
private double lat;
private double lng;
private String place;
private long timestamp; // epoch ms
public static SeismicDto from(SeismicEvent e) {
return SeismicDto.builder()
.usgsId(e.getUsgsId())
.magnitude(e.getMagnitude())
.depth(e.getDepth())
.lat(e.getLat())
.lng(e.getLng())
.place(e.getPlace())
.timestamp(e.getEventTime().toEpochMilli())
.build();
}
}
@Getter
@Builder
public static class PressureDto {
private String station;
private double lat;
private double lng;
private double pressureHpa;
private long timestamp; // epoch ms
public static PressureDto from(PressureReading r) {
return PressureDto.builder()
.station(r.getStation())
.lat(r.getLat())
.lng(r.getLng())
.pressureHpa(r.getPressureHpa())
.timestamp(r.getReadingTime().toEpochMilli())
.build();
}
}
}

파일 보기

@ -0,0 +1,30 @@
SET search_path TO kcg, public;
-- 지진 이벤트 (USGS)
CREATE TABLE IF NOT EXISTS seismic_events (
id BIGSERIAL PRIMARY KEY,
usgs_id VARCHAR(30) UNIQUE NOT NULL,
magnitude DOUBLE PRECISION NOT NULL,
depth DOUBLE PRECISION,
lat DOUBLE PRECISION NOT NULL,
lng DOUBLE PRECISION NOT NULL,
place VARCHAR(255),
event_time TIMESTAMPTZ NOT NULL,
collected_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_seismic_events_time ON seismic_events(event_time DESC);
-- 기압 데이터 (Open-Meteo)
CREATE TABLE IF NOT EXISTS pressure_readings (
id BIGSERIAL PRIMARY KEY,
station VARCHAR(50) NOT NULL,
lat DOUBLE PRECISION NOT NULL,
lng DOUBLE PRECISION NOT NULL,
pressure_hpa DOUBLE PRECISION NOT NULL,
reading_time TIMESTAMPTZ NOT NULL,
collected_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(station, reading_time)
);
CREATE INDEX IF NOT EXISTS idx_pressure_readings_time ON pressure_readings(reading_time DESC);

파일 보기

@ -1131,6 +1131,12 @@
font-family: 'Courier New', monospace;
}
.chart-demo-label {
font-size: 9px;
color: #ef4444;
opacity: 0.7;
}
/* Footer / Controls */
.app-footer {
background: var(--bg-card);

파일 보기

@ -54,19 +54,6 @@ export function SensorChart({ data, currentTime, startTime }: Props) {
</ResponsiveContainer>
</div>
<div className="chart-item">
<h4>{t('sensor.noiseLevelDb')}</h4>
<ResponsiveContainer width="100%" height={80}>
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
<XAxis dataKey="time" tick={{ fontSize: 10, fill: '#888' }} />
<YAxis domain={[0, 140]} tick={{ fontSize: 10, fill: '#888' }} />
<Tooltip contentStyle={{ background: '#1a1a2e', border: '1px solid #333' }} />
<Line type="monotone" dataKey="noiseLevel" stroke="#f97316" dot={false} strokeWidth={1.5} />
</LineChart>
</ResponsiveContainer>
</div>
<div className="chart-item">
<h4>{t('sensor.airPressureHpa')}</h4>
<ResponsiveContainer width="100%" height={80}>
@ -81,7 +68,26 @@ export function SensorChart({ data, currentTime, startTime }: Props) {
</div>
<div className="chart-item">
<h4>{t('sensor.radiationUsv')}</h4>
<h4>
{t('sensor.noiseLevelDb')}{' '}
<span className="chart-demo-label">(DEMO)</span>
</h4>
<ResponsiveContainer width="100%" height={80}>
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
<XAxis dataKey="time" tick={{ fontSize: 10, fill: '#888' }} />
<YAxis domain={[0, 140]} tick={{ fontSize: 10, fill: '#888' }} />
<Tooltip contentStyle={{ background: '#1a1a2e', border: '1px solid #333' }} />
<Line type="monotone" dataKey="noiseLevel" stroke="#f97316" dot={false} strokeWidth={1.5} />
</LineChart>
</ResponsiveContainer>
</div>
<div className="chart-item">
<h4>
{t('sensor.radiationUsv')}{' '}
<span className="chart-demo-label">(DEMO)</span>
</h4>
<ResponsiveContainer width="100%" height={80}>
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#333" />