release: 2026-03-25 (8건 커밋) #104

병합
HYOJIN develop 에서 main 로 8 commits 를 머지했습니다 2026-03-25 17:04:34 +09:00
43개의 변경된 파일16개의 추가작업 그리고 7061개의 파일을 삭제
Showing only changes of commit 6613b70d35 - Show all commits

파일 보기

@ -62,6 +62,8 @@
- 실패 건 수동 재수집 시 414 Request-URI Too Long 오류 수정 (#71)
### 변경
- AIS 수집 및 서비스 API 제거 (배치 분리에 따른 코드 정리, ~7,000 LOC 삭제) (#99)
- API URL 환경별 중복 제거 (application.yml 공통 관리)
- RiskRangeImportJob API URL 변경 및 저장 테이블 통합 (#86)
- RiskDetailImportJob IMO 조회 대상을 tb_ship_default_info로 변경 (#81)
- 파티션 스텝 프로세스 공통 모듈화 (StringListPartitioner, BasePartitionedJobConfig, LastExecutionUpdateTasklet) (#73)

파일 보기

@ -93,9 +93,6 @@ export default function Executions() {
return map;
}, [displayNames]);
const aisJobs = useMemo(() => jobs.filter(j => j.toLowerCase().startsWith('ais')), [jobs]);
const nonAisJobs = useMemo(() => jobs.filter(j => !j.toLowerCase().startsWith('ais')), [jobs]);
const loadJobs = useCallback(async () => {
try {
const data = await batchApi.getJobs();
@ -330,26 +327,6 @@ export default function Executions() {
>
</button>
<button
onClick={() => setSelectedJobs(nonAisJobs)}
className={`px-2 py-1 text-xs rounded-md transition-colors ${
selectedJobs.length > 0 && selectedJobs.length === nonAisJobs.length && selectedJobs.every(j => nonAisJobs.includes(j))
? 'bg-wing-accent text-white'
: 'bg-wing-card text-wing-muted hover:bg-wing-hover'
}`}
>
AIS
</button>
<button
onClick={() => setSelectedJobs([...aisJobs])}
className={`px-2 py-1 text-xs rounded-md transition-colors ${
selectedJobs.length > 0 && selectedJobs.length === aisJobs.length && selectedJobs.every(j => aisJobs.includes(j))
? 'bg-wing-accent text-white'
: 'bg-wing-card text-wing-muted hover:bg-wing-hover'
}`}
>
AIS만
</button>
</div>
{selectedJobs.length > 0 && (
<button

11
pom.xml
파일 보기

@ -111,11 +111,6 @@
<version>2.3.0</version>
</dependency>
<!-- Kafka -->
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
<!-- Caffeine Cache -->
<dependency>
@ -124,12 +119,6 @@
<version>3.1.8</version>
</dependency>
<!-- JTS (Java Topology Suite) - 공간 연산 라이브러리 -->
<dependency>
<groupId>org.locationtech.jts</groupId>
<artifactId>jts-core</artifactId>
<version>1.19.0</version>
</dependency>
<!-- Test Dependencies -->
<dependency>

파일 보기

@ -1,149 +0,0 @@
-- ============================================================
-- ChnPrmShip 캐시 검증 진단 쿼리
-- 대상: std_snp_data.ais_target (일별 파티션)
-- 목적: 최근 2일 내 대상 MMSI별 최종위치 캐싱 검증
-- ============================================================
-- ============================================================
-- 0. 대상 MMSI 임시 테이블 생성
-- ============================================================
CREATE TEMP TABLE tmp_chn_mmsi (mmsi BIGINT PRIMARY KEY);
-- psql에서 실행:
-- \copy tmp_chn_mmsi(mmsi) FROM 'chnprmship-mmsi.txt'
-- ============================================================
-- 1. 기본 현황: 대상 MMSI 중 최근 2일 내 데이터 존재 여부
-- ============================================================
SELECT
(SELECT COUNT(*) FROM tmp_chn_mmsi) AS total_target_mmsi,
COUNT(DISTINCT a.mmsi) AS mmsi_with_data_2d,
(SELECT COUNT(*) FROM tmp_chn_mmsi) - COUNT(DISTINCT a.mmsi) AS mmsi_without_data_2d,
ROUND(COUNT(DISTINCT a.mmsi) * 100.0
/ NULLIF((SELECT COUNT(*) FROM tmp_chn_mmsi), 0), 1) AS hit_rate_pct
FROM std_snp_data.ais_target a
JOIN tmp_chn_mmsi t ON a.mmsi = t.mmsi
WHERE a.message_timestamp >= NOW() - INTERVAL '2 days';
-- ============================================================
-- 2. 워밍업 시뮬레이션: 최근 2일 내 MMSI별 최종위치
-- (수정 후 findLatestByMmsiIn 쿼리와 동일하게 동작)
-- ============================================================
SELECT COUNT(*) AS cached_count,
MIN(message_timestamp) AS oldest_cached,
MAX(message_timestamp) AS newest_cached,
NOW() - MAX(message_timestamp) AS newest_age
FROM (
SELECT DISTINCT ON (a.mmsi) a.mmsi, a.message_timestamp
FROM std_snp_data.ais_target a
JOIN tmp_chn_mmsi t ON a.mmsi = t.mmsi
WHERE a.message_timestamp >= NOW() - INTERVAL '2 days'
ORDER BY a.mmsi, a.message_timestamp DESC
) latest;
-- ============================================================
-- 3. MMSI별 최종위치 상세 (최근 2일 내, 최신순 상위 30건)
-- ============================================================
SELECT DISTINCT ON (a.mmsi)
a.mmsi,
a.message_timestamp,
a.name,
a.vessel_type,
a.lat,
a.lon,
a.sog,
a.cog,
a.heading,
NOW() - a.message_timestamp AS data_age
FROM std_snp_data.ais_target a
JOIN tmp_chn_mmsi t ON a.mmsi = t.mmsi
WHERE a.message_timestamp >= NOW() - INTERVAL '2 days'
ORDER BY a.mmsi, a.message_timestamp DESC
LIMIT 30;
-- ============================================================
-- 4. 데이터 없는 대상 MMSI (최근 2일 내 DB에 없는 선박)
-- ============================================================
SELECT t.mmsi AS missing_mmsi
FROM tmp_chn_mmsi t
LEFT JOIN (
SELECT DISTINCT mmsi
FROM std_snp_data.ais_target
WHERE mmsi IN (SELECT mmsi FROM tmp_chn_mmsi)
AND message_timestamp >= NOW() - INTERVAL '2 days'
) a ON t.mmsi = a.mmsi
WHERE a.mmsi IS NULL
ORDER BY t.mmsi;
-- ============================================================
-- 5. 시간대별 분포 (2일 기준 세부 확인)
-- ============================================================
SELECT
'6시간 이내' AS time_range,
COUNT(DISTINCT mmsi) AS distinct_mmsi
FROM std_snp_data.ais_target a
JOIN tmp_chn_mmsi t ON a.mmsi = t.mmsi
WHERE a.message_timestamp >= NOW() - INTERVAL '6 hours'
UNION ALL
SELECT '12시간 이내', COUNT(DISTINCT mmsi)
FROM std_snp_data.ais_target a
JOIN tmp_chn_mmsi t ON a.mmsi = t.mmsi
WHERE a.message_timestamp >= NOW() - INTERVAL '12 hours'
UNION ALL
SELECT '1일 이내', COUNT(DISTINCT mmsi)
FROM std_snp_data.ais_target a
JOIN tmp_chn_mmsi t ON a.mmsi = t.mmsi
WHERE a.message_timestamp >= NOW() - INTERVAL '1 day'
UNION ALL
SELECT '2일 이내', COUNT(DISTINCT mmsi)
FROM std_snp_data.ais_target a
JOIN tmp_chn_mmsi t ON a.mmsi = t.mmsi
WHERE a.message_timestamp >= NOW() - INTERVAL '2 days'
UNION ALL
SELECT '전체(무제한)', COUNT(DISTINCT mmsi)
FROM std_snp_data.ais_target a
JOIN tmp_chn_mmsi t ON a.mmsi = t.mmsi;
-- ============================================================
-- 6. 파티션별 대상 데이터 분포
-- ============================================================
SELECT
tableoid::regclass AS partition_name,
COUNT(*) AS row_count,
COUNT(DISTINCT mmsi) AS distinct_mmsi,
MIN(message_timestamp) AS min_ts,
MAX(message_timestamp) AS max_ts
FROM std_snp_data.ais_target a
JOIN tmp_chn_mmsi t ON a.mmsi = t.mmsi
GROUP BY tableoid::regclass
ORDER BY max_ts DESC;
-- ============================================================
-- 7. 전체 ais_target 파티션 현황
-- ============================================================
SELECT
c.relname AS partition_name,
pg_size_pretty(pg_relation_size(c.oid)) AS table_size,
s.n_live_tup AS estimated_rows
FROM pg_inherits i
JOIN pg_class c ON c.oid = i.inhrelid
JOIN pg_stat_user_tables s ON s.relid = c.oid
WHERE i.inhparent = 'std_snp_data.ais_target'::regclass
ORDER BY c.relname DESC;
-- ============================================================
-- 정리
-- ============================================================
DROP TABLE IF EXISTS tmp_chn_mmsi;

파일 보기

@ -2,11 +2,10 @@ package com.snp.batch;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.kafka.KafkaAutoConfiguration;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication(exclude = KafkaAutoConfiguration.class)
@SpringBootApplication
@EnableScheduling
@ConfigurationPropertiesScan
public class SnpBatchApplication {

파일 보기

@ -1,143 +0,0 @@
package com.snp.batch.jobs.aistarget.batch.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.snp.batch.common.batch.config.BaseJobConfig;
import com.snp.batch.jobs.aistarget.batch.dto.AisTargetDto;
import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
import com.snp.batch.jobs.aistarget.batch.processor.AisTargetDataProcessor;
import com.snp.batch.jobs.aistarget.batch.reader.AisTargetDataReader;
import com.snp.batch.jobs.aistarget.batch.writer.AisTargetDataWriter;
import com.snp.batch.jobs.aistarget.classifier.Core20CacheManager;
import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.JobExecution;
import org.springframework.batch.core.JobExecutionListener;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.item.ItemProcessor;
import org.springframework.batch.item.ItemReader;
import org.springframework.batch.item.ItemWriter;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.web.reactive.function.client.WebClient;
import java.time.OffsetDateTime;
/**
* AIS Target Import Job Config
*
* 스케줄: 15초 (0 15 * * * * ?)
* API: POST /AisSvc.svc/AIS/GetTargets
* 파라미터: {"sinceSeconds": "60"}
*
* 동작:
* - 최근 60초 동안의 전체 선박 위치 정보 수집
* - 33,000건/ 처리
* - UPSERT 방식으로 DB 저장
*/
@Slf4j
@Configuration
public class AisTargetImportJobConfig extends BaseJobConfig<AisTargetDto, AisTargetEntity> {
private final AisTargetDataProcessor aisTargetDataProcessor;
private final AisTargetDataWriter aisTargetDataWriter;
private final WebClient maritimeAisApiWebClient;
private final ObjectMapper objectMapper;
private final Core20CacheManager core20CacheManager;
@Value("${app.batch.ais-target.since-seconds:60}")
private int sinceSeconds;
@Value("${app.batch.ais-target.chunk-size:5000}")
private int chunkSize;
public AisTargetImportJobConfig(
JobRepository jobRepository,
PlatformTransactionManager transactionManager,
AisTargetDataProcessor aisTargetDataProcessor,
AisTargetDataWriter aisTargetDataWriter,
@Qualifier("maritimeAisApiWebClient") WebClient maritimeAisApiWebClient,
ObjectMapper objectMapper,
Core20CacheManager core20CacheManager) {
super(jobRepository, transactionManager);
this.aisTargetDataProcessor = aisTargetDataProcessor;
this.aisTargetDataWriter = aisTargetDataWriter;
this.maritimeAisApiWebClient = maritimeAisApiWebClient;
this.objectMapper = objectMapper;
this.core20CacheManager = core20CacheManager;
}
@Override
protected String getJobName() {
return "aisTargetImportJob";
}
@Override
protected String getStepName() {
return "aisTargetImportStep";
}
@Override
protected ItemReader<AisTargetDto> createReader() {
return new AisTargetDataReader(maritimeAisApiWebClient, objectMapper, sinceSeconds);
}
@Override
protected ItemProcessor<AisTargetDto, AisTargetEntity> createProcessor() {
return aisTargetDataProcessor;
}
@Override
protected ItemWriter<AisTargetEntity> createWriter() {
return aisTargetDataWriter;
}
@Override
protected int getChunkSize() {
return chunkSize;
}
@Override
protected void configureJob(JobBuilder jobBuilder) {
jobBuilder.listener(new JobExecutionListener() {
@Override
public void beforeJob(JobExecution jobExecution) {
// 배치 수집 시점 설정
OffsetDateTime collectedAt = OffsetDateTime.now();
aisTargetDataProcessor.setCollectedAt(collectedAt);
log.info("[{}] Job 시작 (API → 캐시) - 수집 시점: {}", getJobName(), collectedAt);
// Core20 캐시 관리
// 1. 캐시가 비어있으면 즉시 로딩 ( 실행 또는 재시작 )
// 2. 지정된 시간대(기본 04:00)이면 일일 갱신 수행
if (!core20CacheManager.isLoaded()) {
log.info("[{}] Core20 캐시 초기 로딩 시작", getJobName());
core20CacheManager.refresh();
} else if (core20CacheManager.shouldRefresh()) {
log.info("[{}] Core20 캐시 일일 갱신 시작 (스케줄: {}시)",
getJobName(), core20CacheManager.getLastRefreshTime());
core20CacheManager.refresh();
}
}
@Override
public void afterJob(JobExecution jobExecution) {
log.info("[{}] Job 완료 (API → 캐시) - 상태: {}, 캐시 적재 건수: {}, Core20 캐시 크기: {}",
getJobName(),
jobExecution.getStatus(),
jobExecution.getStepExecutions().stream()
.mapToLong(se -> se.getWriteCount())
.sum(),
core20CacheManager.size());
}
});
}
@Bean(name = "aisTargetImportJob")
public Job aisTargetImportJob() {
return job();
}
}

파일 보기

@ -1,27 +0,0 @@
package com.snp.batch.jobs.aistarget.batch.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* AIS GetTargets API 응답 래퍼
*
* API 응답 구조:
* {
* "targetArr": [...]
* }
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AisTargetApiResponse {
@JsonProperty("targetEnhancedArr")
private List<AisTargetDto> targetArr;
}

파일 보기

@ -1,167 +0,0 @@
package com.snp.batch.jobs.aistarget.batch.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* AIS Target API 응답 DTO
*
* API: POST /AisSvc.svc/AIS/GetTargets
* Request: {"sinceSeconds": "60"}
* Response: {"targetArr": [...]}
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AisTargetDto {
@JsonProperty("MMSI")
private Long mmsi;
@JsonProperty("IMO")
private Long imo;
@JsonProperty("AgeMinutes")
private Double ageMinutes;
@JsonProperty("Lat")
private Double lat;
@JsonProperty("Lon")
private Double lon;
@JsonProperty("Heading")
private Double heading;
@JsonProperty("SoG")
private Double sog; // Speed over Ground
@JsonProperty("CoG")
private Double cog; // Course over Ground (if available)
@JsonProperty("Width")
private Integer width;
@JsonProperty("Length")
private Integer length;
@JsonProperty("Draught")
private Double draught;
@JsonProperty("Name")
private String name;
@JsonProperty("Callsign")
private String callsign;
@JsonProperty("Destination")
private String destination;
@JsonProperty("ETA")
private String eta;
@JsonProperty("Status")
private String status;
@JsonProperty("VesselType")
private String vesselType;
@JsonProperty("ExtraInfo")
private String extraInfo;
@JsonProperty("PositionAccuracy")
private Integer positionAccuracy;
@JsonProperty("RoT")
private Integer rot; // Rate of Turn
@JsonProperty("TimestampUTC")
private Integer timestampUtc;
@JsonProperty("RepeatIndicator")
private Integer repeatIndicator;
@JsonProperty("RAIMFlag")
private Integer raimFlag;
@JsonProperty("RadioStatus")
private Integer radioStatus;
@JsonProperty("Regional")
private Integer regional;
@JsonProperty("Regional2")
private Integer regional2;
@JsonProperty("Spare")
private Integer spare;
@JsonProperty("Spare2")
private Integer spare2;
@JsonProperty("AISVersion")
private Integer aisVersion;
@JsonProperty("PositionFixType")
private Integer positionFixType;
@JsonProperty("DTE")
private Integer dte;
@JsonProperty("BandFlag")
private Integer bandFlag;
@JsonProperty("ReceivedDate")
private String receivedDate;
@JsonProperty("MessageTimestamp")
private String messageTimestamp;
@JsonProperty("LengthBow")
private Integer lengthBow;
@JsonProperty("LengthStern")
private Integer lengthStern;
@JsonProperty("WidthPort")
private Integer widthPort;
@JsonProperty("WidthStarboard")
private Integer widthStarboard;
// TargetEnhanced 컬럼 추가
@JsonProperty("TonnesCargo")
private Integer tonnesCargo;
@JsonProperty("InSTS")
private Integer inSTS;
@JsonProperty("OnBerth")
private Boolean onBerth;
@JsonProperty("DWT")
private Integer dwt;
@JsonProperty("Anomalous")
private String anomalous;
@JsonProperty("DestinationPortID")
private Integer destinationPortID;
@JsonProperty("DestinationTidied")
private String destinationTidied;
@JsonProperty("DestinationUNLOCODE")
private String destinationUNLOCODE;
@JsonProperty("ImoVerified")
private String imoVerified;
@JsonProperty("LastStaticUpdateReceived")
private String lastStaticUpdateReceived;
@JsonProperty("LPCCode")
private Integer lpcCode;
@JsonProperty("MessageType")
private Integer messageType;
@JsonProperty("Source")
private String source;
@JsonProperty("StationId")
private String stationId;
@JsonProperty("ZoneId")
private Double zoneId;
}

파일 보기

@ -1,129 +0,0 @@
package com.snp.batch.jobs.aistarget.batch.entity;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.snp.batch.common.batch.entity.BaseEntity;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;
import java.time.OffsetDateTime;
/**
* AIS Target Entity
*
* 테이블: std_snp_data.ais_target
* PK: mmsi + message_timestamp (복합키)
*
* 용도:
* - 선박 위치 이력 저장 (항적 분석용)
* - 특정 시점/구역 선박 조회
* - LineString 항적 생성 기반 데이터
*/
@Data
@SuperBuilder
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode(callSuper = true)
public class AisTargetEntity extends BaseEntity {
// ========== PK (복합키) ==========
private Long mmsi;
private OffsetDateTime messageTimestamp;
// ========== 선박 식별 정보 ==========
private Long imo;
private String name;
private String callsign;
private String vesselType;
private String extraInfo;
// ========== 위치 정보 ==========
private Double lat;
private Double lon;
// geom은 DB에서 ST_SetSRID(ST_MakePoint(lon, lat), 4326) 생성
// ========== 항해 정보 ==========
private Double heading;
private Double sog; // Speed over Ground
private Double cog; // Course over Ground
private Integer rot; // Rate of Turn
// ========== 선박 제원 ==========
private Integer length;
private Integer width;
private Double draught;
private Integer lengthBow;
private Integer lengthStern;
private Integer widthPort;
private Integer widthStarboard;
// ========== 목적지 정보 ==========
private String destination;
private OffsetDateTime eta;
private String status;
// ========== AIS 메시지 정보 ==========
private Double ageMinutes;
private Integer positionAccuracy;
private Integer timestampUtc;
private Integer repeatIndicator;
private Integer raimFlag;
private Integer radioStatus;
private Integer regional;
private Integer regional2;
private Integer spare;
private Integer spare2;
private Integer aisVersion;
private Integer positionFixType;
private Integer dte;
private Integer bandFlag;
// ========== 타임스탬프 ==========
private OffsetDateTime receivedDate;
private OffsetDateTime collectedAt; // 배치 수집 시점
// ========== 선종 분류 정보 ==========
/**
* MDA 범례코드 (signalKindCode)
* - vesselType + extraInfo 기반으로 치환
* - : "000020"(어선), "000023"(카고), "000027"(일반/기타)
*/
private String signalKindCode;
// ========== ClassType 분류 정보 ==========
/**
* 선박 클래스 타입
* - "A": Core20에 등록된 선박 (Class A AIS 장착 의무 선박)
* - "B": Core20 미등록 선박 (Class B AIS 또는 미등록)
* - null: 미분류 (캐시 저장 )
*/
private String classType;
/**
* Core20 테이블의 MMSI
* - Class A인 경우에만 값이 있을 있음
* - Class A이지만 Core20에 MMSI가 없는 경우 null
* - Class B인 경우 항상 null
*/
private String core20Mmsi;
// TargetEnhanced 컬럼 추가
private Integer tonnesCargo;
private Integer inSTS;
private Boolean onBerth;
private Integer dwt;
private String anomalous;
private Integer destinationPortID;
private String destinationTidied;
private String destinationUNLOCODE;
private String imoVerified;
private OffsetDateTime lastStaticUpdateReceived;
private Integer lpcCode;
private Integer messageType;
private String source;
private String stationId;
private Double zoneId;
}

파일 보기

@ -1,137 +0,0 @@
package com.snp.batch.jobs.aistarget.batch.processor;
import com.snp.batch.common.batch.processor.BaseProcessor;
import com.snp.batch.jobs.aistarget.batch.dto.AisTargetDto;
import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.time.OffsetDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
/**
* AIS Target 데이터 Processor
*
* DTO Entity 변환
* - 타임스탬프 파싱
* - 필터링 (유효한 위치 정보만)
*/
@Slf4j
@Component
public class AisTargetDataProcessor extends BaseProcessor<AisTargetDto, AisTargetEntity> {
private static final DateTimeFormatter ISO_FORMATTER = DateTimeFormatter.ISO_DATE_TIME;
// 배치 수집 시점 (모든 레코드에 동일하게 적용)
private OffsetDateTime collectedAt;
public void setCollectedAt(OffsetDateTime collectedAt) {
this.collectedAt = collectedAt;
}
@Override
protected AisTargetEntity processItem(AisTargetDto dto) throws Exception {
// 유효성 검사: MMSI와 위치 정보는 필수
if (dto.getMmsi() == null || dto.getLat() == null || dto.getLon() == null) {
log.debug("유효하지 않은 데이터 스킵 - MMSI: {}, Lat: {}, Lon: {}",
dto.getMmsi(), dto.getLat(), dto.getLon());
return null;
}
// MessageTimestamp 파싱 (PK의 일부)
OffsetDateTime messageTimestamp = parseTimestamp(dto.getMessageTimestamp());
if (messageTimestamp == null) {
log.debug("MessageTimestamp 파싱 실패 - MMSI: {}, Timestamp: {}",
dto.getMmsi(), dto.getMessageTimestamp());
return null;
}
return AisTargetEntity.builder()
// PK
.mmsi(dto.getMmsi())
.messageTimestamp(messageTimestamp)
// 선박 식별 정보
.imo(dto.getImo())
.name(dto.getName())
.callsign(dto.getCallsign())
.vesselType(dto.getVesselType())
.extraInfo(dto.getExtraInfo())
// 위치 정보
.lat(dto.getLat())
.lon(dto.getLon())
// 항해 정보
.heading(dto.getHeading())
.sog(dto.getSog())
.cog(dto.getCog())
.rot(dto.getRot())
// 선박 제원
.length(dto.getLength())
.width(dto.getWidth())
.draught(dto.getDraught())
.lengthBow(dto.getLengthBow())
.lengthStern(dto.getLengthStern())
.widthPort(dto.getWidthPort())
.widthStarboard(dto.getWidthStarboard())
// 목적지 정보
.destination(dto.getDestination())
.eta(parseEta(dto.getEta()))
.status(dto.getStatus())
// AIS 메시지 정보
.ageMinutes(dto.getAgeMinutes())
.positionAccuracy(dto.getPositionAccuracy())
.timestampUtc(dto.getTimestampUtc())
.repeatIndicator(dto.getRepeatIndicator())
.raimFlag(dto.getRaimFlag())
.radioStatus(dto.getRadioStatus())
.regional(dto.getRegional())
.regional2(dto.getRegional2())
.spare(dto.getSpare())
.spare2(dto.getSpare2())
.aisVersion(dto.getAisVersion())
.positionFixType(dto.getPositionFixType())
.dte(dto.getDte())
.bandFlag(dto.getBandFlag())
// 타임스탬프
.receivedDate(parseTimestamp(dto.getReceivedDate()))
.collectedAt(collectedAt != null ? collectedAt : OffsetDateTime.now())
// TargetEnhanced 컬럼 추가
.tonnesCargo(dto.getTonnesCargo())
.inSTS(dto.getInSTS())
.onBerth(dto.getOnBerth())
.dwt(dto.getDwt())
.anomalous(dto.getAnomalous())
.destinationPortID(dto.getDestinationPortID())
.destinationTidied(dto.getDestinationTidied())
.destinationUNLOCODE(dto.getDestinationUNLOCODE())
.imoVerified(dto.getImoVerified())
.lastStaticUpdateReceived(parseTimestamp(dto.getLastStaticUpdateReceived()))
.lpcCode(dto.getLpcCode())
.messageType(dto.getMessageType())
.source(dto.getSource())
.stationId(dto.getStationId())
.zoneId(dto.getZoneId())
.build();
}
private OffsetDateTime parseTimestamp(String timestamp) {
if (timestamp == null || timestamp.isEmpty()) {
return null;
}
try {
// ISO 8601 형식 파싱 (: "2025-12-01T23:55:01.073Z")
return OffsetDateTime.parse(timestamp, ISO_FORMATTER);
} catch (DateTimeParseException e) {
log.trace("타임스탬프 파싱 실패: {}", timestamp);
return null;
}
}
private OffsetDateTime parseEta(String eta) {
if (eta == null || eta.isEmpty() || "9999-12-31T23:59:59Z".equals(eta)) {
return null; // 유효하지 않은 ETA는 null 처리
}
return parseTimestamp(eta);
}
}

파일 보기

@ -1,158 +0,0 @@
package com.snp.batch.jobs.aistarget.batch.reader;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.snp.batch.common.batch.reader.BaseApiReader;
import com.snp.batch.jobs.aistarget.batch.dto.AisTargetApiResponse;
import com.snp.batch.jobs.aistarget.batch.dto.AisTargetDto;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.core.io.buffer.DataBufferUtils;
import org.springframework.web.reactive.function.client.WebClient;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.Collections;
import java.util.List;
import java.util.Map;
/**
* AIS Target 데이터 Reader
*
* API: POST /AisSvc.svc/AIS/GetTargetsEnhanced
* Request: {"sinceSeconds": "60"}
*
* 동작:
* - 15초에 실행 (Quartz 스케줄)
* - 최근 60초 동안의 전체 선박 위치 정보 조회
* - 33,000건/ 처리
* - 대용량 응답을 임시 파일로 스트리밍하여 메모리 버퍼 제한 우회
*/
@Slf4j
public class AisTargetDataReader extends BaseApiReader<AisTargetDto> {
private final int sinceSeconds;
private final ObjectMapper objectMapper;
public AisTargetDataReader(WebClient webClient, ObjectMapper objectMapper, int sinceSeconds) {
super(webClient);
this.objectMapper = objectMapper;
this.sinceSeconds = sinceSeconds;
}
@Override
protected String getReaderName() {
return "AisTargetDataReader";
}
@Override
protected String getApiPath() {
return "/AisSvc.svc/AIS/GetTargetsEnhanced";
}
@Override
protected String getHttpMethod() {
return "POST";
}
@Override
protected Object getRequestBody() {
return Map.of("sinceSeconds", String.valueOf(sinceSeconds));
}
@Override
protected Class<?> getResponseType() {
return AisTargetApiResponse.class;
}
@Override
protected void beforeFetch() {
log.info("[{}] AIS GetTargets API 호출 준비 - sinceSeconds: {}", getReaderName(), sinceSeconds);
}
@Override
protected List<AisTargetDto> fetchDataFromApi() {
Path tempFile = null;
try {
log.info("[{}] API 호출 시작 (스트리밍 모드): {} {}", getReaderName(), getHttpMethod(), getApiPath());
tempFile = Files.createTempFile("ais-response-", ".json");
// 응답을 DataBuffer 스트림으로 받아 임시 파일에 기록
long bytesWritten = streamResponseToFile(tempFile);
log.info("[{}] 응답 스트리밍 완료: {} bytes → {}", getReaderName(), bytesWritten, tempFile.getFileName());
// 임시 파일에서 JSON 파싱
AisTargetApiResponse response = parseResponseFromFile(tempFile);
if (response != null && response.getTargetArr() != null) {
List<AisTargetDto> targets = response.getTargetArr();
log.info("[{}] API 호출 완료: {} 건 조회", getReaderName(), targets.size());
updateApiCallStats(1, 1);
return targets;
} else {
log.warn("[{}] API 응답이 비어있습니다", getReaderName());
return Collections.emptyList();
}
} catch (Exception e) {
log.error("[{}] API 호출 실패: {}", getReaderName(), e.getMessage(), e);
return handleApiError(e);
} finally {
deleteTempFile(tempFile);
}
}
/**
* WebClient 응답을 DataBuffer 스트림으로 받아 임시 파일에 기록한다.
* bodyToMono() 달리 메모리 버퍼 제한(maxInMemorySize) 영향을 받지 않는다.
*/
private long streamResponseToFile(Path tempFile) throws IOException {
try (OutputStream os = Files.newOutputStream(tempFile, StandardOpenOption.WRITE)) {
webClient.post()
.uri(getApiPath())
.bodyValue(getRequestBody())
.retrieve()
.bodyToFlux(DataBuffer.class)
.doOnNext(dataBuffer -> {
try {
byte[] bytes = new byte[dataBuffer.readableByteCount()];
dataBuffer.read(bytes);
os.write(bytes);
} catch (IOException e) {
throw new RuntimeException("임시 파일 쓰기 실패", e);
} finally {
DataBufferUtils.release(dataBuffer);
}
})
.blockLast();
}
return Files.size(tempFile);
}
private AisTargetApiResponse parseResponseFromFile(Path tempFile) throws IOException {
try (InputStream is = Files.newInputStream(tempFile)) {
return objectMapper.readValue(is, AisTargetApiResponse.class);
}
}
private void deleteTempFile(Path tempFile) {
if (tempFile != null) {
try {
Files.deleteIfExists(tempFile);
} catch (IOException e) {
log.warn("[{}] 임시 파일 삭제 실패: {}", getReaderName(), tempFile, e);
}
}
}
@Override
protected void afterFetch(List<AisTargetDto> data) {
if (data != null && !data.isEmpty()) {
log.info("[{}] 데이터 조회 완료 - 총 {} 건", getReaderName(), data.size());
}
}
}

파일 보기

@ -1,67 +0,0 @@
package com.snp.batch.jobs.aistarget.batch.repository;
import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Optional;
/**
* AIS Target Repository 인터페이스
*/
public interface AisTargetRepository {
/**
* 복합키로 조회 (MMSI + MessageTimestamp)
*/
Optional<AisTargetEntity> findByMmsiAndMessageTimestamp(Long mmsi, OffsetDateTime messageTimestamp);
/**
* MMSI로 최신 위치 조회
*/
Optional<AisTargetEntity> findLatestByMmsi(Long mmsi);
/**
* 여러 MMSI의 최신 위치 조회
*/
List<AisTargetEntity> findLatestByMmsiIn(List<Long> mmsiList);
/**
* 여러 MMSI의 최신 위치 조회 (시간 범위 필터)
*
* @param mmsiList 대상 MMSI 목록
* @param since 시점 이후 데이터만 조회
*/
List<AisTargetEntity> findLatestByMmsiInSince(List<Long> mmsiList, OffsetDateTime since);
/**
* 시간 범위 특정 MMSI의 항적 조회
*/
List<AisTargetEntity> findByMmsiAndTimeRange(Long mmsi, OffsetDateTime start, OffsetDateTime end);
/**
* 시간 범위 + 공간 범위 선박 조회
*/
List<AisTargetEntity> findByTimeRangeAndArea(
OffsetDateTime start,
OffsetDateTime end,
Double centerLon,
Double centerLat,
Double radiusMeters
);
/**
* 배치 INSERT (UPSERT)
*/
void batchUpsert(List<AisTargetEntity> entities);
/**
* 전체 건수 조회
*/
long count();
/**
* 오래된 데이터 삭제 (보존 기간 이전 데이터)
*/
int deleteOlderThan(OffsetDateTime threshold);
}

파일 보기

@ -1,409 +0,0 @@
package com.snp.batch.jobs.aistarget.batch.repository;
import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Optional;
/**
* AIS Target Repository 구현체
*
* 테이블: {targetSchema}.ais_target
* PK: mmsi + message_timestamp (복합키)
*/
@Slf4j
@Repository
public class AisTargetRepositoryImpl implements AisTargetRepository {
private final JdbcTemplate jdbcTemplate;
private final String tableName;
private final String upsertSql;
public AisTargetRepositoryImpl(JdbcTemplate jdbcTemplate,
@Value("${app.batch.target-schema.name}") String targetSchema) {
this.jdbcTemplate = jdbcTemplate;
this.tableName = targetSchema + ".ais_target";
this.upsertSql = buildUpsertSql(targetSchema);
}
private String buildUpsertSql(String schema) {
return """
INSERT INTO %s.ais_target (
mmsi, message_timestamp, imo, name, callsign, vessel_type, extra_info,
lat, lon, geom,
heading, sog, cog, rot,
length, width, draught, length_bow, length_stern, width_port, width_starboard,
destination, eta, status,
age_minutes, position_accuracy, timestamp_utc, repeat_indicator, raim_flag,
radio_status, regional, regional2, spare, spare2,
ais_version, position_fix_type, dte, band_flag,
received_date, collected_at, created_at, updated_at,
tonnes_cargo, in_sts, on_berth, dwt, anomalous,
destination_port_id, destination_tidied, destination_unlocode, imo_verified, last_static_update_received,
lpc_code, message_type, "source", station_id, zone_id
) VALUES (
?, ?, ?, ?, ?, ?, ?,
?, ?, ST_SetSRID(ST_MakePoint(?, ?), 4326),
?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?,
?, ?, ?,
?, ?, ?, ?, ?,
?, ?, ?, ?, ?,
?, ?, ?, ?,
?, ?, NOW(), NOW(),
?, ?, ?, ?, ?,
?, ?, ?, ?, ?,
?, ?, ?, ?, ?
)
ON CONFLICT (mmsi, message_timestamp) DO UPDATE SET
imo = EXCLUDED.imo,
name = EXCLUDED.name,
callsign = EXCLUDED.callsign,
vessel_type = EXCLUDED.vessel_type,
extra_info = EXCLUDED.extra_info,
lat = EXCLUDED.lat,
lon = EXCLUDED.lon,
geom = EXCLUDED.geom,
heading = EXCLUDED.heading,
sog = EXCLUDED.sog,
cog = EXCLUDED.cog,
rot = EXCLUDED.rot,
length = EXCLUDED.length,
width = EXCLUDED.width,
draught = EXCLUDED.draught,
length_bow = EXCLUDED.length_bow,
length_stern = EXCLUDED.length_stern,
width_port = EXCLUDED.width_port,
width_starboard = EXCLUDED.width_starboard,
destination = EXCLUDED.destination,
eta = EXCLUDED.eta,
status = EXCLUDED.status,
age_minutes = EXCLUDED.age_minutes,
position_accuracy = EXCLUDED.position_accuracy,
timestamp_utc = EXCLUDED.timestamp_utc,
repeat_indicator = EXCLUDED.repeat_indicator,
raim_flag = EXCLUDED.raim_flag,
radio_status = EXCLUDED.radio_status,
regional = EXCLUDED.regional,
regional2 = EXCLUDED.regional2,
spare = EXCLUDED.spare,
spare2 = EXCLUDED.spare2,
ais_version = EXCLUDED.ais_version,
position_fix_type = EXCLUDED.position_fix_type,
dte = EXCLUDED.dte,
band_flag = EXCLUDED.band_flag,
received_date = EXCLUDED.received_date,
collected_at = EXCLUDED.collected_at,
updated_at = NOW(),
tonnes_cargo = EXCLUDED.tonnes_cargo,
in_sts = EXCLUDED.in_sts,
on_berth = EXCLUDED.on_berth,
dwt = EXCLUDED.dwt,
anomalous = EXCLUDED.anomalous,
destination_port_id = EXCLUDED.destination_port_id,
destination_tidied = EXCLUDED.destination_tidied,
destination_unlocode = EXCLUDED.destination_unlocode,
imo_verified = EXCLUDED.imo_verified,
last_static_update_received = EXCLUDED.last_static_update_received,
lpc_code = EXCLUDED.lpc_code,
message_type = EXCLUDED.message_type,
"source" = EXCLUDED."source",
station_id = EXCLUDED.station_id,
zone_id = EXCLUDED.zone_id
""".formatted(schema);
}
// ==================== RowMapper ====================
private final RowMapper<AisTargetEntity> rowMapper = (rs, rowNum) -> AisTargetEntity.builder()
.mmsi(rs.getLong("mmsi"))
.messageTimestamp(toOffsetDateTime(rs.getTimestamp("message_timestamp")))
.imo(toLong(rs, "imo"))
.name(rs.getString("name"))
.callsign(rs.getString("callsign"))
.vesselType(rs.getString("vessel_type"))
.extraInfo(rs.getString("extra_info"))
.lat(rs.getObject("lat", Double.class))
.lon(rs.getObject("lon", Double.class))
.heading(rs.getObject("heading", Double.class))
.sog(rs.getObject("sog", Double.class))
.cog(rs.getObject("cog", Double.class))
.rot(toInt(rs, "rot"))
.length(toInt(rs, "length"))
.width(toInt(rs, "width"))
.draught(rs.getObject("draught", Double.class))
.lengthBow(toInt(rs, "length_bow"))
.lengthStern(toInt(rs, "length_stern"))
.widthPort(toInt(rs, "width_port"))
.widthStarboard(toInt(rs, "width_starboard"))
.destination(rs.getString("destination"))
.eta(toOffsetDateTime(rs.getTimestamp("eta")))
.status(rs.getString("status"))
.ageMinutes(rs.getObject("age_minutes", Double.class))
.positionAccuracy(toInt(rs, "position_accuracy"))
.timestampUtc(toInt(rs, "timestamp_utc"))
.repeatIndicator(toInt(rs, "repeat_indicator"))
.raimFlag(toInt(rs, "raim_flag"))
.radioStatus(toInt(rs, "radio_status"))
.regional(toInt(rs, "regional"))
.regional2(toInt(rs, "regional2"))
.spare(toInt(rs, "spare"))
.spare2(toInt(rs, "spare2"))
.aisVersion(toInt(rs, "ais_version"))
.positionFixType(toInt(rs, "position_fix_type"))
.dte(toInt(rs, "dte"))
.bandFlag(toInt(rs, "band_flag"))
.receivedDate(toOffsetDateTime(rs.getTimestamp("received_date")))
.collectedAt(toOffsetDateTime(rs.getTimestamp("collected_at")))
.tonnesCargo(toInt(rs, "tonnes_cargo"))
.inSTS(toInt(rs, "in_sts"))
.onBerth(rs.getObject("on_berth", Boolean.class))
.dwt(toInt(rs, "dwt"))
.anomalous(rs.getString("anomalous"))
.destinationPortID(toInt(rs, "destination_port_id"))
.destinationTidied(rs.getString("destination_tidied"))
.destinationUNLOCODE(rs.getString("destination_unlocode"))
.imoVerified(rs.getString("imo_verified"))
.lastStaticUpdateReceived(toOffsetDateTime(rs.getTimestamp("last_static_update_received")))
.lpcCode(toInt(rs, "lpc_code"))
.messageType(toInt(rs, "message_type"))
.source(rs.getString("source"))
.stationId(rs.getString("station_id"))
.zoneId(rs.getObject("zone_id", Double.class))
.build();
// ==================== Repository Methods ====================
@Override
public Optional<AisTargetEntity> findByMmsiAndMessageTimestamp(Long mmsi, OffsetDateTime messageTimestamp) {
String sql = "SELECT * FROM " + tableName + " WHERE mmsi = ? AND message_timestamp = ?";
List<AisTargetEntity> results = jdbcTemplate.query(sql, rowMapper, mmsi, toTimestamp(messageTimestamp));
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
}
@Override
public Optional<AisTargetEntity> findLatestByMmsi(Long mmsi) {
String sql = """
SELECT * FROM %s
WHERE mmsi = ?
ORDER BY message_timestamp DESC
LIMIT 1
""".formatted(tableName);
List<AisTargetEntity> results = jdbcTemplate.query(sql, rowMapper, mmsi);
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
}
@Override
public List<AisTargetEntity> findLatestByMmsiIn(List<Long> mmsiList) {
if (mmsiList == null || mmsiList.isEmpty()) {
return List.of();
}
// DISTINCT ON을 사용하여 MMSI별 최신 레코드 조회
String sql = """
SELECT DISTINCT ON (mmsi) *
FROM %s
WHERE mmsi = ANY(?)
ORDER BY mmsi, message_timestamp DESC
""".formatted(tableName);
Long[] mmsiArray = mmsiList.toArray(new Long[0]);
return jdbcTemplate.query(sql, rowMapper, (Object) mmsiArray);
}
@Override
public List<AisTargetEntity> findLatestByMmsiInSince(List<Long> mmsiList, OffsetDateTime since) {
if (mmsiList == null || mmsiList.isEmpty()) {
return List.of();
}
String sql = """
SELECT DISTINCT ON (mmsi) *
FROM %s
WHERE mmsi = ANY(?)
AND message_timestamp >= ?
ORDER BY mmsi, message_timestamp DESC
""".formatted(tableName);
Long[] mmsiArray = mmsiList.toArray(new Long[0]);
return jdbcTemplate.query(sql, rowMapper, (Object) mmsiArray, toTimestamp(since));
}
@Override
public List<AisTargetEntity> findByMmsiAndTimeRange(Long mmsi, OffsetDateTime start, OffsetDateTime end) {
String sql = """
SELECT * FROM %s
WHERE mmsi = ?
AND message_timestamp BETWEEN ? AND ?
ORDER BY message_timestamp ASC
""".formatted(tableName);
return jdbcTemplate.query(sql, rowMapper, mmsi, toTimestamp(start), toTimestamp(end));
}
@Override
public List<AisTargetEntity> findByTimeRangeAndArea(
OffsetDateTime start,
OffsetDateTime end,
Double centerLon,
Double centerLat,
Double radiusMeters
) {
String sql = """
SELECT DISTINCT ON (mmsi) *
FROM %s
WHERE message_timestamp BETWEEN ? AND ?
AND ST_DWithin(
geom::geography,
ST_SetSRID(ST_MakePoint(?, ?), 4326)::geography,
?
)
ORDER BY mmsi, message_timestamp DESC
""".formatted(tableName);
return jdbcTemplate.query(sql, rowMapper,
toTimestamp(start), toTimestamp(end),
centerLon, centerLat, radiusMeters);
}
@Override
@Transactional
public void batchUpsert(List<AisTargetEntity> entities) {
if (entities == null || entities.isEmpty()) {
return;
}
log.info("AIS Target 배치 UPSERT 시작: {} 건", entities.size());
jdbcTemplate.batchUpdate(upsertSql, entities, 1000, (ps, entity) -> {
int idx = 1;
// PK
ps.setLong(idx++, entity.getMmsi());
ps.setTimestamp(idx++, toTimestamp(entity.getMessageTimestamp()));
// 선박 식별 정보
ps.setObject(idx++, entity.getImo());
ps.setString(idx++, truncate(entity.getName(), 100));
ps.setString(idx++, truncate(entity.getCallsign(), 20));
ps.setString(idx++, truncate(entity.getVesselType(), 50));
ps.setString(idx++, truncate(entity.getExtraInfo(), 100));
// 위치 정보
ps.setObject(idx++, entity.getLat());
ps.setObject(idx++, entity.getLon());
// geom용 lon, lat
ps.setObject(idx++, entity.getLon());
ps.setObject(idx++, entity.getLat());
// 항해 정보
ps.setObject(idx++, entity.getHeading());
ps.setObject(idx++, entity.getSog());
ps.setObject(idx++, entity.getCog());
ps.setObject(idx++, entity.getRot());
// 선박 제원
ps.setObject(idx++, entity.getLength());
ps.setObject(idx++, entity.getWidth());
ps.setObject(idx++, entity.getDraught());
ps.setObject(idx++, entity.getLengthBow());
ps.setObject(idx++, entity.getLengthStern());
ps.setObject(idx++, entity.getWidthPort());
ps.setObject(idx++, entity.getWidthStarboard());
// 목적지 정보
ps.setString(idx++, truncate(entity.getDestination(), 200));
ps.setTimestamp(idx++, toTimestamp(entity.getEta()));
ps.setString(idx++, truncate(entity.getStatus(), 50));
// AIS 메시지 정보
ps.setObject(idx++, entity.getAgeMinutes());
ps.setObject(idx++, entity.getPositionAccuracy());
ps.setObject(idx++, entity.getTimestampUtc());
ps.setObject(idx++, entity.getRepeatIndicator());
ps.setObject(idx++, entity.getRaimFlag());
ps.setObject(idx++, entity.getRadioStatus());
ps.setObject(idx++, entity.getRegional());
ps.setObject(idx++, entity.getRegional2());
ps.setObject(idx++, entity.getSpare());
ps.setObject(idx++, entity.getSpare2());
ps.setObject(idx++, entity.getAisVersion());
ps.setObject(idx++, entity.getPositionFixType());
ps.setObject(idx++, entity.getDte());
ps.setObject(idx++, entity.getBandFlag());
// 타임스탬프
ps.setTimestamp(idx++, toTimestamp(entity.getReceivedDate()));
ps.setTimestamp(idx++, toTimestamp(entity.getCollectedAt()));
// TargetEnhanced 컬럼 추가
ps.setObject(idx++, entity.getTonnesCargo());
ps.setObject(idx++, entity.getInSTS());
ps.setObject(idx++, entity.getOnBerth());
ps.setObject(idx++, entity.getDwt());
ps.setObject(idx++, entity.getAnomalous());
ps.setObject(idx++, entity.getDestinationPortID());
ps.setObject(idx++, entity.getDestinationTidied());
ps.setObject(idx++, entity.getDestinationUNLOCODE());
ps.setObject(idx++, entity.getImoVerified());
ps.setTimestamp(idx++, toTimestamp(entity.getLastStaticUpdateReceived()));
ps.setObject(idx++, entity.getLpcCode());
ps.setObject(idx++, entity.getMessageType());
ps.setObject(idx++, entity.getSource());
ps.setObject(idx++, entity.getStationId());
ps.setObject(idx++, entity.getZoneId());
});
log.info("AIS Target 배치 UPSERT 완료: {} 건", entities.size());
}
@Override
public long count() {
String sql = "SELECT COUNT(*) FROM " + tableName;
Long count = jdbcTemplate.queryForObject(sql, Long.class);
return count != null ? count : 0L;
}
@Override
@Transactional
public int deleteOlderThan(OffsetDateTime threshold) {
String sql = "DELETE FROM " + tableName + " WHERE message_timestamp < ?";
int deleted = jdbcTemplate.update(sql, toTimestamp(threshold));
log.info("AIS Target 오래된 데이터 삭제 완료: {} 건 (기준: {})", deleted, threshold);
return deleted;
}
// ==================== Helper Methods ====================
/**
* int8(bigint) Integer 안전 변환
* PostgreSQL JDBC 드라이버는 int8 Integer 자동 변환을 지원하지 않아
* getObject("col", Integer.class) 사용 오류 발생. Number로 읽어서 변환.
*/
private Integer toInt(ResultSet rs, String column) throws SQLException {
Object val = rs.getObject(column);
if (val == null) return null;
return ((Number) val).intValue();
}
private Long toLong(ResultSet rs, String column) throws SQLException {
Object val = rs.getObject(column);
if (val == null) return null;
return ((Number) val).longValue();
}
private Timestamp toTimestamp(OffsetDateTime odt) {
return odt != null ? Timestamp.from(odt.toInstant()) : null;
}
private OffsetDateTime toOffsetDateTime(Timestamp ts) {
return ts != null ? ts.toInstant().atOffset(ZoneOffset.UTC) : null;
}
private String truncate(String value, int maxLength) {
if (value == null) return null;
return value.length() > maxLength ? value.substring(0, maxLength) : value;
}
}

파일 보기

@ -1,90 +0,0 @@
package com.snp.batch.jobs.aistarget.batch.writer;
import com.snp.batch.common.batch.writer.BaseWriter;
import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
import com.snp.batch.jobs.aistarget.cache.AisTargetCacheManager;
import com.snp.batch.jobs.aistarget.chnprmship.ChnPrmShipCacheManager;
import com.snp.batch.jobs.aistarget.classifier.AisClassTypeClassifier;
import com.snp.batch.jobs.aistarget.classifier.SignalKindCode;
import com.snp.batch.jobs.aistarget.kafka.AisTargetKafkaProducer;
import lombok.extern.slf4j.Slf4j;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* AIS Target 데이터 Writer (캐시 전용)
*
* 동작:
* 1. ClassType 분류 (Core20 캐시 기반 A/B 분류)
* 2. SignalKindCode 치환 (vesselType + extraInfo MDA 범례코드)
* 3. 캐시에 최신 위치 정보 업데이트 (classType, core20Mmsi, signalKindCode 포함)
* 4. ChnPrmShip 전용 캐시 업데이트 (대상 MMSI만 필터)
* 5. Kafka 토픽으로 AIS Target 정보 전송 (활성화된 경우에만)
*
* 참고:
* - DB 저장은 별도 Job(aisTargetDbSyncJob)에서 15분 주기로 수행
* - Kafka 전송 실패는 기본적으로 로그만 남기고 다음 처리 계속
* - Kafka가 비활성화(enabled=false)이면 kafkaProducer가 null이므로 전송 단계를 스킵
*/
@Slf4j
@Component
public class AisTargetDataWriter extends BaseWriter<AisTargetEntity> {
private final AisTargetCacheManager cacheManager;
private final AisClassTypeClassifier classTypeClassifier;
@Nullable
private final AisTargetKafkaProducer kafkaProducer;
private final ChnPrmShipCacheManager chnPrmShipCacheManager;
public AisTargetDataWriter(
AisTargetCacheManager cacheManager,
AisClassTypeClassifier classTypeClassifier,
@Nullable AisTargetKafkaProducer kafkaProducer,
ChnPrmShipCacheManager chnPrmShipCacheManager) {
super("AisTarget");
this.cacheManager = cacheManager;
this.classTypeClassifier = classTypeClassifier;
this.kafkaProducer = kafkaProducer;
this.chnPrmShipCacheManager = chnPrmShipCacheManager;
}
@Override
protected void writeItems(List<AisTargetEntity> items) throws Exception {
log.info("AIS Target 캐시 업데이트 시작: {} 건", items.size());
// 1. ClassType 분류 (캐시 저장 전에 분류)
// - Core20 캐시의 IMO와 매칭하여 classType(A/B), core20Mmsi 설정
classTypeClassifier.classifyAll(items);
// 2. SignalKindCode 치환 (vesselType + extraInfo MDA 범례코드)
items.forEach(item -> {
SignalKindCode kindCode = SignalKindCode.resolve(item.getVesselType(), item.getExtraInfo());
item.setSignalKindCode(kindCode.getCode());
});
// 3. 캐시 업데이트 (classType, core20Mmsi, signalKindCode 포함)
cacheManager.putAll(items);
log.info("AIS Target 캐시 업데이트 완료: {} 건 (캐시 크기: {})",
items.size(), cacheManager.size());
// 4. ChnPrmShip 전용 캐시 업데이트 (대상 MMSI만 필터)
chnPrmShipCacheManager.putIfTarget(items);
// 5. Kafka 전송 (kafkaProducer 빈이 존재하는 경우에만)
if (kafkaProducer == null) {
log.debug("AIS Kafka Producer 미등록 - topic 전송 스킵");
return;
}
AisTargetKafkaProducer.PublishSummary summary = kafkaProducer.publish(items);
log.info("AIS Kafka 전송 완료 - topic: {}, 요청: {}, 성공: {}, 실패: {}, 스킵: {}",
kafkaProducer.getTopic(),
summary.getRequestedCount(),
summary.getSuccessCount(),
summary.getFailedCount(),
summary.getSkippedCount());
}
}

파일 보기

@ -1,272 +0,0 @@
package com.snp.batch.jobs.aistarget.cache;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.github.benmanes.caffeine.cache.RemovalCause;
import com.github.benmanes.caffeine.cache.stats.CacheStats;
import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import jakarta.annotation.PostConstruct;
import java.time.OffsetDateTime;
import java.util.*;
import java.util.concurrent.TimeUnit;
/**
* AIS Target 캐시 매니저 (Caffeine 기반)
*
* Caffeine 캐시의 장점:
* - 고성능: ConcurrentHashMap 대비 우수한 성능
* - 자동 만료: expireAfterWrite/expireAfterAccess 내장
* - 최대 크기 제한: maximumSize + LRU/LFU 자동 정리
* - 통계: 히트율, 미스율, 로드 시간 상세 통계
* - 비동기 지원: AsyncCache로 비동기 로딩 가능
*
* 동작:
* - 배치 Writer에서 DB 저장과 동시에 캐시 업데이트
* - API 조회 캐시 우선 조회
* - 캐시 미스 DB 조회 캐시 갱신
* - TTL: 마지막 쓰기 이후 N분 자동 만료
*/
@Slf4j
@Component
public class AisTargetCacheManager {
private Cache<Long, AisTargetEntity> cache;
@Value("${app.batch.ais-target-cache.ttl-minutes:5}")
private long ttlMinutes;
@Value("${app.batch.ais-target-cache.max-size:100000}")
private int maxSize;
@PostConstruct
public void init() {
this.cache = Caffeine.newBuilder()
// 최대 캐시 크기 (초과 LRU 방식으로 정리)
.maximumSize(maxSize)
// 마지막 쓰기 이후 TTL (데이터 업데이트 자동 갱신)
.expireAfterWrite(ttlMinutes, TimeUnit.MINUTES)
// 통계 수집 활성화
.recordStats()
// 제거 리스너 (디버깅/모니터링용)
.removalListener((Long key, AisTargetEntity value, RemovalCause cause) -> {
if (cause != RemovalCause.REPLACED) {
log.trace("캐시 제거 - MMSI: {}, 원인: {}", key, cause);
}
})
.build();
log.info("AIS Target Caffeine 캐시 초기화 - TTL: {}분, 최대 크기: {}", ttlMinutes, maxSize);
}
// ==================== 단건 조회/업데이트 ====================
/**
* 캐시에서 최신 위치 조회
*
* @param mmsi MMSI 번호
* @return 캐시된 데이터 (없으면 Optional.empty)
*/
public Optional<AisTargetEntity> get(Long mmsi) {
AisTargetEntity entity = cache.getIfPresent(mmsi);
return Optional.ofNullable(entity);
}
/**
* 캐시에 데이터 저장/업데이트
* - 기존 데이터보다 최신인 경우에만 업데이트
* - 업데이트 TTL 자동 갱신 (expireAfterWrite)
*
* @param entity AIS Target 엔티티
*/
public void put(AisTargetEntity entity) {
if (entity == null || entity.getMmsi() == null) {
return;
}
Long mmsi = entity.getMmsi();
AisTargetEntity existing = cache.getIfPresent(mmsi);
// 기존 데이터보다 최신인 경우에만 업데이트
if (existing == null || isNewer(entity, existing)) {
cache.put(mmsi, entity);
log.trace("캐시 저장 - MMSI: {}", mmsi);
}
}
// ==================== 배치 조회/업데이트 ====================
/**
* 여러 MMSI의 최신 위치 조회
*
* @param mmsiList MMSI 목록
* @return 캐시에서 찾은 데이터 (MMSI -> Entity)
*/
public Map<Long, AisTargetEntity> getAll(List<Long> mmsiList) {
if (mmsiList == null || mmsiList.isEmpty()) {
return Collections.emptyMap();
}
// Caffeine의 getAllPresent는 존재하는 키만 반환
Map<Long, AisTargetEntity> result = cache.getAllPresent(mmsiList);
log.debug("캐시 배치 조회 - 요청: {}, 히트: {}",
mmsiList.size(), result.size());
return result;
}
/**
* 여러 데이터 일괄 저장/업데이트 (배치 Writer에서 호출)
*
* @param entities AIS Target 엔티티 목록
*/
public void putAll(List<AisTargetEntity> entities) {
if (entities == null || entities.isEmpty()) {
return;
}
int updated = 0;
int skipped = 0;
for (AisTargetEntity entity : entities) {
if (entity == null || entity.getMmsi() == null) {
continue;
}
Long mmsi = entity.getMmsi();
AisTargetEntity existing = cache.getIfPresent(mmsi);
// 기존 데이터보다 최신인 경우에만 업데이트
if (existing == null || isNewer(entity, existing)) {
cache.put(mmsi, entity);
updated++;
} else {
skipped++;
}
}
log.debug("캐시 배치 업데이트 - 입력: {}, 업데이트: {}, 스킵: {}, 현재 크기: {}",
entities.size(), updated, skipped, cache.estimatedSize());
}
// ==================== 캐시 관리 ====================
/**
* 특정 MMSI 캐시 삭제
*/
public void evict(Long mmsi) {
cache.invalidate(mmsi);
}
/**
* 여러 MMSI 캐시 삭제
*/
public void evictAll(List<Long> mmsiList) {
cache.invalidateAll(mmsiList);
}
/**
* 전체 캐시 삭제
*/
public void clear() {
long size = cache.estimatedSize();
cache.invalidateAll();
log.info("캐시 전체 삭제 - {} 건", size);
}
/**
* 현재 캐시 크기 (추정값)
*/
public long size() {
return cache.estimatedSize();
}
/**
* 캐시 정리 (만료된 엔트리 즉시 제거)
*/
public void cleanup() {
cache.cleanUp();
}
// ==================== 통계 ====================
/**
* 캐시 통계 조회
*/
public Map<String, Object> getStats() {
CacheStats stats = cache.stats();
Map<String, Object> result = new LinkedHashMap<>();
result.put("estimatedSize", cache.estimatedSize());
result.put("maxSize", maxSize);
result.put("ttlMinutes", ttlMinutes);
result.put("hitCount", stats.hitCount());
result.put("missCount", stats.missCount());
result.put("hitRate", String.format("%.2f%%", stats.hitRate() * 100));
result.put("missRate", String.format("%.2f%%", stats.missRate() * 100));
result.put("evictionCount", stats.evictionCount());
result.put("loadCount", stats.loadCount());
result.put("averageLoadPenalty", String.format("%.2fms", stats.averageLoadPenalty() / 1_000_000.0));
result.put("utilizationPercent", String.format("%.2f%%", (cache.estimatedSize() * 100.0 / maxSize)));
return result;
}
/**
* 상세 통계 조회 (Caffeine CacheStats 원본)
*/
public CacheStats getCacheStats() {
return cache.stats();
}
// ==================== 전체 데이터 조회 (공간 필터링용) ====================
/**
* 캐시의 모든 데이터 조회 (공간 필터링용)
* 주의: 대용량 데이터이므로 신중하게 사용
*
* @return 캐시된 모든 엔티티
*/
public Collection<AisTargetEntity> getAllValues() {
return cache.asMap().values();
}
/**
* 시간 범위 데이터 필터링
*
* @param minutes 최근 N분
* @return 시간 범위 엔티티 목록
*/
public List<AisTargetEntity> getByTimeRange(int minutes) {
java.time.OffsetDateTime threshold = java.time.OffsetDateTime.now(java.time.ZoneOffset.UTC)
.minusMinutes(minutes);
return cache.asMap().values().stream()
.filter(entity -> entity.getMessageTimestamp() != null)
.filter(entity -> entity.getMessageTimestamp().isAfter(threshold))
.collect(java.util.stream.Collectors.toList());
}
// ==================== Private Methods ====================
/**
* 데이터가 기존 데이터보다 최신인지 확인
*/
private boolean isNewer(AisTargetEntity newEntity, AisTargetEntity existing) {
OffsetDateTime newTimestamp = newEntity.getMessageTimestamp();
OffsetDateTime existingTimestamp = existing.getMessageTimestamp();
if (newTimestamp == null) {
return false;
}
if (existingTimestamp == null) {
return true;
}
return newTimestamp.isAfter(existingTimestamp);
}
}

파일 보기

@ -1,229 +0,0 @@
package com.snp.batch.jobs.aistarget.cache;
import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
import com.snp.batch.jobs.aistarget.web.dto.AisTargetFilterRequest;
import com.snp.batch.jobs.aistarget.web.dto.AisTargetSearchRequest;
import com.snp.batch.jobs.aistarget.web.dto.NumericCondition;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
/**
* AIS Target 필터링 유틸리티
*
* 캐시 데이터에 대한 조건 필터링 수행
* - SOG, COG, Heading: 숫자 범위 조건
* - Destination: 문자열 부분 일치
* - Status: 다중 선택 일치
* - ClassType: 선박 클래스 타입 (A/B)
*/
@Slf4j
@Component
public class AisTargetFilterUtil {
/**
* 필터 조건에 따라 엔티티 목록 필터링
*
* @param entities 원본 엔티티 목록
* @param request 필터 조건
* @return 필터링된 엔티티 목록
*/
public List<AisTargetEntity> filter(List<AisTargetEntity> entities, AisTargetFilterRequest request) {
if (entities == null || entities.isEmpty()) {
return List.of();
}
if (!request.hasAnyFilter()) {
return entities;
}
long startTime = System.currentTimeMillis();
List<AisTargetEntity> result = entities.parallelStream()
.filter(entity -> matchesSog(entity, request))
.filter(entity -> matchesCog(entity, request))
.filter(entity -> matchesHeading(entity, request))
.filter(entity -> matchesDestination(entity, request))
.filter(entity -> matchesStatus(entity, request))
.filter(entity -> matchesClassType(entity, request))
.collect(Collectors.toList());
long elapsed = System.currentTimeMillis() - startTime;
log.debug("필터링 완료 - 입력: {}, 결과: {}, 소요: {}ms",
entities.size(), result.size(), elapsed);
return result;
}
/**
* AisTargetSearchRequest 기반 ClassType 필터링
*
* @param entities 원본 엔티티 목록
* @param request 검색 조건
* @return 필터링된 엔티티 목록
*/
public List<AisTargetEntity> filterByClassType(List<AisTargetEntity> entities, AisTargetSearchRequest request) {
if (entities == null || entities.isEmpty()) {
return Collections.emptyList();
}
if (!request.hasClassTypeFilter()) {
return entities;
}
long startTime = System.currentTimeMillis();
List<AisTargetEntity> result = entities.parallelStream()
.filter(entity -> matchesClassType(entity, request.getClassType()))
.collect(Collectors.toList());
long elapsed = System.currentTimeMillis() - startTime;
log.debug("ClassType 필터링 완료 - 입력: {}, 결과: {}, 필터: {}, 소요: {}ms",
entities.size(), result.size(), request.getClassType(), elapsed);
return result;
}
/**
* 문자열 classType으로 직접 필터링
*/
private boolean matchesClassType(AisTargetEntity entity, String classTypeFilter) {
if (classTypeFilter == null) {
return true;
}
String entityClassType = entity.getClassType();
// classType이 미분류(null) 데이터 처리
if (entityClassType == null) {
// B 필터인 경우 미분류 데이터도 포함 (보수적 접근)
return "B".equalsIgnoreCase(classTypeFilter);
}
return classTypeFilter.equalsIgnoreCase(entityClassType);
}
/**
* SOG (속도) 조건 매칭
*/
private boolean matchesSog(AisTargetEntity entity, AisTargetFilterRequest request) {
if (!request.hasSogFilter()) {
return true; // 필터 없으면 통과
}
NumericCondition condition = NumericCondition.fromString(request.getSogCondition());
if (condition == null) {
return true;
}
return condition.matches(
entity.getSog(),
request.getSogValue(),
request.getSogMin(),
request.getSogMax()
);
}
/**
* COG (침로) 조건 매칭
*/
private boolean matchesCog(AisTargetEntity entity, AisTargetFilterRequest request) {
if (!request.hasCogFilter()) {
return true;
}
NumericCondition condition = NumericCondition.fromString(request.getCogCondition());
if (condition == null) {
return true;
}
return condition.matches(
entity.getCog(),
request.getCogValue(),
request.getCogMin(),
request.getCogMax()
);
}
/**
* Heading (선수방위) 조건 매칭
*/
private boolean matchesHeading(AisTargetEntity entity, AisTargetFilterRequest request) {
if (!request.hasHeadingFilter()) {
return true;
}
NumericCondition condition = NumericCondition.fromString(request.getHeadingCondition());
if (condition == null) {
return true;
}
return condition.matches(
entity.getHeading(),
request.getHeadingValue(),
request.getHeadingMin(),
request.getHeadingMax()
);
}
/**
* Destination (목적지) 조건 매칭 - 부분 일치, 대소문자 무시
*/
private boolean matchesDestination(AisTargetEntity entity, AisTargetFilterRequest request) {
if (!request.hasDestinationFilter()) {
return true;
}
String entityDestination = entity.getDestination();
if (entityDestination == null || entityDestination.isEmpty()) {
return false;
}
return entityDestination.toUpperCase().contains(request.getDestination().toUpperCase().trim());
}
/**
* Status (항행상태) 조건 매칭 - 다중 선택 하나라도 일치
*/
private boolean matchesStatus(AisTargetEntity entity, AisTargetFilterRequest request) {
if (!request.hasStatusFilter()) {
return true;
}
String entityStatus = entity.getStatus();
if (entityStatus == null || entityStatus.isEmpty()) {
return false;
}
// statusList에 포함되어 있으면 통과
return request.getStatusList().stream()
.anyMatch(status -> entityStatus.equalsIgnoreCase(status.trim()));
}
/**
* ClassType (선박 클래스 타입) 조건 매칭
*
* - A: Core20에 등록된 선박
* - B: Core20 미등록 선박
* - 필터 미지정: 전체 통과
* - classType이 null인 데이터: B 필터에만 포함 (보수적 접근)
*/
private boolean matchesClassType(AisTargetEntity entity, AisTargetFilterRequest request) {
if (!request.hasClassTypeFilter()) {
return true;
}
String entityClassType = entity.getClassType();
// classType이 미분류(null) 데이터 처리
if (entityClassType == null) {
// B 필터인 경우 미분류 데이터도 포함 (보수적 접근)
return "B".equalsIgnoreCase(request.getClassType());
}
return request.getClassType().equalsIgnoreCase(entityClassType);
}
}

파일 보기

@ -1,317 +0,0 @@
package com.snp.batch.jobs.aistarget.cache;
import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
import lombok.extern.slf4j.Slf4j;
import org.locationtech.jts.geom.*;
import org.locationtech.jts.geom.impl.CoordinateArraySequence;
import org.locationtech.jts.operation.distance.DistanceOp;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.stream.Collectors;
/**
* 공간 필터링 유틸리티 (JTS 기반)
*
* 지원 기능:
* - 원형 범위 선박 필터링 (Point + Radius)
* - 폴리곤 범위 선박 필터링 (Polygon)
* - 거리 계산 (Haversine 공식 - 지구 곡률 고려)
*
* 성능:
* - 25만 필터링: 50-100ms (병렬 처리 )
* - 단순 거리 계산은 JTS 없이 Haversine으로 처리 ( 빠름)
* - 복잡한 폴리곤은 JTS 사용
*/
@Slf4j
@Component
public class SpatialFilterUtil {
private static final GeometryFactory GEOMETRY_FACTORY = new GeometryFactory(new PrecisionModel(), 4326);
// 지구 반경 (미터)
private static final double EARTH_RADIUS_METERS = 6_371_000;
// ==================== 원형 범위 필터링 ====================
/**
* 원형 범위 선박 필터링 (Haversine 거리 계산 - 빠름)
*
* @param entities 전체 엔티티 목록
* @param centerLon 중심 경도
* @param centerLat 중심 위도
* @param radiusMeters 반경 (미터)
* @return 범위 엔티티 목록
*/
public List<AisTargetEntity> filterByCircle(
Collection<AisTargetEntity> entities,
double centerLon,
double centerLat,
double radiusMeters) {
if (entities == null || entities.isEmpty()) {
return new ArrayList<>();
}
long startTime = System.currentTimeMillis();
// 병렬 스트림으로 필터링 (대용량 데이터 최적화)
List<AisTargetEntity> result = entities.parallelStream()
.filter(entity -> entity.getLat() != null && entity.getLon() != null)
.filter(entity -> {
double distance = haversineDistance(
centerLat, centerLon,
entity.getLat(), entity.getLon()
);
return distance <= radiusMeters;
})
.collect(Collectors.toList());
long elapsed = System.currentTimeMillis() - startTime;
log.debug("원형 필터링 완료 - 입력: {}, 결과: {}, 소요: {}ms",
entities.size(), result.size(), elapsed);
return result;
}
/**
* 원형 범위 선박 필터링 + 거리 정보 포함
*/
public List<EntityWithDistance> filterByCircleWithDistance(
Collection<AisTargetEntity> entities,
double centerLon,
double centerLat,
double radiusMeters) {
if (entities == null || entities.isEmpty()) {
return new ArrayList<>();
}
return entities.parallelStream()
.filter(entity -> entity.getLat() != null && entity.getLon() != null)
.map(entity -> {
double distance = haversineDistance(
centerLat, centerLon,
entity.getLat(), entity.getLon()
);
return new EntityWithDistance(entity, distance);
})
.filter(ewd -> ewd.getDistanceMeters() <= radiusMeters)
.sorted((a, b) -> Double.compare(a.getDistanceMeters(), b.getDistanceMeters()))
.collect(Collectors.toList());
}
// ==================== 폴리곤 범위 필터링 ====================
/**
* 폴리곤 범위 선박 필터링 (JTS 사용)
*
* @param entities 전체 엔티티 목록
* @param polygonCoordinates 폴리곤 좌표 [[lon, lat], [lon, lat], ...] (닫힌 형태)
* @return 범위 엔티티 목록
*/
public List<AisTargetEntity> filterByPolygon(
Collection<AisTargetEntity> entities,
double[][] polygonCoordinates) {
if (entities == null || entities.isEmpty()) {
return new ArrayList<>();
}
if (polygonCoordinates == null || polygonCoordinates.length < 4) {
log.warn("유효하지 않은 폴리곤 좌표 (최소 4개 점 필요)");
return new ArrayList<>();
}
long startTime = System.currentTimeMillis();
// JTS Polygon 생성
Polygon polygon = createPolygon(polygonCoordinates);
if (polygon == null || !polygon.isValid()) {
log.warn("유효하지 않은 폴리곤");
return new ArrayList<>();
}
// 병렬 스트림으로 필터링
List<AisTargetEntity> result = entities.parallelStream()
.filter(entity -> entity.getLat() != null && entity.getLon() != null)
.filter(entity -> {
Point point = createPoint(entity.getLon(), entity.getLat());
return polygon.contains(point);
})
.collect(Collectors.toList());
long elapsed = System.currentTimeMillis() - startTime;
log.debug("폴리곤 필터링 완료 - 입력: {}, 결과: {}, 소요: {}ms",
entities.size(), result.size(), elapsed);
return result;
}
/**
* WKT(Well-Known Text) 형식 폴리곤으로 필터링
*
* @param entities 전체 엔티티 목록
* @param wkt WKT 문자열 (: "POLYGON((129 35, 130 35, 130 36, 129 36, 129 35))")
* @return 범위 엔티티 목록
*/
public List<AisTargetEntity> filterByWkt(
Collection<AisTargetEntity> entities,
String wkt) {
if (entities == null || entities.isEmpty()) {
return new ArrayList<>();
}
try {
Geometry geometry = new org.locationtech.jts.io.WKTReader(GEOMETRY_FACTORY).read(wkt);
return entities.parallelStream()
.filter(entity -> entity.getLat() != null && entity.getLon() != null)
.filter(entity -> {
Point point = createPoint(entity.getLon(), entity.getLat());
return geometry.contains(point);
})
.collect(Collectors.toList());
} catch (Exception e) {
log.error("WKT 파싱 실패: {}", wkt, e);
return new ArrayList<>();
}
}
// ==================== GeoJSON 지원 ====================
/**
* GeoJSON 형식 폴리곤으로 필터링
*
* @param entities 전체 엔티티 목록
* @param geoJsonCoordinates GeoJSON coordinates 배열 [[[lon, lat], ...]]
* @return 범위 엔티티 목록
*/
public List<AisTargetEntity> filterByGeoJson(
Collection<AisTargetEntity> entities,
double[][][] geoJsonCoordinates) {
if (geoJsonCoordinates == null || geoJsonCoordinates.length == 0) {
return new ArrayList<>();
}
// GeoJSON의 번째 (외부 경계)
return filterByPolygon(entities, geoJsonCoordinates[0]);
}
// ==================== 거리 계산 ====================
/**
* Haversine 공식을 사용한 지점 거리 계산 (미터)
* 지구 곡률을 고려한 정확한 거리 계산
*/
public double haversineDistance(double lat1, double lon1, double lat2, double lon2) {
double dLat = Math.toRadians(lat2 - lat1);
double dLon = Math.toRadians(lon2 - lon1);
double a = Math.sin(dLat / 2) * Math.sin(dLat / 2)
+ Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2))
* Math.sin(dLon / 2) * Math.sin(dLon / 2);
double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return EARTH_RADIUS_METERS * c;
}
/**
* 엔티티 거리 계산 (미터)
*/
public double calculateDistance(AisTargetEntity entity1, AisTargetEntity entity2) {
if (entity1.getLat() == null || entity1.getLon() == null ||
entity2.getLat() == null || entity2.getLon() == null) {
return Double.MAX_VALUE;
}
return haversineDistance(
entity1.getLat(), entity1.getLon(),
entity2.getLat(), entity2.getLon()
);
}
// ==================== JTS 헬퍼 메서드 ====================
/**
* JTS Point 생성
*/
public Point createPoint(double lon, double lat) {
return GEOMETRY_FACTORY.createPoint(new Coordinate(lon, lat));
}
/**
* JTS Polygon 생성
*/
public Polygon createPolygon(double[][] coordinates) {
try {
Coordinate[] coords = new Coordinate[coordinates.length];
for (int i = 0; i < coordinates.length; i++) {
coords[i] = new Coordinate(coordinates[i][0], coordinates[i][1]);
}
// 폴리곤이 닫혀있지 않으면 닫기
if (!coords[0].equals(coords[coords.length - 1])) {
Coordinate[] closedCoords = new Coordinate[coords.length + 1];
System.arraycopy(coords, 0, closedCoords, 0, coords.length);
closedCoords[coords.length] = coords[0];
coords = closedCoords;
}
LinearRing ring = GEOMETRY_FACTORY.createLinearRing(coords);
return GEOMETRY_FACTORY.createPolygon(ring);
} catch (Exception e) {
log.error("폴리곤 생성 실패", e);
return null;
}
}
/**
* 원형 폴리곤 생성 (근사치)
*
* @param centerLon 중심 경도
* @param centerLat 중심 위도
* @param radiusMeters 반경 (미터)
* @param numPoints 폴리곤 개수 (기본: 64)
*/
public Polygon createCirclePolygon(double centerLon, double centerLat, double radiusMeters, int numPoints) {
Coordinate[] coords = new Coordinate[numPoints + 1];
for (int i = 0; i < numPoints; i++) {
double angle = (2 * Math.PI * i) / numPoints;
// 위도/경도 변환 (근사치)
double dLat = (radiusMeters / EARTH_RADIUS_METERS) * (180 / Math.PI);
double dLon = dLat / Math.cos(Math.toRadians(centerLat));
double lat = centerLat + dLat * Math.sin(angle);
double lon = centerLon + dLon * Math.cos(angle);
coords[i] = new Coordinate(lon, lat);
}
coords[numPoints] = coords[0]; // 닫기
LinearRing ring = GEOMETRY_FACTORY.createLinearRing(coords);
return GEOMETRY_FACTORY.createPolygon(ring);
}
// ==================== 내부 클래스 ====================
/**
* 엔티티 + 거리 정보
*/
@lombok.Data
@lombok.AllArgsConstructor
public static class EntityWithDistance {
private AisTargetEntity entity;
private double distanceMeters;
}
}

파일 보기

@ -1,131 +0,0 @@
package com.snp.batch.jobs.aistarget.chnprmship;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
/**
* 중국 허가선박 전용 캐시
*
* - 대상 MMSI(~1,400척) 별도 관리
* - TTL: expireAfterWrite (마지막 put 이후 N일 경과 만료)
* - 순수 캐시 조회 전용 (DB fallback 없음)
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ChnPrmShipCacheManager {
private final ChnPrmShipProperties properties;
private Cache<Long, AisTargetEntity> cache;
@PostConstruct
public void init() {
this.cache = Caffeine.newBuilder()
.maximumSize(properties.getMaxSize())
.expireAfterWrite(properties.getTtlDays(), TimeUnit.DAYS)
.recordStats()
.build();
log.info("ChnPrmShip 캐시 초기화 - TTL: {}일, 최대 크기: {}",
properties.getTtlDays(), properties.getMaxSize());
}
/**
* 대상 MMSI에 해당하는 항목만 필터링하여 캐시에 저장
*
* @param items 전체 AIS Target 데이터 (배치 수집 결과)
* @return 저장된 건수
*/
public int putIfTarget(List<AisTargetEntity> items) {
if (items == null || items.isEmpty()) {
return 0;
}
int updated = 0;
for (AisTargetEntity item : items) {
if (!properties.isTarget(item.getMmsi())) {
continue;
}
AisTargetEntity existing = cache.getIfPresent(item.getMmsi());
if (existing == null || isNewerOrEqual(item, existing)) {
cache.put(item.getMmsi(), item);
updated++;
}
}
if (updated > 0) {
log.debug("ChnPrmShip 캐시 업데이트 - 입력: {}, 대상 저장: {}, 현재 크기: {}",
items.size(), updated, cache.estimatedSize());
}
return updated;
}
/**
* 시간 범위 캐시 데이터 조회
*
* @param minutes 조회 범위 ()
* @return 시간 범위 데이터 목록
*/
public List<AisTargetEntity> getByTimeRange(int minutes) {
OffsetDateTime threshold = OffsetDateTime.now(ZoneOffset.UTC).minusMinutes(minutes);
return cache.asMap().values().stream()
.filter(entity -> entity.getMessageTimestamp() != null)
.filter(entity -> entity.getMessageTimestamp().isAfter(threshold))
.collect(Collectors.toList());
}
/**
* 워밍업용 직접 저장 (시간 비교 없이 저장)
*/
public void putAll(List<AisTargetEntity> entities) {
if (entities == null || entities.isEmpty()) {
return;
}
for (AisTargetEntity entity : entities) {
if (entity != null && entity.getMmsi() != null) {
cache.put(entity.getMmsi(), entity);
}
}
}
public long size() {
return cache.estimatedSize();
}
public Map<String, Object> getStats() {
var stats = cache.stats();
return Map.of(
"estimatedSize", cache.estimatedSize(),
"maxSize", properties.getMaxSize(),
"ttlDays", properties.getTtlDays(),
"targetMmsiCount", properties.getMmsiSet().size(),
"hitCount", stats.hitCount(),
"missCount", stats.missCount(),
"hitRate", String.format("%.2f%%", stats.hitRate() * 100)
);
}
private boolean isNewerOrEqual(AisTargetEntity candidate, AisTargetEntity existing) {
if (candidate.getMessageTimestamp() == null) {
return false;
}
if (existing.getMessageTimestamp() == null) {
return true;
}
return !candidate.getMessageTimestamp().isBefore(existing.getMessageTimestamp());
}
}

파일 보기

@ -1,79 +0,0 @@
package com.snp.batch.jobs.aistarget.chnprmship;
import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
import com.snp.batch.jobs.aistarget.batch.repository.AisTargetRepository;
import com.snp.batch.jobs.aistarget.classifier.SignalKindCode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.List;
/**
* 기동 ChnPrmShip 캐시 워밍업
*
* DB(ais_target)에서 대상 MMSI의 최근 데이터를 조회하여 캐시를 채운다.
* 이후 배치 수집에서 실시간 데이터가 캐시를 갱신한다.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ChnPrmShipCacheWarmer implements ApplicationRunner {
private static final int DB_QUERY_CHUNK_SIZE = 500;
private final ChnPrmShipProperties properties;
private final ChnPrmShipCacheManager cacheManager;
private final AisTargetRepository aisTargetRepository;
@Override
public void run(ApplicationArguments args) {
if (!properties.isWarmupEnabled()) {
log.info("ChnPrmShip 캐시 워밍업 비활성화");
return;
}
if (properties.getMmsiSet().isEmpty()) {
log.warn("ChnPrmShip 대상 MMSI가 없어 워밍업을 건너뜁니다");
return;
}
OffsetDateTime since = OffsetDateTime.now(ZoneOffset.UTC)
.minusDays(properties.getWarmupDays());
log.info("ChnPrmShip 캐시 워밍업 시작 - 대상: {}건, 조회 범위: 최근 {}일 (since: {})",
properties.getMmsiSet().size(), properties.getWarmupDays(), since);
long startTime = System.currentTimeMillis();
List<Long> mmsiList = new ArrayList<>(properties.getMmsiSet());
int totalLoaded = 0;
for (int i = 0; i < mmsiList.size(); i += DB_QUERY_CHUNK_SIZE) {
List<Long> chunk = mmsiList.subList(i,
Math.min(i + DB_QUERY_CHUNK_SIZE, mmsiList.size()));
List<AisTargetEntity> fromDb = aisTargetRepository.findLatestByMmsiInSince(chunk, since);
// signalKindCode 치환 (DB 데이터는 치환이 되어 있을 있음)
fromDb.forEach(entity -> {
if (entity.getSignalKindCode() == null) {
SignalKindCode kindCode = SignalKindCode.resolve(
entity.getVesselType(), entity.getExtraInfo());
entity.setSignalKindCode(kindCode.getCode());
}
});
cacheManager.putAll(fromDb);
totalLoaded += fromDb.size();
}
long elapsed = System.currentTimeMillis() - startTime;
log.info("ChnPrmShip 캐시 워밍업 완료 - 대상: {}, 로딩: {}건, 소요: {}ms",
properties.getMmsiSet().size(), totalLoaded, elapsed);
}
}

파일 보기

@ -1,82 +0,0 @@
package com.snp.batch.jobs.aistarget.chnprmship;
import jakarta.annotation.PostConstruct;
import lombok.Getter;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.core.io.DefaultResourceLoader;
import org.springframework.core.io.Resource;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 중국 허가선박(ChnPrmShip) 설정
*
* 대상 MMSI 목록을 리소스 파일에서 로딩하여 Set으로 보관한다.
*/
@Slf4j
@Getter
@Setter
@ConfigurationProperties(prefix = "app.batch.chnprmship")
public class ChnPrmShipProperties {
/**
* MMSI 목록 리소스 경로
*/
private String mmsiResourcePath = "classpath:chnprmship-mmsi.txt";
/**
* 캐시 TTL ()
* - 마지막 put() 이후 기간이 지나면 만료
*/
private int ttlDays = 2;
/**
* 최대 캐시 크기
*/
private int maxSize = 2000;
/**
* 기동 DB 워밍업 활성화 여부
*/
private boolean warmupEnabled = true;
/**
* DB 워밍업 조회 범위 ()
*/
private int warmupDays = 2;
/**
* 로딩된 대상 MMSI 집합
*/
private Set<Long> mmsiSet = Collections.emptySet();
@PostConstruct
public void init() {
try {
Resource resource = new DefaultResourceLoader().getResource(mmsiResourcePath);
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(resource.getInputStream(), StandardCharsets.UTF_8))) {
mmsiSet = reader.lines()
.map(String::trim)
.filter(line -> !line.isEmpty() && !line.startsWith("#"))
.map(Long::parseLong)
.collect(Collectors.toUnmodifiableSet());
}
log.info("ChnPrmShip MMSI 로딩 완료 - {}건 (경로: {})", mmsiSet.size(), mmsiResourcePath);
} catch (Exception e) {
log.error("ChnPrmShip MMSI 로딩 실패 - 경로: {}, 오류: {}", mmsiResourcePath, e.getMessage());
mmsiSet = Collections.emptySet();
}
}
public boolean isTarget(Long mmsi) {
return mmsi != null && mmsiSet.contains(mmsi);
}
}

파일 보기

@ -1,160 +0,0 @@
package com.snp.batch.jobs.aistarget.classifier;
import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Optional;
import java.util.regex.Pattern;
/**
* AIS Target ClassType 분류기
*
* 분류 기준:
* - Core20 테이블에 IMO가 등록되어 있으면 Class A
* - 등록되어 있지 않으면 Class B (기본값)
*
* 분류 결과:
* - classType: "A" 또는 "B"
* - core20Mmsi: Core20에 등록된 MMSI (Class A일 때만, nullable)
*
* 특이 케이스:
* 1. IMO가 0이거나 null Class B
* 2. IMO가 7자리가 아닌 의미없는 숫자 Class B
* 3. IMO가 7자리이지만 Core20에 미등록 Class B
* 4. IMO가 Core20에 있지만 MMSI가 null Class A, core20Mmsi = null
*
* 향후 제거 가능하도록 독립적인 모듈로 구현
*/
@Slf4j
@Component
public class AisClassTypeClassifier {
/**
* 유효한 IMO 패턴 (7자리 숫자)
*/
private static final Pattern IMO_PATTERN = Pattern.compile("^\\d{7}$");
private final Core20CacheManager core20CacheManager;
/**
* ClassType 분류 기능 활성화 여부
*/
@Value("${app.batch.class-type.enabled:true}")
private boolean enabled;
public AisClassTypeClassifier(Core20CacheManager core20CacheManager) {
this.core20CacheManager = core20CacheManager;
}
/**
* 단일 Entity의 ClassType 분류
*
* @param entity AIS Target Entity
*/
public void classify(AisTargetEntity entity) {
if (!enabled || entity == null) {
return;
}
Long imo = entity.getImo();
// 1. IMO가 null이거나 0이면 Class B
if (imo == null || imo == 0) {
setClassB(entity);
return;
}
// 2. IMO가 7자리 숫자인지 확인
String imoStr = String.valueOf(imo);
if (!isValidImo(imoStr)) {
setClassB(entity);
return;
}
// 3. Core20 캐시에서 IMO 존재 여부 확인
if (core20CacheManager.containsImo(imoStr)) {
// Class A - Core20에 등록된 선박
entity.setClassType("A");
// Core20의 MMSI 조회 (nullable - Core20에 MMSI가 없을 수도 있음)
Optional<String> core20Mmsi = core20CacheManager.getMmsiByImo(imoStr);
entity.setCore20Mmsi(core20Mmsi.orElse(null));
return;
}
// 4. Core20에 없음 - Class B
setClassB(entity);
}
/**
* 여러 Entity 일괄 분류
*
* @param entities AIS Target Entity 목록
*/
public void classifyAll(List<AisTargetEntity> entities) {
if (!enabled || entities == null || entities.isEmpty()) {
return;
}
int classACount = 0;
int classBCount = 0;
int classAWithMmsi = 0;
int classAWithoutMmsi = 0;
for (AisTargetEntity entity : entities) {
classify(entity);
if ("A".equals(entity.getClassType())) {
classACount++;
if (entity.getCore20Mmsi() != null) {
classAWithMmsi++;
} else {
classAWithoutMmsi++;
}
} else {
classBCount++;
}
}
if (log.isDebugEnabled()) {
log.debug("ClassType 분류 완료 - 총: {}, Class A: {} (MMSI있음: {}, MMSI없음: {}), Class B: {}",
entities.size(), classACount, classAWithMmsi, classAWithoutMmsi, classBCount);
}
}
/**
* Class B로 설정 (기본값)
*/
private void setClassB(AisTargetEntity entity) {
entity.setClassType("B");
entity.setCore20Mmsi(null);
}
/**
* 유효한 IMO 번호인지 확인 (7자리 숫자)
*
* @param imo IMO 문자열
* @return 유효 여부
*/
private boolean isValidImo(String imo) {
return imo != null && IMO_PATTERN.matcher(imo).matches();
}
/**
* 기능 활성화 여부
*/
public boolean isEnabled() {
return enabled;
}
/**
* Core20 캐시 상태 확인
*/
public boolean isCacheReady() {
return core20CacheManager.isLoaded();
}
}

파일 보기

@ -1,219 +0,0 @@
package com.snp.batch.jobs.aistarget.classifier;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
/**
* Core20 테이블의 IMO MMSI 매핑 캐시 매니저
*
* 동작:
* - 애플리케이션 시작 또는 조회 자동 로딩
* - 매일 지정된 시간(기본 04:00) 전체 갱신
* - TTL 없음 (명시적 갱신만)
*
* 데이터 구조:
* - Key: IMO/LRNO (7자리 문자열, NOT NULL)
* - Value: MMSI (문자열, NULLABLE - 문자열로 저장)
*
* 특이사항:
* - Core20에 IMO는 있지만 MMSI가 null인 경우도 존재
* - 경우 containsImo() true, getMmsiByImo() Optional.empty()
* - ConcurrentHashMap은 null을 허용하지 않으므로 문자열("") sentinel 값으로 사용
*/
@Slf4j
@Component
public class Core20CacheManager {
private final JdbcTemplate jdbcTemplate;
private final Core20Properties properties;
/**
* MMSI가 없는 경우를 나타내는 sentinel
* ConcurrentHashMap은 null을 허용하지 않으므로 문자열 사용
*/
private static final String NO_MMSI = "";
/**
* IMO MMSI 매핑 캐시
* - Key: IMO (NOT NULL)
* - Value: MMSI ( 문자열이면 MMSI 없음)
*/
private volatile Map<String, String> imoToMmsiMap = new ConcurrentHashMap<>();
/**
* 마지막 갱신 시간
*/
private volatile LocalDateTime lastRefreshTime;
/**
* Core20 캐시 갱신 시간 (기본: 04시)
*/
@Value("${app.batch.class-type.refresh-hour:4}")
private int refreshHour;
public Core20CacheManager(JdbcTemplate jdbcTemplate, Core20Properties properties) {
this.jdbcTemplate = jdbcTemplate;
this.properties = properties;
}
/**
* IMO로 MMSI 조회
*
* @param imo IMO 번호 (문자열)
* @return MMSI (없거나 null/ 문자열이면 Optional.empty)
*/
public Optional<String> getMmsiByImo(String imo) {
ensureCacheLoaded();
if (imo == null || !imoToMmsiMap.containsKey(imo)) {
return Optional.empty();
}
String mmsi = imoToMmsiMap.get(imo);
// MMSI가 문자열(NO_MMSI) 경우
if (mmsi == null || mmsi.isEmpty()) {
return Optional.empty();
}
return Optional.of(mmsi);
}
/**
* IMO 존재 여부만 확인 (MMSI 유무와 무관)
* - Core20에 등록된 선박인지 판단하는 용도
* - MMSI가 null이어도 IMO가 있으면 true
*
* @param imo IMO 번호
* @return Core20에 등록 여부
*/
public boolean containsImo(String imo) {
ensureCacheLoaded();
return imo != null && imoToMmsiMap.containsKey(imo);
}
/**
* 캐시 전체 갱신 (DB에서 다시 로딩)
*/
public synchronized void refresh() {
log.info("Core20 캐시 갱신 시작 - 테이블: {}", properties.getFullTableName());
try {
String sql = properties.buildSelectSql();
log.debug("Core20 조회 SQL: {}", sql);
Map<String, String> newMap = new ConcurrentHashMap<>();
jdbcTemplate.query(sql, rs -> {
String imo = rs.getString(1);
String mmsi = rs.getString(2); // nullable
if (imo != null && !imo.isBlank()) {
// IMO는 trim하여 저장, MMSI는 문자열로 대체 (ConcurrentHashMap은 null 불가)
String trimmedImo = imo.trim();
String trimmedMmsi = (mmsi != null && !mmsi.isBlank()) ? mmsi.trim() : NO_MMSI;
newMap.put(trimmedImo, trimmedMmsi);
}
});
this.imoToMmsiMap = newMap;
this.lastRefreshTime = LocalDateTime.now();
// 통계 로깅
long withMmsi = newMap.values().stream()
.filter(v -> !v.isEmpty())
.count();
log.info("Core20 캐시 갱신 완료 - 총 {} 건 (MMSI 있음: {} 건, MMSI 없음: {} 건)",
newMap.size(), withMmsi, newMap.size() - withMmsi);
} catch (Exception e) {
log.error("Core20 캐시 갱신 실패: {}", e.getMessage(), e);
// 기존 캐시 유지 (실패해도 서비스 중단 방지)
}
}
/**
* 캐시가 비어있으면 자동 로딩
*/
private void ensureCacheLoaded() {
if (imoToMmsiMap.isEmpty() && lastRefreshTime == null) {
log.warn("Core20 캐시 비어있음 - 자동 로딩 실행");
refresh();
}
}
/**
* 지정된 시간대에 갱신이 필요한지 확인
* - 기본: 04:00 ~ 04:01 사이
* - 같은 이미 갱신했으면 스킵
*
* @return 갱신 필요 여부
*/
public boolean shouldRefresh() {
LocalDateTime now = LocalDateTime.now();
int currentHour = now.getHour();
int currentMinute = now.getMinute();
// 지정된 시간(: 04:00~04:01) 체크
if (currentHour != refreshHour || currentMinute > 0) {
return false;
}
// 오늘 해당 시간에 이미 갱신했으면 스킵
if (lastRefreshTime != null &&
lastRefreshTime.toLocalDate().equals(now.toLocalDate()) &&
lastRefreshTime.getHour() == refreshHour) {
return false;
}
return true;
}
/**
* 현재 캐시 크기
*/
public int size() {
return imoToMmsiMap.size();
}
/**
* 마지막 갱신 시간
*/
public LocalDateTime getLastRefreshTime() {
return lastRefreshTime;
}
/**
* 캐시가 로드되었는지 확인
*/
public boolean isLoaded() {
return lastRefreshTime != null && !imoToMmsiMap.isEmpty();
}
/**
* 캐시 통계 조회 (모니터링/디버깅용)
*/
public Map<String, Object> getStats() {
Map<String, Object> stats = new LinkedHashMap<>();
stats.put("totalCount", imoToMmsiMap.size());
stats.put("withMmsiCount", imoToMmsiMap.values().stream()
.filter(v -> !v.isEmpty()).count());
stats.put("withoutMmsiCount", imoToMmsiMap.values().stream()
.filter(String::isEmpty).count());
stats.put("lastRefreshTime", lastRefreshTime);
stats.put("refreshHour", refreshHour);
stats.put("tableName", properties.getFullTableName());
stats.put("imoColumn", properties.getImoColumn());
stats.put("mmsiColumn", properties.getMmsiColumn());
return stats;
}
}

파일 보기

@ -1,71 +0,0 @@
package com.snp.batch.jobs.aistarget.classifier;
import jakarta.annotation.PostConstruct;
import lombok.Getter;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* Core20 테이블 설정 프로퍼티
*
* 환경별(dev/qa/prod) 테이블명, 컬럼명이 다를 있으므로
* 프로파일별 설정 파일에서 지정할 있도록 구성
*
* 사용 :
* - dev: std_snp_svc.tb_ship_main_info (imo_no, mmsi_no)
* - prod: std_snp_svc.tb_ship_main_info (imo_no, mmsi_no)
*/
@Slf4j
@Getter
@Setter
@ConfigurationProperties(prefix = "app.batch.core20")
public class Core20Properties {
/**
* 스키마명 (: std_snp_svc)
*/
private String schema = "std_snp_svc";
/**
* 테이블명 (: tb_ship_main_info)
*/
private String table = "tb_ship_main_info";
/**
* IMO/LRNO 컬럼명 (PK, NOT NULL)
*/
private String imoColumn = "imo_no";
/**
* MMSI 컬럼명 (NULLABLE)
*/
private String mmsiColumn = "mmsi_no";
/**
* 전체 테이블명 반환 (schema.table)
*/
public String getFullTableName() {
if (schema != null && !schema.isBlank()) {
return schema + "." + table;
}
return table;
}
/**
* SELECT 쿼리 생성
* IMO가 NOT NULL인 레코드만 조회
*/
public String buildSelectSql() {
return String.format(
"SELECT %s, %s FROM %s WHERE %s IS NOT NULL",
imoColumn, mmsiColumn, getFullTableName(), imoColumn
);
}
@PostConstruct
public void logConfig() {
log.info("Core20 설정 로드 - 테이블: {}, IMO컬럼: {}, MMSI컬럼: {}",
getFullTableName(), imoColumn, mmsiColumn);
}
}

파일 보기

@ -1,118 +0,0 @@
package com.snp.batch.jobs.aistarget.classifier;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
/**
* MDA 선종 범례코드
*
* GlobalAIS 원본 데이터의 vesselType + extraInfo를 기반으로
* MDA 범례코드(signalKindCode) 치환한다.
*
* @see <a href="GLOBALAIS - MDA 선종 범례 치환표.pdf">치환 규칙표</a>
*/
@Getter
@RequiredArgsConstructor
public enum SignalKindCode {
FISHING("000020", "어선"),
KCGV("000021", "함정"),
FERRY("000022", "여객선"),
CARGO("000023", "카고"),
TANKER("000024", "탱커"),
GOV("000025", "관공선"),
DEFAULT("000027", "일반/기타선박"),
BUOY("000028", "부이/항로표지");
private final String code;
private final String koreanName;
/**
* GlobalAIS vesselType + extraInfo MDA 범례코드 치환
*
* 치환 우선순위:
* 1. vesselType 단독 매칭 (Cargo, Tanker, Passenger, AtoN )
* 2. vesselType + extraInfo 조합 매칭 (Vessel + Fishing )
* 3. fallback DEFAULT (000027)
*/
public static SignalKindCode resolve(String vesselType, String extraInfo) {
String vt = normalizeOrEmpty(vesselType);
String ei = normalizeOrEmpty(extraInfo);
// 1. vesselType 단독 매칭 (extraInfo 무관)
switch (vt) {
case "cargo":
return CARGO;
case "tanker":
return TANKER;
case "passenger":
return FERRY;
case "aton":
return BUOY;
case "law enforcement":
return GOV;
case "search and rescue":
return KCGV;
case "local vessel":
return FISHING;
default:
break;
}
// vesselType 그룹 매칭 (복합 선종명)
if (matchesAny(vt, "tug", "pilot boat", "tender", "anti pollution", "medical transport")) {
return GOV;
}
if (matchesAny(vt, "high speed craft", "wing in ground-effect")) {
return FERRY;
}
// 2. "Vessel" + extraInfo 조합
if ("vessel".equals(vt)) {
return resolveVesselExtraInfo(ei);
}
// 3. "N/A" + extraInfo 조합
if ("n/a".equals(vt)) {
if (ei.startsWith("hazardous cat")) {
return CARGO;
}
return DEFAULT;
}
// 4. fallback
return DEFAULT;
}
private static SignalKindCode resolveVesselExtraInfo(String extraInfo) {
if ("fishing".equals(extraInfo)) {
return FISHING;
}
if ("military operations".equals(extraInfo)) {
return GOV;
}
if (matchesAny(extraInfo, "towing", "towing (large)", "dredging/underwater ops", "diving operations")) {
return GOV;
}
if (matchesAny(extraInfo, "pleasure craft", "sailing", "n/a")) {
return FISHING;
}
if (extraInfo.startsWith("hazardous cat")) {
return CARGO;
}
return DEFAULT;
}
private static boolean matchesAny(String value, String... candidates) {
for (String candidate : candidates) {
if (candidate.equals(value)) {
return true;
}
}
return false;
}
private static String normalizeOrEmpty(String value) {
return (value == null || value.isBlank()) ? "" : value.strip().toLowerCase();
}
}

파일 보기

@ -1,23 +0,0 @@
package com.snp.batch.jobs.aistarget.kafka;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.autoconfigure.kafka.KafkaAutoConfiguration;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
/**
* Kafka 조건부 활성화 설정
*
* SnpBatchApplication에서 KafkaAutoConfiguration을 기본 제외한 ,
* app.batch.ais-target.kafka.enabled=true인 경우에만 재활성화한다.
*
* enabled=false(기본값)이면 KafkaTemplate Kafka 관련 빈이 전혀 생성되지 않는다.
*/
@Configuration
@ConditionalOnProperty(
name = "app.batch.ais-target.kafka.enabled",
havingValue = "true"
)
@Import(KafkaAutoConfiguration.class)
public class AisTargetKafkaConfig {
}

파일 보기

@ -1,55 +0,0 @@
package com.snp.batch.jobs.aistarget.kafka;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
/**
* AIS Target Kafka 메시지 스키마
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class AisTargetKafkaMessage {
/**
* 이벤트 고유 식별자
* - 형식: {mmsi}_{messageTimestamp}
*/
private String eventId;
/**
* Kafka key와 동일한 선박 식별자
*/
private String key;
/**
* Kafka 발행 시각(UTC)
*/
private OffsetDateTime publishedAt;
/**
* AIS 원본/가공 데이터 전체 필드
*/
private AisTargetEntity payload;
public static AisTargetKafkaMessage from(AisTargetEntity entity) {
String key = entity.getMmsi() != null ? String.valueOf(entity.getMmsi()) : null;
String messageTs = entity.getMessageTimestamp() != null ? entity.getMessageTimestamp().toString() : "null";
return AisTargetKafkaMessage.builder()
.eventId(key + "_" + messageTs)
.key(key)
.publishedAt(OffsetDateTime.now(ZoneOffset.UTC))
.payload(entity)
.build();
}
}

파일 보기

@ -1,211 +0,0 @@
package com.snp.batch.jobs.aistarget.kafka;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicInteger;
/**
* AIS Target Kafka Producer
*
* 정책:
* - key: MMSI
* - value: AisTargetKafkaMessage(JSON)
* - 실패 기본적으로 로그만 남기고 계속 진행 (failOnSendError=false)
*
* app.batch.ais-target.kafka.enabled=true인 경우에만 빈으로 등록된다.
*/
@Slf4j
@Component
@RequiredArgsConstructor
@ConditionalOnProperty(name = "app.batch.ais-target.kafka.enabled", havingValue = "true")
public class AisTargetKafkaProducer {
private final KafkaTemplate<String, String> kafkaTemplate;
private final ObjectMapper objectMapper;
private final AisTargetKafkaProperties kafkaProperties;
public boolean isEnabled() {
return kafkaProperties.isEnabled();
}
public String getTopic() {
return kafkaProperties.getTopic();
}
/**
* 수집 청크 데이터를 Kafka 전송용 서브청크로 분할해 전송
*/
public PublishSummary publish(List<AisTargetEntity> entities) {
if (!isEnabled()) {
return PublishSummary.disabled();
}
if (entities == null || entities.isEmpty()) {
return PublishSummary.empty();
}
int subChunkSize = Math.max(1, kafkaProperties.getSendChunkSize());
PublishSummary totalSummary = PublishSummary.empty();
for (int from = 0; from < entities.size(); from += subChunkSize) {
int to = Math.min(from + subChunkSize, entities.size());
List<AisTargetEntity> subChunk = entities.subList(from, to);
PublishSummary chunkSummary = publishSubChunk(subChunk);
totalSummary.merge(chunkSummary);
log.info("AIS Kafka 서브청크 전송 완료 - topic: {}, 범위: {}~{}, 요청: {}, 성공: {}, 실패: {}, 스킵: {}",
getTopic(), from, to - 1,
chunkSummary.getRequestedCount(),
chunkSummary.getSuccessCount(),
chunkSummary.getFailedCount(),
chunkSummary.getSkippedCount());
}
if (kafkaProperties.isFailOnSendError() && totalSummary.getFailedCount() > 0) {
throw new IllegalStateException("AIS Kafka 전송 실패 건수: " + totalSummary.getFailedCount());
}
return totalSummary;
}
private PublishSummary publishSubChunk(List<AisTargetEntity> subChunk) {
AtomicInteger successCount = new AtomicInteger(0);
AtomicInteger failedCount = new AtomicInteger(0);
AtomicInteger skippedCount = new AtomicInteger(0);
AtomicInteger sampledErrorLogs = new AtomicInteger(0);
List<CompletableFuture<Void>> futures = new ArrayList<>(subChunk.size());
for (AisTargetEntity entity : subChunk) {
if (!isValid(entity)) {
skippedCount.incrementAndGet();
continue;
}
try {
String key = String.valueOf(entity.getMmsi());
String payload = objectMapper.writeValueAsString(AisTargetKafkaMessage.from(entity));
CompletableFuture<Void> trackedFuture = kafkaTemplate.send(getTopic(), key, payload)
.handle((result, ex) -> {
if (ex != null) {
failedCount.incrementAndGet();
logSendError(sampledErrorLogs,
"AIS Kafka 전송 실패 - topic: " + getTopic()
+ ", key: " + key
+ ", messageTimestamp: " + entity.getMessageTimestamp()
+ ", error: " + ex.getMessage());
} else {
successCount.incrementAndGet();
}
return null;
});
futures.add(trackedFuture);
} catch (JsonProcessingException e) {
failedCount.incrementAndGet();
logSendError(sampledErrorLogs,
"AIS Kafka 메시지 직렬화 실패 - mmsi: " + entity.getMmsi()
+ ", messageTimestamp: " + entity.getMessageTimestamp()
+ ", error: " + e.getMessage());
} catch (Exception e) {
failedCount.incrementAndGet();
logSendError(sampledErrorLogs,
"AIS Kafka 전송 요청 실패 - mmsi: " + entity.getMmsi()
+ ", messageTimestamp: " + entity.getMessageTimestamp()
+ ", error: " + e.getMessage());
}
}
if (!futures.isEmpty()) {
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
kafkaTemplate.flush();
}
return PublishSummary.of(
false,
subChunk.size(),
successCount.get(),
failedCount.get(),
skippedCount.get()
);
}
private boolean isValid(AisTargetEntity entity) {
return entity != null
&& entity.getMmsi() != null
&& entity.getMessageTimestamp() != null;
}
private void logSendError(AtomicInteger sampledErrorLogs, String message) {
int current = sampledErrorLogs.incrementAndGet();
if (current <= 5) {
log.error(message);
return;
}
if (current == 6) {
log.error("AIS Kafka 전송 오류 로그가 많아 이후 상세 로그는 생략합니다.");
}
}
@Getter
public static class PublishSummary {
private final boolean disabled;
private int requestedCount;
private int successCount;
private int failedCount;
private int skippedCount;
private PublishSummary(
boolean disabled,
int requestedCount,
int successCount,
int failedCount,
int skippedCount
) {
this.disabled = disabled;
this.requestedCount = requestedCount;
this.successCount = successCount;
this.failedCount = failedCount;
this.skippedCount = skippedCount;
}
public static PublishSummary disabled() {
return of(true, 0, 0, 0, 0);
}
public static PublishSummary empty() {
return of(false, 0, 0, 0, 0);
}
public static PublishSummary of(
boolean disabled,
int requestedCount,
int successCount,
int failedCount,
int skippedCount
) {
return new PublishSummary(disabled, requestedCount, successCount, failedCount, skippedCount);
}
public void merge(PublishSummary other) {
this.requestedCount += other.requestedCount;
this.successCount += other.successCount;
this.failedCount += other.failedCount;
this.skippedCount += other.skippedCount;
}
}
}

파일 보기

@ -1,36 +0,0 @@
package com.snp.batch.jobs.aistarget.kafka;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* AIS Target Kafka 전송 설정
*/
@Getter
@Setter
@ConfigurationProperties(prefix = "app.batch.ais-target.kafka")
public class AisTargetKafkaProperties {
/**
* Kafka 전송 활성화 여부
*/
private boolean enabled = true;
/**
* 전송 대상 토픽
*/
private String topic = "tp_SNP_AIS_Signal";
/**
* Kafka 전송 서브청크 크기
* 수집 청크(: 5만) 별도로 전송 배치를 분할한다.
*/
private int sendChunkSize = 5000;
/**
* 전송 실패 Step 실패 여부
* false면 실패 로그만 남기고 다음 처리를 계속한다.
*/
private boolean failOnSendError = false;
}

파일 보기

@ -1,500 +0,0 @@
package com.snp.batch.jobs.aistarget.web.controller;
import com.snp.batch.common.web.ApiResponse;
import com.snp.batch.jobs.aistarget.web.dto.AisTargetFilterRequest;
import com.snp.batch.jobs.aistarget.web.dto.AisTargetResponseDto;
import com.snp.batch.jobs.aistarget.web.dto.AisTargetSearchRequest;
import com.snp.batch.jobs.aistarget.web.service.AisTargetService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import java.util.List;
import java.util.Map;
/**
* AIS Target REST API Controller
*
* 캐시 우선 조회 전략:
* - 캐시에서 먼저 조회
* - 캐시 미스 DB 조회 캐시 업데이트
*/
@Slf4j
@Validated
@RestController
@RequestMapping("/api/ais-target")
@RequiredArgsConstructor
@Tag(name = "AIS Target", description = "AIS 선박 위치 정보 API")
public class AisTargetController {
private final AisTargetService aisTargetService;
// ==================== 중국 허가선박 전용 ====================
@Operation(
summary = "중국 허가선박 위치 조회",
description = """
중국 허가 어선(~1,400척) 전용 캐시에서 위치 정보를 조회합니다.
- 순수 캐시 조회 (DB fallback 없음)
- 캐시에 없으면 배열 반환
- 응답 구조는 /search와 동일
"""
)
@GetMapping("/chnprmship")
public ResponseEntity<ApiResponse<List<AisTargetResponseDto>>> getChnPrmShip(
@Parameter(description = "조회 범위 (분, 기본: 2880 = 2일)", example = "2880")
@RequestParam(defaultValue = "2880") Integer minutes) {
log.info("ChnPrmShip 조회 요청 - minutes: {}", minutes);
List<AisTargetResponseDto> result = aisTargetService.findChnPrmShip(minutes);
return ResponseEntity.ok(ApiResponse.success(
"ChnPrmShip 조회 완료: " + result.size() + "",
result
));
}
@Operation(
summary = "중국 허가선박 캐시 통계",
description = "중국 허가선박 전용 캐시의 현재 상태를 조회합니다"
)
@GetMapping("/chnprmship/stats")
public ResponseEntity<ApiResponse<Map<String, Object>>> getChnPrmShipStats() {
Map<String, Object> stats = aisTargetService.getChnPrmShipCacheStats();
return ResponseEntity.ok(ApiResponse.success(stats));
}
// ==================== 단건 조회 ====================
@Operation(
summary = "MMSI로 최신 위치 조회",
description = "특정 MMSI의 최신 위치 정보를 조회합니다 (캐시 우선)",
responses = {
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "해당 MMSI의 위치 정보 없음")
}
)
@GetMapping("/{mmsi}")
public ResponseEntity<ApiResponse<AisTargetResponseDto>> getLatestByMmsi(
@Parameter(description = "MMSI 번호", required = true, example = "440123456")
@PathVariable Long mmsi) {
log.info("최신 위치 조회 요청 - MMSI: {}", mmsi);
return aisTargetService.findLatestByMmsi(mmsi)
.map(dto -> ResponseEntity.ok(ApiResponse.success(dto)))
.orElse(ResponseEntity.notFound().build());
}
// ==================== 다건 조회 ====================
@Operation(
summary = "여러 MMSI의 최신 위치 조회",
description = "여러 MMSI의 최신 위치 정보를 일괄 조회합니다 (캐시 우선)"
)
@PostMapping("/batch")
public ResponseEntity<ApiResponse<List<AisTargetResponseDto>>> getLatestByMmsiList(
@Parameter(description = "MMSI 번호 목록", required = true)
@RequestBody List<Long> mmsiList) {
log.info("다건 최신 위치 조회 요청 - 요청 수: {}", mmsiList.size());
List<AisTargetResponseDto> result = aisTargetService.findLatestByMmsiList(mmsiList);
return ResponseEntity.ok(ApiResponse.success(
"조회 완료: " + result.size() + "/" + mmsiList.size() + "",
result
));
}
// ==================== 검색 조회 ====================
@Operation(
summary = "시간/공간 범위로 선박 검색",
description = """
시간 범위 (필수) + 공간 범위 (옵션) + 선박 클래스 타입 (옵션)으로 선박을 검색합니다.
- minutes: 조회 범위 (, 필수)
- centerLon, centerLat: 중심 좌표 (옵션)
- radiusMeters: 반경 (미터, 옵션)
- classType: 선박 클래스 타입 필터 (A/B, 옵션)
---
## ClassType 설명
- **A**: Core20에 등록된 선박 (Class A AIS 장착 의무 선박)
- **B**: Core20 미등록 선박 (Class B AIS 또는 미등록)
- 미지정: 전체 조회
---
## 응답 필드 설명
- **classType**: 선박 클래스 타입 (A/B)
- **core20Mmsi**: Core20 테이블의 MMSI (Class A인 경우에만 존재할 있음)
공간 범위가 지정되지 않으면 전체 선박의 최신 위치를 반환합니다.
"""
)
@GetMapping("/search")
public ResponseEntity<ApiResponse<List<AisTargetResponseDto>>> search(
@Parameter(description = "조회 범위 (분)", required = true, example = "5")
@RequestParam @Min(value = 1, message = "minutes는 최소 1분 이상이어야 합니다") Integer minutes,
@Parameter(description = "중심 경도", example = "129.0")
@RequestParam(required = false) Double centerLon,
@Parameter(description = "중심 위도", example = "35.0")
@RequestParam(required = false) Double centerLat,
@Parameter(description = "반경 (미터)", example = "50000")
@RequestParam(required = false) Double radiusMeters,
@Parameter(description = "선박 클래스 타입 필터 (A: Core20 등록, B: 미등록)", example = "A")
@RequestParam(required = false) String classType) {
log.info("선박 검색 요청 - minutes: {}, center: ({}, {}), radius: {}, classType: {}",
minutes, centerLon, centerLat, radiusMeters, classType);
AisTargetSearchRequest request = AisTargetSearchRequest.builder()
.minutes(minutes)
.centerLon(centerLon)
.centerLat(centerLat)
.radiusMeters(radiusMeters)
.classType(classType)
.build();
List<AisTargetResponseDto> result = aisTargetService.search(request);
return ResponseEntity.ok(ApiResponse.success(
"검색 완료: " + result.size() + "",
result
));
}
@Operation(
summary = "시간/공간 범위로 선박 검색 (POST)",
responses = {
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "검색 성공"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "요청 파라미터 검증 실패 (minutes 누락 또는 1 미만)")
},
description = """
POST 방식으로 검색 조건을 전달합니다.
---
## 요청 예시
```json
{
"minutes": 5,
"centerLon": 129.0,
"centerLat": 35.0,
"radiusMeters": 50000,
"classType": "A"
}
```
---
## ClassType 설명
- **A**: Core20에 등록된 선박 (Class A AIS 장착 의무 선박)
- **B**: Core20 미등록 선박 (Class B AIS 또는 미등록)
- 미지정: 전체 조회
"""
)
@PostMapping("/search")
public ResponseEntity<ApiResponse<List<AisTargetResponseDto>>> searchPost(
@Valid @RequestBody AisTargetSearchRequest request) {
log.info("선박 검색 요청 (POST) - minutes: {}, hasArea: {}, classType: {}",
request.getMinutes(), request.hasAreaFilter(), request.getClassType());
List<AisTargetResponseDto> result = aisTargetService.search(request);
return ResponseEntity.ok(ApiResponse.success(
"검색 완료: " + result.size() + "",
result
));
}
// ==================== 조건 필터 검색 ====================
@Operation(
summary = "항해 조건 필터 검색",
responses = {
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "필터 검색 성공"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "요청 파라미터 검증 실패")
},
description = """
속도(SOG), 침로(COG), 선수방위(Heading), 목적지, 항행상태로 선박을 필터링합니다.
---
## 조건 타입 파라미터 사용법
| 조건 | 의미 | 사용 파라미터 |
|------|------|--------------|
| GTE | 이상 (>=) | *Value (: sogValue) |
| GT | 초과 (>) | *Value |
| LTE | 이하 (<=) | *Value |
| LT | 미만 (<) | *Value |
| BETWEEN | 범위 | *Min, *Max (: sogMin, sogMax) |
---
## 요청 예시
**예시 1: 단일 조건 (속도 10knots 이상)**
```json
{
"minutes": 5,
"sogCondition": "GTE",
"sogValue": 10.0
}
```
**예시 2: 범위 조건 (속도 5~15knots, 침로 90~180도)**
```json
{
"minutes": 5,
"sogCondition": "BETWEEN",
"sogMin": 5.0,
"sogMax": 15.0,
"cogCondition": "BETWEEN",
"cogMin": 90.0,
"cogMax": 180.0
}
```
**예시 3: 복합 조건**
```json
{
"minutes": 5,
"sogCondition": "GTE",
"sogValue": 10.0,
"cogCondition": "BETWEEN",
"cogMin": 90.0,
"cogMax": 180.0,
"headingCondition": "LT",
"headingValue": 180.0,
"destination": "BUSAN",
"statusList": ["Under way using engine", "At anchor", "Moored"]
}
```
---
## 항행상태 (statusList)
statusList에는 **텍스트 문자열** 전달해야 합니다 (대소문자 무시).
| | 설명 |
|------|------|
| Under way using engine | 기관 사용 항해 |
| At anchor | 정박 |
| Not under command | 조종불능 |
| Restricted manoeuverability | 조종제한 |
| Constrained by her draught | 흘수제약 |
| Moored | 계류 |
| Aground | 좌초 |
| Engaged in Fishing | 어로 |
| Under way sailing | 항해 |
| Power Driven Towing Astern | 예인선 (후방) |
| Power Driven Towing Alongside | 예인선 (측방) |
| AIS Sart | 비상위치지시기 |
| N/A | 정보없음 |
---
**참고:** 모든 필터는 선택사항이며, 미지정 해당 필드는 조건에서 제외됩니다 (전체 포함).
"""
)
@PostMapping("/search/filter")
public ResponseEntity<ApiResponse<List<AisTargetResponseDto>>> searchByFilter(
@Valid @RequestBody AisTargetFilterRequest request) {
log.info("필터 검색 요청 - minutes: {}, sog: {}/{}, cog: {}/{}, heading: {}/{}, dest: {}, status: {}",
request.getMinutes(),
request.getSogCondition(), request.getSogValue(),
request.getCogCondition(), request.getCogValue(),
request.getHeadingCondition(), request.getHeadingValue(),
request.getDestination(),
request.getStatusList());
List<AisTargetResponseDto> result = aisTargetService.searchByFilter(request);
return ResponseEntity.ok(ApiResponse.success(
"필터 검색 완료: " + result.size() + "",
result
));
}
// ==================== 폴리곤 검색 ====================
@Operation(
summary = "폴리곤 범위 내 선박 검색",
responses = {
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "검색 성공"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "요청 파라미터 검증 실패 (coordinates 또는 minutes 누락)")
},
description = """
폴리곤 범위 선박을 검색합니다.
요청 예시:
{
"minutes": 5,
"coordinates": [[129.0, 35.0], [130.0, 35.0], [130.0, 36.0], [129.0, 36.0], [129.0, 35.0]]
}
좌표는 [경도, 위도] 순서이며, 폴리곤은 닫힌 형태여야 합니다 (첫점 = 끝점).
"""
)
@PostMapping("/search/polygon")
public ResponseEntity<ApiResponse<List<AisTargetResponseDto>>> searchByPolygon(
@Valid @RequestBody PolygonSearchRequest request) {
log.info("폴리곤 검색 요청 - minutes: {}, points: {}",
request.getMinutes(), request.getCoordinates().length);
List<AisTargetResponseDto> result = aisTargetService.searchByPolygon(
request.getMinutes(),
request.getCoordinates()
);
return ResponseEntity.ok(ApiResponse.success(
"폴리곤 검색 완료: " + result.size() + "",
result
));
}
@Operation(
summary = "WKT 범위 내 선박 검색",
responses = {
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "검색 성공"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "요청 파라미터 검증 실패 (wkt 또는 minutes 누락)")
},
description = """
WKT(Well-Known Text) 형식으로 정의된 범위 선박을 검색합니다.
요청 예시:
{
"minutes": 5,
"wkt": "POLYGON((129 35, 130 35, 130 36, 129 36, 129 35))"
}
지원 형식: POLYGON, MULTIPOLYGON
"""
)
@PostMapping("/search/wkt")
public ResponseEntity<ApiResponse<List<AisTargetResponseDto>>> searchByWkt(
@Valid @RequestBody WktSearchRequest request) {
log.info("WKT 검색 요청 - minutes: {}, wkt: {}", request.getMinutes(), request.getWkt());
List<AisTargetResponseDto> result = aisTargetService.searchByWkt(
request.getMinutes(),
request.getWkt()
);
return ResponseEntity.ok(ApiResponse.success(
"WKT 검색 완료: " + result.size() + "",
result
));
}
@Operation(
summary = "거리 포함 원형 범위 검색",
description = """
원형 범위 선박을 검색하고, 중심점으로부터의 거리 정보를 함께 반환합니다.
결과는 거리순으로 정렬됩니다.
"""
)
@GetMapping("/search/with-distance")
public ResponseEntity<ApiResponse<List<AisTargetService.AisTargetWithDistanceDto>>> searchWithDistance(
@Parameter(description = "조회 범위 (분)", required = true, example = "5")
@RequestParam Integer minutes,
@Parameter(description = "중심 경도", required = true, example = "129.0")
@RequestParam Double centerLon,
@Parameter(description = "중심 위도", required = true, example = "35.0")
@RequestParam Double centerLat,
@Parameter(description = "반경 (미터)", required = true, example = "50000")
@RequestParam Double radiusMeters) {
log.info("거리 포함 검색 요청 - minutes: {}, center: ({}, {}), radius: {}",
minutes, centerLon, centerLat, radiusMeters);
List<AisTargetService.AisTargetWithDistanceDto> result =
aisTargetService.searchWithDistance(minutes, centerLon, centerLat, radiusMeters);
return ResponseEntity.ok(ApiResponse.success(
"거리 포함 검색 완료: " + result.size() + "",
result
));
}
// ==================== 항적 조회 ====================
@Operation(
summary = "항적 조회",
description = "특정 MMSI의 시간 범위 내 항적 (위치 이력)을 조회합니다"
)
@GetMapping("/{mmsi}/track")
public ResponseEntity<ApiResponse<List<AisTargetResponseDto>>> getTrack(
@Parameter(description = "MMSI 번호", required = true, example = "440123456")
@PathVariable Long mmsi,
@Parameter(description = "조회 범위 (분)", required = true, example = "60")
@RequestParam Integer minutes) {
log.info("항적 조회 요청 - MMSI: {}, 범위: {}분", mmsi, minutes);
List<AisTargetResponseDto> track = aisTargetService.getTrack(mmsi, minutes);
return ResponseEntity.ok(ApiResponse.success(
"항적 조회 완료: " + track.size() + " 포인트",
track
));
}
// ==================== 캐시 관리 ====================
@Operation(
summary = "캐시 통계 조회",
description = "AIS Target 캐시의 현재 상태를 조회합니다"
)
@GetMapping("/cache/stats")
public ResponseEntity<ApiResponse<Map<String, Object>>> getCacheStats() {
Map<String, Object> stats = aisTargetService.getCacheStats();
return ResponseEntity.ok(ApiResponse.success(stats));
}
@Operation(
summary = "캐시 초기화",
description = "AIS Target 캐시를 초기화합니다"
)
@DeleteMapping("/cache")
public ResponseEntity<ApiResponse<Void>> clearCache() {
log.warn("캐시 초기화 요청");
aisTargetService.clearCache();
return ResponseEntity.ok(ApiResponse.success("캐시가 초기화되었습니다", null));
}
// ==================== 요청 DTO (내부 클래스) ====================
/**
* 폴리곤 검색 요청 DTO
*/
@lombok.Data
@Schema(description = "폴리곤 범위 검색 요청")
public static class PolygonSearchRequest {
@NotNull(message = "minutes는 필수입니다")
@Min(value = 1, message = "minutes는 최소 1분 이상이어야 합니다")
@Schema(description = "조회 범위 (분)", example = "5", requiredMode = Schema.RequiredMode.REQUIRED)
private Integer minutes;
@NotNull(message = "coordinates는 필수입니다")
@Schema(description = "폴리곤 좌표 [[경도, 위도], ...] (닫힌 형태: 첫점=끝점)",
example = "[[129.0, 35.0], [130.0, 35.0], [130.0, 36.0], [129.0, 36.0], [129.0, 35.0]]",
requiredMode = Schema.RequiredMode.REQUIRED)
private double[][] coordinates;
}
/**
* WKT 검색 요청 DTO
*/
@lombok.Data
@Schema(description = "WKT 범위 검색 요청")
public static class WktSearchRequest {
@NotNull(message = "minutes는 필수입니다")
@Min(value = 1, message = "minutes는 최소 1분 이상이어야 합니다")
@Schema(description = "조회 범위 (분)", example = "5", requiredMode = Schema.RequiredMode.REQUIRED)
private Integer minutes;
@NotNull(message = "wkt는 필수입니다")
@Schema(description = "WKT 문자열 (POLYGON, MULTIPOLYGON 지원)",
example = "POLYGON((129 35, 130 35, 130 36, 129 36, 129 35))",
requiredMode = Schema.RequiredMode.REQUIRED)
private String wkt;
}
}

파일 보기

@ -1,165 +0,0 @@
package com.snp.batch.jobs.aistarget.web.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import java.util.List;
/**
* AIS Target 필터 검색 요청 DTO
*
* 조건 타입 (condition):
* - GTE: 이상 (>=)
* - GT: 초과 (>)
* - LTE: 이하 (<=)
* - LT: 미만 (<)
* - BETWEEN: 범위 (min <= value <= max)
*
* 모든 필터는 선택사항이며, 미지정 해당 필드 전체 포함
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "AIS Target 필터 검색 요청")
public class AisTargetFilterRequest {
@NotNull(message = "minutes는 필수입니다")
@Min(value = 1, message = "minutes는 최소 1분 이상이어야 합니다")
@Schema(description = "조회 범위 (분)", example = "5", requiredMode = Schema.RequiredMode.REQUIRED)
private Integer minutes;
// ==================== 속도 (SOG) 필터 ====================
@Schema(description = """
속도(SOG) 조건 타입
- GTE: 이상 (>=) - sogValue 사용
- GT: 초과 (>) - sogValue 사용
- LTE: 이하 (<=) - sogValue 사용
- LT: 미만 (<) - sogValue 사용
- BETWEEN: 범위 - sogMin, sogMax 사용
""",
example = "GTE", allowableValues = {"GTE", "GT", "LTE", "LT", "BETWEEN"})
private String sogCondition;
@Schema(description = "속도 값 (knots) - GTE/GT/LTE/LT 조건에서 사용", example = "10.0")
private Double sogValue;
@Schema(description = "속도 최소값 (knots) - BETWEEN 조건에서 사용 (sogMin <= 속도 <= sogMax)", example = "5.0")
private Double sogMin;
@Schema(description = "속도 최대값 (knots) - BETWEEN 조건에서 사용 (sogMin <= 속도 <= sogMax)", example = "15.0")
private Double sogMax;
// ==================== 침로 (COG) 필터 ====================
@Schema(description = """
침로(COG) 조건 타입
- GTE: 이상 (>=) - cogValue 사용
- GT: 초과 (>) - cogValue 사용
- LTE: 이하 (<=) - cogValue 사용
- LT: 미만 (<) - cogValue 사용
- BETWEEN: 범위 - cogMin, cogMax 사용
""",
example = "BETWEEN", allowableValues = {"GTE", "GT", "LTE", "LT", "BETWEEN"})
private String cogCondition;
@Schema(description = "침로 값 (degrees, 0-360) - GTE/GT/LTE/LT 조건에서 사용", example = "180.0")
private Double cogValue;
@Schema(description = "침로 최소값 (degrees) - BETWEEN 조건에서 사용 (cogMin <= 침로 <= cogMax)", example = "90.0")
private Double cogMin;
@Schema(description = "침로 최대값 (degrees) - BETWEEN 조건에서 사용 (cogMin <= 침로 <= cogMax)", example = "270.0")
private Double cogMax;
// ==================== 선수방위 (Heading) 필터 ====================
@Schema(description = """
선수방위(Heading) 조건 타입
- GTE: 이상 (>=) - headingValue 사용
- GT: 초과 (>) - headingValue 사용
- LTE: 이하 (<=) - headingValue 사용
- LT: 미만 (<) - headingValue 사용
- BETWEEN: 범위 - headingMin, headingMax 사용
""",
example = "LTE", allowableValues = {"GTE", "GT", "LTE", "LT", "BETWEEN"})
private String headingCondition;
@Schema(description = "선수방위 값 (degrees, 0-360) - GTE/GT/LTE/LT 조건에서 사용", example = "90.0")
private Double headingValue;
@Schema(description = "선수방위 최소값 (degrees) - BETWEEN 조건에서 사용 (headingMin <= 선수방위 <= headingMax)", example = "0.0")
private Double headingMin;
@Schema(description = "선수방위 최대값 (degrees) - BETWEEN 조건에서 사용 (headingMin <= 선수방위 <= headingMax)", example = "180.0")
private Double headingMax;
// ==================== 목적지 (Destination) 필터 ====================
@Schema(description = "목적지 (부분 일치, 대소문자 무시)", example = "BUSAN")
private String destination;
// ==================== 항행상태 (Status) 필터 ====================
@Schema(description = """
항행상태 목록 (다중 선택 가능, 미선택 전체)
- Under way using engine (기관 사용 항해 )
- Under way sailing ( 항해 )
- Anchored (정박 )
- Moored (계류 )
- Not under command (조종불능)
- Restriced manoeuverability (조종제한)
- Constrained by draught (흘수제약)
- Aground (좌초)
- Engaged in fishing (어로 )
- Power Driven Towing Astern (예인선-후방)
- Power Driven Towing Alongside (예인선-측방)
- AIS Sart (비상위치지시기)
- N/A (정보없음)
""",
example = "[\"Under way using engine\", \"Anchored\", \"Moored\"]")
private List<String> statusList;
// ==================== 선박 클래스 타입 (ClassType) 필터 ====================
@Schema(description = """
선박 클래스 타입 필터
- A: Core20에 등록된 선박 (Class A AIS 장착 의무 선박)
- B: Core20 미등록 선박 (Class B AIS 또는 미등록)
- 미지정: 전체 조회
""",
example = "A", allowableValues = {"A", "B"})
private String classType;
// ==================== 필터 존재 여부 확인 ====================
public boolean hasSogFilter() {
return sogCondition != null && !sogCondition.isEmpty();
}
public boolean hasCogFilter() {
return cogCondition != null && !cogCondition.isEmpty();
}
public boolean hasHeadingFilter() {
return headingCondition != null && !headingCondition.isEmpty();
}
public boolean hasDestinationFilter() {
return destination != null && !destination.trim().isEmpty();
}
public boolean hasStatusFilter() {
return statusList != null && !statusList.isEmpty();
}
public boolean hasClassTypeFilter() {
return classType != null &&
(classType.equalsIgnoreCase("A") || classType.equalsIgnoreCase("B"));
}
public boolean hasAnyFilter() {
return hasSogFilter() || hasCogFilter() || hasHeadingFilter()
|| hasDestinationFilter() || hasStatusFilter() || hasClassTypeFilter();
}
}

파일 보기

@ -1,157 +0,0 @@
package com.snp.batch.jobs.aistarget.web.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.OffsetDateTime;
/**
* AIS Target API 응답 DTO
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
@Schema(description = "AIS Target 응답")
public class AisTargetResponseDto {
// 선박 식별 정보
@Schema(description = "MMSI (Maritime Mobile Service Identity) 번호", example = "440123456")
private Long mmsi;
@Schema(description = "IMO 번호 (0인 경우 미등록)", example = "9137960")
private Long imo;
@Schema(description = "선박명", example = "ROYAUME DES OCEANS")
private String name;
@Schema(description = "호출 부호", example = "4SFTEST")
private String callsign;
@Schema(description = "선박 유형 (외부 API 원본 텍스트)", example = "Vessel")
private String vesselType;
// 위치 정보
@Schema(description = "위도 (WGS84)", example = "35.0796")
private Double lat;
@Schema(description = "경도 (WGS84)", example = "129.0756")
private Double lon;
// 항해 정보
@Schema(description = "선수방위 (degrees, 0-360)", example = "36.0")
private Double heading;
@Schema(description = "대지속력 (knots)", example = "12.5")
private Double sog;
@Schema(description = "대지침로 (degrees, 0-360)", example = "36.2")
private Double cog;
@Schema(description = "회전율 (Rate of Turn)", example = "0")
private Integer rot;
// 선박 제원
@Schema(description = "선박 길이 (미터)", example = "19")
private Integer length;
@Schema(description = "선박 폭 (미터)", example = "15")
private Integer width;
@Schema(description = "흘수 (미터)", example = "5.5")
private Double draught;
// 목적지 정보
@Schema(description = "목적지", example = "BUSAN")
private String destination;
@Schema(description = "예정 도착 시간 (UTC)")
private OffsetDateTime eta;
@Schema(description = "항행상태 (텍스트)", example = "Under way using engine")
private String status;
// 타임스탬프
@Schema(description = "AIS 메시지 발생 시각 (UTC)")
private OffsetDateTime messageTimestamp;
@Schema(description = "데이터 수신 시각 (UTC)")
private OffsetDateTime receivedDate;
// 데이터 소스 (캐시/DB)
@Schema(description = "데이터 소스", example = "cache", allowableValues = {"cache", "db"})
private String source;
// 선종 분류 정보
@Schema(description = """
MDA 범례코드 (선종 분류)
- 000020: 어선 (FISHING)
- 000021: 함정 (KCGV)
- 000022: 여객선 (FERRY)
- 000023: 카고 (CARGO)
- 000024: 탱커 (TANKER)
- 000025: 관공선 (GOV)
- 000027: 일반/기타선박 (DEFAULT)
- 000028: 부이/항로표지 (BUOY)
""",
example = "000023")
private String signalKindCode;
// ClassType 분류 정보
@Schema(description = """
선박 클래스 타입
- A: Core20에 등록된 선박 (Class A AIS 장착 의무 선박)
- B: Core20 미등록 선박 (Class B AIS 또는 미등록)
""",
example = "A", allowableValues = {"A", "B"})
private String classType;
@Schema(description = """
Core20 테이블의 MMSI
- Class A인 경우에만 값이 있을 있음
- null: Class B 또는 Core20에 MMSI가 미등록된 경우
""",
example = "440123456", nullable = true)
private String core20Mmsi;
/**
* Entity -> DTO 변환
*/
public static AisTargetResponseDto from(AisTargetEntity entity, String source) {
if (entity == null) {
return null;
}
return AisTargetResponseDto.builder()
.mmsi(entity.getMmsi())
.imo(entity.getImo())
.name(entity.getName())
.callsign(entity.getCallsign())
.vesselType(entity.getVesselType())
.lat(entity.getLat())
.lon(entity.getLon())
.heading(entity.getHeading())
.sog(entity.getSog())
.cog(entity.getCog())
.rot(entity.getRot())
.length(entity.getLength())
.width(entity.getWidth())
.draught(entity.getDraught())
.destination(entity.getDestination())
.eta(entity.getEta())
.status(entity.getStatus())
.messageTimestamp(entity.getMessageTimestamp())
.receivedDate(entity.getReceivedDate())
.source(source)
.signalKindCode(entity.getSignalKindCode())
.classType(entity.getClassType())
.core20Mmsi(entity.getCore20Mmsi())
.build();
}
}

파일 보기

@ -1,66 +0,0 @@
package com.snp.batch.jobs.aistarget.web.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
/**
* AIS Target 검색 요청 DTO
*
* 필수 파라미터:
* - minutes: 단위 조회 범위 (1~60)
*
* 옵션 파라미터:
* - centerLon, centerLat, radiusMeters: 공간 범위 필터
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "AIS Target 검색 요청")
public class AisTargetSearchRequest {
@NotNull(message = "minutes는 필수입니다")
@Min(value = 1, message = "minutes는 최소 1분 이상이어야 합니다")
@Schema(description = "조회 범위 (분)", example = "5", required = true)
private Integer minutes;
@Schema(description = "중심 경도 (옵션)", example = "129.0")
private Double centerLon;
@Schema(description = "중심 위도 (옵션)", example = "35.0")
private Double centerLat;
@Schema(description = "반경 (미터, 옵션)", example = "50000")
private Double radiusMeters;
@Schema(description = """
선박 클래스 타입 필터
- A: Core20에 등록된 선박 (Class A AIS 장착 의무 선박)
- B: Core20 미등록 선박 (Class B AIS 또는 미등록)
- 미지정: 전체 조회
""",
example = "A", allowableValues = {"A", "B"})
private String classType;
/**
* 공간 필터 사용 여부
*/
public boolean hasAreaFilter() {
return centerLon != null && centerLat != null && radiusMeters != null;
}
/**
* ClassType 필터 사용 여부
* - "A" 또는 "B" 경우에만 true
*/
public boolean hasClassTypeFilter() {
return classType != null &&
(classType.equalsIgnoreCase("A") || classType.equalsIgnoreCase("B"));
}
}

파일 보기

@ -1,90 +0,0 @@
package com.snp.batch.jobs.aistarget.web.dto;
/**
* 숫자 비교 조건 열거형
*
* 사용: SOG, COG, Heading 필터링
*/
public enum NumericCondition {
/**
* 이상 (>=)
*/
GTE,
/**
* 초과 (>)
*/
GT,
/**
* 이하 (<=)
*/
LTE,
/**
* 미만 (<)
*/
LT,
/**
* 범위 (min <= value <= max)
*/
BETWEEN;
/**
* 문자열을 NumericCondition으로 변환
*
* @param value 조건 문자열
* @return NumericCondition (null이면 null 반환)
*/
public static NumericCondition fromString(String value) {
if (value == null || value.trim().isEmpty()) {
return null;
}
try {
return NumericCondition.valueOf(value.toUpperCase().trim());
} catch (IllegalArgumentException e) {
return null;
}
}
/**
* 주어진 값이 조건을 만족하는지 확인
*
* @param fieldValue 필드
* @param compareValue 비교 (GTE, GT, LTE, LT용)
* @param minValue 최소값 (BETWEEN용)
* @param maxValue 최대값 (BETWEEN용)
* @return 조건 만족 여부
*/
public boolean matches(Double fieldValue, Double compareValue, Double minValue, Double maxValue) {
if (fieldValue == null) {
return false;
}
return switch (this) {
case GTE -> compareValue != null && fieldValue >= compareValue;
case GT -> compareValue != null && fieldValue > compareValue;
case LTE -> compareValue != null && fieldValue <= compareValue;
case LT -> compareValue != null && fieldValue < compareValue;
case BETWEEN -> minValue != null && maxValue != null
&& fieldValue >= minValue && fieldValue <= maxValue;
};
}
/**
* SQL 조건절 생성 (DB 쿼리용)
*
* @param columnName 컬럼명
* @return SQL 조건절 문자열
*/
public String toSqlCondition(String columnName) {
return switch (this) {
case GTE -> columnName + " >= ?";
case GT -> columnName + " > ?";
case LTE -> columnName + " <= ?";
case LT -> columnName + " < ?";
case BETWEEN -> columnName + " BETWEEN ? AND ?";
};
}
}

파일 보기

@ -1,422 +0,0 @@
package com.snp.batch.jobs.aistarget.web.service;
import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
import com.snp.batch.jobs.aistarget.batch.repository.AisTargetRepository;
import com.snp.batch.jobs.aistarget.cache.AisTargetCacheManager;
import com.snp.batch.jobs.aistarget.cache.AisTargetFilterUtil;
import com.snp.batch.jobs.aistarget.cache.SpatialFilterUtil;
import com.snp.batch.jobs.aistarget.chnprmship.ChnPrmShipCacheManager;
import com.snp.batch.jobs.aistarget.web.dto.AisTargetFilterRequest;
import com.snp.batch.jobs.aistarget.web.dto.AisTargetResponseDto;
import com.snp.batch.jobs.aistarget.web.dto.AisTargetSearchRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.*;
import java.util.stream.Collectors;
/**
* AIS Target 서비스
*
* 조회 전략:
* 1. 캐시 우선 조회 (Caffeine 캐시)
* 2. 캐시 미스 DB Fallback
* 3. 공간 필터링은 캐시에서 수행 (JTS 기반)
*
* 성능:
* - 캐시 조회: O(1)
* - 공간 필터링: O(n) with 병렬 처리 (25만건 ~50-100ms)
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AisTargetService {
private final AisTargetRepository aisTargetRepository;
private final AisTargetCacheManager cacheManager;
private final SpatialFilterUtil spatialFilterUtil;
private final AisTargetFilterUtil filterUtil;
private final ChnPrmShipCacheManager chnPrmShipCacheManager;
private static final String SOURCE_CACHE = "cache";
private static final String SOURCE_DB = "db";
// ==================== 단건 조회 ====================
/**
* MMSI로 최신 위치 조회 (캐시 우선)
*/
public Optional<AisTargetResponseDto> findLatestByMmsi(Long mmsi) {
log.debug("최신 위치 조회 - MMSI: {}", mmsi);
// 1. 캐시 조회
Optional<AisTargetEntity> cached = cacheManager.get(mmsi);
if (cached.isPresent()) {
log.debug("캐시 히트 - MMSI: {}", mmsi);
return Optional.of(AisTargetResponseDto.from(cached.get(), SOURCE_CACHE));
}
// 2. DB 조회 (캐시 미스)
log.debug("캐시 미스, DB 조회 - MMSI: {}", mmsi);
Optional<AisTargetEntity> fromDb = aisTargetRepository.findLatestByMmsi(mmsi);
if (fromDb.isPresent()) {
// 3. 캐시 업데이트
cacheManager.put(fromDb.get());
log.debug("DB 조회 성공, 캐시 업데이트 - MMSI: {}", mmsi);
return Optional.of(AisTargetResponseDto.from(fromDb.get(), SOURCE_DB));
}
return Optional.empty();
}
// ==================== 다건 조회 ====================
/**
* 여러 MMSI의 최신 위치 조회 (캐시 우선)
*/
public List<AisTargetResponseDto> findLatestByMmsiList(List<Long> mmsiList) {
if (mmsiList == null || mmsiList.isEmpty()) {
return Collections.emptyList();
}
log.debug("다건 최신 위치 조회 - 요청: {} 건", mmsiList.size());
List<AisTargetResponseDto> result = new ArrayList<>();
// 1. 캐시에서 조회
Map<Long, AisTargetEntity> cachedData = cacheManager.getAll(mmsiList);
for (AisTargetEntity entity : cachedData.values()) {
result.add(AisTargetResponseDto.from(entity, SOURCE_CACHE));
}
// 2. 캐시 미스 목록
List<Long> missedMmsiList = mmsiList.stream()
.filter(mmsi -> !cachedData.containsKey(mmsi))
.collect(Collectors.toList());
// 3. DB에서 캐시 미스 데이터 조회
if (!missedMmsiList.isEmpty()) {
log.debug("캐시 미스 DB 조회 - {} 건", missedMmsiList.size());
List<AisTargetEntity> fromDb = aisTargetRepository.findLatestByMmsiIn(missedMmsiList);
for (AisTargetEntity entity : fromDb) {
// 캐시 업데이트
cacheManager.put(entity);
result.add(AisTargetResponseDto.from(entity, SOURCE_DB));
}
}
log.debug("조회 완료 - 캐시: {}, DB: {}, 총: {}",
cachedData.size(), result.size() - cachedData.size(), result.size());
return result;
}
// ==================== 검색 조회 (캐시 기반) ====================
/**
* 시간 범위 + 옵션 공간 범위로 선박 검색 (캐시 우선)
*
* 전략:
* 1. 캐시에서 시간 범위 데이터 조회
* 2. 공간 필터 있으면 JTS로 필터링
* 3. ClassType 필터 있으면 적용
* 4. 캐시 데이터가 없으면 DB Fallback
*/
public List<AisTargetResponseDto> search(AisTargetSearchRequest request) {
log.debug("선박 검색 - minutes: {}, hasArea: {}, classType: {}",
request.getMinutes(), request.hasAreaFilter(), request.getClassType());
long startTime = System.currentTimeMillis();
// 1. 캐시에서 시간 범위 데이터 조회
List<AisTargetEntity> entities = cacheManager.getByTimeRange(request.getMinutes());
String source = SOURCE_CACHE;
// 캐시가 비어있으면 DB Fallback
if (entities.isEmpty()) {
log.debug("캐시 비어있음, DB Fallback");
entities = searchFromDb(request);
source = SOURCE_DB;
// DB 결과를 캐시에 저장
for (AisTargetEntity entity : entities) {
cacheManager.put(entity);
}
} else if (request.hasAreaFilter()) {
// 2. 공간 필터링 (JTS 기반, 병렬 처리)
entities = spatialFilterUtil.filterByCircle(
entities,
request.getCenterLon(),
request.getCenterLat(),
request.getRadiusMeters()
);
}
// 3. ClassType 필터 적용
if (request.hasClassTypeFilter()) {
entities = filterUtil.filterByClassType(entities, request);
}
long elapsed = System.currentTimeMillis() - startTime;
log.info("선박 검색 완료 - 소스: {}, 결과: {} 건, 소요: {}ms",
source, entities.size(), elapsed);
final String finalSource = source;
return entities.stream()
.map(e -> AisTargetResponseDto.from(e, finalSource))
.collect(Collectors.toList());
}
/**
* DB에서 검색 (Fallback)
*/
private List<AisTargetEntity> searchFromDb(AisTargetSearchRequest request) {
OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC);
OffsetDateTime start = now.minusMinutes(request.getMinutes());
if (request.hasAreaFilter()) {
return aisTargetRepository.findByTimeRangeAndArea(
start, now,
request.getCenterLon(),
request.getCenterLat(),
request.getRadiusMeters()
);
} else {
// 공간 필터 없으면 전체 조회 (주의: 대량 데이터)
return aisTargetRepository.findByTimeRangeAndArea(
start, now,
0.0, 0.0, Double.MAX_VALUE
);
}
}
// ==================== 조건 필터 검색 ====================
/**
* 항해 조건 필터 검색 (캐시 우선)
*
* 필터 조건:
* - SOG (속도): 이상/초과/이하/미만/범위
* - COG (침로): 이상/초과/이하/미만/범위
* - Heading (선수방위): 이상/초과/이하/미만/범위
* - Destination (목적지): 부분 일치
* - Status (항행상태): 다중 선택
*
* @param request 필터 조건
* @return 조건에 맞는 선박 목록
*/
public List<AisTargetResponseDto> searchByFilter(AisTargetFilterRequest request) {
log.debug("필터 검색 - minutes: {}, hasFilter: {}",
request.getMinutes(), request.hasAnyFilter());
long startTime = System.currentTimeMillis();
// 1. 캐시에서 시간 범위 데이터 조회
List<AisTargetEntity> entities = cacheManager.getByTimeRange(request.getMinutes());
String source = SOURCE_CACHE;
// 캐시가 비어있으면 DB Fallback
if (entities.isEmpty()) {
log.debug("캐시 비어있음, DB Fallback");
entities = searchByFilterFromDb(request);
source = SOURCE_DB;
// DB 결과를 캐시에 저장
for (AisTargetEntity entity : entities) {
cacheManager.put(entity);
}
// DB에서 가져온 후에도 필터 적용 (DB 쿼리는 시간 범위만 적용)
entities = filterUtil.filter(entities, request);
} else {
// 2. 캐시 데이터에 필터 적용
entities = filterUtil.filter(entities, request);
}
long elapsed = System.currentTimeMillis() - startTime;
log.info("필터 검색 완료 - 소스: {}, 결과: {} 건, 소요: {}ms",
source, entities.size(), elapsed);
final String finalSource = source;
return entities.stream()
.map(e -> AisTargetResponseDto.from(e, finalSource))
.collect(Collectors.toList());
}
/**
* DB에서 필터 검색 (Fallback) - 시간 범위만 적용
*/
private List<AisTargetEntity> searchByFilterFromDb(AisTargetFilterRequest request) {
OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC);
OffsetDateTime start = now.minusMinutes(request.getMinutes());
// DB에서는 시간 범위만 조회하고, 나머지 필터는 메모리에서 적용
return aisTargetRepository.findByTimeRangeAndArea(
start, now,
0.0, 0.0, Double.MAX_VALUE
);
}
// ==================== 폴리곤 검색 ====================
/**
* 폴리곤 범위 선박 검색 (캐시 기반)
*
* @param minutes 시간 범위 ()
* @param polygonCoordinates 폴리곤 좌표 [[lon, lat], ...]
* @return 범위 선박 목록
*/
public List<AisTargetResponseDto> searchByPolygon(int minutes, double[][] polygonCoordinates) {
log.debug("폴리곤 검색 - minutes: {}, points: {}", minutes, polygonCoordinates.length);
long startTime = System.currentTimeMillis();
// 1. 캐시에서 시간 범위 데이터 조회
List<AisTargetEntity> entities = cacheManager.getByTimeRange(minutes);
// 2. 폴리곤 필터링 (JTS 기반)
entities = spatialFilterUtil.filterByPolygon(entities, polygonCoordinates);
long elapsed = System.currentTimeMillis() - startTime;
log.info("폴리곤 검색 완료 - 결과: {} 건, 소요: {}ms", entities.size(), elapsed);
return entities.stream()
.map(e -> AisTargetResponseDto.from(e, SOURCE_CACHE))
.collect(Collectors.toList());
}
/**
* WKT 형식 폴리곤으로 검색
*
* @param minutes 시간 범위 ()
* @param wkt WKT 문자열 (: "POLYGON((129 35, 130 35, 130 36, 129 36, 129 35))")
* @return 범위 선박 목록
*/
public List<AisTargetResponseDto> searchByWkt(int minutes, String wkt) {
log.debug("WKT 검색 - minutes: {}, wkt: {}", minutes, wkt);
long startTime = System.currentTimeMillis();
// 1. 캐시에서 시간 범위 데이터 조회
List<AisTargetEntity> entities = cacheManager.getByTimeRange(minutes);
// 2. WKT 필터링 (JTS 기반)
entities = spatialFilterUtil.filterByWkt(entities, wkt);
long elapsed = System.currentTimeMillis() - startTime;
log.info("WKT 검색 완료 - 결과: {} 건, 소요: {}ms", entities.size(), elapsed);
return entities.stream()
.map(e -> AisTargetResponseDto.from(e, SOURCE_CACHE))
.collect(Collectors.toList());
}
// ==================== 거리 포함 검색 ====================
/**
* 원형 범위 검색 + 거리 정보 포함
*/
public List<AisTargetWithDistanceDto> searchWithDistance(
int minutes, double centerLon, double centerLat, double radiusMeters) {
log.debug("거리 포함 검색 - minutes: {}, center: ({}, {}), radius: {}",
minutes, centerLon, centerLat, radiusMeters);
// 1. 캐시에서 시간 범위 데이터 조회
List<AisTargetEntity> entities = cacheManager.getByTimeRange(minutes);
// 2. 거리 포함 필터링
List<SpatialFilterUtil.EntityWithDistance> filtered =
spatialFilterUtil.filterByCircleWithDistance(entities, centerLon, centerLat, radiusMeters);
return filtered.stream()
.map(ewd -> new AisTargetWithDistanceDto(
AisTargetResponseDto.from(ewd.getEntity(), SOURCE_CACHE),
ewd.getDistanceMeters()
))
.collect(Collectors.toList());
}
// ==================== 항적 조회 ====================
/**
* 특정 MMSI의 시간 범위 항적 조회
*/
public List<AisTargetResponseDto> getTrack(Long mmsi, Integer minutes) {
log.debug("항적 조회 - MMSI: {}, 범위: {}분", mmsi, minutes);
OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC);
OffsetDateTime start = now.minusMinutes(minutes);
List<AisTargetEntity> track = aisTargetRepository.findByMmsiAndTimeRange(mmsi, start, now);
log.debug("항적 조회 완료 - MMSI: {}, 포인트: {} 개", mmsi, track.size());
return track.stream()
.map(e -> AisTargetResponseDto.from(e, SOURCE_DB))
.collect(Collectors.toList());
}
// ==================== 중국 허가선박 전용 조회 ====================
/**
* 중국 허가선박 전용 캐시 조회 (DB fallback 없음)
*
* @param minutes 조회 범위 ()
* @return 시간 범위 대상 선박 목록
*/
public List<AisTargetResponseDto> findChnPrmShip(int minutes) {
log.debug("ChnPrmShip 조회 - minutes: {}", minutes);
long startTime = System.currentTimeMillis();
List<AisTargetEntity> entities = chnPrmShipCacheManager.getByTimeRange(minutes);
long elapsed = System.currentTimeMillis() - startTime;
log.info("ChnPrmShip 조회 완료 - 결과: {} 건, 소요: {}ms", entities.size(), elapsed);
return entities.stream()
.map(e -> AisTargetResponseDto.from(e, SOURCE_CACHE))
.collect(Collectors.toList());
}
/**
* ChnPrmShip 캐시 통계 조회
*/
public Map<String, Object> getChnPrmShipCacheStats() {
return chnPrmShipCacheManager.getStats();
}
// ==================== 캐시 관리 ====================
/**
* 캐시 통계 조회
*/
public Map<String, Object> getCacheStats() {
return cacheManager.getStats();
}
/**
* 캐시 초기화
*/
public void clearCache() {
cacheManager.clear();
}
// ==================== 내부 DTO ====================
/**
* 거리 정보 포함 응답 DTO
*/
@lombok.Data
@lombok.AllArgsConstructor
public static class AisTargetWithDistanceDto {
private AisTargetResponseDto target;
private double distanceMeters;
}
}

파일 보기

@ -1,92 +0,0 @@
package com.snp.batch.jobs.aistargetdbsync.batch.config;
import com.snp.batch.jobs.aistargetdbsync.batch.tasklet.AisTargetDbSyncTasklet;
import com.snp.batch.jobs.aistargetdbsync.batch.tasklet.ShipLastPositionSyncTasklet;
import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.JobExecution;
import org.springframework.batch.core.JobExecutionListener;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.step.builder.StepBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.PlatformTransactionManager;
/**
* AIS Target DB Sync Job Config
*
* 스케줄: 15분 (0 0/15 * * * ?)
* API: 없음 (캐시 기반)
*
* 동작:
* - Step 1: Caffeine 캐시에서 최근 15분 이내 데이터 조회 ais_target DB UPSERT
* - Step 2: 캐시에서 IMO별 최신 위치 조회 tb_ship_main_info 위치 컬럼 UPDATE
*
* 데이터 흐름:
* - aisTargetImportJob (1분): API 캐시 업데이트
* - aisTargetDbSyncJob (15분): 캐시 DB 저장 ( Job)
* - Step 1: 캐시 ais_target UPSERT
* - Step 2: 캐시 tb_ship_main_info UPDATE (IMO별 최신 1건)
*/
@Slf4j
@Configuration
public class AisTargetDbSyncJobConfig {
private final JobRepository jobRepository;
private final PlatformTransactionManager transactionManager;
private final AisTargetDbSyncTasklet aisTargetDbSyncTasklet;
private final ShipLastPositionSyncTasklet shipLastPositionSyncTasklet;
public AisTargetDbSyncJobConfig(
JobRepository jobRepository,
PlatformTransactionManager transactionManager,
AisTargetDbSyncTasklet aisTargetDbSyncTasklet,
ShipLastPositionSyncTasklet shipLastPositionSyncTasklet) {
this.jobRepository = jobRepository;
this.transactionManager = transactionManager;
this.aisTargetDbSyncTasklet = aisTargetDbSyncTasklet;
this.shipLastPositionSyncTasklet = shipLastPositionSyncTasklet;
}
@Bean(name = "aisTargetDbSyncStep")
public Step aisTargetDbSyncStep() {
return new StepBuilder("aisTargetDbSyncStep", jobRepository)
.tasklet(aisTargetDbSyncTasklet, transactionManager)
.build();
}
@Bean(name = "shipLastPositionSyncStep")
public Step shipLastPositionSyncStep() {
return new StepBuilder("shipLastPositionSyncStep", jobRepository)
.tasklet(shipLastPositionSyncTasklet, transactionManager)
.build();
}
@Bean(name = "aisTargetDbSyncJob")
public Job aisTargetDbSyncJob() {
log.info("Job 생성: aisTargetDbSyncJob");
return new JobBuilder("aisTargetDbSyncJob", jobRepository)
.listener(new JobExecutionListener() {
@Override
public void beforeJob(JobExecution jobExecution) {
log.info("[aisTargetDbSyncJob] DB Sync Job 시작");
}
@Override
public void afterJob(JobExecution jobExecution) {
long writeCount = jobExecution.getStepExecutions().stream()
.mapToLong(se -> se.getWriteCount())
.sum();
log.info("[aisTargetDbSyncJob] DB Sync Job 완료 - 상태: {}, 저장 건수: {}",
jobExecution.getStatus(), writeCount);
}
})
.start(aisTargetDbSyncStep())
.next(shipLastPositionSyncStep())
.build();
}
}

파일 보기

@ -1,16 +0,0 @@
package com.snp.batch.jobs.aistargetdbsync.batch.repository;
import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
import java.util.List;
/**
* 캐시 기반 Ship Last Position 동기화 Repository
*
* AisTargetEntity(캐시 데이터) 받아 tb_ship_main_info의 위치/항해 컬럼을 UPDATE
*/
public interface ShipLastPositionSyncRepository {
int updateLastPositions(List<AisTargetEntity> entities);
void updateLastPositionsTemp(List<AisTargetEntity> entities);
}

파일 보기

@ -1,185 +0,0 @@
package com.snp.batch.jobs.aistargetdbsync.batch.repository;
import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import java.sql.PreparedStatement;
import java.sql.Types;
import java.util.List;
/**
* 캐시 기반 Ship Last Position 동기화 Repository 구현
*
* AisTargetEntity의 필드를 tb_ship_main_info 컬럼에 매핑하여 UPDATE 수행.
* 기존 ShipLastPositionRepositoryImpl과 동일한 SQL을 사용하되,
* AisTargetEntity의 타입(OffsetDateTime, Integer ) 맞게 변환 처리.
*/
@Slf4j
@Repository("shipLastPositionSyncRepository")
public class ShipLastPositionSyncRepositoryImpl implements ShipLastPositionSyncRepository {
private static final int BATCH_SIZE = 1000;
private final JdbcTemplate jdbcTemplate;
@Value("${app.batch.service-schema.name}")
private String targetSchema;
@Value("${app.batch.service-schema.tables.service-001}")
private String tableName;
public ShipLastPositionSyncRepositoryImpl(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
private String getTableName() {
return targetSchema + "." + tableName;
}
private String getUpdateSql() {
return """
UPDATE %s
SET last_cptr_hr_utc = ?::timestamptz,
last_port = ?,
now_position_lat = ?,
now_position_lon = ?,
ship_dest = ?,
arvl_prnmnt_hr = ?::timestamptz,
bow_drctn = ?,
cog = ?,
sog = ?,
ship_nav_status = ?,
cargo_ton = ?,
add_info = ?,
sts_yn = ?,
ancrg_yn = ?,
mmsi_no = ?
WHERE imo_no = ?
""".formatted(getTableName());
}
private String getCoreUpdateSql() {
return """
UPDATE new_snp.core20
SET lastseen = ?::timestamptz,
lastport = ?,
position_latitude = ?,
position_longitude = ?,
destination = ?,
eta = ?::timestamptz,
heading = ?,
cog = ?,
speedservice = ?,
navstat = ?,
tonnes_cargo = ?,
extra_info = ?,
in_sts = ?,
on_berth = ?,
mmsi = ?
WHERE lrno = ?;
""";
}
@Override
@Transactional
public int updateLastPositions(List<AisTargetEntity> entities) {
if (entities == null || entities.isEmpty()) {
return 0;
}
String sql = getUpdateSql();
int totalUpdated = 0;
int[][] batchResults = jdbcTemplate.batchUpdate(sql, entities, BATCH_SIZE,
(ps, entity) -> setUpdateParameters(ps, entity));
for (int[] batchResult : batchResults) {
for (int result : batchResult) {
if (result > 0) {
totalUpdated += result;
}
}
}
log.info("Ship Last Position 동기화 완료 ({}): 대상={} 건, 갱신={} 건", tableName, entities.size(), totalUpdated);
return totalUpdated;
}
@Override
@Transactional
public void updateLastPositionsTemp(List<AisTargetEntity> entities) {
String sql = getCoreUpdateSql();
int totalUpdated = 0;
int[][] batchResults = jdbcTemplate.batchUpdate(sql, entities, BATCH_SIZE,
(ps, entity) -> setUpdateParameters(ps, entity));
for (int[] batchResult : batchResults) {
for (int result : batchResult) {
if (result > 0) {
totalUpdated += result;
}
}
}
log.info("Ship Last Position 동기화 완료 (Core20): 대상={} 건, 갱신={} 건", entities.size(), totalUpdated);
}
private void setUpdateParameters(PreparedStatement ps, AisTargetEntity entity) throws java.sql.SQLException {
int idx = 1;
// last_cptr_hr_utc receivedDate (OffsetDateTime String for ::timestamptz)
ps.setString(idx++, entity.getReceivedDate() != null ? entity.getReceivedDate().toString() : null);
// last_port lpcCode (Integer String)
ps.setString(idx++, entity.getLpcCode() != null ? String.valueOf(entity.getLpcCode()) : null);
// now_position_lat lat (Double, null-safe)
ps.setObject(idx++, entity.getLat(), Types.DOUBLE);
// now_position_lon lon (Double, null-safe)
ps.setObject(idx++, entity.getLon(), Types.DOUBLE);
// ship_dest destination (String)
ps.setString(idx++, entity.getDestination());
// arvl_prnmnt_hr eta (OffsetDateTime String for ::timestamptz)
ps.setString(idx++, entity.getEta() != null ? entity.getEta().toString() : null);
// bow_drctn heading (Double, null-safe)
ps.setObject(idx++, entity.getHeading(), Types.DOUBLE);
// cog cog (Double, null-safe)
ps.setObject(idx++, entity.getCog(), Types.DOUBLE);
// sog sog (Double, null-safe)
ps.setObject(idx++, entity.getSog(), Types.DOUBLE);
// ship_nav_status status (String)
ps.setString(idx++, entity.getStatus());
// cargo_ton tonnesCargo (Integer, null-safe)
ps.setObject(idx++, entity.getTonnesCargo(), Types.INTEGER);
// add_info extraInfo (String)
ps.setString(idx++, entity.getExtraInfo());
// sts_yn (tb_ship_main_info) / in_sts (core20) inSTS (Integer, null-safe)
ps.setObject(idx++, entity.getInSTS(), Types.INTEGER);
// ancrg_yn onBerth (Boolean, null-safe)
ps.setObject(idx++, entity.getOnBerth(), Types.BOOLEAN);
// mmsi_no mmsi (Long 9자리 zero-padded String)
ps.setString(idx++, entity.getMmsi() != null
? String.format("%09d", entity.getMmsi()) : null);
// WHERE imo_no imoVerified (String)
ps.setString(idx++, entity.getImoVerified());
}
}

파일 보기

@ -1,121 +0,0 @@
package com.snp.batch.jobs.aistargetdbsync.batch.tasklet;
import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
import com.snp.batch.jobs.aistarget.batch.repository.AisTargetRepository;
import com.snp.batch.jobs.aistarget.cache.AisTargetCacheManager;
import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.core.StepContribution;
import org.springframework.batch.core.scope.context.ChunkContext;
import org.springframework.batch.core.step.tasklet.Tasklet;
import org.springframework.batch.repeat.RepeatStatus;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.time.Instant;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
/**
* AIS Target DB Sync Tasklet
*
* 동작:
* - Caffeine 캐시에서 마지막 성공 이후 ~ 현재까지의 데이터를 조회
* - MMSI별 최신 위치 1건씩 DB에 UPSERT
* - 캐시의 모든 컬럼 정보를 그대로 DB에 저장
*
* 시간 범위 결정 전략:
* - 실행 또는 마지막 실행 정보 없음 fallback(time-range-minutes) 사용
* - 이후 실행 마지막 성공 시각 기준으로 경과 시간 자동 계산
* - cron 주기를 변경해도 별도 설정 불필요 (자동 동기화)
*
* 참고:
* - 캐시에는 MMSI별 최신 데이터만 유지됨 (120분 TTL)
* - 기존 aisTargetImportJob은 캐시 업데이트만 수행
*/
@Slf4j
@Component
public class AisTargetDbSyncTasklet implements Tasklet {
private final AisTargetCacheManager cacheManager;
private final AisTargetRepository aisTargetRepository;
private final int fallbackMinutes;
/**
* 마지막 성공 시각 (JVM 유지, 재기동 fallback 사용)
*/
private final AtomicReference<Instant> lastSuccessTime = new AtomicReference<>();
public AisTargetDbSyncTasklet(
AisTargetCacheManager cacheManager,
AisTargetRepository aisTargetRepository,
@Value("${app.batch.ais-target-db-sync.time-range-minutes:15}") int fallbackMinutes) {
this.cacheManager = cacheManager;
this.aisTargetRepository = aisTargetRepository;
this.fallbackMinutes = fallbackMinutes;
}
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception {
Instant now = Instant.now();
int rangeMinutes = resolveRangeMinutes(now);
log.info("========================================");
log.info("AIS Target DB Sync 시작");
log.info("조회 범위: 최근 {}분 (방식: {})", rangeMinutes,
lastSuccessTime.get() != null ? "마지막 성공 기준" : "fallback");
log.info("현재 캐시 크기: {}", cacheManager.size());
log.info("========================================");
long startTime = System.currentTimeMillis();
// 1. 캐시에서 시간 범위 데이터 조회
List<AisTargetEntity> entities = cacheManager.getByTimeRange(rangeMinutes);
if (entities.isEmpty()) {
log.warn("캐시에서 조회된 데이터가 없습니다 (범위: {}분)", rangeMinutes);
lastSuccessTime.set(now);
return RepeatStatus.FINISHED;
}
log.info("캐시에서 {} 건 조회 완료", entities.size());
// 2. DB에 UPSERT
aisTargetRepository.batchUpsert(entities);
long elapsed = System.currentTimeMillis() - startTime;
// 성공 시각 기록
lastSuccessTime.set(now);
log.info("========================================");
log.info("AIS Target DB Sync 완료");
log.info("저장 건수: {} 건", entities.size());
log.info("소요 시간: {}ms", elapsed);
log.info("========================================");
// Step 통계 업데이트
contribution.incrementWriteCount(entities.size());
return RepeatStatus.FINISHED;
}
private static final int MAX_RANGE_MINUTES = 60;
/**
* 조회 범위() 결정
* - 마지막 성공 시각이 있으면: 경과 시간 + 1분 버퍼 (최대 60분)
* - 없으면: fallback 사용
* - 오래 중단 재가동 시에도 최대 60분으로 제한하여 과부하 방지
*/
private int resolveRangeMinutes(Instant now) {
Instant last = lastSuccessTime.get();
if (last == null) {
return Math.min(fallbackMinutes, MAX_RANGE_MINUTES);
}
long elapsedMinutes = java.time.Duration.between(last, now).toMinutes();
// 경과 시간 + 1분 버퍼 (겹침 허용, UPSERT이므로 중복 안전), 최대 60분
int range = (int) Math.max(elapsedMinutes + 1, 1);
return Math.min(range, MAX_RANGE_MINUTES);
}
}

파일 보기

@ -1,134 +0,0 @@
package com.snp.batch.jobs.aistargetdbsync.batch.tasklet;
import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
import com.snp.batch.jobs.aistarget.cache.AisTargetCacheManager;
import com.snp.batch.jobs.aistargetdbsync.batch.repository.ShipLastPositionSyncRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.core.StepContribution;
import org.springframework.batch.core.scope.context.ChunkContext;
import org.springframework.batch.core.step.tasklet.Tasklet;
import org.springframework.batch.repeat.RepeatStatus;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.time.Instant;
import java.util.Comparator;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.Collectors;
/**
* Ship Last Position Sync Tasklet
*
* 동작:
* - Caffeine 캐시에서 마지막 성공 이후 ~ 현재까지의 AIS 데이터를 조회
* - imoVerified가 유효한 항목만 필터링
* - IMO별 messageTimestamp 최신 1건만 선택
* - tb_ship_main_info의 위치/항해 14개 컬럼을 UPDATE
*
* 시간 범위 결정 전략:
* - AisTargetDbSyncTasklet과 동일 (lastSuccessTime 기반, fallback 사용)
*/
@Slf4j
@Component
public class ShipLastPositionSyncTasklet implements Tasklet {
private final AisTargetCacheManager cacheManager;
private final ShipLastPositionSyncRepository repository;
private final int fallbackMinutes;
private final AtomicReference<Instant> lastSuccessTime = new AtomicReference<>();
public ShipLastPositionSyncTasklet(
AisTargetCacheManager cacheManager,
ShipLastPositionSyncRepository repository,
@Value("${app.batch.ais-target-db-sync.time-range-minutes:15}") int fallbackMinutes) {
this.cacheManager = cacheManager;
this.repository = repository;
this.fallbackMinutes = fallbackMinutes;
}
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception {
Instant now = Instant.now();
int rangeMinutes = resolveRangeMinutes(now);
log.info("========================================");
log.info("Ship Last Position Sync 시작");
log.info("조회 범위: 최근 {}분 (방식: {})", rangeMinutes,
lastSuccessTime.get() != null ? "마지막 성공 기준" : "fallback");
log.info("========================================");
long startTime = System.currentTimeMillis();
// 1. 캐시에서 시간 범위 데이터 조회
List<AisTargetEntity> entities = cacheManager.getByTimeRange(rangeMinutes);
if (entities.isEmpty()) {
log.warn("캐시에서 조회된 데이터가 없습니다 (범위: {}분)", rangeMinutes);
lastSuccessTime.set(now);
return RepeatStatus.FINISHED;
}
// 2. imoVerified가 유효한 항목만 필터
List<AisTargetEntity> imoFiltered = entities.stream()
.filter(e -> isValidImo(e.getImoVerified()))
.toList();
if (imoFiltered.isEmpty()) {
log.info("유효한 IMO가 있는 데이터가 없습니다 (전체: {} 건)", entities.size());
lastSuccessTime.set(now);
return RepeatStatus.FINISHED;
}
// 3. IMO별 messageTimestamp 최신 1건만 선택
List<AisTargetEntity> latestPerImo = imoFiltered.stream()
.collect(Collectors.groupingBy(AisTargetEntity::getImoVerified))
.values().stream()
.map(group -> group.stream()
.max(Comparator.comparing(
AisTargetEntity::getMessageTimestamp,
Comparator.nullsFirst(Comparator.naturalOrder())))
.orElseThrow())
.toList();
log.info("캐시 조회: {} 건 → IMO 유효: {} 건 → IMO별 최신: {} 건",
entities.size(), imoFiltered.size(), latestPerImo.size());
// 4. DB UPDATE
int updated = repository.updateLastPositions(latestPerImo);
repository.updateLastPositionsTemp(latestPerImo); // 임시 추가
long elapsed = System.currentTimeMillis() - startTime;
lastSuccessTime.set(now);
log.info("========================================");
log.info("Ship Last Position Sync 완료");
log.info("대상: {} 건, 갱신: {} 건", latestPerImo.size(), updated);
log.info("소요 시간: {}ms", elapsed);
log.info("========================================");
contribution.incrementWriteCount(updated);
return RepeatStatus.FINISHED;
}
private static final int MAX_RANGE_MINUTES = 60;
private int resolveRangeMinutes(Instant now) {
Instant last = lastSuccessTime.get();
if (last == null) {
return Math.min(fallbackMinutes, MAX_RANGE_MINUTES);
}
long elapsedMinutes = Duration.between(last, now).toMinutes();
int range = (int) Math.max(elapsedMinutes + 1, 1);
return Math.min(range, MAX_RANGE_MINUTES);
}
private boolean isValidImo(String imoVerified) {
return imoVerified != null && !imoVerified.isBlank() && !"0".equals(imoVerified);
}
}

파일 보기

@ -53,22 +53,6 @@ spring:
org.quartz.jobStore.isClustered: false
org.quartz.jobStore.misfireThreshold: 60000
# Kafka Configuration (DEV)
kafka:
bootstrap-servers: localhost:9092 # TODO: DEV Kafka Broker IP/PORT 설정
producer:
key-serializer: org.apache.kafka.common.serialization.StringSerializer
value-serializer: org.apache.kafka.common.serialization.StringSerializer
acks: all
retries: 3
properties:
enable.idempotence: true
compression.type: snappy
linger.ms: 20
batch.size: 65536
max.block.ms: 3000
request.timeout.ms: 5000
delivery.timeout.ms: 10000
# Server Configuration
server:
@ -95,12 +79,6 @@ logging:
app:
batch:
chunk-size: 1000
ship-api:
url: https://shipsapi.maritime.spglobal.com
ais-api:
url: https://aisapi.maritime.spglobal.com
webservice-api:
url: https://webservices.maritime.spglobal.com
schedule:
enabled: true
cron: "0 0 * * * ?" # Every hour
@ -116,48 +94,11 @@ app:
max-retry-count: 3
partition-count: 4 # dev: 2개 파티션
# AIS Target 배치 설정
ais-target:
since-seconds: 60 # API 조회 범위 (초)
chunk-size: 50000 # 배치 청크 크기
schedule:
cron: "15 * * * * ?" # 매 분 15초 실행
kafka:
enabled: false
topic: tp_Global_AIS_Signal
send-chunk-size: 5000
fail-on-send-error: false
# AIS Target 캐시 설정
ais-target-cache:
ttl-minutes: 120 # 캐시 TTL (분) - 2시간
max-size: 300000 # 최대 캐시 크기 - 30만 건
# ClassType 분류 설정
class-type:
refresh-hour: 4 # Core20 캐시 갱신 시간 (기본: 04시)
# Core20 캐시 테이블 설정 (환경별로 테이블/컬럼명이 다를 수 있음)
core20:
schema: std_snp_svc # 스키마명
table: tb_ship_main_info # 테이블명
imo-column: imo_no # IMO/LRNO 컬럼명 (PK, NOT NULL)
mmsi-column: mmsi_no # MMSI 컬럼명 (NULLABLE)
# 파티션 관리 설정
partition:
# 일별 파티션 테이블 목록 (네이밍: {table}_YYMMDD)
daily-tables:
- schema: std_snp_data
table-name: ais_target
partition-column: message_timestamp
periods-ahead: 3 # 미리 생성할 일수
# 월별 파티션 테이블 목록 (네이밍: {table}_YYYY_MM)
monthly-tables: [] # 현재 없음
# 기본 보관기간
daily-tables: []
monthly-tables: []
retention:
daily-default-days: 14 # 일별 파티션 기본 보관기간 (14일)
monthly-default-months: 1 # 월별 파티션 기본 보관기간 (1개월)
# 개별 테이블 보관기간 설정 (옵션)
custom:
# - table-name: ais_target
# retention-days: 30 # ais_target만 30일 보관
daily-default-days: 14
monthly-default-months: 1
custom: []

파일 보기

@ -53,22 +53,6 @@ spring:
org.quartz.jobStore.isClustered: false
org.quartz.jobStore.misfireThreshold: 60000
# Kafka Configuration (PROD)
kafka:
bootstrap-servers: 10.188.141.104:9092,10.188.141.105:9092,10.188.141.102:9092
producer:
key-serializer: org.apache.kafka.common.serialization.StringSerializer
value-serializer: org.apache.kafka.common.serialization.StringSerializer
acks: all
retries: 3
properties:
enable.idempotence: true
compression.type: snappy
linger.ms: 20
batch.size: 65536
max.block.ms: 3000
request.timeout.ms: 5000
delivery.timeout.ms: 10000
# Server Configuration
server:
@ -96,12 +80,6 @@ logging:
app:
batch:
chunk-size: 1000
ship-api:
url: http://10.29.16.219:18030/shipsapi
ais-api:
url: http://10.29.16.219:18030/aisapi
webservice-api:
url: http://10.29.16.219:18030/webservices
schedule:
enabled: true
cron: "0 0 * * * ?" # Every hour
@ -117,48 +95,11 @@ app:
max-retry-count: 3 # 최대 재시도 횟수
partition-count: 4 # prod: 4개 파티션
# AIS Target 배치 설정
ais-target:
since-seconds: 60 # API 조회 범위 (초)
chunk-size: 50000 # 배치 청크 크기
schedule:
cron: "15 * * * * ?" # 매 분 15초 실행
kafka:
enabled: true
topic: tp_Global_AIS_Signal
send-chunk-size: 5000
fail-on-send-error: false
# AIS Target 캐시 설정
ais-target-cache:
ttl-minutes: 120 # 캐시 TTL (분) - 2시간
max-size: 300000 # 최대 캐시 크기 - 30만 건
# ClassType 분류 설정
class-type:
refresh-hour: 4 # Core20 캐시 갱신 시간 (기본: 04시)
# Core20 캐시 테이블 설정 (환경별로 테이블/컬럼명이 다를 수 있음)
core20:
schema: std_snp_svc # 스키마명
table: tb_ship_main_info # 테이블명
imo-column: imo_no # IMO/LRNO 컬럼명 (PK, NOT NULL)
mmsi-column: mmsi_no # MMSI 컬럼명 (NULLABLE)
# 파티션 관리 설정
partition:
# 일별 파티션 테이블 목록 (네이밍: {table}_YYMMDD)
daily-tables:
- schema: std_snp_data
table-name: ais_target
partition-column: message_timestamp
periods-ahead: 3 # 미리 생성할 일수
# 월별 파티션 테이블 목록 (네이밍: {table}_YYYY_MM)
monthly-tables: [] # 현재 없음
# 기본 보관기간
daily-tables: []
monthly-tables: []
retention:
daily-default-days: 14 # 일별 파티션 기본 보관기간 (14일)
monthly-default-months: 1 # 월별 파티션 기본 보관기간 (1개월)
# 개별 테이블 보관기간 설정 (옵션)
custom:
# - table-name: ais_target
# retention-days: 30 # ais_target만 30일 보관
daily-default-days: 14
monthly-default-months: 1
custom: []

파일 보기

@ -53,22 +53,6 @@ spring:
org.quartz.jobStore.isClustered: false
org.quartz.jobStore.misfireThreshold: 60000
# Kafka Configuration
kafka:
bootstrap-servers: localhost:9092
producer:
key-serializer: org.apache.kafka.common.serialization.StringSerializer
value-serializer: org.apache.kafka.common.serialization.StringSerializer
acks: all
retries: 3
properties:
enable.idempotence: true
compression.type: snappy
linger.ms: 20
batch.size: 65536
max.block.ms: 3000
request.timeout.ms: 5000
delivery.timeout.ms: 10000
# Server Configuration
server:
@ -181,48 +165,6 @@ app:
max-retry-count: 3 # 최대 재시도 횟수
partition-count: 4 # 병렬 파티션 수
# AIS Target Import 배치 설정 (캐시 업데이트 전용)
ais-target:
since-seconds: 60 # API 조회 범위 (초)
chunk-size: 50000 # 배치 청크 크기
schedule:
cron: "15 * * * * ?" # 매 분 15초 실행
kafka:
enabled: false # true로 변경 시 Kafka 브로커 연결 필요
topic: tp_Global_AIS_Signal
send-chunk-size: 5000
fail-on-send-error: false
# AIS Target DB Sync 배치 설정 (캐시 → DB 저장)
ais-target-db-sync:
time-range-minutes: 15 # 캐시에서 조회할 시간 범위 (분)
schedule:
cron: "0 0/15 * * * ?" # 매 15분 정각 실행 (00, 15, 30, 45분)
# AIS Target 캐시 설정
ais-target-cache:
ttl-minutes: 120 # 캐시 TTL (분) - 2시간
max-size: 300000 # 최대 캐시 크기 - 30만 건
# 중국 허가선박 전용 캐시 설정
chnprmship:
mmsi-resource-path: classpath:chnprmship-mmsi.txt
ttl-days: 2
max-size: 2000
warmup-enabled: true
warmup-days: 2
# ClassType 분류 설정
class-type:
refresh-hour: 4 # Core20 캐시 갱신 시간 (기본: 04시)
# Core20 캐시 테이블 설정 (환경별로 테이블/컬럼명이 다를 수 있음)
core20:
schema: std_snp_svc # 스키마명
table: tb_ship_main_info # 테이블명
imo-column: imo_no # IMO/LRNO 컬럼명 (PK, NOT NULL)
mmsi-column: mmsi_no # MMSI 컬럼명 (NULLABLE)
# 배치 로그 정리 설정
log-cleanup:
api-log-retention-days: 30 # batch_api_log 보존 기간
@ -233,18 +175,11 @@ app:
# 파티션 관리 설정
partition:
# 일별 파티션 테이블 목록 (네이밍: {table}_YYMMDD)
daily-tables:
- schema: std_snp_data
table-name: ais_target
partition-column: message_timestamp
periods-ahead: 3 # 미리 생성할 일수
daily-tables: []
# 월별 파티션 테이블 목록 (네이밍: {table}_YYYY_MM)
monthly-tables: [] # 현재 없음
monthly-tables: []
# 기본 보관기간
retention:
daily-default-days: 14 # 일별 파티션 기본 보관기간 (14일)
monthly-default-months: 1 # 월별 파티션 기본 보관기간 (1개월)
# 개별 테이블 보관기간 설정 (옵션)
custom:
# - table-name: ais_target
# retention-days: 30 # ais_target만 30일 보관
custom: []

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. Load Diff