🔖 Batch Release Version : 1.0.0

 S&P 수집 배치 Version 1.0.0 (정규화 이전)
* AIS
* Movements
* Events
* Risk&Compliance
* PSC
* Ships
* Facilities
This commit is contained in:
hyojin-kim4 2026-01-23 14:48:54 +09:00 committed by hyojin kim
부모 1241a71d31
커밋 0c48b9f1b1
354개의 변경된 파일30900개의 추가작업 그리고 2526개의 파일을 삭제

14
pom.xml
파일 보기

@ -111,6 +111,20 @@
<version>2.3.0</version>
</dependency>
<!-- Caffeine Cache -->
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
<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>
<groupId>org.springframework.boot</groupId>

파일 보기

@ -2,10 +2,12 @@ package com.snp.batch;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableScheduling
@ConfigurationPropertiesScan
public class SnpBatchApplication {
public static void main(String[] args) {

파일 보기

@ -0,0 +1,149 @@
package com.snp.batch.api.logging;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import org.springframework.web.util.ContentCachingRequestWrapper;
import org.springframework.web.util.ContentCachingResponseWrapper;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.UUID;
/**
* API 요청/응답 로깅 필터
*
* 로그 파일: logs/api-access.log
* 기록 내용: 요청 IP, HTTP Method, URI, 파라미터, 응답 상태, 처리 시간
*/
@Slf4j
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class ApiAccessLoggingFilter extends OncePerRequestFilter {
private static final int MAX_PAYLOAD_LENGTH = 1000;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
// 정적 리소스 actuator 제외
String uri = request.getRequestURI();
if (shouldSkip(uri)) {
filterChain.doFilter(request, response);
return;
}
// 요청 래핑 (body 읽기용)
ContentCachingRequestWrapper requestWrapper = new ContentCachingRequestWrapper(request);
ContentCachingResponseWrapper responseWrapper = new ContentCachingResponseWrapper(response);
String requestId = UUID.randomUUID().toString().substring(0, 8);
long startTime = System.currentTimeMillis();
try {
filterChain.doFilter(requestWrapper, responseWrapper);
} finally {
long duration = System.currentTimeMillis() - startTime;
logRequest(requestId, requestWrapper, responseWrapper, duration);
responseWrapper.copyBodyToResponse();
}
}
private boolean shouldSkip(String uri) {
return uri.startsWith("/actuator")
|| uri.startsWith("/css")
|| uri.startsWith("/js")
|| uri.startsWith("/images")
|| uri.startsWith("/favicon")
|| uri.endsWith(".html")
|| uri.endsWith(".css")
|| uri.endsWith(".js")
|| uri.endsWith(".ico");
}
private void logRequest(String requestId,
ContentCachingRequestWrapper request,
ContentCachingResponseWrapper response,
long duration) {
String clientIp = getClientIp(request);
String method = request.getMethod();
String uri = request.getRequestURI();
String queryString = request.getQueryString();
int status = response.getStatus();
StringBuilder logMessage = new StringBuilder();
logMessage.append(String.format("[%s] %s %s %s",
requestId, clientIp, method, uri));
// Query String
if (queryString != null && !queryString.isEmpty()) {
logMessage.append("?").append(truncate(queryString, 200));
}
// Request Body (POST/PUT/PATCH)
if (isBodyRequest(method)) {
String body = getRequestBody(request);
if (!body.isEmpty()) {
logMessage.append(" | body=").append(truncate(body, MAX_PAYLOAD_LENGTH));
}
}
// Response
logMessage.append(String.format(" | status=%d | %dms", status, duration));
// 상태에 따른 로그 레벨
if (status >= 500) {
log.error(logMessage.toString());
} else if (status >= 400) {
log.warn(logMessage.toString());
} else {
log.info(logMessage.toString());
}
}
private String getClientIp(HttpServletRequest request) {
String ip = request.getHeader("X-Forwarded-For");
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("X-Real-IP");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getRemoteAddr();
}
// 여러 IP가 있는 경우 번째만
if (ip != null && ip.contains(",")) {
ip = ip.split(",")[0].trim();
}
return ip;
}
private boolean isBodyRequest(String method) {
return "POST".equalsIgnoreCase(method)
|| "PUT".equalsIgnoreCase(method)
|| "PATCH".equalsIgnoreCase(method);
}
private String getRequestBody(ContentCachingRequestWrapper request) {
byte[] content = request.getContentAsByteArray();
if (content.length == 0) {
return "";
}
return new String(content, StandardCharsets.UTF_8)
.replaceAll("\\s+", " ")
.trim();
}
private String truncate(String str, int maxLength) {
if (str == null) return "";
if (str.length() <= maxLength) return str;
return str.substring(0, maxLength) + "...";
}
}

파일 보기

@ -0,0 +1,44 @@
package com.snp.batch.common.batch.config;
import org.springframework.batch.core.Job;
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.transaction.PlatformTransactionManager;
/**
* 기존 단일 스텝 기능을 유지하면서 멀티 스텝 구성을 지원하는 확장 클래스
*/
public abstract class BaseMultiStepJobConfig<I, O> extends BaseJobConfig<I, O> {
public BaseMultiStepJobConfig(JobRepository jobRepository, PlatformTransactionManager transactionManager) {
super(jobRepository, transactionManager);
}
/**
* 하위 클래스에서 멀티 스텝 흐름을 정의합니다.
*/
protected abstract Job createJobFlow(JobBuilder jobBuilder);
/**
* 부모의 job() 메서드를 오버라이드하여 멀티 스텝 흐름을 태웁니다.
*/
@Override
public Job job() {
JobBuilder jobBuilder = new JobBuilder(getJobName(), jobRepository);
configureJob(jobBuilder); // 기존 리스너 설정 유지
return createJobFlow(jobBuilder);
}
// 단일 스텝용 Reader/Processor/Writer는 사용하지 않을 경우
// 기본적으로 null이나 예외를 던지도록 구현하여 구현 부담을 줄일 있습니다.
@Override
protected ItemReader<I> createReader() { return null; }
@Override
protected ItemProcessor<I, O> createProcessor() { return null; }
@Override
protected ItemWriter<O> createWriter() { return null; }
}

파일 보기

@ -55,7 +55,7 @@ public abstract class BaseProcessor<I, O> implements ItemProcessor<I, O> {
return null;
}
log.debug("데이터 처리 중: {}", item);
// log.debug("데이터 처리 중: {}", item);
return processItem(item);
}
}

파일 보기

@ -1,5 +1,7 @@
package com.snp.batch.common.batch.reader;
import com.snp.batch.global.model.BatchApiLog;
import com.snp.batch.service.BatchApiLogService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.core.StepExecution;
import org.springframework.batch.core.annotation.BeforeStep;
@ -7,8 +9,13 @@ import org.springframework.batch.item.ExecutionContext;
import org.springframework.batch.item.ItemReader;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.WebClientResponseException;
import org.springframework.web.util.UriBuilder;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.web.util.UriComponentsBuilder;
import java.net.URI;
import java.time.LocalDateTime;
@ -72,12 +79,180 @@ public abstract class BaseApiReader<T> implements ItemReader<T> {
private int totalApiCalls = 0;
private int completedApiCalls = 0;
// Batch Execution Id
private Long jobExecutionId; // 현재 Job 실행 ID
private Long stepExecutionId; // 현재 Step 실행 ID
/**
* 스프링 배치가 Step을 시작할 실행 ID를 주입해줍니다.
*/
public void setExecutionIds(Long jobExecutionId, Long stepExecutionId) {
this.jobExecutionId = jobExecutionId;
this.stepExecutionId = stepExecutionId;
}
/**
* 기본 생성자 (WebClient 없이 사용 - Mock 데이터용)
*/
protected BaseApiReader() {
this.webClient = null;
}
/**
* API 호출 로그 적재 통합 메서드
* Response Json 구조 : [...]
*/
protected <R> List<R> executeListApiCall(
String baseUrl,
String path,
Map<String, String> params,
ParameterizedTypeReference<List<R>> typeReference,
BatchApiLogService logService) {
// 1. 전체 URI 생성 (로그용)
MultiValueMap<String, String> multiValueParams = new LinkedMultiValueMap<>();
if (params != null) {
params.forEach((key, value) ->
multiValueParams.put(key, Collections.singletonList(value))
);
}
String fullUri = UriComponentsBuilder.fromHttpUrl(baseUrl)
.path(path)
.queryParams(multiValueParams)
.build()
.toUriString();
long startTime = System.currentTimeMillis();
int statusCode = 200;
String errorMessage = null;
Long responseSize = 0L;
try {
log.info("[{}] API 요청 시작: {}", getReaderName(), fullUri);
List<R> result = webClient.get()
.uri(uriBuilder -> {
uriBuilder.path(path);
if (params != null) params.forEach(uriBuilder::queryParam);
return uriBuilder.build();
})
.retrieve()
.bodyToMono(typeReference)
.block();
responseSize = (result != null) ? (long) result.size() : 0L;
return result;
} catch (WebClientResponseException e) {
// API 서버에서 응답은 왔으나 에러인 경우 (4xx, 5xx)
statusCode = e.getStatusCode().value();
errorMessage = String.format("API Error: %s", e.getResponseBodyAsString());
throw e;
} catch (Exception e) {
// 네트워크 오류, 타임아웃 기타 예외
statusCode = 500;
errorMessage = String.format("System Error: %s", e.getMessage());
throw e;
} finally {
// 성공/실패 여부와 관계없이 무조건 로그 저장
long duration = System.currentTimeMillis() - startTime;
logService.saveLog(BatchApiLog.builder()
.apiRequestLocation(getReaderName())
.requestUri(fullUri)
.httpMethod("GET")
.statusCode(statusCode)
.responseTimeMs(duration)
.responseCount(responseSize)
.errorMessage(errorMessage)
.createdAt(LocalDateTime.now())
.jobExecutionId(this.jobExecutionId) // 추가
.stepExecutionId(this.stepExecutionId) // 추가
.build());
}
}
/**
* API 호출 로그 적재 통합 메서드
* Response Json 구조 : { "data": [...] }
*/
protected <R> R executeSingleApiCall(
String baseUrl,
String path,
Map<String, String> params,
ParameterizedTypeReference<R> typeReference,
BatchApiLogService logService,
Function<R, Long> sizeExtractor) { // 사이즈 추출 함수 추가
// 1. 전체 URI 생성 (로그용)
MultiValueMap<String, String> multiValueParams = new LinkedMultiValueMap<>();
if (params != null) {
params.forEach((key, value) ->
multiValueParams.put(key, Collections.singletonList(value))
);
}
String fullUri = UriComponentsBuilder.fromHttpUrl(baseUrl)
.path(path)
.queryParams(multiValueParams)
.build()
.toUriString();
long startTime = System.currentTimeMillis();
int statusCode = 200;
String errorMessage = null;
R result = null;
try {
log.info("[{}] Single API 요청 시작: {}", getReaderName(), fullUri);
result = webClient.get()
.uri(uriBuilder -> {
uriBuilder.path(path);
if (params != null) params.forEach(uriBuilder::queryParam);
return uriBuilder.build();
})
.retrieve()
.bodyToMono(typeReference)
.block();
return result;
} catch (WebClientResponseException e) {
statusCode = e.getStatusCode().value();
errorMessage = String.format("API Error: %s", e.getResponseBodyAsString());
throw e;
} catch (Exception e) {
statusCode = 500;
errorMessage = String.format("System Error: %s", e.getMessage());
throw e;
} finally {
long duration = System.currentTimeMillis() - startTime;
// 2. 주입받은 함수를 통해 데이터 건수(size) 계산
long size = 0L;
if (result != null && sizeExtractor != null) {
try {
size = sizeExtractor.apply(result);
} catch (Exception e) {
log.warn("[{}] 사이즈 추출 중 오류 발생: {}", getReaderName(), e.getMessage());
}
}
// 3. 로그 저장 (api_request_location, response_size 반영)
logService.saveLog(BatchApiLog.builder()
.apiRequestLocation(getReaderName())
.jobExecutionId(this.jobExecutionId)
.stepExecutionId(this.stepExecutionId)
.requestUri(fullUri)
.httpMethod("GET")
.statusCode(statusCode)
.responseTimeMs(duration)
.responseCount(size)
.errorMessage(errorMessage)
.createdAt(LocalDateTime.now())
.build());
}
}
/**
* WebClient를 주입받는 생성자 (실제 API 연동용)
@ -87,7 +262,7 @@ public abstract class BaseApiReader<T> implements ItemReader<T> {
protected BaseApiReader(WebClient webClient) {
this.webClient = webClient;
}
/**
/**
* Step 실행 초기화 API 정보 저장
* Spring Batch가 자동으로 StepExecution을 주입하고 메서드를 호출함
@ -98,6 +273,9 @@ public abstract class BaseApiReader<T> implements ItemReader<T> {
public void saveApiInfoToContext(StepExecution stepExecution) {
this.stepExecution = stepExecution;
// Reader 상태 초기화 (Job 재실행 필수)
resetReaderState();
// API 정보를 StepExecutionContext에 저장
ExecutionContext context = stepExecution.getExecutionContext();
@ -140,6 +318,48 @@ public abstract class BaseApiReader<T> implements ItemReader<T> {
return "";
}
/**
* Reader 상태 초기화
* Job 재실행 이전 실행의 상태를 클리어하여 새로 데이터를 읽을 있도록
*/
private void resetReaderState() {
// Chunk 모드 상태 초기화
this.currentBatch = null;
this.initialized = false;
// Legacy 모드 상태 초기화
this.legacyDataList = null;
this.legacyNextIndex = 0;
// 통계 초기화
this.totalApiCalls = 0;
this.completedApiCalls = 0;
// 하위 클래스 상태 초기화 호출
resetCustomState();
log.debug("[{}] Reader 상태 초기화 완료", getReaderName());
}
/**
* 하위 클래스 커스텀 상태 초기화
* Chunk 모드에서 사용하는 currentBatchIndex, allImoNumbers 등의 필드를 초기화할 오버라이드
*
* 예시:
* <pre>
* @Override
* protected void resetCustomState() {
* this.currentBatchIndex = 0;
* this.allImoNumbers = null;
* this.dbMasterHashes = null;
* }
* </pre>
*/
protected void resetCustomState() {
// 기본 구현: 아무것도 하지 않음
// 하위 클래스에서 필요 오버라이드
}
/**
* API 호출 통계 업데이트
*/
@ -209,21 +429,42 @@ public abstract class BaseApiReader<T> implements ItemReader<T> {
}
// currentBatch가 비어있으면 다음 배치 로드
if (currentBatch == null || !currentBatch.hasNext()) {
/*if (currentBatch == null || !currentBatch.hasNext()) {
List<T> nextBatch = fetchNextBatch();
// 이상 데이터가 없으면 종료
if (nextBatch == null || nextBatch.isEmpty()) {
// if (nextBatch == null || nextBatch.isEmpty()) {
if (nextBatch == null ) {
afterFetch(null);
log.info("[{}] 모든 배치 처리 완료", getReaderName());
return null;
}
// Iterator 갱신
currentBatch = nextBatch.iterator();
log.debug("[{}] 배치 로드 완료: {} 건", getReaderName(), nextBatch.size());
}*/
// currentBatch가 비어있으면 다음 배치 로드
while (currentBatch == null || !currentBatch.hasNext()) {
List<T> nextBatch = fetchNextBatch();
if (nextBatch == null) { // 진짜 종료
afterFetch(null);
log.info("[{}] 모든 배치 처리 완료", getReaderName());
return null;
}
if (nextBatch.isEmpty()) { // emptyList면 다음 batch를 시도
log.warn("[{}] 빈 배치 수신 → 다음 배치 재요청", getReaderName());
continue; // while 반복문으로 다시 fetch
}
currentBatch = nextBatch.iterator();
log.debug("[{}] 배치 로드 완료: {} 건", getReaderName(), nextBatch.size());
}
// Iterator에서 1건씩 반환
return currentBatch.next();
}

파일 보기

@ -13,14 +13,44 @@ public class JsonChangeDetector {
// 해시 비교에서 제외할 필드 목록 (DataSetVersion )
// 목록은 모든 JSON 계층에 걸쳐 적용됩니다.
private static final java.util.Set<String> EXCLUDE_KEYS =
java.util.Set.of("DataSetVersion", "APSStatus", "LastUpdateDate", "LastUpdateDateTime");
java.util.Set.of("DataSetVersion", "APSStatus", "LastUpdateDateTime");
private static final Map<String, String> LIST_SORT_KEYS = Map.of(
// List 필드명 // 정렬 기준
"OwnerHistory" ,"Sequence", // OwnerHistory는 Sequence를 기준으로 정렬
"SurveyDatesHistoryUnique" , "SurveyDate" // SurveyDatesHistoryUnique는 SurveyDate를 기준으로 정렬
// 추가적인 List/Array 필드가 있다면 여기에 추가
);
// =========================================================================
// LIST_SORT_KEYS: 정적 초기화 블록을 사용한 Map 정의
// =========================================================================
private static final Map<String, String> LIST_SORT_KEYS;
static {
// TreeMap을 사용하여 키를 알파벳 순으로 정렬할 수도 있지만, 여기서는 HashMap을 사용하고 final로 만듭니다.
Map<String, String> map = new HashMap<>();
// List 필드명 // 정렬 기준 복합 (JSON 필드명, 쉼표로 구분)
map.put("OwnerHistory", "OwnerCode,EffectiveDate,Sequence");
map.put("CrewList", "LRNO,Shipname,Nationality");
map.put("StowageCommodity", "Sequence,CommodityCode,StowageCode");
map.put("GroupBeneficialOwnerHistory", "EffectiveDate,GroupBeneficialOwnerCode,Sequence");
map.put("ShipManagerHistory", "EffectiveDate,ShipManagerCode,Sequence");
map.put("OperatorHistory", "EffectiveDate,OperatorCode,Sequence");
map.put("TechnicalManagerHistory", "EffectiveDate,Sequence,TechnicalManagerCode");
map.put("BareBoatCharterHistory", "Sequence,EffectiveDate,BBChartererCode");
map.put("NameHistory", "Sequence,EffectiveDate");
map.put("FlagHistory", "FlagCode,EffectiveDate,Sequence");
map.put("PandIHistory", "PandIClubCode,EffectiveDate");
map.put("CallSignAndMmsiHistory", "EffectiveDate,SeqNo");
map.put("IceClass", "IceClassCode");
map.put("SafetyManagementCertificateHistory", "Sequence");
map.put("ClassHistory", "ClassCode,EffectiveDate,Sequence");
map.put("SurveyDatesHistory", "ClassSocietyCode");
map.put("SurveyDatesHistoryUnique", "ClassSocietyCode,SurveyDate,SurveyType");
map.put("SisterShipLinks", "LinkedLRNO");
map.put("StatusHistory", "Sequence,StatusCode,StatusDate");
map.put("SpecialFeature", "Sequence,SpecialFeatureCode");
map.put("Thrusters", "Sequence");
map.put("DarkActivityConfirmed", "Lrno,Mmsi,Dark_Time,Dark_Status");
map.put("CompanyComplianceDetails", "OwCode");
map.put("CompanyVesselRelationships", "LRNO");
map.put("CompanyDetailsComplexWithCodesAndParent", "OWCODE,LastChangeDate");
LIST_SORT_KEYS = Collections.unmodifiableMap(map);
}
// =========================================================================
// 1. JSON 문자열을 정렬 필터링된 Map으로 변환하는 핵심 로직
@ -90,14 +120,16 @@ public class JsonChangeDetector {
}
}
// 2. 🔑 List 필드명에 따른 순서 정렬 로직 (추가 핵심 로직)
// 2. 🔑 List 필드명에 따른 복합 순서 정렬 로직 (수정 핵심 로직)
String listFieldName = entry.getKey();
String sortKey = LIST_SORT_KEYS.get(listFieldName);
String sortKeysString = LIST_SORT_KEYS.get(listFieldName); // 쉼표로 구분된 복합 문자열
if (sortKeysString != null && !filteredList.isEmpty() && filteredList.get(0) instanceof Map) {
// 복합 문자열을 개별 배열로 분리
final String[] sortKeys = sortKeysString.split(",");
if (sortKey != null && !filteredList.isEmpty() && filteredList.get(0) instanceof Map) {
// Map 요소를 가진 리스트인 경우에만 정렬 실행
try {
// 정렬 기준 키를 사용하여 Comparator를 생성
Collections.sort(filteredList, new Comparator<Object>() {
@Override
@SuppressWarnings("unchecked")
@ -105,22 +137,45 @@ public class JsonChangeDetector {
Map<String, Object> map1 = (Map<String, Object>) o1;
Map<String, Object> map2 = (Map<String, Object>) o2;
// 정렬 기준 (sortKey) 값을 가져와 비교
Object key1 = map1.get(sortKey);
Object key2 = map2.get(sortKey);
// 복합 (sortKeys) 순서대로 순회하며 비교
for (String rawSortKey : sortKeys) {
// 키의 공백 제거
String sortKey = rawSortKey.trim();
if (key1 == null || key2 == null) {
// 값이 null인 경우, Map의 전체 문자열로 비교 (안전장치)
return map1.toString().compareTo(map2.toString());
Object key1 = map1.get(sortKey);
Object key2 = map2.get(sortKey);
// null 처리 로직
if (key1 == null && key2 == null) {
continue; // 값이 동일하므로 다음 키로 이동
}
if (key1 == null) {
// key1이 null이고 key2는 null이 아니면, key2가 크다고 ( 순서) 간주하고 1 반환
return 1;
}
if (key2 == null) {
// key2가 null이고 key1은 null이 아니면, key1이 크다고 ( 순서) 간주하고 -1 반환
return -1;
}
// 값을 문자열로 변환하여 비교 (String, Number, Date 타입 모두 처리 가능)
int comparisonResult = key1.toString().compareTo(key2.toString());
// 현재 키에서 순서가 결정되면 즉시 반환
if (comparisonResult != 0) {
return comparisonResult;
}
// comparisonResult == 0 이면 다음 키로 이동하여 비교를 계속함
}
// String 타입으로 변환하여 비교 (Date, Number 타입도 대부분 String으로 처리 가능)
return key1.toString().compareTo(key2.toString());
// 모든 키를 비교해도 동일한 경우
// 경우 Map은 해시값 측면에서 동일한 것으로 간주되어야 합니다.
return 0;
}
});
} catch (Exception e) {
System.err.println("List sort failed for key " + listFieldName + ": " + e.getMessage());
// 정렬 실패 원래 순서 유지
// 정렬 실패 원래 순서 유지 (filteredList 상태 유지)
}
}
sortedMap.put(key, filteredList);
@ -132,7 +187,6 @@ public class JsonChangeDetector {
return sortedMap;
}
// =========================================================================
// 2. 해시 생성 로직
// =========================================================================

파일 보기

@ -0,0 +1,24 @@
package com.snp.batch.global.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;
@Configuration
@EnableAsync // 비동기 기능 활성화
public class AsyncConfig {
@Bean(name = "apiLogExecutor")
public Executor apiLogExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2); // 기본 스레드
executor.setMaxPoolSize(5); // 최대 스레드
executor.setQueueCapacity(500); // 대기 크기
executor.setThreadNamePrefix("ApiLogThread-");
executor.initialize();
return executor;
}
}

파일 보기

@ -29,9 +29,13 @@ public class MaritimeApiWebClientConfig {
@Value("${app.batch.ship-api.url}")
private String maritimeApiUrl;
@Value("https://aisapi.maritime.spglobal.com")
@Value("${app.batch.ais-api.url}")
private String maritimeAisApiUrl;
@Value("${app.batch.webservice-api.url}")
private String maritimeServiceApiUrl;
@Value("${app.batch.ship-api.username}")
private String maritimeApiUsername;
@ -60,7 +64,7 @@ public class MaritimeApiWebClientConfig {
.defaultHeaders(headers -> headers.setBasicAuth(maritimeApiUsername, maritimeApiPassword))
.codecs(configurer -> configurer
.defaultCodecs()
.maxInMemorySize(20 * 1024 * 1024)) // 20MB 버퍼
.maxInMemorySize(100 * 1024 * 1024)) // 30MB 버퍼
.build();
}
@ -76,7 +80,23 @@ public class MaritimeApiWebClientConfig {
.defaultHeaders(headers -> headers.setBasicAuth(maritimeApiUsername, maritimeApiPassword))
.codecs(configurer -> configurer
.defaultCodecs()
.maxInMemorySize(20 * 1024 * 1024)) // 20MB 버퍼
.maxInMemorySize(50 * 1024 * 1024)) // 50MB 버퍼 (AIS GetTargets 응답 ~20MB+)
.build();
}
@Bean(name = "maritimeServiceApiWebClient")
public WebClient maritimeServiceApiWebClient(){
log.info("========================================");
log.info("Maritime AIS API WebClient 생성");
log.info("Base URL: {}", maritimeServiceApiUrl);
log.info("========================================");
return WebClient.builder()
.baseUrl(maritimeServiceApiUrl)
.defaultHeaders(headers -> headers.setBasicAuth(maritimeApiUsername, maritimeApiPassword))
.codecs(configurer -> configurer
.defaultCodecs()
.maxInMemorySize(100 * 1024 * 1024)) // 100MB 버퍼
.build();
}
}

파일 보기

@ -30,20 +30,29 @@ public class SwaggerConfig {
@Value("${server.port:8081}")
private int serverPort;
@Value("${server.servlet.context-path:}")
private String contextPath;
@Bean
public OpenAPI openAPI() {
return new OpenAPI()
.info(apiInfo())
.servers(List.of(
new Server()
.url("http://localhost:" + serverPort)
.url("http://localhost:" + serverPort + contextPath)
.description("로컬 개발 서버"),
new Server()
.url("http://211.208.115.83:" + serverPort)
.url("http://10.26.252.39:" + serverPort + contextPath)
.description("로컬 개발 서버"),
new Server()
.url("http://211.208.115.83:" + serverPort + contextPath)
.description("중계 서버"),
new Server()
.url("https://api.snp-batch.com")
.description("운영 서버 (예시)")
.url("http://10.187.58.58:" + serverPort + contextPath)
.description("운영 서버"),
new Server()
.url("https://mda.kcg.go.kr" + contextPath)
.description("운영 서버 프록시")
));
}
@ -79,4 +88,4 @@ public class SwaggerConfig {
.name("Apache 2.0")
.url("https://www.apache.org/licenses/LICENSE-2.0"));
}
}
}

파일 보기

@ -1,20 +1,26 @@
package com.snp.batch.global.controller;
import com.snp.batch.global.dto.JobExecutionDto;
import com.snp.batch.global.dto.JobLaunchRequest;
import com.snp.batch.global.dto.ScheduleRequest;
import com.snp.batch.global.dto.ScheduleResponse;
import com.snp.batch.service.BatchService;
import com.snp.batch.service.ScheduleService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.enums.Explode;
import io.swagger.v3.oas.annotations.enums.ParameterIn;
import io.swagger.v3.oas.annotations.enums.ParameterStyle;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springdoc.core.annotations.ParameterObject;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@ -56,6 +62,39 @@ public class BatchController {
}
}
@Operation(summary = "배치 작업 실행", description = "지정된 배치 작업을 즉시 실행합니다. 쿼리 파라미터로 Job Parameters 전달 가능")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "작업 실행 성공"),
@ApiResponse(responseCode = "500", description = "작업 실행 실패")
})
@PostMapping("/jobs/{jobName}/executeJobTest")
public ResponseEntity<Map<String, Object>> executeJobTest(
@Parameter( description = "실행할 배치 작업 이름", required = true,example = "sampleProductImportJob")
@PathVariable String jobName,
@ParameterObject JobLaunchRequest request
) {
Map<String, String> params = new HashMap<>();
if (request.getStartDate() != null) params.put("startDate", request.getStartDate());
if (request.getStopDate() != null) params.put("stopDate", request.getStopDate());
log.info("Executing job: {} with params: {}", jobName, params);
try {
Long executionId = batchService.executeJob(jobName, params);
return ResponseEntity.ok(Map.of(
"success", true,
"message", "Job started successfully",
"executionId", executionId
));
} catch (Exception e) {
log.error("Error executing job: {}", jobName, e);
return ResponseEntity.internalServerError().body(Map.of(
"success", false,
"message", "Failed to start job: " + e.getMessage()
));
}
}
@Operation(summary = "배치 작업 목록 조회", description = "등록된 모든 배치 작업 목록을 조회합니다")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "조회 성공")
@ -178,7 +217,7 @@ public class BatchController {
}
}
@PutMapping("/schedules/{jobName}")
@PostMapping("/schedules/{jobName}/update")
public ResponseEntity<Map<String, Object>> updateSchedule(
@PathVariable String jobName,
@RequestBody Map<String, String> request) {
@ -206,7 +245,7 @@ public class BatchController {
@ApiResponse(responseCode = "200", description = "삭제 성공"),
@ApiResponse(responseCode = "500", description = "삭제 실패")
})
@DeleteMapping("/schedules/{jobName}")
@PostMapping("/schedules/{jobName}/delete")
public ResponseEntity<Map<String, Object>> deleteSchedule(
@Parameter(description = "배치 작업 이름", required = true)
@PathVariable String jobName) {
@ -226,7 +265,7 @@ public class BatchController {
}
}
@PatchMapping("/schedules/{jobName}/toggle")
@PostMapping("/schedules/{jobName}/toggle")
public ResponseEntity<Map<String, Object>> toggleSchedule(
@PathVariable String jobName,
@RequestBody Map<String, Boolean> request) {

파일 보기

@ -0,0 +1,16 @@
package com.snp.batch.global.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
import lombok.Setter;
import org.springframework.stereotype.Service;
@Getter
@Setter
public class JobLaunchRequest {
@Schema(description = "조회 시작일 (ISO 8601)", example = "2023-12-01T00:00:00Z")
private String startDate;
@Schema(description = "조회 종료일 (ISO 8601)", example = "2023-12-02T00:00:00Z")
private String stopDate;
}

파일 보기

@ -0,0 +1,46 @@
package com.snp.batch.global.model;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;
import java.time.LocalDateTime;
@Entity
@Table(name = "batch_api_log", schema = "snp_data")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class BatchApiLog {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY) // PostgreSQL BIGSERIAL과 매핑
private Long logId;
@Column(name = "api_request_location") // job_name에서 변경
private String apiRequestLocation;
@Column(columnDefinition = "TEXT", nullable = false)
private String requestUri;
@Column(nullable = false, length = 10)
private String httpMethod;
private Integer statusCode;
private Long responseTimeMs;
@Column(name = "response_count")
private Long responseCount;
@Column(columnDefinition = "TEXT")
private String errorMessage;
@CreationTimestamp // 엔티티가 생성될 자동으로 시간 설정
@Column(updatable = false)
private LocalDateTime createdAt;
private Long jobExecutionId; // 추가
private Long stepExecutionId; // 추가
}

파일 보기

@ -0,0 +1,46 @@
package com.snp.batch.global.model;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import jakarta.persistence.*;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
@Entity
@Getter
@Setter
@NoArgsConstructor
@Table(name = "BATCH_LAST_EXECUTION")
@EntityListeners(AuditingEntityListener.class)
public class BatchLastExecution {
@Id
@Column(name = "API_KEY", length = 50)
private String apiKey;
@Column(name = "LAST_SUCCESS_DATE", nullable = false)
private LocalDateTime lastSuccessDate;
@Column(name = "RANGE_FROM_DATE", nullable = true)
private LocalDateTime rangeFromDate;
@Column(name = "RANGE_TO_DATE", nullable = true)
private LocalDateTime rangeToDate;
@CreatedDate
@Column(name = "CREATED_AT", updatable = false, nullable = false)
private LocalDateTime createdAt;
@LastModifiedDate
@Column(name = "UPDATED_AT", nullable = false)
private LocalDateTime updatedAt;
public BatchLastExecution(String apiKey, LocalDateTime lastSuccessDate) {
this.apiKey = apiKey;
this.lastSuccessDate = lastSuccessDate;
}
}

파일 보기

@ -0,0 +1,133 @@
package com.snp.batch.global.partition;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
/**
* 파티션 관리 설정 (application.yml 기반)
*
* 설정 예시:
* app.batch.partition:
* daily-tables:
* - schema: snp_data
* table-name: ais_target
* partition-column: message_timestamp
* periods-ahead: 3
* monthly-tables:
* - schema: snp_data
* table-name: some_table
* partition-column: created_at
* periods-ahead: 2
* retention:
* daily-default-days: 14
* monthly-default-months: 1
* custom:
* - table-name: ais_target
* retention-days: 30
*/
@Getter
@Setter
@Component
@ConfigurationProperties(prefix = "app.batch.partition")
public class PartitionConfig {
/**
* 일별 파티션 테이블 목록 (파티션 네이밍: {table}_YYMMDD)
*/
private List<PartitionTableConfig> dailyTables = new ArrayList<>();
/**
* 월별 파티션 테이블 목록 (파티션 네이밍: {table}_YYYY_MM)
*/
private List<PartitionTableConfig> monthlyTables = new ArrayList<>();
/**
* 보관기간 설정
*/
private RetentionConfig retention = new RetentionConfig();
/**
* 파티션 테이블 설정
*/
@Getter
@Setter
public static class PartitionTableConfig {
private String schema = "snp_data";
private String tableName;
private String partitionColumn;
private int periodsAhead = 3; // 미리 생성할 기간 (daily: , monthly: )
public String getFullTableName() {
return schema + "." + tableName;
}
}
/**
* 보관기간 설정
*/
@Getter
@Setter
public static class RetentionConfig {
/**
* 일별 파티션 기본 보관기간 ()
*/
private int dailyDefaultDays = 14;
/**
* 월별 파티션 기본 보관기간 (개월)
*/
private int monthlyDefaultMonths = 1;
/**
* 개별 테이블 보관기간 설정
*/
private List<CustomRetention> custom = new ArrayList<>();
}
/**
* 개별 테이블 보관기간 설정
*/
@Getter
@Setter
public static class CustomRetention {
private String tableName;
private Integer retentionDays; // 단위 보관기간 (일별 파티션용)
private Integer retentionMonths; // 단위 보관기간 (월별 파티션용)
}
/**
* 일별 파티션 테이블의 보관기간 조회 ( 단위)
*/
public int getDailyRetentionDays(String tableName) {
return getCustomRetention(tableName)
.map(c -> c.getRetentionDays() != null ? c.getRetentionDays() : retention.getDailyDefaultDays())
.orElse(retention.getDailyDefaultDays());
}
/**
* 월별 파티션 테이블의 보관기간 조회 ( 단위)
*/
public int getMonthlyRetentionMonths(String tableName) {
return getCustomRetention(tableName)
.map(c -> c.getRetentionMonths() != null ? c.getRetentionMonths() : retention.getMonthlyDefaultMonths())
.orElse(retention.getMonthlyDefaultMonths());
}
/**
* 개별 테이블 보관기간 설정 조회
*/
private Optional<CustomRetention> getCustomRetention(String tableName) {
if (retention.getCustom() == null) {
return Optional.empty();
}
return retention.getCustom().stream()
.filter(c -> tableName.equals(c.getTableName()))
.findFirst();
}
}

파일 보기

@ -0,0 +1,68 @@
package com.snp.batch.global.partition;
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;
/**
* 파티션 관리 Job Config
*
* 스케줄: 매일 00:10 (0 10 0 * * ?)
*
* 동작:
* - Daily 파티션: 매일 실행
* - Monthly 파티션: 매월 말일에만 실행 (Job 내부에서 말일 감지)
*/
@Slf4j
@Configuration
public class PartitionManagerJobConfig {
private final JobRepository jobRepository;
private final PlatformTransactionManager transactionManager;
private final PartitionManagerTasklet partitionManagerTasklet;
public PartitionManagerJobConfig(
JobRepository jobRepository,
PlatformTransactionManager transactionManager,
PartitionManagerTasklet partitionManagerTasklet) {
this.jobRepository = jobRepository;
this.transactionManager = transactionManager;
this.partitionManagerTasklet = partitionManagerTasklet;
}
@Bean(name = "partitionManagerStep")
public Step partitionManagerStep() {
return new StepBuilder("partitionManagerStep", jobRepository)
.tasklet(partitionManagerTasklet, transactionManager)
.build();
}
@Bean(name = "partitionManagerJob")
public Job partitionManagerJob() {
log.info("Job 생성: partitionManagerJob");
return new JobBuilder("partitionManagerJob", jobRepository)
.listener(new JobExecutionListener() {
@Override
public void beforeJob(JobExecution jobExecution) {
log.info("[partitionManagerJob] 파티션 관리 Job 시작");
}
@Override
public void afterJob(JobExecution jobExecution) {
log.info("[partitionManagerJob] 파티션 관리 Job 완료 - 상태: {}",
jobExecution.getStatus());
}
})
.start(partitionManagerStep())
.build();
}
}

파일 보기

@ -0,0 +1,416 @@
package com.snp.batch.global.partition;
import com.snp.batch.global.partition.PartitionConfig.PartitionTableConfig;
import lombok.RequiredArgsConstructor;
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.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import java.time.LocalDate;
import java.time.YearMonth;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
/**
* 파티션 관리 Tasklet
*
* 스케줄: 매일 실행
* - Daily 파티션: 매일 생성/삭제 (네이밍: {table}_YYMMDD)
* - Monthly 파티션: 매월 말일에만 생성/삭제 (네이밍: {table}_YYYY_MM)
*
* 보관기간:
* - 기본값: 일별 14일, 월별 1개월
* - 개별 테이블별 보관기간 설정 가능 (application.yml)
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class PartitionManagerTasklet implements Tasklet {
private final JdbcTemplate jdbcTemplate;
private final PartitionConfig partitionConfig;
private static final DateTimeFormatter DAILY_PARTITION_FORMAT = DateTimeFormatter.ofPattern("yyMMdd");
private static final DateTimeFormatter MONTHLY_PARTITION_FORMAT = DateTimeFormatter.ofPattern("yyyy_MM");
private static final String PARTITION_EXISTS_SQL = """
SELECT EXISTS (
SELECT 1 FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = ?
AND c.relname = ?
AND c.relkind = 'r'
)
""";
private static final String FIND_PARTITIONS_SQL = """
SELECT c.relname
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
JOIN pg_inherits i ON i.inhrelid = c.oid
WHERE n.nspname = ?
AND c.relname LIKE ?
AND c.relkind = 'r'
ORDER BY c.relname
""";
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception {
LocalDate today = LocalDate.now();
boolean isLastDayOfMonth = isLastDayOfMonth(today);
log.info("========================================");
log.info("파티션 관리 Job 시작");
log.info("실행 일자: {}", today);
log.info("월 말일 여부: {}", isLastDayOfMonth);
log.info("========================================");
// 1. Daily 파티션 생성 (매일)
createDailyPartitions(today);
// 2. Daily 파티션 삭제 (보관기간 초과분)
deleteDailyPartitions(today);
// 3. Monthly 파티션 생성 (매월 말일만)
if (isLastDayOfMonth) {
createMonthlyPartitions(today);
} else {
log.info("Monthly 파티션 생성: 말일이 아니므로 스킵");
}
// 4. Monthly 파티션 삭제 (매월 1일에만, 보관기간 초과분)
if (today.getDayOfMonth() == 1) {
deleteMonthlyPartitions(today);
} else {
log.info("Monthly 파티션 삭제: 1일이 아니므로 스킵");
}
log.info("========================================");
log.info("파티션 관리 Job 완료");
log.info("========================================");
return RepeatStatus.FINISHED;
}
/**
* 매월 말일 여부 확인
*/
private boolean isLastDayOfMonth(LocalDate date) {
return date.getDayOfMonth() == YearMonth.from(date).lengthOfMonth();
}
// ==================== Daily 파티션 생성 ====================
/**
* Daily 파티션 생성
*/
private void createDailyPartitions(LocalDate today) {
List<PartitionTableConfig> tables = partitionConfig.getDailyTables();
if (tables == null || tables.isEmpty()) {
log.info("Daily 파티션 생성: 대상 테이블 없음");
return;
}
log.info("Daily 파티션 생성 시작: {} 개 테이블", tables.size());
for (PartitionTableConfig table : tables) {
createDailyPartitionsForTable(table, today);
}
}
/**
* 개별 테이블 Daily 파티션 생성
*/
private void createDailyPartitionsForTable(PartitionTableConfig table, LocalDate today) {
List<String> created = new ArrayList<>();
List<String> skipped = new ArrayList<>();
for (int i = 0; i <= table.getPeriodsAhead(); i++) {
LocalDate targetDate = today.plusDays(i);
String partitionName = getDailyPartitionName(table.getTableName(), targetDate);
if (partitionExists(table.getSchema(), partitionName)) {
skipped.add(partitionName);
} else {
createDailyPartition(table, targetDate, partitionName);
created.add(partitionName);
}
}
log.info("[{}] Daily 파티션 생성 - 생성: {}, 스킵: {}",
table.getTableName(), created.size(), skipped.size());
if (!created.isEmpty()) {
log.info("[{}] 생성된 파티션: {}", table.getTableName(), created);
}
}
// ==================== Daily 파티션 삭제 ====================
/**
* Daily 파티션 삭제 (보관기간 초과분)
*/
private void deleteDailyPartitions(LocalDate today) {
List<PartitionTableConfig> tables = partitionConfig.getDailyTables();
if (tables == null || tables.isEmpty()) {
log.info("Daily 파티션 삭제: 대상 테이블 없음");
return;
}
log.info("Daily 파티션 삭제 시작: {} 개 테이블", tables.size());
for (PartitionTableConfig table : tables) {
int retentionDays = partitionConfig.getDailyRetentionDays(table.getTableName());
deleteDailyPartitionsForTable(table, today, retentionDays);
}
}
/**
* 개별 테이블 Daily 파티션 삭제
*/
private void deleteDailyPartitionsForTable(PartitionTableConfig table, LocalDate today, int retentionDays) {
LocalDate cutoffDate = today.minusDays(retentionDays);
String likePattern = table.getTableName() + "_%";
List<String> partitions = jdbcTemplate.queryForList(
FIND_PARTITIONS_SQL, String.class, table.getSchema(), likePattern);
List<String> deleted = new ArrayList<>();
for (String partitionName : partitions) {
// 파티션 이름에서 날짜 추출 (table_YYMMDD)
LocalDate partitionDate = parseDailyPartitionDate(table.getTableName(), partitionName);
if (partitionDate != null && partitionDate.isBefore(cutoffDate)) {
dropPartition(table.getSchema(), partitionName);
deleted.add(partitionName);
}
}
if (!deleted.isEmpty()) {
log.info("[{}] Daily 파티션 삭제 - 보관기간: {}일, 삭제: {} 개",
table.getTableName(), retentionDays, deleted.size());
log.info("[{}] 삭제된 파티션: {}", table.getTableName(), deleted);
} else {
log.info("[{}] Daily 파티션 삭제 - 보관기간: {}일, 삭제할 파티션 없음",
table.getTableName(), retentionDays);
}
}
// ==================== Monthly 파티션 생성 ====================
/**
* Monthly 파티션 생성
*/
private void createMonthlyPartitions(LocalDate today) {
List<PartitionTableConfig> tables = partitionConfig.getMonthlyTables();
if (tables == null || tables.isEmpty()) {
log.info("Monthly 파티션 생성: 대상 테이블 없음");
return;
}
log.info("Monthly 파티션 생성 시작: {} 개 테이블", tables.size());
for (PartitionTableConfig table : tables) {
createMonthlyPartitionsForTable(table, today);
}
}
/**
* 개별 테이블 Monthly 파티션 생성
*/
private void createMonthlyPartitionsForTable(PartitionTableConfig table, LocalDate today) {
List<String> created = new ArrayList<>();
List<String> skipped = new ArrayList<>();
for (int i = 0; i <= table.getPeriodsAhead(); i++) {
LocalDate targetDate = today.plusMonths(i).withDayOfMonth(1);
String partitionName = getMonthlyPartitionName(table.getTableName(), targetDate);
if (partitionExists(table.getSchema(), partitionName)) {
skipped.add(partitionName);
} else {
createMonthlyPartition(table, targetDate, partitionName);
created.add(partitionName);
}
}
log.info("[{}] Monthly 파티션 생성 - 생성: {}, 스킵: {}",
table.getTableName(), created.size(), skipped.size());
if (!created.isEmpty()) {
log.info("[{}] 생성된 파티션: {}", table.getTableName(), created);
}
}
// ==================== Monthly 파티션 삭제 ====================
/**
* Monthly 파티션 삭제 (보관기간 초과분)
*/
private void deleteMonthlyPartitions(LocalDate today) {
List<PartitionTableConfig> tables = partitionConfig.getMonthlyTables();
if (tables == null || tables.isEmpty()) {
log.info("Monthly 파티션 삭제: 대상 테이블 없음");
return;
}
log.info("Monthly 파티션 삭제 시작: {} 개 테이블", tables.size());
for (PartitionTableConfig table : tables) {
int retentionMonths = partitionConfig.getMonthlyRetentionMonths(table.getTableName());
deleteMonthlyPartitionsForTable(table, today, retentionMonths);
}
}
/**
* 개별 테이블 Monthly 파티션 삭제
*/
private void deleteMonthlyPartitionsForTable(PartitionTableConfig table, LocalDate today, int retentionMonths) {
LocalDate cutoffDate = today.minusMonths(retentionMonths).withDayOfMonth(1);
String likePattern = table.getTableName() + "_%";
List<String> partitions = jdbcTemplate.queryForList(
FIND_PARTITIONS_SQL, String.class, table.getSchema(), likePattern);
List<String> deleted = new ArrayList<>();
for (String partitionName : partitions) {
// 파티션 이름에서 날짜 추출 (table_YYYY_MM)
LocalDate partitionDate = parseMonthlyPartitionDate(table.getTableName(), partitionName);
if (partitionDate != null && partitionDate.isBefore(cutoffDate)) {
dropPartition(table.getSchema(), partitionName);
deleted.add(partitionName);
}
}
if (!deleted.isEmpty()) {
log.info("[{}] Monthly 파티션 삭제 - 보관기간: {}개월, 삭제: {} 개",
table.getTableName(), retentionMonths, deleted.size());
log.info("[{}] 삭제된 파티션: {}", table.getTableName(), deleted);
} else {
log.info("[{}] Monthly 파티션 삭제 - 보관기간: {}개월, 삭제할 파티션 없음",
table.getTableName(), retentionMonths);
}
}
// ==================== 파티션 이름 생성 ====================
/**
* Daily 파티션 이름 생성 (table_YYMMDD)
*/
private String getDailyPartitionName(String tableName, LocalDate date) {
return tableName + "_" + date.format(DAILY_PARTITION_FORMAT);
}
/**
* Monthly 파티션 이름 생성 (table_YYYY_MM)
*/
private String getMonthlyPartitionName(String tableName, LocalDate date) {
return tableName + "_" + date.format(MONTHLY_PARTITION_FORMAT);
}
// ==================== 파티션 이름에서 날짜 추출 ====================
/**
* Daily 파티션 이름에서 날짜 추출 (table_YYMMDD -> LocalDate)
*/
private LocalDate parseDailyPartitionDate(String tableName, String partitionName) {
try {
String prefix = tableName + "_";
if (!partitionName.startsWith(prefix)) {
return null;
}
String dateStr = partitionName.substring(prefix.length());
// YYMMDD 형식 (6자리)
if (dateStr.length() == 6 && dateStr.matches("\\d{6}")) {
return LocalDate.parse(dateStr, DAILY_PARTITION_FORMAT);
}
return null;
} catch (Exception e) {
log.trace("파티션 날짜 파싱 실패: {}", partitionName);
return null;
}
}
/**
* Monthly 파티션 이름에서 날짜 추출 (table_YYYY_MM -> LocalDate)
*/
private LocalDate parseMonthlyPartitionDate(String tableName, String partitionName) {
try {
String prefix = tableName + "_";
if (!partitionName.startsWith(prefix)) {
return null;
}
String dateStr = partitionName.substring(prefix.length());
// YYYY_MM 형식 (7자리)
if (dateStr.length() == 7 && dateStr.matches("\\d{4}_\\d{2}")) {
return LocalDate.parse(dateStr + "_01", DateTimeFormatter.ofPattern("yyyy_MM_dd"));
}
return null;
} catch (Exception e) {
log.trace("파티션 날짜 파싱 실패: {}", partitionName);
return null;
}
}
// ==================== DB 작업 ====================
/**
* 파티션 존재 여부 확인
*/
private boolean partitionExists(String schema, String partitionName) {
Boolean exists = jdbcTemplate.queryForObject(PARTITION_EXISTS_SQL, Boolean.class, schema, partitionName);
return Boolean.TRUE.equals(exists);
}
/**
* Daily 파티션 생성
*/
private void createDailyPartition(PartitionTableConfig table, LocalDate targetDate, String partitionName) {
LocalDate endDate = targetDate.plusDays(1);
String sql = String.format("""
CREATE TABLE %s.%s PARTITION OF %s
FOR VALUES FROM ('%s 00:00:00+00') TO ('%s 00:00:00+00')
""",
table.getSchema(), partitionName, table.getFullTableName(),
targetDate, endDate);
jdbcTemplate.execute(sql);
log.debug("Daily 파티션 생성: {}", partitionName);
}
/**
* Monthly 파티션 생성
*/
private void createMonthlyPartition(PartitionTableConfig table, LocalDate targetDate, String partitionName) {
LocalDate startDate = targetDate.withDayOfMonth(1);
LocalDate endDate = startDate.plusMonths(1);
String sql = String.format("""
CREATE TABLE %s.%s PARTITION OF %s
FOR VALUES FROM ('%s 00:00:00+00') TO ('%s 00:00:00+00')
""",
table.getSchema(), partitionName, table.getFullTableName(),
startDate, endDate);
jdbcTemplate.execute(sql);
log.debug("Monthly 파티션 생성: {}", partitionName);
}
/**
* 파티션 삭제
*/
private void dropPartition(String schema, String partitionName) {
String sql = String.format("DROP TABLE IF EXISTS %s.%s", schema, partitionName);
jdbcTemplate.execute(sql);
log.debug("파티션 삭제: {}", partitionName);
}
}

파일 보기

@ -0,0 +1,9 @@
package com.snp.batch.global.projection;
import java.time.LocalDateTime;
public interface DateRangeProjection {
LocalDateTime getLastSuccessDate();
LocalDateTime getRangeFromDate();
LocalDateTime getRangeToDate();
}

파일 보기

@ -0,0 +1,10 @@
package com.snp.batch.global.repository;
import com.snp.batch.global.model.BatchApiLog;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface BatchApiLogRepository extends JpaRepository<BatchApiLog, Long> {
}

파일 보기

@ -0,0 +1,47 @@
package com.snp.batch.global.repository;
import com.snp.batch.global.model.BatchLastExecution;
import com.snp.batch.global.projection.DateRangeProjection;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.util.Optional;
@Repository
public interface BatchLastExecutionRepository extends JpaRepository<BatchLastExecution, String> {
// 1. findLastSuccessDate 함수 구현
/**
* API 키를 기준으로 마지막 성공 일자를 조회합니다.
* @param apiKey 조회할 API (: "SHIP_UPDATE_API")
* @return 마지막 성공 일자 (LocalDate) 포함하는 Optional
*/
@Query("SELECT b.lastSuccessDate FROM BatchLastExecution b WHERE b.apiKey = :apiKey")
Optional<LocalDate> findLastSuccessDate(@Param("apiKey") String apiKey);
// 2. findDateRangeByApiKey 함수 구현
/**
* API 키를 기준으로 범위 설정 날짜를 조회합니다.
* @param apiKey 조회할 API (: "PSC_IMPORT_API")
* @return 마지막 성공 일자 (LocalDate) 포함하는 Optional
*/
@Query("SELECT b.lastSuccessDate AS lastSuccessDate, b.rangeFromDate AS rangeFromDate, b.rangeToDate AS rangeToDate FROM BatchLastExecution b WHERE b.apiKey = :apiKey")
Optional<DateRangeProjection> findDateRangeByApiKey(@Param("apiKey") String apiKey);
// 3. updateLastSuccessDate 함수 구현 (직접 UPDATE 쿼리 사용)
/**
* 특정 API 키의 마지막 성공 일자를 업데이트합니다.
*
* @param apiKey 업데이트할 API
* @param successDate 업데이트할 성공 일자
* @return 업데이트된 레코드
*/
@Modifying
@Query("UPDATE BatchLastExecution b SET b.lastSuccessDate = :successDate WHERE b.apiKey = :apiKey")
int updateLastSuccessDate(@Param("apiKey") String apiKey, @Param("successDate") LocalDateTime successDate);
}

파일 보기

@ -1,6 +1,6 @@
package com.snp.batch.global.repository;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
@ -13,10 +13,25 @@ import java.util.Map;
* Step Context 불필요한 데이터를 조회하지 않고 필요한 정보만 가져옴
*/
@Repository
@RequiredArgsConstructor
public class TimelineRepository {
private final JdbcTemplate jdbcTemplate;
private final String tablePrefix;
public TimelineRepository(
JdbcTemplate jdbcTemplate,
@Value("${spring.batch.jdbc.table-prefix:BATCH_}") String tablePrefix) {
this.jdbcTemplate = jdbcTemplate;
this.tablePrefix = tablePrefix;
}
private String getJobExecutionTable() {
return tablePrefix + "JOB_EXECUTION";
}
private String getJobInstanceTable() {
return tablePrefix + "JOB_INSTANCE";
}
/**
* 특정 Job의 특정 범위 실행 이력 조회 (경량)
@ -27,19 +42,19 @@ public class TimelineRepository {
LocalDateTime startTime,
LocalDateTime endTime) {
String sql = """
String sql = String.format("""
SELECT
je.JOB_EXECUTION_ID as executionId,
je.STATUS as status,
je.START_TIME as startTime,
je.END_TIME as endTime
FROM BATCH_JOB_EXECUTION je
INNER JOIN BATCH_JOB_INSTANCE ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID
FROM %s je
INNER JOIN %s ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID
WHERE ji.JOB_NAME = ?
AND je.START_TIME >= ?
AND je.START_TIME < ?
ORDER BY je.START_TIME DESC
""";
""", getJobExecutionTable(), getJobInstanceTable());
return jdbcTemplate.queryForList(sql, jobName, startTime, endTime);
}
@ -51,19 +66,19 @@ public class TimelineRepository {
LocalDateTime startTime,
LocalDateTime endTime) {
String sql = """
String sql = String.format("""
SELECT
ji.JOB_NAME as jobName,
je.JOB_EXECUTION_ID as executionId,
je.STATUS as status,
je.START_TIME as startTime,
je.END_TIME as endTime
FROM BATCH_JOB_EXECUTION je
INNER JOIN BATCH_JOB_INSTANCE ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID
FROM %s je
INNER JOIN %s ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID
WHERE je.START_TIME >= ?
AND je.START_TIME < ?
ORDER BY ji.JOB_NAME, je.START_TIME DESC
""";
""", getJobExecutionTable(), getJobInstanceTable());
return jdbcTemplate.queryForList(sql, startTime, endTime);
}
@ -72,17 +87,17 @@ public class TimelineRepository {
* 현재 실행 중인 Job 조회 (STARTED, STARTING 상태)
*/
public List<Map<String, Object>> findRunningExecutions() {
String sql = """
String sql = String.format("""
SELECT
ji.JOB_NAME as jobName,
je.JOB_EXECUTION_ID as executionId,
je.STATUS as status,
je.START_TIME as startTime
FROM BATCH_JOB_EXECUTION je
INNER JOIN BATCH_JOB_INSTANCE ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID
FROM %s je
INNER JOIN %s ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID
WHERE je.STATUS IN ('STARTED', 'STARTING')
ORDER BY je.START_TIME DESC
""";
""", getJobExecutionTable(), getJobInstanceTable());
return jdbcTemplate.queryForList(sql);
}
@ -91,18 +106,18 @@ public class TimelineRepository {
* 최근 실행 이력 조회 (상위 N개)
*/
public List<Map<String, Object>> findRecentExecutions(int limit) {
String sql = """
String sql = String.format("""
SELECT
ji.JOB_NAME as jobName,
je.JOB_EXECUTION_ID as executionId,
je.STATUS as status,
je.START_TIME as startTime,
je.END_TIME as endTime
FROM BATCH_JOB_EXECUTION je
INNER JOIN BATCH_JOB_INSTANCE ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID
FROM %s je
INNER JOIN %s ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID
ORDER BY je.START_TIME DESC
LIMIT ?
""";
""", getJobExecutionTable(), getJobInstanceTable());
return jdbcTemplate.queryForList(sql, limit);
}

파일 보기

@ -0,0 +1,139 @@
package com.snp.batch.jobs.aistarget.batch.config;
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 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,
Core20CacheManager core20CacheManager) {
super(jobRepository, transactionManager);
this.aisTargetDataProcessor = aisTargetDataProcessor;
this.aisTargetDataWriter = aisTargetDataWriter;
this.maritimeAisApiWebClient = maritimeAisApiWebClient;
this.core20CacheManager = core20CacheManager;
}
@Override
protected String getJobName() {
return "aisTargetImportJob";
}
@Override
protected String getStepName() {
return "aisTargetImportStep";
}
@Override
protected ItemReader<AisTargetDto> createReader() {
return new AisTargetDataReader(maritimeAisApiWebClient, 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 시작 - 수집 시점: {}", 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 완료 - 상태: {}, 처리 건수: {}, Core20 캐시 크기: {}",
getJobName(),
jobExecution.getStatus(),
jobExecution.getStepExecutions().stream()
.mapToLong(se -> se.getWriteCount())
.sum(),
core20CacheManager.size());
}
});
}
@Bean(name = "aisTargetImportJob")
public Job aisTargetImportJob() {
return job();
}
}

파일 보기

@ -0,0 +1,27 @@
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;
}

파일 보기

@ -0,0 +1,167 @@
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;
}

파일 보기

@ -0,0 +1,121 @@
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
*
* 테이블: 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; // 배치 수집 시점
// ========== 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;
}

파일 보기

@ -0,0 +1,137 @@
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);
}
}

파일 보기

@ -0,0 +1,98 @@
package com.snp.batch.jobs.aistarget.batch.reader;
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.web.reactive.function.client.WebClient;
import java.util.Collections;
import java.util.List;
import java.util.Map;
/**
* AIS Target 데이터 Reader
*
* API: POST /AisSvc.svc/AIS/GetTargets
* Request: {"sinceSeconds": "60"}
*
* 동작:
* - 15초에 실행 (Quartz 스케줄)
* - 최근 60초 동안의 전체 선박 위치 정보 조회
* - 33,000건/ 처리
*/
@Slf4j
public class AisTargetDataReader extends BaseApiReader<AisTargetDto> {
private final int sinceSeconds;
public AisTargetDataReader(WebClient webClient, int sinceSeconds) {
super(webClient);
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() {
try {
log.info("[{}] API 호출 시작: {} {}", getReaderName(), getHttpMethod(), getApiPath());
AisTargetApiResponse response = webClient.post()
.uri(getApiPath())
.bodyValue(getRequestBody())
.retrieve()
.bodyToMono(AisTargetApiResponse.class)
.block();
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);
}
}
@Override
protected void afterFetch(List<AisTargetDto> data) {
if (data != null && !data.isEmpty()) {
log.info("[{}] 데이터 조회 완료 - 총 {} 건", getReaderName(), data.size());
}
}
}

파일 보기

@ -0,0 +1,59 @@
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의 항적 조회
*/
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);
}

파일 보기

@ -0,0 +1,367 @@
package com.snp.batch.jobs.aistarget.batch.repository;
import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
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 구현체
*
* 테이블: snp_data.ais_target
* PK: mmsi + message_timestamp (복합키)
*/
@Slf4j
@Repository
@RequiredArgsConstructor
public class AisTargetRepositoryImpl implements AisTargetRepository {
private final JdbcTemplate jdbcTemplate;
private static final String TABLE_NAME = "snp_data.ais_target";
// ==================== UPSERT SQL ====================
private static final String UPSERT_SQL = """
INSERT INTO snp_data.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
""";
// ==================== RowMapper ====================
private final RowMapper<AisTargetEntity> rowMapper = (rs, rowNum) -> AisTargetEntity.builder()
.mmsi(rs.getLong("mmsi"))
.messageTimestamp(toOffsetDateTime(rs.getTimestamp("message_timestamp")))
.imo(rs.getObject("imo", Long.class))
.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(rs.getObject("rot", Integer.class))
.length(rs.getObject("length", Integer.class))
.width(rs.getObject("width", Integer.class))
.draught(rs.getObject("draught", Double.class))
.lengthBow(rs.getObject("length_bow", Integer.class))
.lengthStern(rs.getObject("length_stern", Integer.class))
.widthPort(rs.getObject("width_port", Integer.class))
.widthStarboard(rs.getObject("width_starboard", Integer.class))
.destination(rs.getString("destination"))
.eta(toOffsetDateTime(rs.getTimestamp("eta")))
.status(rs.getString("status"))
.ageMinutes(rs.getObject("age_minutes", Double.class))
.positionAccuracy(rs.getObject("position_accuracy", Integer.class))
.timestampUtc(rs.getObject("timestamp_utc", Integer.class))
.repeatIndicator(rs.getObject("repeat_indicator", Integer.class))
.raimFlag(rs.getObject("raim_flag", Integer.class))
.radioStatus(rs.getObject("radio_status", Integer.class))
.regional(rs.getObject("regional", Integer.class))
.regional2(rs.getObject("regional2", Integer.class))
.spare(rs.getObject("spare", Integer.class))
.spare2(rs.getObject("spare2", Integer.class))
.aisVersion(rs.getObject("ais_version", Integer.class))
.positionFixType(rs.getObject("position_fix_type", Integer.class))
.dte(rs.getObject("dte", Integer.class))
.bandFlag(rs.getObject("band_flag", Integer.class))
.receivedDate(toOffsetDateTime(rs.getTimestamp("received_date")))
.collectedAt(toOffsetDateTime(rs.getTimestamp("collected_at")))
.tonnesCargo(rs.getObject("tonnes_cargo", Integer.class))
.inSTS(rs.getObject("in_sts", Integer.class))
.onBerth(rs.getObject("on_berth", Boolean.class))
.dwt(rs.getObject("dwt", Integer.class))
.anomalous(rs.getString("anomalous"))
.destinationPortID(rs.getObject("destination_port_id", Integer.class))
.destinationTidied(rs.getString("destination_tidied"))
.destinationUNLOCODE(rs.getString("destination_unlocode"))
.imoVerified(rs.getString("imo_verified"))
.lastStaticUpdateReceived(toOffsetDateTime(rs.getTimestamp("last_static_update_received")))
.lpcCode(rs.getObject("lpc_code", Integer.class))
.messageType(rs.getObject("message_type", Integer.class))
.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 " + TABLE_NAME + " 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(TABLE_NAME);
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(TABLE_NAME);
Long[] mmsiArray = mmsiList.toArray(new Long[0]);
return jdbcTemplate.query(sql, rowMapper, (Object) mmsiArray);
}
@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(TABLE_NAME);
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(TABLE_NAME);
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(UPSERT_SQL, 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 " + TABLE_NAME;
Long count = jdbcTemplate.queryForObject(sql, Long.class);
return count != null ? count : 0L;
}
@Override
@Transactional
public int deleteOlderThan(OffsetDateTime threshold) {
String sql = "DELETE FROM " + TABLE_NAME + " WHERE message_timestamp < ?";
int deleted = jdbcTemplate.update(sql, toTimestamp(threshold));
log.info("AIS Target 오래된 데이터 삭제 완료: {} 건 (기준: {})", deleted, threshold);
return deleted;
}
// ==================== Helper Methods ====================
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;
}
}

파일 보기

@ -0,0 +1,52 @@
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.classifier.AisClassTypeClassifier;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* AIS Target 데이터 Writer (캐시 전용)
*
* 동작:
* 1. ClassType 분류 (Core20 캐시 기반 A/B 분류)
* 2. 캐시에 최신 위치 정보 업데이트 (classType, core20Mmsi 포함)
*
* 참고:
* - DB 저장은 별도 Job(aisTargetDbSyncJob)에서 15분 주기로 수행
* - Writer는 캐시 업데이트만 담당
*/
@Slf4j
@Component
public class AisTargetDataWriter extends BaseWriter<AisTargetEntity> {
private final AisTargetCacheManager cacheManager;
private final AisClassTypeClassifier classTypeClassifier;
public AisTargetDataWriter(
AisTargetCacheManager cacheManager,
AisClassTypeClassifier classTypeClassifier) {
super("AisTarget");
this.cacheManager = cacheManager;
this.classTypeClassifier = classTypeClassifier;
}
@Override
protected void writeItems(List<AisTargetEntity> items) throws Exception {
log.debug("AIS Target 캐시 업데이트 시작: {} 건", items.size());
// 1. ClassType 분류 (캐시 저장 전에 분류)
// - Core20 캐시의 IMO와 매칭하여 classType(A/B), core20Mmsi 설정
classTypeClassifier.classifyAll(items);
// 2. 캐시 업데이트 (classType, core20Mmsi 포함)
cacheManager.putAll(items);
log.debug("AIS Target 캐시 업데이트 완료: {} 건 (캐시 크기: {})",
items.size(), cacheManager.size());
}
}

파일 보기

@ -0,0 +1,272 @@
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);
}
}

파일 보기

@ -0,0 +1,229 @@
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);
}
}

파일 보기

@ -0,0 +1,317 @@
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;
}
}

파일 보기

@ -0,0 +1,160 @@
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();
}
}

파일 보기

@ -0,0 +1,219 @@
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;
}
}

파일 보기

@ -0,0 +1,71 @@
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: snp_data.core20 (ihslrorimoshipno, maritimemobileserviceidentitymmsinumber)
* - prod: new_snp.core20 (lrno, mmsi)
*/
@Slf4j
@Getter
@Setter
@ConfigurationProperties(prefix = "app.batch.core20")
public class Core20Properties {
/**
* 스키마명 (: snp_data, new_snp)
*/
private String schema = "snp_data";
/**
* 테이블명 (: core20)
*/
private String table = "core20";
/**
* IMO/LRNO 컬럼명 (PK, NOT NULL)
*/
private String imoColumn = "ihslrorimoshipno";
/**
* MMSI 컬럼명 (NULLABLE)
*/
private String mmsiColumn = "maritimemobileserviceidentitymmsinumber";
/**
* 전체 테이블명 반환 (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);
}
}

파일 보기

@ -0,0 +1,428 @@
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.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import jakarta.validation.Valid;
import java.util.List;
import java.util.Map;
/**
* AIS Target REST API Controller
*
* 캐시 우선 조회 전략:
* - 캐시에서 먼저 조회
* - 캐시 미스 DB 조회 캐시 업데이트
*/
@Slf4j
@RestController
@RequestMapping("/api/ais-target")
@RequiredArgsConstructor
@Tag(name = "AIS Target", description = "AIS 선박 위치 정보 API")
public class AisTargetController {
private final AisTargetService aisTargetService;
// ==================== 단건 조회 ====================
@Operation(
summary = "MMSI로 최신 위치 조회",
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 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)",
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 = "항해 조건 필터 검색",
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": ["0", "1", "5"]
}
```
---
## 항행상태 코드 (statusList)
| 코드 | 상태 |
|------|------|
| 0 | Under way using engine (기관 사용 항해 ) |
| 1 | At anchor (정박 ) |
| 2 | Not under command (조종불능) |
| 3 | Restricted manoeuverability (조종제한) |
| 4 | Constrained by her draught (흘수제약) |
| 5 | Moored (계류 ) |
| 6 | Aground (좌초) |
| 7 | Engaged in Fishing (어로 ) |
| 8 | Under way sailing ( 항해 ) |
| 9-10 | Reserved for future use |
| 11 | Power-driven vessel towing astern |
| 12 | Power-driven vessel pushing ahead |
| 13 | Reserved for future use |
| 14 | AIS-SART, MOB-AIS, EPIRB-AIS |
| 15 | Undefined (default) |
---
**참고:** 모든 필터는 선택사항이며, 미지정 해당 필드는 조건에서 제외됩니다 (전체 포함).
"""
)
@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 = "폴리곤 범위 내 선박 검색",
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(
@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 범위 내 선박 검색",
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(
@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
public static class PolygonSearchRequest {
@Parameter(description = "조회 범위 (분)", required = true, example = "5")
private int minutes;
@Parameter(description = "폴리곤 좌표 [[lon, lat], ...]", required = true)
private double[][] coordinates;
}
/**
* WKT 검색 요청 DTO
*/
@lombok.Data
public static class WktSearchRequest {
@Parameter(description = "조회 범위 (분)", required = true, example = "5")
private int minutes;
@Parameter(description = "WKT 문자열", required = true,
example = "POLYGON((129 35, 130 35, 130 36, 129 36, 129 35))")
private String wkt;
}
}

파일 보기

@ -0,0 +1,165 @@
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();
}
}

파일 보기

@ -0,0 +1,109 @@
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 {
// 선박 식별 정보
private Long mmsi;
private Long imo;
private String name;
private String callsign;
private String vesselType;
// 위치 정보
private Double lat;
private Double lon;
// 항해 정보
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 String destination;
private OffsetDateTime eta;
private String status;
// 타임스탬프
private OffsetDateTime messageTimestamp;
private OffsetDateTime receivedDate;
// 데이터 소스 (캐시/DB)
@Schema(description = "데이터 소스", example = "cache", allowableValues = {"cache", "db"})
private String source;
// 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)
.classType(entity.getClassType())
.core20Mmsi(entity.getCore20Mmsi())
.build();
}
}

파일 보기

@ -0,0 +1,66 @@
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"));
}
}

파일 보기

@ -0,0 +1,90 @@
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 ?";
};
}
}

파일 보기

@ -0,0 +1,390 @@
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.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 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());
}
// ==================== 캐시 관리 ====================
/**
* 캐시 통계 조회
*/
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;
}
}

파일 보기

@ -0,0 +1,79 @@
package com.snp.batch.jobs.aistargetdbsync.batch.config;
import com.snp.batch.jobs.aistargetdbsync.batch.tasklet.AisTargetDbSyncTasklet;
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: 없음 (캐시 기반)
*
* 동작:
* - Caffeine 캐시에서 최근 15분 이내 데이터 조회
* - MMSI별 최신 위치 1건씩 DB에 UPSERT
* - 1분 주기 aisTargetImportJob과 분리하여 DB 볼륨 최적화
*
* 데이터 흐름:
* - aisTargetImportJob (1분): API 캐시 업데이트
* - aisTargetDbSyncJob (15분): 캐시 DB 저장 ( Job)
*/
@Slf4j
@Configuration
public class AisTargetDbSyncJobConfig {
private final JobRepository jobRepository;
private final PlatformTransactionManager transactionManager;
private final AisTargetDbSyncTasklet aisTargetDbSyncTasklet;
public AisTargetDbSyncJobConfig(
JobRepository jobRepository,
PlatformTransactionManager transactionManager,
AisTargetDbSyncTasklet aisTargetDbSyncTasklet) {
this.jobRepository = jobRepository;
this.transactionManager = transactionManager;
this.aisTargetDbSyncTasklet = aisTargetDbSyncTasklet;
}
@Bean(name = "aisTargetDbSyncStep")
public Step aisTargetDbSyncStep() {
return new StepBuilder("aisTargetDbSyncStep", jobRepository)
.tasklet(aisTargetDbSyncTasklet, 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())
.build();
}
}

파일 보기

@ -0,0 +1,83 @@
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.RequiredArgsConstructor;
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.util.List;
/**
* AIS Target DB Sync Tasklet
*
* 스케줄: 15분 (0 0/15 * * * ?)
*
* 동작:
* - Caffeine 캐시에서 최근 N분 이내 데이터 조회
* - MMSI별 최신 위치 1건씩 DB에 UPSERT
* - 캐시의 모든 컬럼 정보를 그대로 DB에 저장
*
* 참고:
* - 캐시에는 MMSI별 최신 데이터만 유지됨 (120분 TTL)
* - DB 저장은 15분 주기로 수행하여 볼륨 절감
* - 기존 aisTargetImportJob은 캐시 업데이트만 수행
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class AisTargetDbSyncTasklet implements Tasklet {
private final AisTargetCacheManager cacheManager;
private final AisTargetRepository aisTargetRepository;
/**
* DB 동기화 조회할 캐시 데이터 시간 범위 ()
* 기본값: 15분 (스케줄 주기와 동일)
*/
@Value("${app.batch.ais-target-db-sync.time-range-minutes:15}")
private int timeRangeMinutes;
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception {
log.info("========================================");
log.info("AIS Target DB Sync 시작");
log.info("조회 범위: 최근 {}분", timeRangeMinutes);
log.info("현재 캐시 크기: {}", cacheManager.size());
log.info("========================================");
long startTime = System.currentTimeMillis();
// 1. 캐시에서 최근 N분 이내 데이터 조회
List<AisTargetEntity> entities = cacheManager.getByTimeRange(timeRangeMinutes);
if (entities.isEmpty()) {
log.warn("캐시에서 조회된 데이터가 없습니다 (범위: {}분)", timeRangeMinutes);
return RepeatStatus.FINISHED;
}
log.info("캐시에서 {} 건 조회 완료", entities.size());
// 2. DB에 UPSERT
aisTargetRepository.batchUpsert(entities);
long elapsed = System.currentTimeMillis() - startTime;
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;
}
}

파일 보기

@ -0,0 +1,93 @@
package com.snp.batch.jobs.common.batch.config;
import com.snp.batch.common.batch.config.BaseJobConfig;
import com.snp.batch.jobs.common.batch.dto.FlagCodeDto;
import com.snp.batch.jobs.common.batch.entity.FlagCodeEntity;
import com.snp.batch.jobs.common.batch.processor.FlagCodeDataProcessor;
import com.snp.batch.jobs.common.batch.reader.FlagCodeDataReader;
import com.snp.batch.jobs.common.batch.repository.FlagCodeRepository;
import com.snp.batch.jobs.common.batch.writer.FlagCodeDataWriter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
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;
@Slf4j
@Configuration
public class FlagCodeImportJobConfig extends BaseJobConfig<FlagCodeDto, FlagCodeEntity> {
private final FlagCodeRepository flagCodeRepository;
private final WebClient maritimeApiWebClient;
@Value("${app.batch.chunk-size:1000}")
private int chunkSize;
/**
* 생성자 주입
* maritimeApiWebClient: MaritimeApiWebClientConfig에서 등록한 Bean 주입
*/
public FlagCodeImportJobConfig(
JobRepository jobRepository,
PlatformTransactionManager transactionManager,
FlagCodeRepository flagCodeRepository,
@Qualifier("maritimeApiWebClient") WebClient maritimeApiWebClient) {
super(jobRepository, transactionManager);
this.flagCodeRepository = flagCodeRepository;
this.maritimeApiWebClient = maritimeApiWebClient;
}
@Override
protected String getJobName() {
return "FlagCodeImportJob";
}
@Override
protected String getStepName() {
return "FlagCodeImportStep";
}
@Override
protected ItemReader<FlagCodeDto> createReader() {
return new FlagCodeDataReader(maritimeApiWebClient);
}
@Override
protected ItemProcessor<FlagCodeDto, FlagCodeEntity> createProcessor() {
return new FlagCodeDataProcessor(flagCodeRepository);
}
@Override
protected ItemWriter<FlagCodeEntity> createWriter() {
return new FlagCodeDataWriter(flagCodeRepository);
}
@Override
protected int getChunkSize() {
return chunkSize;
}
/**
* Job Bean 등록
*/
@Bean(name = "FlagCodeImportJob")
public Job flagCodeImportJob() {
return job();
}
/**
* Step Bean 등록
*/
@Bean(name = "FlagCodeImportStep")
public Step flagCodeImportStep() {
return step();
}
}

파일 보기

@ -0,0 +1,68 @@
package com.snp.batch.jobs.common.batch.config;
import com.snp.batch.common.batch.config.BaseJobConfig;
import com.snp.batch.jobs.common.batch.dto.Stat5CodeDto;
import com.snp.batch.jobs.common.batch.entity.Stat5CodeEntity;
import com.snp.batch.jobs.common.batch.processor.Stat5CodeDataProcessor;
import com.snp.batch.jobs.common.batch.reader.Stat5CodeDataReader;
import com.snp.batch.jobs.common.batch.repository.Stat5CodeRepository;
import com.snp.batch.jobs.common.batch.writer.Stat5CodeDataWriter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
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.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.web.reactive.function.client.WebClient;
@Slf4j
@Configuration
public class Stat5CodeImportJobConfig extends BaseJobConfig<Stat5CodeDto, Stat5CodeEntity> {
private final Stat5CodeRepository stat5CodeRepository;
private final WebClient maritimeAisApiWebClient;
public Stat5CodeImportJobConfig(
JobRepository jobRepository,
PlatformTransactionManager transactionManager,
Stat5CodeRepository stat5CodeRepository,
@Qualifier("maritimeAisApiWebClient") WebClient maritimeAisApiWebClient) {
super(jobRepository, transactionManager);
this.stat5CodeRepository = stat5CodeRepository;
this.maritimeAisApiWebClient = maritimeAisApiWebClient;
}
@Override
protected String getJobName() { return "Stat5CodeImportJob"; }
@Override
protected String getStepName() {
return "Stat5CodeImportStep";
}
@Override
protected ItemReader<Stat5CodeDto> createReader() { return new Stat5CodeDataReader(maritimeAisApiWebClient); }
@Override
protected ItemProcessor<Stat5CodeDto, Stat5CodeEntity> createProcessor() { return new Stat5CodeDataProcessor(stat5CodeRepository); }
@Override
protected ItemWriter<Stat5CodeEntity> createWriter() { return new Stat5CodeDataWriter(stat5CodeRepository); }
@Bean(name = "Stat5CodeImportJob")
public Job stat5CodeImportJob() {
return job();
}
/**
* Step Bean 등록
*/
@Bean(name = "Stat5CodeImportStep")
public Step stat5CodeImportStep() {
return step();
}
}

파일 보기

@ -0,0 +1,26 @@
package com.snp.batch.jobs.common.batch.dto;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class FlagCodeApiResponse {
@JsonProperty("associatedName")
private String associatedName;
@JsonProperty("associatedCount")
private Integer associatedCount;
@JsonProperty("APSAssociatedFlagISODetails")
private List<FlagCodeDto> associatedFlagISODetails;
}

파일 보기

@ -0,0 +1,40 @@
package com.snp.batch.jobs.common.batch.dto;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class FlagCodeDto {
@JsonProperty("DataSetVersion")
private DataSetVersion dataSetVersion;
@JsonProperty("Code")
private String code;
@JsonProperty("Decode")
private String decode;
@JsonProperty("ISO2")
private String iso2;
@JsonProperty("ISO3")
private String iso3;
@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public static class DataSetVersion {
@JsonProperty("DataSetVersion")
private String version;
}
}

파일 보기

@ -0,0 +1,18 @@
package com.snp.batch.jobs.common.batch.dto;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class Stat5CodeApiResponse {
@JsonProperty("StatcodeArr")
private List<Stat5CodeDto> statcodeArr;
}

파일 보기

@ -0,0 +1,40 @@
package com.snp.batch.jobs.common.batch.dto;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class Stat5CodeDto {
@JsonProperty("Level1")
private String level1;
@JsonProperty("Level1Decode")
private String Level1Decode;
@JsonProperty("Level2")
private String Level2;
@JsonProperty("Level2Decode")
private String Level2Decode;
@JsonProperty("Level3")
private String Level3;
@JsonProperty("Level3Decode")
private String Level3Decode;
@JsonProperty("Level4")
private String Level4;
@JsonProperty("Level4Decode")
private String Level4Decode;
@JsonProperty("Level5")
private String Level5;
@JsonProperty("Level5Decode")
private String Level5Decode;
@JsonProperty("Description")
private String Description;
@JsonProperty("Release")
private Integer Release;
}

파일 보기

@ -0,0 +1,26 @@
package com.snp.batch.jobs.common.batch.entity;
import com.snp.batch.common.batch.entity.BaseEntity;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;
@Data
@SuperBuilder
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode(callSuper = true)
public class FlagCodeEntity extends BaseEntity {
private String dataSetVersion;
private String code;
private String decode;
private String iso2;
private String iso3;
}

파일 보기

@ -0,0 +1,40 @@
package com.snp.batch.jobs.common.batch.entity;
import com.snp.batch.common.batch.entity.BaseEntity;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;
@Data
@SuperBuilder
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode(callSuper = true)
public class Stat5CodeEntity extends BaseEntity {
private String level1;
private String Level1Decode;
private String Level2;
private String Level2Decode;
private String Level3;
private String Level3Decode;
private String Level4;
private String Level4Decode;
private String Level5;
private String Level5Decode;
private String Description;
private String Release;
}

파일 보기

@ -0,0 +1,33 @@
package com.snp.batch.jobs.common.batch.processor;
import com.snp.batch.common.batch.processor.BaseProcessor;
import com.snp.batch.jobs.common.batch.dto.FlagCodeDto;
import com.snp.batch.jobs.common.batch.entity.FlagCodeEntity;
import com.snp.batch.jobs.common.batch.repository.FlagCodeRepository;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class FlagCodeDataProcessor extends BaseProcessor<FlagCodeDto, FlagCodeEntity> {
private final FlagCodeRepository commonCodeRepository;
public FlagCodeDataProcessor(FlagCodeRepository commonCodeRepository) {
this.commonCodeRepository = commonCodeRepository;
}
@Override
protected FlagCodeEntity processItem(FlagCodeDto dto) throws Exception {
FlagCodeEntity entity = FlagCodeEntity.builder()
.dataSetVersion(dto.getDataSetVersion().getVersion())
.code(dto.getCode())
.decode(dto.getDecode())
.iso2(dto.getIso2())
.iso3(dto.getIso3())
.build();
log.debug("국가코드 데이터 처리 완료: FlagCode={}", dto.getCode());
return entity;
}
}

파일 보기

@ -0,0 +1,38 @@
package com.snp.batch.jobs.common.batch.processor;
import com.snp.batch.common.batch.processor.BaseProcessor;
import com.snp.batch.jobs.common.batch.dto.Stat5CodeDto;
import com.snp.batch.jobs.common.batch.entity.Stat5CodeEntity;
import com.snp.batch.jobs.common.batch.repository.Stat5CodeRepository;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class Stat5CodeDataProcessor extends BaseProcessor<Stat5CodeDto, Stat5CodeEntity> {
private final Stat5CodeRepository stat5CodeRepository;
public Stat5CodeDataProcessor(Stat5CodeRepository stat5CodeRepository) {
this.stat5CodeRepository = stat5CodeRepository;
}
@Override
protected Stat5CodeEntity processItem(Stat5CodeDto dto) throws Exception {
Stat5CodeEntity entity = Stat5CodeEntity.builder()
.level1(dto.getLevel1())
.Level1Decode(dto.getLevel1Decode())
.Level2(dto.getLevel2())
.Level2Decode(dto.getLevel2Decode())
.Level3(dto.getLevel3())
.Level3Decode(dto.getLevel3Decode())
.Level4(dto.getLevel4())
.Level4Decode(dto.getLevel4Decode())
.Level5(dto.getLevel5())
.Level5Decode(dto.getLevel5Decode())
.Description(dto.getDescription())
.Release(Integer.toString(dto.getRelease()))
.build();
log.debug("Stat5Code 데이터 처리 완료: Stat5Code={}", dto.getLevel5());
return entity;
}
}

파일 보기

@ -0,0 +1,56 @@
package com.snp.batch.jobs.common.batch.reader;
import com.snp.batch.common.batch.reader.BaseApiReader;
import com.snp.batch.jobs.common.batch.dto.FlagCodeApiResponse;
import com.snp.batch.jobs.common.batch.dto.FlagCodeDto;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.reactive.function.client.WebClient;
import java.util.ArrayList;
import java.util.List;
@Slf4j
public class FlagCodeDataReader extends BaseApiReader<FlagCodeDto> {
public FlagCodeDataReader(WebClient webClient) {
super(webClient); // BaseApiReader에 WebClient 전달
}
// ========================================
// 필수 구현 메서드
// ========================================
@Override
protected String getReaderName() {
return "FlagCodeDataReader";
}
@Override
protected List<FlagCodeDto> fetchDataFromApi() {
try {
log.info("GetAssociatedFlagISOByName API 호출 시작");
FlagCodeApiResponse response = webClient
.get()
.uri(uriBuilder -> uriBuilder
.path("/MaritimeWCF/APSShipService.svc/RESTFul/GetAssociatedFlagISOByName")
.build())
.retrieve()
.bodyToMono(FlagCodeApiResponse.class)
.block();
if (response != null && response.getAssociatedFlagISODetails() != null) {
log.info("API 응답 성공: 총 {} 건의 국가코드 데이터 수신", response.getAssociatedCount());
return response.getAssociatedFlagISODetails();
} else {
log.warn("API 응답이 null이거나 국가코드 데이터가 없습니다");
return new ArrayList<>();
}
} catch (Exception e) {
log.error("GetAssociatedFlagISOByName API 호출 실패", e);
log.error("에러 메시지: {}", e.getMessage());
return new ArrayList<>();
}
}
}

파일 보기

@ -0,0 +1,49 @@
package com.snp.batch.jobs.common.batch.reader;
import com.snp.batch.common.batch.reader.BaseApiReader;
import com.snp.batch.jobs.common.batch.dto.Stat5CodeApiResponse;
import com.snp.batch.jobs.common.batch.dto.Stat5CodeDto;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.reactive.function.client.WebClient;
import java.util.ArrayList;
import java.util.List;
@Slf4j
public class Stat5CodeDataReader extends BaseApiReader<Stat5CodeDto> {
public Stat5CodeDataReader(WebClient webClient) {
super(webClient); // BaseApiReader에 WebClient 전달
}
@Override
protected String getReaderName() {
return "Stat5CodeDataReader";
}
@Override
protected List<Stat5CodeDto> fetchDataFromApi() {
try {
log.info("GetStatcodes API 호출 시작");
Stat5CodeApiResponse response = webClient
.get()
.uri(uriBuilder -> uriBuilder
.path("/AisSvc.svc/AIS/GetStatcodes")
.build())
.retrieve()
.bodyToMono(Stat5CodeApiResponse.class)
.block();
if (response != null && response.getStatcodeArr() != null) {
log.info("API 응답 성공: 총 {} 건의 Stat5Code 데이터 수신", response.getStatcodeArr().size());
return response.getStatcodeArr();
} else {
log.warn("API 응답이 null이거나 Stat5Code 데이터가 없습니다");
return new ArrayList<>();
}
} catch (Exception e) {
log.error("GetAssociatedFlagISOByName API 호출 실패", e);
log.error("에러 메시지: {}", e.getMessage());
return new ArrayList<>();
}
}
}

파일 보기

@ -0,0 +1,12 @@
package com.snp.batch.jobs.common.batch.repository;
import com.snp.batch.jobs.common.batch.entity.FlagCodeEntity;
import java.util.List;
public interface FlagCodeRepository {
void saveAllFlagCode(List<FlagCodeEntity> items);
}

파일 보기

@ -0,0 +1,95 @@
package com.snp.batch.jobs.common.batch.repository;
import com.snp.batch.common.batch.repository.BaseJdbcRepository;
import com.snp.batch.jobs.common.batch.entity.FlagCodeEntity;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Repository;
import java.sql.PreparedStatement;
import java.util.List;
@Slf4j
@Repository("FlagCodeRepository")
public class FlagCodeRepositoryImpl extends BaseJdbcRepository<FlagCodeEntity, String> implements FlagCodeRepository {
public FlagCodeRepositoryImpl(JdbcTemplate jdbcTemplate) {
super(jdbcTemplate);
}
@Override
protected String getEntityName() {
return "FlagCodeEntity";
}
@Override
protected String getTableName() {
return "snp_data.flagcode";
}
@Override
protected String getInsertSql() {
return null;
}
@Override
protected String getUpdateSql() {
return """
INSERT INTO snp_data.flagcode (
datasetversion, code, decode, iso2, iso3
) VALUES (?, ?, ?, ?, ?)
ON CONFLICT (code)
DO UPDATE SET
datasetversion = EXCLUDED.datasetversion,
decode = EXCLUDED.decode,
iso2 = EXCLUDED.iso2,
iso3 = EXCLUDED.iso3,
batch_flag = 'N'
""";
}
@Override
protected void setInsertParameters(PreparedStatement ps, FlagCodeEntity entity) throws Exception {
}
@Override
protected void setUpdateParameters(PreparedStatement ps, FlagCodeEntity entity) throws Exception {
int idx = 1;
ps.setString(idx++, entity.getDataSetVersion());
ps.setString(idx++, entity.getCode());
ps.setString(idx++, entity.getDecode());
ps.setString(idx++, entity.getIso2());
ps.setString(idx++, entity.getIso3());
}
@Override
protected RowMapper<FlagCodeEntity> getRowMapper() {
return null;
}
@Override
protected String extractId(FlagCodeEntity entity) {
return null;
}
@Override
public void saveAllFlagCode(List<FlagCodeEntity> items) {
if (items == null || items.isEmpty()) {
return;
}
jdbcTemplate.batchUpdate(getUpdateSql(), items, items.size(),
(ps, entity) -> {
try {
setUpdateParameters(ps, entity);
} catch (Exception e) {
log.error("배치 삽입 파라미터 설정 실패", e);
throw new RuntimeException(e);
}
});
log.info("{} 전체 저장 완료: {} 건", getEntityName(), items.size());
}
}

파일 보기

@ -0,0 +1,9 @@
package com.snp.batch.jobs.common.batch.repository;
import com.snp.batch.jobs.common.batch.entity.Stat5CodeEntity;
import java.util.List;
public interface Stat5CodeRepository {
void saveAllStat5Code(List<Stat5CodeEntity> items);
}

파일 보기

@ -0,0 +1,109 @@
package com.snp.batch.jobs.common.batch.repository;
import com.snp.batch.common.batch.repository.BaseJdbcRepository;
import com.snp.batch.jobs.common.batch.entity.Stat5CodeEntity;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Repository;
import java.sql.PreparedStatement;
import java.util.List;
@Slf4j
@Repository("Stat5CodeRepository")
public class Stat5CodeRepositoryImpl extends BaseJdbcRepository<Stat5CodeEntity, String> implements Stat5CodeRepository{
public Stat5CodeRepositoryImpl(JdbcTemplate jdbcTemplate) {
super(jdbcTemplate);
}
@Override
protected String getEntityName() {
return "Stat5CodeEntity";
}
@Override
protected String getTableName() {
return "snp_data.stat5code";
}
@Override
protected RowMapper<Stat5CodeEntity> getRowMapper() {
return null;
}
@Override
protected String extractId(Stat5CodeEntity entity) {
return null;
}
@Override
protected String getInsertSql() {
return null;
}
@Override
protected String getUpdateSql() {
return """
INSERT INTO snp_data.stat5code (
level1, level1decode, level2, level2decode, level3, level3decode, level4, level4decode, level5, level5decode, description, release
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (level1, level2, level3, level4, level5)
DO UPDATE SET
level1 = EXCLUDED.level1,
level1decode = EXCLUDED.level1decode,
level2 = EXCLUDED.level2,
level2decode = EXCLUDED.level2decode,
level3 = EXCLUDED.level3,
level3decode = EXCLUDED.level3decode,
level4 = EXCLUDED.level4,
level4decode = EXCLUDED.level4decode,
level5 = EXCLUDED.level5,
level5decode = EXCLUDED.level5decode,
description = EXCLUDED.description,
release = EXCLUDED.release,
batch_flag = 'N'
""";
}
@Override
protected void setInsertParameters(PreparedStatement ps, Stat5CodeEntity entity) throws Exception {
}
@Override
protected void setUpdateParameters(PreparedStatement ps, Stat5CodeEntity entity) throws Exception {
int idx = 1;
ps.setString(idx++, entity.getLevel1());
ps.setString(idx++, entity.getLevel1Decode());
ps.setString(idx++, entity.getLevel2());
ps.setString(idx++, entity.getLevel2Decode());
ps.setString(idx++, entity.getLevel3());
ps.setString(idx++, entity.getLevel3Decode());
ps.setString(idx++, entity.getLevel4());
ps.setString(idx++, entity.getLevel4Decode());
ps.setString(idx++, entity.getLevel5());
ps.setString(idx++, entity.getLevel5Decode());
ps.setString(idx++, entity.getDescription());
ps.setString(idx++, entity.getRelease());
}
@Override
public void saveAllStat5Code(List<Stat5CodeEntity> items) {
if (items == null || items.isEmpty()) {
return;
}
jdbcTemplate.batchUpdate(getUpdateSql(), items, items.size(),
(ps, entity) -> {
try {
setUpdateParameters(ps, entity);
} catch (Exception e) {
log.error("배치 삽입 파라미터 설정 실패", e);
throw new RuntimeException(e);
}
});
log.info("{} 전체 저장 완료: {} 건", getEntityName(), items.size());
}
}

파일 보기

@ -0,0 +1,25 @@
package com.snp.batch.jobs.common.batch.writer;
import com.snp.batch.common.batch.writer.BaseWriter;
import com.snp.batch.jobs.common.batch.entity.FlagCodeEntity;
import com.snp.batch.jobs.common.batch.repository.FlagCodeRepository;
import lombok.extern.slf4j.Slf4j;
import java.util.List;
@Slf4j
public class FlagCodeDataWriter extends BaseWriter<FlagCodeEntity> {
private final FlagCodeRepository flagCodeRepository;
public FlagCodeDataWriter(FlagCodeRepository flagCodeRepository) {
super("FlagCodeEntity");
this.flagCodeRepository = flagCodeRepository;
}
@Override
protected void writeItems(List<FlagCodeEntity> items) throws Exception {
flagCodeRepository.saveAllFlagCode(items);
log.info("FlagCode 저장 완료: {} 건", items.size());
}
}

파일 보기

@ -0,0 +1,25 @@
package com.snp.batch.jobs.common.batch.writer;
import com.snp.batch.common.batch.writer.BaseWriter;
import com.snp.batch.jobs.common.batch.entity.Stat5CodeEntity;
import com.snp.batch.jobs.common.batch.repository.Stat5CodeRepository;
import lombok.extern.slf4j.Slf4j;
import java.util.List;
@Slf4j
public class Stat5CodeDataWriter extends BaseWriter<Stat5CodeEntity> {
private final Stat5CodeRepository stat5CodeRepository;
public Stat5CodeDataWriter(Stat5CodeRepository stat5CodeRepository) {
super("Stat5CodeEntity");
this.stat5CodeRepository = stat5CodeRepository;
}
@Override
protected void writeItems(List<Stat5CodeEntity> items) throws Exception {
stat5CodeRepository.saveAllStat5Code(items);
log.info("Stat5Code 저장 완료: {} 건", items.size());
}
}

파일 보기

@ -0,0 +1,206 @@
package com.snp.batch.jobs.compliance.batch.config;
import com.snp.batch.common.batch.config.BaseMultiStepJobConfig;
import com.snp.batch.jobs.compliance.batch.dto.CompanyComplianceDto;
import com.snp.batch.jobs.compliance.batch.dto.ComplianceDto;
import com.snp.batch.jobs.compliance.batch.entity.CompanyComplianceEntity;
import com.snp.batch.jobs.compliance.batch.entity.ComplianceEntity;
import com.snp.batch.jobs.compliance.batch.processor.CompanyComplianceDataProcessor;
import com.snp.batch.jobs.compliance.batch.reader.CompanyComplianceDataRangeReader;
import com.snp.batch.jobs.compliance.batch.reader.ComplianceDataRangeReader;
import com.snp.batch.jobs.compliance.batch.writer.CompanyComplianceDataWriter;
import com.snp.batch.service.BatchApiLogService;
import com.snp.batch.service.BatchDateService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.configuration.annotation.StepScope;
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.batch.core.step.tasklet.Tasklet;
import org.springframework.batch.item.ItemProcessor;
import org.springframework.batch.item.ItemReader;
import org.springframework.batch.item.ItemWriter;
import org.springframework.batch.repeat.RepeatStatus;
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.jdbc.core.JdbcTemplate;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.web.reactive.function.client.WebClient;
import java.time.OffsetDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.Map;
@Slf4j
@Configuration
public class CompanyComplianceImportRangeJobConfig extends BaseMultiStepJobConfig<CompanyComplianceDto, CompanyComplianceEntity> {
private final JdbcTemplate jdbcTemplate;
private final WebClient maritimeServiceApiWebClient;
private final CompanyComplianceDataRangeReader companyComplianceDataRangeReader;
private final CompanyComplianceDataProcessor companyComplianceDataProcessor;
private final CompanyComplianceDataWriter companyComplianceDataWriter;
private final BatchDateService batchDateService;
private final BatchApiLogService batchApiLogService;
@Value("${app.batch.webservice-api.url}")
private String maritimeServiceApiUrl;
protected String getApiKey() {return "COMPANY_COMPLIANCE_IMPORT_API";}
protected String getBatchUpdateSql() {
return String.format("UPDATE SNP_DATA.BATCH_LAST_EXECUTION SET LAST_SUCCESS_DATE = NOW(), UPDATED_AT = NOW() WHERE API_KEY = '%s'", getApiKey());}
@Override
protected int getChunkSize() {
return 5000;
}
public CompanyComplianceImportRangeJobConfig(
JobRepository jobRepository,
PlatformTransactionManager transactionManager,
CompanyComplianceDataRangeReader companyComplianceDataRangeReader,
CompanyComplianceDataProcessor companyComplianceDataProcessor,
CompanyComplianceDataWriter companyComplianceDataWriter,
JdbcTemplate jdbcTemplate,
@Qualifier("maritimeServiceApiWebClient")WebClient maritimeServiceApiWebClient,
BatchDateService batchDateService,
BatchApiLogService batchApiLogService) {
super(jobRepository, transactionManager);
this.jdbcTemplate = jdbcTemplate;
this.companyComplianceDataRangeReader = companyComplianceDataRangeReader;
this.maritimeServiceApiWebClient = maritimeServiceApiWebClient;
this.companyComplianceDataProcessor = companyComplianceDataProcessor;
this.companyComplianceDataWriter = companyComplianceDataWriter;
this.batchDateService = batchDateService;
this.batchApiLogService = batchApiLogService;
}
@Override
protected String getJobName() {
return "CompanyComplianceImportRangeJob";
}
@Override
protected String getStepName() {
return "CompanyComplianceImportRangeStep";
}
@Override
protected Job createJobFlow(JobBuilder jobBuilder) {
return jobBuilder
.start(companyComplianceImportRangeStep()) // 1단계 실행
.next(companyComplianceHistoryValueChangeManageStep()) // 2단계 실행 (2단계 실패 실행 )
.next(companyComplianceLastExecutionUpdateStep()) // 3단계: 모두 완료 , BATCH_LAST_EXECUTION 마지막 성공일자 업데이트
.build();
}
@Override
protected ItemReader<CompanyComplianceDto> createReader() {
return companyComplianceDataRangeReader;
}
@Bean
@StepScope
public CompanyComplianceDataRangeReader companyComplianceDataRangeReader(
@Value("#{stepExecution.jobExecution.id}") Long jobExecutionId, // SpEL로 ID 추출
@Value("#{stepExecution.id}") Long stepExecutionId
) {
CompanyComplianceDataRangeReader reader = new CompanyComplianceDataRangeReader(maritimeServiceApiWebClient, batchDateService, batchApiLogService, maritimeServiceApiUrl);
reader.setExecutionIds(jobExecutionId, stepExecutionId); // ID 세팅
return reader;
}
@Override
protected ItemProcessor<CompanyComplianceDto, CompanyComplianceEntity> createProcessor() {
return companyComplianceDataProcessor;
}
@Override
protected ItemWriter<CompanyComplianceEntity> createWriter() {
return companyComplianceDataWriter;
}
@Bean(name = "CompanyComplianceImportRangeJob")
public Job companyComplianceImportRangeJob() {
return job();
}
@Bean(name = "CompanyComplianceImportRangeStep")
public Step companyComplianceImportRangeStep() {
return step();
}
/**
* 2단계: Compliance History Value Change 관리
*/
@Bean
public Tasklet companyComplianceHistoryValueChangeManageTasklet() {
return (contribution, chunkContext) -> {
log.info(">>>>> Company Compliance History Value Change Manage 프로시저 호출 시작");
// 1. 입력 포맷(UTC 'Z' 포함) 프로시저용 타겟 포맷 정의
DateTimeFormatter inputFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSX");
DateTimeFormatter targetFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS");
Map<String, String> params = batchDateService.getDateRangeWithTimezoneParams(getApiKey());
String rawFromDate = params.get("fromDate");
String rawToDate = params.get("toDate");
// 2. UTC 문자열 -> OffsetDateTime -> Asia/Seoul 변환 -> LocalDateTime 추출
String startDt = convertToKstString(rawFromDate, inputFormatter, targetFormatter);
String endDt = convertToKstString(rawToDate, inputFormatter, targetFormatter);
log.info("Company Compliance History Value Change Manage 프로시저 변수 (KST 변환): 시작일: {}, 종료일: {}", startDt, endDt);
// 3. 프로시저 호출 (안전한 파라미터 바인딩 권장)
jdbcTemplate.update("CALL new_snp.company_compliance_history_value_change_manage(CAST(? AS TIMESTAMP), CAST(? AS TIMESTAMP))", startDt, endDt);
log.info(">>>>> Company Compliance History Value Change Manage 프로시저 호출 완료");
return RepeatStatus.FINISHED;
};
}
/**
* UTC 문자열을 한국 시간(KST) 문자열로 변환하는 헬퍼 메소드
*/
private String convertToKstString(String rawDate, DateTimeFormatter input, DateTimeFormatter target) {
if (rawDate == null) return null;
// 1. 문자열을 OffsetDateTime으로 파싱 (Z를 인식하여 UTC 시간으로 인지함)
return OffsetDateTime.parse(rawDate, input)
// 2. 시간대를 서울(+09:00) 변경 (값이 9시간 더해짐)
.atZoneSameInstant(ZoneId.of("Asia/Seoul"))
// 3. 프로시저 형식에 맞게 포맷팅
.format(target);
}
@Bean(name = "CompanyComplianceHistoryValueChangeManageStep")
public Step companyComplianceHistoryValueChangeManageStep() {
return new StepBuilder("CompanyComplianceHistoryValueChangeManageStep", jobRepository)
.tasklet(companyComplianceHistoryValueChangeManageTasklet(), transactionManager)
.build();
}
/**
* 3단계: 모든 스텝 성공 배치 실행 로그(날짜) 업데이트
*/
@Bean
public Tasklet companyComplianceLastExecutionUpdateTasklet() {
return (contribution, chunkContext) -> {
log.info(">>>>> 모든 스텝 성공: BATCH_LAST_EXECUTION 업데이트 시작");
jdbcTemplate.execute(getBatchUpdateSql());
log.info(">>>>> BATCH_LAST_EXECUTION 업데이트 완료");
return RepeatStatus.FINISHED;
};
}
@Bean(name = "CompanyComplianceLastExecutionUpdateStep")
public Step companyComplianceLastExecutionUpdateStep() {
return new StepBuilder("CompanyComplianceLastExecutionUpdateStep", jobRepository)
.tasklet(companyComplianceLastExecutionUpdateTasklet(), transactionManager)
.build();
}
}

파일 보기

@ -0,0 +1,86 @@
package com.snp.batch.jobs.compliance.batch.config;
import com.snp.batch.common.batch.config.BaseJobConfig;
import com.snp.batch.jobs.compliance.batch.dto.ComplianceDto;
import com.snp.batch.jobs.compliance.batch.entity.ComplianceEntity;
import com.snp.batch.jobs.compliance.batch.processor.ComplianceDataProcessor;
import com.snp.batch.jobs.compliance.batch.reader.ComplianceDataReader;
import com.snp.batch.jobs.compliance.batch.writer.ComplianceDataWriter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
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.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.web.reactive.function.client.WebClient;
@Slf4j
@Configuration
public class ComplianceImportJobConfig extends BaseJobConfig<ComplianceDto, ComplianceEntity> {
private final JdbcTemplate jdbcTemplate;
private final WebClient maritimeServiceApiWebClient;
private final ComplianceDataProcessor complianceDataProcessor;
private final ComplianceDataWriter complianceDataWriter;
@Override
protected int getChunkSize() {
return 5000; // API에서 5000개씩 가져오므로 chunk도 5000으로 설정
}
public ComplianceImportJobConfig(
JobRepository jobRepository,
PlatformTransactionManager transactionManager,
ComplianceDataProcessor complianceDataProcessor,
ComplianceDataWriter complianceDataWriter,
JdbcTemplate jdbcTemplate,
@Qualifier("maritimeServiceApiWebClient")WebClient maritimeServiceApiWebClient) {
super(jobRepository, transactionManager);
this.jdbcTemplate = jdbcTemplate;
this.maritimeServiceApiWebClient = maritimeServiceApiWebClient;
this.complianceDataProcessor = complianceDataProcessor;
this.complianceDataWriter = complianceDataWriter;
}
@Override
protected String getJobName() {
return "ComplianceImportJob";
}
@Override
protected String getStepName() {
return "ComplianceImportStep";
}
@Override
protected ItemReader<ComplianceDto> createReader() {
return new ComplianceDataReader(maritimeServiceApiWebClient, jdbcTemplate);
}
@Override
protected ItemProcessor<ComplianceDto, ComplianceEntity> createProcessor() {
return complianceDataProcessor;
}
@Override
protected ItemWriter<ComplianceEntity> createWriter() {
return complianceDataWriter;
}
@Bean(name = "ComplianceImportJob")
public Job complianceImportJob() {
return job();
}
@Bean(name = "ComplianceImportStep")
public Step complianceImportStep() {
return step();
}
}

파일 보기

@ -0,0 +1,200 @@
package com.snp.batch.jobs.compliance.batch.config;
import com.snp.batch.common.batch.config.BaseMultiStepJobConfig;
import com.snp.batch.jobs.compliance.batch.dto.ComplianceDto;
import com.snp.batch.jobs.compliance.batch.entity.ComplianceEntity;
import com.snp.batch.jobs.compliance.batch.processor.ComplianceDataProcessor;
import com.snp.batch.jobs.compliance.batch.reader.ComplianceDataRangeReader;
import com.snp.batch.jobs.compliance.batch.writer.ComplianceDataWriter;
import com.snp.batch.service.BatchApiLogService;
import com.snp.batch.service.BatchDateService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.configuration.annotation.StepScope;
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.batch.core.step.tasklet.Tasklet;
import org.springframework.batch.item.ItemProcessor;
import org.springframework.batch.item.ItemReader;
import org.springframework.batch.item.ItemWriter;
import org.springframework.batch.repeat.RepeatStatus;
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.jdbc.core.JdbcTemplate;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.web.reactive.function.client.WebClient;
import java.time.*;
import java.time.format.DateTimeFormatter;
import java.util.Map;
@Slf4j
@Configuration
public class ComplianceImportRangeJobConfig extends BaseMultiStepJobConfig<ComplianceDto, ComplianceEntity> {
private final JdbcTemplate jdbcTemplate;
private final WebClient maritimeServiceApiWebClient;
private final ComplianceDataProcessor complianceDataProcessor;
private final ComplianceDataWriter complianceDataWriter;
private final ComplianceDataRangeReader complianceDataRangeReader;
private final BatchDateService batchDateService;
private final BatchApiLogService batchApiLogService;
@Value("${app.batch.webservice-api.url}")
private String maritimeServiceApiUrl;
protected String getApiKey() {return "COMPLIANCE_IMPORT_API";}
protected String getBatchUpdateSql() {
return String.format("UPDATE SNP_DATA.BATCH_LAST_EXECUTION SET LAST_SUCCESS_DATE = NOW(), UPDATED_AT = NOW() WHERE API_KEY = '%s'", getApiKey());}
@Override
protected int getChunkSize() {
return 5000;
}
public ComplianceImportRangeJobConfig(
JobRepository jobRepository,
PlatformTransactionManager transactionManager,
ComplianceDataProcessor complianceDataProcessor,
ComplianceDataWriter complianceDataWriter,
JdbcTemplate jdbcTemplate,
@Qualifier("maritimeServiceApiWebClient")WebClient maritimeServiceApiWebClient,
ComplianceDataRangeReader complianceDataRangeReader,
BatchDateService batchDateService,
BatchApiLogService batchApiLogService) {
super(jobRepository, transactionManager);
this.jdbcTemplate = jdbcTemplate;
this.maritimeServiceApiWebClient = maritimeServiceApiWebClient;
this.complianceDataProcessor = complianceDataProcessor;
this.complianceDataWriter = complianceDataWriter;
this.complianceDataRangeReader = complianceDataRangeReader;
this.batchDateService = batchDateService;
this.batchApiLogService = batchApiLogService;
}
@Override
protected String getJobName() {
return "ComplianceImportRangeJob";
}
@Override
protected String getStepName() {
return "ComplianceImportRangeStep";
}
@Override
protected Job createJobFlow(JobBuilder jobBuilder) {
return jobBuilder
.start(complianceImportRangeStep()) // 1단계 실행
.next(complianceHistoryValueChangeManageStep()) // 2단계 실행 (2단계 실패 실행 )
.next(complianceLastExecutionUpdateStep()) // 3단계: 모두 완료 , BATCH_LAST_EXECUTION 마지막 성공일자 업데이트
.build();
}
@Override
protected ItemReader<ComplianceDto> createReader() {
return complianceDataRangeReader;
}
@Bean
@StepScope
public ComplianceDataRangeReader complianceDataRangeReader(
@Value("#{stepExecution.jobExecution.id}") Long jobExecutionId, // SpEL로 ID 추출
@Value("#{stepExecution.id}") Long stepExecutionId
) {
ComplianceDataRangeReader reader = new ComplianceDataRangeReader(maritimeServiceApiWebClient, jdbcTemplate, batchDateService, batchApiLogService, maritimeServiceApiUrl);
reader.setExecutionIds(jobExecutionId, stepExecutionId); // ID 세팅
return reader;
}
@Override
protected ItemProcessor<ComplianceDto, ComplianceEntity> createProcessor() {
return complianceDataProcessor;
}
@Override
protected ItemWriter<ComplianceEntity> createWriter() {
return complianceDataWriter;
}
@Bean(name = "ComplianceImportRangeJob")
public Job complianceImportRangeJob() {
return job();
}
@Bean(name = "ComplianceImportRangeStep")
public Step complianceImportRangeStep() {
return step();
}
/**
* 2단계: Compliance History Value Change 관리
*/
@Bean
public Tasklet complianceHistoryValueChangeManageTasklet() {
return (contribution, chunkContext) -> {
log.info(">>>>> Compliance History Value Change Manage 프로시저 호출 시작");
// 1. 입력 포맷(UTC 'Z' 포함) 프로시저용 타겟 포맷 정의
DateTimeFormatter inputFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSX");
DateTimeFormatter targetFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS");
Map<String, String> params = batchDateService.getDateRangeWithTimezoneParams(getApiKey());
String rawFromDate = params.get("fromDate");
String rawToDate = params.get("toDate");
// 2. UTC 문자열 -> OffsetDateTime -> Asia/Seoul 변환 -> LocalDateTime 추출
String startDt = convertToKstString(rawFromDate, inputFormatter, targetFormatter);
String endDt = convertToKstString(rawToDate, inputFormatter, targetFormatter);
log.info("Compliance History Value Change Manage 프로시저 변수 (KST 변환): 시작일: {}, 종료일: {}", startDt, endDt);
// 3. 프로시저 호출 (안전한 파라미터 바인딩 권장)
jdbcTemplate.update("CALL new_snp.compliance_history_value_change_manage(CAST(? AS TIMESTAMP), CAST(? AS TIMESTAMP))", startDt, endDt);
log.info(">>>>> Compliance History Value Change Manage 프로시저 호출 완료");
return RepeatStatus.FINISHED;
};
}
/**
* UTC 문자열을 한국 시간(KST) 문자열로 변환하는 헬퍼 메소드
*/
private String convertToKstString(String rawDate, DateTimeFormatter input, DateTimeFormatter target) {
if (rawDate == null) return null;
// 1. 문자열을 OffsetDateTime으로 파싱 (Z를 인식하여 UTC 시간으로 인지함)
return OffsetDateTime.parse(rawDate, input)
// 2. 시간대를 서울(+09:00) 변경 (값이 9시간 더해짐)
.atZoneSameInstant(ZoneId.of("Asia/Seoul"))
// 3. 프로시저 형식에 맞게 포맷팅
.format(target);
}
@Bean(name = "ComplianceHistoryValueChangeManageStep")
public Step complianceHistoryValueChangeManageStep() {
return new StepBuilder("ComplianceHistoryValueChangeManageStep", jobRepository)
.tasklet(complianceHistoryValueChangeManageTasklet(), transactionManager)
.build();
}
/**
* 3단계: 모든 스텝 성공 배치 실행 로그(날짜) 업데이트
*/
@Bean
public Tasklet complianceLastExecutionUpdateTasklet() {
return (contribution, chunkContext) -> {
log.info(">>>>> 모든 스텝 성공: BATCH_LAST_EXECUTION 업데이트 시작");
jdbcTemplate.execute(getBatchUpdateSql());
log.info(">>>>> BATCH_LAST_EXECUTION 업데이트 완료");
return RepeatStatus.FINISHED;
};
}
@Bean(name = "ComplianceLastExecutionUpdateStep")
public Step complianceLastExecutionUpdateStep() {
return new StepBuilder("ComplianceLastExecutionUpdateStep", jobRepository)
.tasklet(complianceLastExecutionUpdateTasklet(), transactionManager)
.build();
}
}

파일 보기

@ -0,0 +1,61 @@
package com.snp.batch.jobs.compliance.batch.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CompanyComplianceDto {
@JsonProperty("owcode")
private String owcode;
@JsonProperty("lastUpdated")
private String lastUpdated;
@JsonProperty("companyOverallComplianceStatus")
private Integer companyOverallComplianceStatus;
@JsonProperty("companyOnAustralianSanctionList")
private Integer companyOnAustralianSanctionList;
@JsonProperty("companyOnBESSanctionList")
private Integer companyOnBESSanctionList;
@JsonProperty("companyOnCanadianSanctionList")
private Integer companyOnCanadianSanctionList;
@JsonProperty("companyInOFACSanctionedCountry")
private Integer companyInOFACSanctionedCountry;
@JsonProperty("companyInFATFJurisdiction")
private Integer companyInFATFJurisdiction;
@JsonProperty("companyOnEUSanctionList")
private Integer companyOnEUSanctionList;
@JsonProperty("companyOnOFACSanctionList")
private Integer companyOnOFACSanctionList;
@JsonProperty("companyOnOFACNONSDNSanctionList")
private Integer companyOnOFACNONSDNSanctionList;
@JsonProperty("companyOnOFACSSISanctionList")
private Integer companyOnOFACSSISanctionList;
@JsonProperty("parentCompanyNonCompliance")
private Integer parentCompanyNonCompliance;
@JsonProperty("companyOnSwissSanctionList")
private Integer companyOnSwissSanctionList;
@JsonProperty("companyOnUAESanctionList")
private Integer companyOnUAESanctionList;
@JsonProperty("companyOnUNSanctionList")
private Integer companyOnUNSanctionList;
}

파일 보기

@ -0,0 +1,121 @@
package com.snp.batch.jobs.compliance.batch.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ComplianceDto {
@JsonProperty("shipEUSanctionList")
private Integer shipEUSanctionList;
@JsonProperty("shipUNSanctionList")
private Integer shipUNSanctionList;
@JsonProperty("lrimoShipNo")
private String lrimoShipNo;
@JsonProperty("dateAmended")
private String dateAmended; // 수정일시
// 2. Compliance Status (Integer 타입은 0, 1, 2 등의 코드 null 처리)
@JsonProperty("legalOverall")
private Integer legalOverall; // 종합제재
@JsonProperty("shipBESSanctionList")
private Integer shipBESSanctionList; // 선박BES제재
@JsonProperty("shipDarkActivityIndicator")
private Integer shipDarkActivityIndicator; // 선박다크활동
@JsonProperty("shipDetailsNoLongerMaintained")
private Integer shipDetailsNoLongerMaintained; // 선박세부정보미유지
@JsonProperty("shipFlagDisputed")
private Integer shipFlagDisputed; // 선박국기논쟁
@JsonProperty("shipFlagSanctionedCountry")
private Integer shipFlagSanctionedCountry; // 선박국가제재
@JsonProperty("shipHistoricalFlagSanctionedCountry")
private Integer shipHistoricalFlagSanctionedCountry; // 선박국가제재이력
@JsonProperty("shipOFACNonSDNSanctionList")
private Integer shipOFACNonSDNSanctionList; // 선박OFAC비SDN제재
@JsonProperty("shipOFACSanctionList")
private Integer shipOFACSanctionList; // 선박OFAC제재
@JsonProperty("shipOFACAdvisoryList")
private Integer shipOFACAdvisoryList; // 선박OFAC주의
@JsonProperty("shipOwnerOFACSSIList")
private Integer shipOwnerOFACSSIList; // 선박소유자OFCS제재
@JsonProperty("shipOwnerAustralianSanctionList")
private Integer shipOwnerAustralianSanctionList; // 선박소유자AUS제재
@JsonProperty("shipOwnerBESSanctionList")
private Integer shipOwnerBESSanctionList; // 선박소유자BES제재
@JsonProperty("shipOwnerCanadianSanctionList")
private Integer shipOwnerCanadianSanctionList; // 선박소유자CAN제재
@JsonProperty("shipOwnerEUSanctionList")
private Integer shipOwnerEUSanctionList; // 선박소유자EU제재
@JsonProperty("shipOwnerFATFJurisdiction")
private Integer shipOwnerFATFJurisdiction; // 선박소유자FATF규제구역
@JsonProperty("shipOwnerHistoricalOFACSanctionedCountry")
private Integer shipOwnerHistoricalOFACSanctionedCountry; // 선박소유자OFAC제재이력
@JsonProperty("shipOwnerOFACSanctionList")
private Integer shipOwnerOFACSanctionList; // 선박소유자OFAC제재
@JsonProperty("shipOwnerOFACSanctionedCountry")
private Integer shipOwnerOFACSanctionedCountry; // 선박소유자OFAC제재국가
@JsonProperty("shipOwnerParentCompanyNonCompliance")
private Integer shipOwnerParentCompanyNonCompliance; // 선박소유자모회사비준수
@JsonProperty("shipOwnerParentFATFJurisdiction")
private Integer shipOwnerParentFATFJurisdiction; // 선박소유자모회사FATF규제구역 (JSON에 null 포함)
@JsonProperty("shipOwnerParentOFACSanctionedCountry")
private Integer shipOwnerParentOFACSanctionedCountry; // 선박소유자모회사OFAC제재국가 (JSON에 null 포함)
@JsonProperty("shipOwnerSwissSanctionList")
private Integer shipOwnerSwissSanctionList; // 선박소유자SWI제재
@JsonProperty("shipOwnerUAESanctionList")
private Integer shipOwnerUAESanctionList; // 선박소유자UAE제재
@JsonProperty("shipOwnerUNSanctionList")
private Integer shipOwnerUNSanctionList; // 선박소유자UN제재
@JsonProperty("shipSanctionedCountryPortCallLast12m")
private Integer shipSanctionedCountryPortCallLast12m; // 선박제재국가기항최종12M
@JsonProperty("shipSanctionedCountryPortCallLast3m")
private Integer shipSanctionedCountryPortCallLast3m; // 선박제재국가기항최종3M
@JsonProperty("shipSanctionedCountryPortCallLast6m")
private Integer shipSanctionedCountryPortCallLast6m; // 선박제재국가기항최종6M
@JsonProperty("shipSecurityLegalDisputeEvent")
private Integer shipSecurityLegalDisputeEvent; // 선박보안법적분쟁이벤트
@JsonProperty("shipSTSPartnerNonComplianceLast12m")
private Integer shipSTSPartnerNonComplianceLast12m; // 선박STS파트너비준수12M
@JsonProperty("shipSwissSanctionList")
private Integer shipSwissSanctionList; // 선박SWI제재
}

파일 보기

@ -0,0 +1,47 @@
package com.snp.batch.jobs.compliance.batch.entity;
import com.snp.batch.common.batch.entity.BaseEntity;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;
@Data
@SuperBuilder
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode(callSuper = true)
public class CompanyComplianceEntity extends BaseEntity {
private String owcode;
private String lastUpdated;
private Integer companyOverallComplianceStatus;
private Integer companyOnAustralianSanctionList;
private Integer companyOnBESSanctionList;
private Integer companyOnCanadianSanctionList;
private Integer companyInOFACSanctionedCountry;
private Integer companyInFATFJurisdiction;
private Integer companyOnEUSanctionList;
private Integer companyOnOFACSanctionList;
private Integer companyOnOFACNONSDNSanctionList;
private Integer companyOnOFACSSISanctionList;
private Integer parentCompanyNonCompliance;
private Integer companyOnSwissSanctionList;
private Integer companyOnUAESanctionList;
private Integer companyOnUNSanctionList;
}

파일 보기

@ -0,0 +1,89 @@
package com.snp.batch.jobs.compliance.batch.entity;
import com.snp.batch.common.batch.entity.BaseEntity;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;
@Data
@SuperBuilder
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode(callSuper = true)
public class ComplianceEntity extends BaseEntity {
private String lrimoShipNo; // LR/IMO번호
private String dateAmended; // 수정일시
// 2. Compliance Status (모든 필드는 DTO와 동일한 Integer 타입)
private Integer legalOverall; // 종합제재
private Integer shipBESSanctionList; // 선박BES제재
private Integer shipDarkActivityIndicator; // 선박다크활동
private Integer shipDetailsNoLongerMaintained; // 선박세부정보미유지
private Integer shipEUSanctionList; // 선박EU제재
private Integer shipFlagDisputed; // 선박국기논쟁
private Integer shipFlagSanctionedCountry; // 선박국가제재
private Integer shipHistoricalFlagSanctionedCountry; // 선박국가제재이력
private Integer shipOFACNonSDNSanctionList; // 선박OFAC비SDN제재
private Integer shipOFACSanctionList; // 선박OFAC제재
private Integer shipOFACAdvisoryList; // 선박OFAC주의
private Integer shipOwnerOFACSSIList; // 선박소유자OFCS제재
private Integer shipOwnerAustralianSanctionList; // 선박소유자AUS제재
private Integer shipOwnerBESSanctionList; // 선박소유자BES제재
private Integer shipOwnerCanadianSanctionList; // 선박소유자CAN제재
private Integer shipOwnerEUSanctionList; // 선박소유자EU제재
private Integer shipOwnerFATFJurisdiction; // 선박소유자FATF규제구역
private Integer shipOwnerHistoricalOFACSanctionedCountry; // 선박소유자OFAC제재이력
private Integer shipOwnerOFACSanctionList; // 선박소유자OFAC제재
private Integer shipOwnerOFACSanctionedCountry; // 선박소유자OFAC제재국가
private Integer shipOwnerParentCompanyNonCompliance; // 선박소유자모회사비준수
private Integer shipOwnerParentFATFJurisdiction; // 선박소유자모회사FATF규제구역
private Integer shipOwnerParentOFACSanctionedCountry; // 선박소유자모회사OFAC제재국가
private Integer shipOwnerSwissSanctionList; // 선박소유자SWI제재
private Integer shipOwnerUAESanctionList; // 선박소유자UAE제재
private Integer shipOwnerUNSanctionList; // 선박소유자UN제재
private Integer shipSanctionedCountryPortCallLast12m; // 선박제재국가기항최종12M
private Integer shipSanctionedCountryPortCallLast3m; // 선박제재국가기항최종3M
private Integer shipSanctionedCountryPortCallLast6m; // 선박제재국가기항최종6M
private Integer shipSecurityLegalDisputeEvent; // 선박보안법적분쟁이벤트
private Integer shipSTSPartnerNonComplianceLast12m; // 선박STS파트너비준수12M
private Integer shipSwissSanctionList; // 선박SWI제재
private Integer shipUNSanctionList; // 선박UN제재
}

파일 보기

@ -0,0 +1,36 @@
package com.snp.batch.jobs.compliance.batch.processor;
import com.snp.batch.common.batch.processor.BaseProcessor;
import com.snp.batch.jobs.compliance.batch.dto.CompanyComplianceDto;
import com.snp.batch.jobs.compliance.batch.entity.CompanyComplianceEntity;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
@Slf4j
@Component
public class CompanyComplianceDataProcessor extends BaseProcessor<CompanyComplianceDto, CompanyComplianceEntity> {
@Override
protected CompanyComplianceEntity processItem(CompanyComplianceDto dto) throws Exception {
CompanyComplianceEntity entity = CompanyComplianceEntity.builder()
.owcode(dto.getOwcode())
.lastUpdated(dto.getLastUpdated())
.companyOverallComplianceStatus(dto.getCompanyOverallComplianceStatus())
.companyOnAustralianSanctionList(dto.getCompanyOnAustralianSanctionList())
.companyOnBESSanctionList(dto.getCompanyOnBESSanctionList())
.companyOnCanadianSanctionList(dto.getCompanyOnCanadianSanctionList())
.companyInOFACSanctionedCountry(dto.getCompanyInOFACSanctionedCountry())
.companyInFATFJurisdiction(dto.getCompanyInFATFJurisdiction())
.companyOnEUSanctionList(dto.getCompanyOnEUSanctionList())
.companyOnOFACSanctionList(dto.getCompanyOnOFACSanctionList())
.companyOnOFACNONSDNSanctionList(dto.getCompanyOnOFACNONSDNSanctionList())
.companyOnOFACSSISanctionList(dto.getCompanyOnOFACSSISanctionList())
.parentCompanyNonCompliance(dto.getParentCompanyNonCompliance())
.companyOnSwissSanctionList(dto.getCompanyOnSwissSanctionList())
.companyOnUAESanctionList(dto.getCompanyOnUAESanctionList())
.companyOnUNSanctionList(dto.getCompanyOnUNSanctionList())
.build();
return entity;
}
}

파일 보기

@ -0,0 +1,57 @@
package com.snp.batch.jobs.compliance.batch.processor;
import com.snp.batch.common.batch.processor.BaseProcessor;
import com.snp.batch.jobs.compliance.batch.dto.ComplianceDto;
import com.snp.batch.jobs.compliance.batch.entity.ComplianceEntity;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
@Slf4j
@Component
public class ComplianceDataProcessor extends BaseProcessor<ComplianceDto, ComplianceEntity> {
@Override
protected ComplianceEntity processItem(ComplianceDto dto) throws Exception {
ComplianceEntity entity = ComplianceEntity.builder()
// 1. Primary Keys
.lrimoShipNo(dto.getLrimoShipNo())
.dateAmended(dto.getDateAmended())
// 2. Compliance Status
.legalOverall(dto.getLegalOverall())
.shipBESSanctionList(dto.getShipBESSanctionList())
.shipDarkActivityIndicator(dto.getShipDarkActivityIndicator())
.shipDetailsNoLongerMaintained(dto.getShipDetailsNoLongerMaintained())
.shipEUSanctionList(dto.getShipEUSanctionList())
.shipFlagDisputed(dto.getShipFlagDisputed())
.shipFlagSanctionedCountry(dto.getShipFlagSanctionedCountry())
.shipHistoricalFlagSanctionedCountry(dto.getShipHistoricalFlagSanctionedCountry())
.shipOFACNonSDNSanctionList(dto.getShipOFACNonSDNSanctionList())
.shipOFACSanctionList(dto.getShipOFACSanctionList())
.shipOFACAdvisoryList(dto.getShipOFACAdvisoryList())
.shipOwnerOFACSSIList(dto.getShipOwnerOFACSSIList())
.shipOwnerAustralianSanctionList(dto.getShipOwnerAustralianSanctionList())
.shipOwnerBESSanctionList(dto.getShipOwnerBESSanctionList())
.shipOwnerCanadianSanctionList(dto.getShipOwnerCanadianSanctionList())
.shipOwnerEUSanctionList(dto.getShipOwnerEUSanctionList())
.shipOwnerFATFJurisdiction(dto.getShipOwnerFATFJurisdiction())
.shipOwnerHistoricalOFACSanctionedCountry(dto.getShipOwnerHistoricalOFACSanctionedCountry())
.shipOwnerOFACSanctionList(dto.getShipOwnerOFACSanctionList())
.shipOwnerOFACSanctionedCountry(dto.getShipOwnerOFACSanctionedCountry())
.shipOwnerParentCompanyNonCompliance(dto.getShipOwnerParentCompanyNonCompliance())
.shipOwnerParentFATFJurisdiction(dto.getShipOwnerParentFATFJurisdiction())
.shipOwnerParentOFACSanctionedCountry(dto.getShipOwnerParentOFACSanctionedCountry())
.shipOwnerSwissSanctionList(dto.getShipOwnerSwissSanctionList())
.shipOwnerUAESanctionList(dto.getShipOwnerUAESanctionList())
.shipOwnerUNSanctionList(dto.getShipOwnerUNSanctionList())
.shipSanctionedCountryPortCallLast12m(dto.getShipSanctionedCountryPortCallLast12m())
.shipSanctionedCountryPortCallLast3m(dto.getShipSanctionedCountryPortCallLast3m())
.shipSanctionedCountryPortCallLast6m(dto.getShipSanctionedCountryPortCallLast6m())
.shipSecurityLegalDisputeEvent(dto.getShipSecurityLegalDisputeEvent())
.shipSTSPartnerNonComplianceLast12m(dto.getShipSTSPartnerNonComplianceLast12m())
.shipSwissSanctionList(dto.getShipSwissSanctionList())
.shipUNSanctionList(dto.getShipUNSanctionList())
.build();
return entity;
}
}

파일 보기

@ -0,0 +1,108 @@
package com.snp.batch.jobs.compliance.batch.reader;
import com.snp.batch.common.batch.reader.BaseApiReader;
import com.snp.batch.jobs.compliance.batch.dto.CompanyComplianceDto;
import com.snp.batch.service.BatchApiLogService;
import com.snp.batch.service.BatchDateService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.web.reactive.function.client.WebClient;
import java.util.List;
import java.util.Map;
@Slf4j
public class CompanyComplianceDataRangeReader extends BaseApiReader<CompanyComplianceDto> {
private final BatchDateService batchDateService; // BatchDateService 필드 추가
private final BatchApiLogService batchApiLogService;
String maritimeServiceApiUrl;
private List<CompanyComplianceDto> allData;
private int currentBatchIndex = 0;
private final int batchSize = 5000;
public CompanyComplianceDataRangeReader(WebClient webClient, BatchDateService batchDateService, BatchApiLogService batchApiLogService, String maritimeServiceApiUrl) {
super(webClient);
this.batchDateService = batchDateService;
this.batchApiLogService = batchApiLogService;
this.maritimeServiceApiUrl = maritimeServiceApiUrl;
enableChunkMode();
}
@Override
protected String getReaderName() {
return "CompanyComplianceDataRangeReader";
}
@Override
protected String getApiPath() {
return "/RiskAndCompliance/UpdatedCompanyComplianceList";
}
protected String getApiKey() {
return "COMPANY_COMPLIANCE_IMPORT_API";
}
@Override
protected void resetCustomState() {
this.currentBatchIndex = 0;
this.allData = null;
}
@Override
protected List<CompanyComplianceDto> fetchNextBatch() throws Exception{
// 모든 배치 처리 완료 확인
if (allData == null) {
allData = callApiWithBatch();
if (allData == null || allData.isEmpty()) {
log.warn("[{}] 조회된 데이터 없음 → 종료", getReaderName());
return null;
}
log.info("[{}] 총 {}건 데이터 조회됨. batchSize = {}", getReaderName(), allData.size(), batchSize);
}
// 2) 이미 끝까지 읽었으면 종료
if (currentBatchIndex >= allData.size()) {
log.info("[{}] 모든 배치 처리 완료", getReaderName());
return null;
}
// 3) 이번 배치의 end 계산
int end = Math.min(currentBatchIndex + batchSize, allData.size());
// 4) 현재 batch 리스트 잘라서 반환
List<CompanyComplianceDto> batch = allData.subList(currentBatchIndex, end);
int batchNum = (currentBatchIndex / batchSize) + 1;
int totalBatches = (int) Math.ceil((double) allData.size() / batchSize);
log.info("[{}] 배치 {}/{} 처리 중: {}건", getReaderName(), batchNum, totalBatches, batch.size());
// 다음 batch 인덱스 이동
currentBatchIndex = end;
updateApiCallStats(totalBatches, batchNum);
return batch;
}
@Override
protected void afterFetch(List<CompanyComplianceDto> data){
try{
if (data == null) {
log.info("[{}] 배치 처리 성공", getReaderName());
}
}catch (Exception e){
log.info("[{}] 배치 처리 실패", getReaderName());
log.info("[{}] API 호출 종료", getReaderName());
}
}
private List<CompanyComplianceDto> callApiWithBatch() {
Map<String, String> params = batchDateService.getDateRangeWithTimezoneParams(getApiKey());
// 부모 클래스의 공통 모듈 호출 ( 줄로 처리 가능)
return executeListApiCall(
maritimeServiceApiUrl,
getApiPath(),
params,
new ParameterizedTypeReference<List<CompanyComplianceDto>>() {},
batchApiLogService
);
}
}

파일 보기

@ -0,0 +1,116 @@
package com.snp.batch.jobs.compliance.batch.reader;
import com.snp.batch.common.batch.reader.BaseApiReader;
import com.snp.batch.jobs.compliance.batch.dto.ComplianceDto;
import com.snp.batch.service.BatchApiLogService;
import com.snp.batch.service.BatchDateService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.web.reactive.function.client.WebClient;
import java.util.List;
import java.util.Map;
@Slf4j
public class ComplianceDataRangeReader extends BaseApiReader<ComplianceDto> {
private final JdbcTemplate jdbcTemplate;
private final BatchDateService batchDateService; // BatchDateService 필드 추가
private final BatchApiLogService batchApiLogService;
String maritimeServiceApiUrl;
private List<ComplianceDto> allData;
private int currentBatchIndex = 0;
private final int batchSize = 5000;
public ComplianceDataRangeReader(WebClient webClient, JdbcTemplate jdbcTemplate, BatchDateService batchDateService, BatchApiLogService batchApiLogService, String maritimeServiceApiUrl) {
super(webClient);
this.jdbcTemplate = jdbcTemplate;
this.batchDateService = batchDateService;
this.batchApiLogService = batchApiLogService;
this.maritimeServiceApiUrl = maritimeServiceApiUrl;
enableChunkMode();
}
@Override
protected String getReaderName() {
return "ComplianceDataRangeReader";
}
@Override
protected String getApiPath() {
return "/RiskAndCompliance/UpdatedComplianceList";
}
protected String getApiKey() {
return "COMPLIANCE_IMPORT_API";
}
@Override
protected void resetCustomState() {
this.currentBatchIndex = 0;
this.allData = null;
}
@Override
protected List<ComplianceDto> fetchNextBatch() throws Exception {
// 모든 배치 처리 완료 확인
if (allData == null) {
allData = callApiWithBatch();
if (allData == null || allData.isEmpty()) {
log.warn("[{}] 조회된 데이터 없음 → 종료", getReaderName());
return null;
}
log.info("[{}] 총 {}건 데이터 조회됨. batchSize = {}", getReaderName(), allData.size(), batchSize);
}
// 2) 이미 끝까지 읽었으면 종료
if (currentBatchIndex >= allData.size()) {
log.info("[{}] 모든 배치 처리 완료", getReaderName());
return null;
}
// 3) 이번 배치의 end 계산
int end = Math.min(currentBatchIndex + batchSize, allData.size());
// 4) 현재 batch 리스트 잘라서 반환
List<ComplianceDto> batch = allData.subList(currentBatchIndex, end);
int batchNum = (currentBatchIndex / batchSize) + 1;
int totalBatches = (int) Math.ceil((double) allData.size() / batchSize);
log.info("[{}] 배치 {}/{} 처리 중: {}건", getReaderName(), batchNum, totalBatches, batch.size());
// 다음 batch 인덱스 이동
currentBatchIndex = end;
updateApiCallStats(totalBatches, batchNum);
return batch;
}
@Override
protected void afterFetch(List<ComplianceDto> data) {
try{
if (data == null) {
log.info("[{}] 배치 처리 성공", getReaderName());
}
}catch (Exception e){
log.info("[{}] 배치 처리 실패", getReaderName());
log.info("[{}] API 호출 종료", getReaderName());
}
}
private List<ComplianceDto> callApiWithBatch() {
Map<String, String> params = batchDateService.getDateRangeWithTimezoneParams(getApiKey());
// 부모 클래스의 공통 모듈 호출 ( 줄로 처리 가능)
return executeListApiCall(
maritimeServiceApiUrl,
getApiPath(),
params,
new ParameterizedTypeReference<List<ComplianceDto>>() {},
batchApiLogService
);
}
}

파일 보기

@ -0,0 +1,147 @@
package com.snp.batch.jobs.compliance.batch.reader;
import com.snp.batch.common.batch.reader.BaseApiReader;
import com.snp.batch.jobs.compliance.batch.dto.ComplianceDto;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.web.reactive.function.client.WebClient;
import java.util.Collections;
import java.util.List;
@Slf4j
public class ComplianceDataReader extends BaseApiReader<ComplianceDto> {
//TODO :
// 1. Core20 IMO_NUMBER 전체 조회
// 2. IMO번호에 대한 마지막 AIS 신호 요청 (1회 최대 5000개 : Chunk 단위로 반복)
// 3. Response Data -> Core20에 업데이트 (Chunk 단위로 반복)
private final JdbcTemplate jdbcTemplate;
private List<String> allImoNumbers;
private int currentBatchIndex = 0;
private final int batchSize = 100;
public ComplianceDataReader(WebClient webClient, JdbcTemplate jdbcTemplate) {
super(webClient);
this.jdbcTemplate = jdbcTemplate;
enableChunkMode(); // Chunk 모드 활성화
}
@Override
protected String getReaderName() {
return "ComplianceDataReader";
}
@Override
protected void resetCustomState() {
this.currentBatchIndex = 0;
this.allImoNumbers = null;
}
@Override
protected String getApiPath() {
return "/RiskAndCompliance/CompliancesByImos";
}
private String getTargetTable(){
return "snp_data.core20";
}
private String GET_CORE_IMO_LIST =
// "SELECT ihslrorimoshipno FROM " + getTargetTable() + " ORDER BY ihslrorimoshipno";
"select imo_number as ihslrorimoshipno from snp_data.ship_data order by imo_number";
@Override
protected void beforeFetch(){
log.info("[{}] Core20 테이블에서 IMO 번호 조회 시작...", getReaderName());
allImoNumbers = jdbcTemplate.queryForList(GET_CORE_IMO_LIST, String.class);
int totalBatches = (int) Math.ceil((double) allImoNumbers.size() / batchSize);
log.info("[{}] 총 {} 개의 IMO 번호 조회 완료", getReaderName(), allImoNumbers.size());
log.info("[{}] {}개씩 배치로 분할하여 API 호출 예정", getReaderName(), batchSize);
log.info("[{}] 예상 배치 수: {} 개", getReaderName(), totalBatches);
updateApiCallStats(totalBatches, 0);
}
@Override
protected List<ComplianceDto> fetchNextBatch() throws Exception {
// 모든 배치 처리 완료 확인
if (allImoNumbers == null || currentBatchIndex >= allImoNumbers.size()) {
return null; // Job 종료
}
// 현재 배치의 시작/ 인덱스 계산
int startIndex = currentBatchIndex;
int endIndex = Math.min(currentBatchIndex + batchSize, allImoNumbers.size());
// 현재 배치의 IMO 번호 추출 (100개)
List<String> currentBatch = allImoNumbers.subList(startIndex, endIndex);
int currentBatchNumber = (currentBatchIndex / batchSize) + 1;
int totalBatches = (int) Math.ceil((double) allImoNumbers.size() / batchSize);
log.info("[{}] 배치 {}/{} 처리 중 (IMO {} 개)...",
getReaderName(), currentBatchNumber, totalBatches, currentBatch.size());
try {
// IMO 번호를 쉼표로 연결 (: "1000019,1000021,1000033,...")
String imoParam = String.join(",", currentBatch);
// API 호출
List<ComplianceDto> response = callAisApiWithBatch(imoParam);
// 다음 배치로 인덱스 이동
currentBatchIndex = endIndex;
// 응답 처리
if (response != null) {
// List<ComplianceDto> targets = response;
log.info("[{}] 배치 {}/{} 완료: {} 건 조회",
getReaderName(), currentBatchNumber, totalBatches, response.size());
// API 호출 통계 업데이트
updateApiCallStats(totalBatches, currentBatchNumber);
// API 과부하 방지 (다음 배치 0.5초 대기)
if (currentBatchIndex < allImoNumbers.size()) {
Thread.sleep(500);
}
return response;
} else {
log.warn("[{}] 배치 {}/{} 응답 없음",
getReaderName(), currentBatchNumber, totalBatches);
// API 호출 통계 업데이트 (실패도 카운트)
updateApiCallStats(totalBatches, currentBatchNumber);
return Collections.emptyList();
}
} catch (Exception e) {
log.error("[{}] 배치 {}/{} 처리 중 오류: {}",
getReaderName(), currentBatchNumber, totalBatches, e.getMessage(), e);
// 오류 발생 시에도 다음 배치로 이동 (부분 실패 허용)
currentBatchIndex = endIndex;
// 리스트 반환 (Job 계속 진행)
return Collections.emptyList();
}
}
private List<ComplianceDto> callAisApiWithBatch(String imoNumbers) {
String url = getApiPath() + "?imos=" + imoNumbers;
log.debug("[{}] API 호출: {}", getReaderName(), url);
return webClient.get()
.uri(url)
.retrieve()
.bodyToMono(new ParameterizedTypeReference<List<ComplianceDto>>() {})
.block();
}
}

파일 보기

@ -0,0 +1,10 @@
package com.snp.batch.jobs.compliance.batch.repository;
import com.snp.batch.jobs.compliance.batch.entity.CompanyComplianceEntity;
import java.util.List;
public interface CompanyComplianceRepository {
void saveCompanyComplianceAll(List<CompanyComplianceEntity> items);
void saveCompanyComplianceHistoryAll(List<CompanyComplianceEntity> items);
}

파일 보기

@ -0,0 +1,137 @@
package com.snp.batch.jobs.compliance.batch.repository;
import com.snp.batch.common.batch.repository.BaseJdbcRepository;
import com.snp.batch.jobs.compliance.batch.entity.CompanyComplianceEntity;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Repository;
import java.sql.PreparedStatement;
import java.sql.Types;
import java.util.List;
@Slf4j
@Repository("CompanyComplianceRepository")
public class CompanyComplianceRepositoryImpl extends BaseJdbcRepository<CompanyComplianceEntity, Long> implements CompanyComplianceRepository{
public CompanyComplianceRepositoryImpl(JdbcTemplate jdbcTemplate) {
super(jdbcTemplate);
}
@Override
protected String getTableName() {
return null;
}
@Override
protected RowMapper<CompanyComplianceEntity> getRowMapper() {
return null;
}
@Override
protected Long extractId(CompanyComplianceEntity entity) {
return null;
}
@Override
protected String getInsertSql() {
return null;
}
@Override
protected String getUpdateSql() {
return null;
}
protected String getUpdateSql(String targetTable, String targetIndex) {
return """
INSERT INTO new_snp.%s(
owcode, lastupdated,
companyoverallcompliancestatus, companyonaustraliansanctionlist, companyonbessanctionlist, companyoncanadiansanctionlist, companyinofacsanctionedcountry,
companyinfatfjurisdiction, companyoneusanctionlist, companyonofacsanctionlist, companyonofacnonsdnsanctionlist, companyonofacssilist,
companyonswisssanctionlist, companyonuaesanctionlist, companyonunsanctionlist, parentcompanycompliancerisk
)VALUES(
?, ?::timestamp, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
)ON CONFLICT (%s)
DO UPDATE SET
companyoverallcompliancestatus = EXCLUDED.companyoverallcompliancestatus,
companyonaustraliansanctionlist = EXCLUDED.companyonaustraliansanctionlist,
companyonbessanctionlist = EXCLUDED.companyonbessanctionlist,
companyoncanadiansanctionlist = EXCLUDED.companyoncanadiansanctionlist,
companyinofacsanctionedcountry = EXCLUDED.companyinofacsanctionedcountry,
companyinfatfjurisdiction = EXCLUDED.companyinfatfjurisdiction,
companyoneusanctionlist = EXCLUDED.companyoneusanctionlist,
companyonofacsanctionlist = EXCLUDED.companyonofacsanctionlist,
companyonofacnonsdnsanctionlist = EXCLUDED.companyonofacnonsdnsanctionlist,
companyonofacssilist = EXCLUDED.companyonofacssilist,
companyonswisssanctionlist = EXCLUDED.companyonswisssanctionlist,
companyonuaesanctionlist = EXCLUDED.companyonuaesanctionlist,
companyonunsanctionlist = EXCLUDED.companyonunsanctionlist,
parentcompanycompliancerisk = EXCLUDED.parentcompanycompliancerisk
""".formatted(targetTable, targetIndex);
}
@Override
protected void setInsertParameters(PreparedStatement ps, CompanyComplianceEntity entity) throws Exception {
}
@Override
protected void setUpdateParameters(PreparedStatement ps, CompanyComplianceEntity entity) throws Exception {
int idx = 1;
ps.setString(idx++, entity.getOwcode());
ps.setString(idx++, entity.getLastUpdated());
ps.setObject(idx++, entity.getCompanyOverallComplianceStatus(), Types.INTEGER);
ps.setObject(idx++, entity.getCompanyOnAustralianSanctionList(), Types.INTEGER);
ps.setObject(idx++, entity.getCompanyOnBESSanctionList(), Types.INTEGER);
ps.setObject(idx++, entity.getCompanyOnCanadianSanctionList(), Types.INTEGER);
ps.setObject(idx++, entity.getCompanyInOFACSanctionedCountry(), Types.INTEGER);
ps.setObject(idx++, entity.getCompanyInFATFJurisdiction(), Types.INTEGER);
ps.setObject(idx++, entity.getCompanyOnEUSanctionList(), Types.INTEGER);
ps.setObject(idx++, entity.getCompanyOnOFACSanctionList(), Types.INTEGER);
ps.setObject(idx++, entity.getCompanyOnOFACNONSDNSanctionList(), Types.INTEGER);
ps.setObject(idx++, entity.getCompanyOnOFACSSISanctionList(), Types.INTEGER);
ps.setObject(idx++, entity.getCompanyOnSwissSanctionList(), Types.INTEGER);
ps.setObject(idx++, entity.getCompanyOnUAESanctionList(), Types.INTEGER);
ps.setObject(idx++, entity.getCompanyOnUNSanctionList(), Types.INTEGER);
ps.setObject(idx++, entity.getParentCompanyNonCompliance(), Types.INTEGER);
}
@Override
protected String getEntityName() {
return "CompanyComplianceEntity";
}
@Override
public void saveCompanyComplianceAll(List<CompanyComplianceEntity> items) {
if (items == null || items.isEmpty()) {
return;
}
jdbcTemplate.batchUpdate(getUpdateSql("tb_company_compliance_info", "owcode"), items, items.size(),
(ps, entity) -> {
try {
setUpdateParameters(ps, entity);
} catch (Exception e) {
log.error("배치 수정 파라미터 설정 실패", e);
throw new RuntimeException(e);
}
});
log.info("{} 전체 저장 완료: 수정={} 건", getEntityName(), items.size());
}
@Override
public void saveCompanyComplianceHistoryAll(List<CompanyComplianceEntity> items) {
if (items == null || items.isEmpty()) {
return;
}
jdbcTemplate.batchUpdate(getUpdateSql("tb_company_compliance_hstry", "owcode, lastupdated"), items, items.size(),
(ps, entity) -> {
try {
setUpdateParameters(ps, entity);
} catch (Exception e) {
log.error("배치 수정 파라미터 설정 실패", e);
throw new RuntimeException(e);
}
});
log.info("{} 전체 저장 완료: 수정={} 건", getEntityName(), items.size());
}
}

파일 보기

@ -0,0 +1,10 @@
package com.snp.batch.jobs.compliance.batch.repository;
import com.snp.batch.jobs.compliance.batch.entity.ComplianceEntity;
import java.util.List;
public interface ComplianceRepository {
void saveComplianceAll(List<ComplianceEntity> items);
void saveComplianceHistoryAll(List<ComplianceEntity> items);
}

파일 보기

@ -0,0 +1,186 @@
package com.snp.batch.jobs.compliance.batch.repository;
import com.snp.batch.common.batch.repository.BaseJdbcRepository;
import com.snp.batch.jobs.compliance.batch.entity.ComplianceEntity;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Repository;
import java.sql.PreparedStatement;
import java.sql.Types;
import java.util.List;
@Slf4j
@Repository("ComplianceRepository")
public class ComplianceRepositoryImpl extends BaseJdbcRepository<ComplianceEntity, Long> implements ComplianceRepository {
public ComplianceRepositoryImpl(JdbcTemplate jdbcTemplate) {
super(jdbcTemplate);
}
@Override
protected String getTableName() {
return null;
}
@Override
protected RowMapper<ComplianceEntity> getRowMapper() {
return null;
}
@Override
protected Long extractId(ComplianceEntity entity) {
return null;
}
@Override
protected String getInsertSql() {
return null;
}
@Override
protected String getUpdateSql() {
return null;
}
protected String getUpdateSql(String targetTable, String targetIndex) {
return """
INSERT INTO new_snp.%s (
lrimoshipno, dateamended, legaloverall, shipbessanctionlist, shipdarkactivityindicator,
shipdetailsnolongermaintained, shipeusanctionlist, shipflagdisputed, shipflagsanctionedcountry,
shiphistoricalflagsanctionedcountry, shipofacnonsdnsanctionlist, shipofacsanctionlist,
shipofacadvisorylist, shipownerofacssilist, shipowneraustraliansanctionlist, shipownerbessanctionlist,
shipownercanadiansanctionlist, shipownereusanctionlist, shipownerfatfjurisdiction,
shipownerhistoricalofacsanctionedcountry, shipownerofacsanctionlist, shipownerofacsanctionedcountry,
shipownerparentcompanynoncompliance, shipownerparentfatfjurisdiction, shipownerparentofacsanctionedcountry,
shipownerswisssanctionlist, shipowneruaesanctionlist, shipownerunsanctionlist,
shipsanctionedcountryportcalllast12m, shipsanctionedcountryportcalllast3m, shipsanctionedcountryportcalllast6m,
shipsecuritylegaldisputeevent, shipstspartnernoncompliancelast12m, shipswisssanctionlist,
shipunsanctionlist
)
VALUES (
?, ?::timestamptz, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
)
ON CONFLICT (%s)
DO UPDATE SET
legaloverall = EXCLUDED.legaloverall,
shipbessanctionlist = EXCLUDED.shipbessanctionlist,
shipdarkactivityindicator = EXCLUDED.shipdarkactivityindicator,
shipdetailsnolongermaintained = EXCLUDED.shipdetailsnolongermaintained,
shipeusanctionlist = EXCLUDED.shipeusanctionlist,
shipflagdisputed = EXCLUDED.shipflagdisputed,
shipflagsanctionedcountry = EXCLUDED.shipflagsanctionedcountry,
shiphistoricalflagsanctionedcountry = EXCLUDED.shiphistoricalflagsanctionedcountry,
shipofacnonsdnsanctionlist = EXCLUDED.shipofacnonsdnsanctionlist,
shipofacsanctionlist = EXCLUDED.shipofacsanctionlist,
shipofacadvisorylist = EXCLUDED.shipofacadvisorylist,
shipownerofacssilist = EXCLUDED.shipownerofacssilist,
shipowneraustraliansanctionlist = EXCLUDED.shipowneraustraliansanctionlist,
shipownerbessanctionlist = EXCLUDED.shipownerbessanctionlist,
shipownercanadiansanctionlist = EXCLUDED.shipownercanadiansanctionlist,
shipownereusanctionlist = EXCLUDED.shipownereusanctionlist,
shipownerfatfjurisdiction = EXCLUDED.shipownerfatfjurisdiction,
shipownerhistoricalofacsanctionedcountry = EXCLUDED.shipownerhistoricalofacsanctionedcountry,
shipownerofacsanctionlist = EXCLUDED.shipownerofacsanctionlist,
shipownerofacsanctionedcountry = EXCLUDED.shipownerofacsanctionedcountry,
shipownerparentcompanynoncompliance = EXCLUDED.shipownerparentcompanynoncompliance,
shipownerparentfatfjurisdiction = EXCLUDED.shipownerparentfatfjurisdiction,
shipownerparentofacsanctionedcountry = EXCLUDED.shipownerparentofacsanctionedcountry,
shipownerswisssanctionlist = EXCLUDED.shipownerswisssanctionlist,
shipowneruaesanctionlist = EXCLUDED.shipowneruaesanctionlist,
shipownerunsanctionlist = EXCLUDED.shipownerunsanctionlist,
shipsanctionedcountryportcalllast12m = EXCLUDED.shipsanctionedcountryportcalllast12m,
shipsanctionedcountryportcalllast3m = EXCLUDED.shipsanctionedcountryportcalllast3m,
shipsanctionedcountryportcalllast6m = EXCLUDED.shipsanctionedcountryportcalllast6m,
shipsecuritylegaldisputeevent = EXCLUDED.shipsecuritylegaldisputeevent,
shipstspartnernoncompliancelast12m = EXCLUDED.shipstspartnernoncompliancelast12m,
shipswisssanctionlist = EXCLUDED.shipswisssanctionlist,
shipunsanctionlist = EXCLUDED.shipunsanctionlist
""".formatted(targetTable, targetIndex);
}
@Override
protected void setInsertParameters(PreparedStatement ps, ComplianceEntity entity) throws Exception {
}
@Override
protected void setUpdateParameters(PreparedStatement ps, ComplianceEntity entity) throws Exception {
int idx = 1;
ps.setString(idx++, entity.getLrimoShipNo());
ps.setString(idx++, entity.getDateAmended());
ps.setObject(idx++, entity.getLegalOverall(), Types.INTEGER);
ps.setObject(idx++, entity.getShipBESSanctionList(), Types.INTEGER);
ps.setObject(idx++, entity.getShipDarkActivityIndicator(), Types.INTEGER);
ps.setObject(idx++, entity.getShipDetailsNoLongerMaintained(), Types.INTEGER);
ps.setObject(idx++, entity.getShipEUSanctionList(), Types.INTEGER);
ps.setObject(idx++, entity.getShipFlagDisputed(), Types.INTEGER);
ps.setObject(idx++, entity.getShipFlagSanctionedCountry(), Types.INTEGER);
ps.setObject(idx++, entity.getShipHistoricalFlagSanctionedCountry(), Types.INTEGER);
ps.setObject(idx++, entity.getShipOFACNonSDNSanctionList(), Types.INTEGER);
ps.setObject(idx++, entity.getShipOFACSanctionList(), Types.INTEGER);
ps.setObject(idx++, entity.getShipOFACAdvisoryList(), Types.INTEGER);
ps.setObject(idx++, entity.getShipOwnerOFACSSIList(), Types.INTEGER);
ps.setObject(idx++, entity.getShipOwnerAustralianSanctionList(), Types.INTEGER);
ps.setObject(idx++, entity.getShipOwnerBESSanctionList(), Types.INTEGER);
ps.setObject(idx++, entity.getShipOwnerCanadianSanctionList(), Types.INTEGER);
ps.setObject(idx++, entity.getShipOwnerEUSanctionList(), Types.INTEGER);
ps.setObject(idx++, entity.getShipOwnerFATFJurisdiction(), Types.INTEGER);
ps.setObject(idx++, entity.getShipOwnerHistoricalOFACSanctionedCountry(), Types.INTEGER);
ps.setObject(idx++, entity.getShipOwnerOFACSanctionList(), Types.INTEGER);
ps.setObject(idx++, entity.getShipOwnerOFACSanctionedCountry(), Types.INTEGER);
ps.setObject(idx++, entity.getShipOwnerParentCompanyNonCompliance(), Types.INTEGER);
ps.setObject(idx++, entity.getShipOwnerParentFATFJurisdiction(), Types.INTEGER);
ps.setObject(idx++, entity.getShipOwnerParentOFACSanctionedCountry(), Types.INTEGER);
ps.setObject(idx++, entity.getShipOwnerSwissSanctionList(), Types.INTEGER);
ps.setObject(idx++, entity.getShipOwnerUAESanctionList(), Types.INTEGER);
ps.setObject(idx++, entity.getShipOwnerUNSanctionList(), Types.INTEGER);
ps.setObject(idx++, entity.getShipSanctionedCountryPortCallLast12m(), Types.INTEGER);
ps.setObject(idx++, entity.getShipSanctionedCountryPortCallLast3m(), Types.INTEGER);
ps.setObject(idx++, entity.getShipSanctionedCountryPortCallLast6m(), Types.INTEGER);
ps.setObject(idx++, entity.getShipSecurityLegalDisputeEvent(), Types.INTEGER);
ps.setObject(idx++, entity.getShipSTSPartnerNonComplianceLast12m(), Types.INTEGER);
ps.setObject(idx++, entity.getShipSwissSanctionList(), Types.INTEGER);
ps.setObject(idx++, entity.getShipUNSanctionList(), Types.INTEGER);
}
@Override
protected String getEntityName() {
return "ComplianceEntity";
}
@Override
public void saveComplianceAll(List<ComplianceEntity> items) {
if (items == null || items.isEmpty()) {
return;
}
jdbcTemplate.batchUpdate(getUpdateSql("compliance", "lrimoshipno"), items, items.size(),
(ps, entity) -> {
try {
setUpdateParameters(ps, entity);
} catch (Exception e) {
log.error("배치 수정 파라미터 설정 실패", e);
throw new RuntimeException(e);
}
});
log.info("{} 전체 저장 완료: 수정={} 건", getEntityName(), items.size());
}
@Override
public void saveComplianceHistoryAll(List<ComplianceEntity> items) {
if (items == null || items.isEmpty()) {
return;
}
jdbcTemplate.batchUpdate(getUpdateSql("compliance_history", "lrimoshipno, dateamended"), items, items.size(),
(ps, entity) -> {
try {
setUpdateParameters(ps, entity);
} catch (Exception e) {
log.error("배치 수정 파라미터 설정 실패", e);
throw new RuntimeException(e);
}
});
log.info("{} 전체 저장 완료: 수정={} 건", getEntityName(), items.size());
}
}

파일 보기

@ -0,0 +1,25 @@
package com.snp.batch.jobs.compliance.batch.writer;
import com.snp.batch.common.batch.writer.BaseWriter;
import com.snp.batch.jobs.compliance.batch.entity.CompanyComplianceEntity;
import com.snp.batch.jobs.compliance.batch.repository.CompanyComplianceRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.List;
@Slf4j
@Component
public class CompanyComplianceDataWriter extends BaseWriter<CompanyComplianceEntity> {
private final CompanyComplianceRepository complianceRepository;
public CompanyComplianceDataWriter(CompanyComplianceRepository complianceRepository) {
super("CompanyComplianceRepository");
this.complianceRepository = complianceRepository;
}
@Override
protected void writeItems(List<CompanyComplianceEntity> items) throws Exception {
complianceRepository.saveCompanyComplianceAll(items);
complianceRepository.saveCompanyComplianceHistoryAll(items);
}
}

파일 보기

@ -0,0 +1,24 @@
package com.snp.batch.jobs.compliance.batch.writer;
import com.snp.batch.common.batch.writer.BaseWriter;
import com.snp.batch.jobs.compliance.batch.entity.ComplianceEntity;
import com.snp.batch.jobs.compliance.batch.repository.ComplianceRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.List;
@Slf4j
@Component
public class ComplianceDataWriter extends BaseWriter<ComplianceEntity> {
private final ComplianceRepository complianceRepository;
public ComplianceDataWriter(ComplianceRepository complianceRepository) {
super("ComplianceRepository");
this.complianceRepository = complianceRepository;
}
@Override
protected void writeItems(List<ComplianceEntity> items) throws Exception {
complianceRepository.saveComplianceAll(items);
complianceRepository.saveComplianceHistoryAll(items);
}
}

파일 보기

@ -0,0 +1,146 @@
package com.snp.batch.jobs.event.batch.config;
import com.snp.batch.common.batch.config.BaseMultiStepJobConfig;
import com.snp.batch.jobs.event.batch.dto.EventDetailDto;
import com.snp.batch.jobs.event.batch.entity.EventDetailEntity;
import com.snp.batch.jobs.event.batch.processor.EventDataProcessor;
import com.snp.batch.jobs.event.batch.reader.EventDataReader;
import com.snp.batch.jobs.event.batch.writer.EventDataWriter;
import com.snp.batch.service.BatchApiLogService;
import com.snp.batch.service.BatchDateService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.configuration.annotation.StepScope;
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.batch.core.step.tasklet.Tasklet;
import org.springframework.batch.item.ItemProcessor;
import org.springframework.batch.item.ItemReader;
import org.springframework.batch.item.ItemWriter;
import org.springframework.batch.repeat.RepeatStatus;
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.jdbc.core.JdbcTemplate;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.web.reactive.function.client.WebClient;
@Slf4j
@Configuration
public class EventImportJobConfig extends BaseMultiStepJobConfig<EventDetailDto, EventDetailEntity> {
private final EventDataProcessor eventDataProcessor;
private final EventDataWriter eventDataWriter;
private final EventDataReader eventDataReader;
private final JdbcTemplate jdbcTemplate;
private final WebClient maritimeApiWebClient;
private final BatchDateService batchDateService;
private final BatchApiLogService batchApiLogService;
@Value("${app.batch.ship-api.url}")
private String maritimeApiUrl;
protected String getApiKey() {return "EVENT_IMPORT_API";}
protected String getBatchUpdateSql() {
return String.format("UPDATE SNP_DATA.BATCH_LAST_EXECUTION SET LAST_SUCCESS_DATE = NOW(), UPDATED_AT = NOW() WHERE API_KEY = '%s'", getApiKey());}
@Override
protected int getChunkSize() {
return 1000; // API에서 5000개씩 가져오므로 chunk도 5000으로 설정
}
public EventImportJobConfig(
JobRepository jobRepository,
PlatformTransactionManager transactionManager,
EventDataProcessor eventDataProcessor,
EventDataWriter eventDataWriter,
EventDataReader eventDataReader,
JdbcTemplate jdbcTemplate,
@Qualifier("maritimeApiWebClient")WebClient maritimeApiWebClient,
BatchDateService batchDateService,
BatchApiLogService batchApiLogService) {
super(jobRepository, transactionManager);
this.jdbcTemplate = jdbcTemplate;
this.maritimeApiWebClient = maritimeApiWebClient;
this.eventDataProcessor = eventDataProcessor;
this.eventDataWriter = eventDataWriter;
this.eventDataReader = eventDataReader;
this.batchDateService = batchDateService;
this.batchApiLogService = batchApiLogService;
}
@Override
protected String getJobName() {
return "EventImportJob";
}
@Override
protected String getStepName() {
return "EventImportStep";
}
@Override
protected Job createJobFlow(JobBuilder jobBuilder) {
return jobBuilder
.start(eventImportStep())
.next(eventLastExecutionUpdateStep())
.build();
}
@Bean
@StepScope
public EventDataReader eventDataReader(
@Value("#{stepExecution.jobExecution.id}") Long jobExecutionId, // SpEL로 ID 추출
@Value("#{stepExecution.id}") Long stepExecutionId
) {
EventDataReader reader = new EventDataReader(maritimeApiWebClient, jdbcTemplate, batchDateService, batchApiLogService, maritimeApiUrl);
reader.setExecutionIds(jobExecutionId, stepExecutionId); // ID 세팅
return reader;
}
@Override
protected ItemReader<EventDetailDto> createReader() {
return eventDataReader;
}
@Override
protected ItemProcessor<EventDetailDto, EventDetailEntity> createProcessor() {
return eventDataProcessor;
}
@Override
protected ItemWriter<EventDetailEntity> createWriter() { return eventDataWriter; }
@Bean(name = "EventImportJob")
public Job eventImportJob() {
return job();
}
@Bean(name = "EventImportStep")
public Step eventImportStep() {
return step();
}
/**
* 2단계: 모든 스텝 성공 배치 실행 로그(날짜) 업데이트
*/
@Bean
public Tasklet eventLastExecutionUpdateTasklet() {
return (contribution, chunkContext) -> {
log.info(">>>>> 모든 스텝 성공: BATCH_LAST_EXECUTION 업데이트 시작");
jdbcTemplate.execute(getBatchUpdateSql());
log.info(">>>>> BATCH_LAST_EXECUTION 업데이트 완료");
return RepeatStatus.FINISHED;
};
}
@Bean(name = "EventLastExecutionUpdateStep")
public Step eventLastExecutionUpdateStep() {
return new StepBuilder("EventLastExecutionUpdateStep", jobRepository)
.tasklet(eventLastExecutionUpdateTasklet(), transactionManager)
.build();
}
}

파일 보기

@ -0,0 +1,50 @@
package com.snp.batch.jobs.event.batch.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.snp.batch.jobs.event.batch.entity.CargoEntity;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CargoDto {
@JsonProperty("EventID")
private Integer eventID;
@JsonProperty("Sequence")
private String sequence;
@JsonProperty("IHSLRorIMOShipNo")
private String ihslrOrImoShipNo;
@JsonProperty("Type")
private String type;
@JsonProperty("Quantity")
private Integer quantity;
@JsonProperty("UnitShort")
private String unitShort;
@JsonProperty("Unit")
private String unit;
@JsonProperty("Text")
private String text;
@JsonProperty("CargoDamage")
private String cargoDamage;
@JsonProperty("Dangerous")
private String dangerous;
public CargoEntity toEntity() {
return CargoEntity.builder()
.eventID(this.eventID)
.sequence(this.sequence)
.ihslrOrImoShipNo(this.ihslrOrImoShipNo)
.type(this.type)
.unit(this.unit)
.quantity(this.quantity)
.unitShort(this.unitShort)
.text(this.text)
.cargoDamage(this.cargoDamage)
.dangerous(this.dangerous)
.build();
}
}

파일 보기

@ -0,0 +1,109 @@
package com.snp.batch.jobs.event.batch.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class EventDetailDto {
@JsonProperty("IncidentID")
private Integer incidentID;
@JsonProperty("EventID")
private Long eventID;
@JsonProperty("EventTypeID")
private Integer eventTypeID;
@JsonProperty("EventType")
private String eventType;
@JsonProperty("Significance")
private String significance;
@JsonProperty("Headline")
private String headline;
@JsonProperty("IHSLRorIMOShipNo")
private String ihslrOrImoShipNo;
@JsonProperty("VesselName")
private String vesselName;
@JsonProperty("VesselType")
private String vesselType;
@JsonProperty("VesselTypeDecode")
private String vesselTypeDecode;
@JsonProperty("VesselFlag")
private String vesselFlagCode;
@JsonProperty("Flag")
private String vesselFlagDecode;
@JsonProperty("CargoLoadingStatusCode")
private String cargoLoadingStatusCode;
@JsonProperty("VesselDWT")
private Integer vesselDWT;
@JsonProperty("VesselGT")
private Integer vesselGT;
@JsonProperty("LDTAtTime")
private Integer ldtAtTime;
@JsonProperty("DateOfBuild")
private Integer dateOfBuild;
@JsonProperty("RegisteredOwnerCodeAtTime")
private String registeredOwnerCodeAtTime;
@JsonProperty("RegisteredOwnerAtTime")
private String registeredOwnerAtTime;
@JsonProperty("RegisteredOwnerCoDAtTime")
private String registeredOwnerCountryCodeAtTime;
@JsonProperty("RegisteredOwnerCountryAtTime")
private String registeredOwnerCountryAtTime;
@JsonProperty("Weather")
private String weather;
@JsonProperty("EventTypeDetail")
private String eventTypeDetail;
@JsonProperty("EventTypeDetailID")
private Integer eventTypeDetailID;
@JsonProperty("CasualtyAction")
private String casualtyAction;
@JsonProperty("LocationName")
private String locationName;
@JsonProperty("TownName")
private String townName;
@JsonProperty("MarsdenGridReference")
private Integer marsdenGridReference;
@JsonProperty("EnvironmentLocation")
private String environmentLocation;
@JsonProperty("CasualtyZone")
private String casualtyZone;
@JsonProperty("CasualtyZoneCode")
private String casualtyZoneCode;
@JsonProperty("CountryCode")
private String countryCode;
@JsonProperty("AttemptedBoarding")
private String attemptedBoarding;
@JsonProperty("Description")
private String description;
@JsonProperty("Pollutant")
private String pollutant;
@JsonProperty("PollutantUnit")
private String pollutantUnit;
@JsonProperty("PollutantQuantity")
private Double pollutantQuantity;
@JsonProperty("PublishedDate")
private String publishedDate;
@JsonProperty("Component2")
private String component2;
@JsonProperty("FiredUpon")
private String firedUpon;
private String eventStartDate;
private String eventEndDate;
@JsonProperty("Cargoes")
private List<CargoDto> cargoes;
@JsonProperty("HumanCasualties")
private List<HumanCasualtyDto> humanCasualties;
@JsonProperty("Relationships")
private List<RelationshipDto> relationships;
}

파일 보기

@ -0,0 +1,18 @@
package com.snp.batch.jobs.event.batch.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class EventDetailResponse {
@JsonProperty("MaritimeEvent")
private EventDetailDto eventDetailDto;
}

파일 보기

@ -0,0 +1,51 @@
package com.snp.batch.jobs.event.batch.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class EventDto {
@JsonProperty("IncidentID")
private Long incidentId;
@JsonProperty("EventID")
private Long eventId;
@JsonProperty("StartDate")
private String startDate;
@JsonProperty("EventType")
private String eventType;
@JsonProperty("Significance")
private String significance;
@JsonProperty("Headline")
private String headline;
@JsonProperty("EndDate")
private String endDate;
@JsonProperty("IHSLRorIMOShipNo")
private String ihslRorImoShipNo;
@JsonProperty("VesselName")
private String vesselName;
@JsonProperty("VesselType")
private String vesselType;
@JsonProperty("LocationName")
private String locationName;
@JsonProperty("PublishedDate")
private String publishedDate;
}

파일 보기

@ -0,0 +1,12 @@
package com.snp.batch.jobs.event.batch.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@AllArgsConstructor
public class EventPeriod {
private String eventStartDate;
private String eventEndDate;}

파일 보기

@ -0,0 +1,21 @@
package com.snp.batch.jobs.event.batch.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class EventResponse {
@JsonProperty("EventCount")
private Integer eventCount;
@JsonProperty("MaritimeEvents")
private List<EventDto> MaritimeEvents;
}

파일 보기

@ -0,0 +1,35 @@
package com.snp.batch.jobs.event.batch.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.snp.batch.jobs.event.batch.entity.HumanCasualtyEntity;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class HumanCasualtyDto {
@JsonProperty("EventID")
private Integer eventID;
@JsonProperty("Scope")
private String scope;
@JsonProperty("Type")
private String type;
@JsonProperty("Qualifier")
private String qualifier;
@JsonProperty("Count")
private Integer count;
public HumanCasualtyEntity toEntity() {
return HumanCasualtyEntity.builder()
.eventID(this.eventID)
.scope(this.scope)
.type(this.type)
.qualifier(this.qualifier)
.count(this.count)
.build();
}
}

파일 보기

@ -0,0 +1,41 @@
package com.snp.batch.jobs.event.batch.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.snp.batch.jobs.event.batch.entity.RelationshipEntity;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class RelationshipDto {
@JsonProperty("IncidentID")
private String incidentID;
@JsonProperty("EventID")
private Integer eventID;
@JsonProperty("RelationshipType")
private String relationshipType;
@JsonProperty("RelationshipTypeCode")
private String relationshipTypeCode;
@JsonProperty("EventID2")
private Integer eventID2;
@JsonProperty("EventType")
private String eventType;
@JsonProperty("EventTypeCode")
private String eventTypeCode;
public RelationshipEntity toEntity() {
return RelationshipEntity.builder()
.incidentID(this.incidentID)
.eventID(this.eventID)
.relationshipType(this.relationshipType)
.relationshipTypeCode(this.relationshipTypeCode)
.eventID2(this.eventID2)
.eventType(this.eventType)
.eventTypeCode(this.eventTypeCode)
.build();
}
}

파일 보기

@ -0,0 +1,26 @@
package com.snp.batch.jobs.event.batch.entity;
import com.snp.batch.common.batch.entity.BaseEntity;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;
@Data
@SuperBuilder
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode(callSuper = true)
public class CargoEntity extends BaseEntity {
private Integer eventID;
private String sequence;
private String ihslrOrImoShipNo;
private String type;
private Integer quantity;
private String unitShort;
private String unit;
private String text;
private String cargoDamage;
private String dangerous;
}

파일 보기

@ -0,0 +1,67 @@
package com.snp.batch.jobs.event.batch.entity;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer;
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.LocalDateTime;
import java.util.List;
@Data
@SuperBuilder
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode(callSuper = true)
public class EventDetailEntity extends BaseEntity {
private Integer incidentID;
private Long eventID;
private Integer eventTypeID;
private String eventType;
private String significance;
private String headline;
private String ihslrOrImoShipNo;
private String vesselName;
private String vesselType;
private String vesselTypeDecode;
private String vesselFlagCode;
private String vesselFlagDecode;
private String cargoLoadingStatusCode;
private Integer vesselDWT;
private Integer vesselGT;
private Integer ldtAtTime;
private Integer dateOfBuild;
private String registeredOwnerCodeAtTime;
private String registeredOwnerAtTime;
private String registeredOwnerCountryCodeAtTime;
private String registeredOwnerCountryAtTime;
private String weather;
private String eventTypeDetail;
private Integer eventTypeDetailID;
private String casualtyAction;
private String locationName;
private String townName;
private Integer marsdenGridReference;
private String environmentLocation;
private String casualtyZone;
private String casualtyZoneCode;
private String countryCode;
private String attemptedBoarding;
private String description;
private String pollutant;
private String pollutantUnit;
private Double pollutantQuantity;
private String publishedDate;
private String component2;
private String firedUpon;
private String eventStartDate;
private String eventEndDate;
private List<CargoEntity> cargoes;
private List<HumanCasualtyEntity> humanCasualties;
private List<RelationshipEntity> relationships;
}

파일 보기

@ -0,0 +1,30 @@
package com.snp.batch.jobs.event.batch.entity;
import com.snp.batch.common.batch.entity.BaseEntity;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;
@Data
@SuperBuilder
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode(callSuper = true)
public class EventEntity extends BaseEntity {
private Long incidentId;
private Long eventId;
private String startDate;
private String eventType;
private String significance;
private String headline;
private String endDate;
private String ihslRorImoShipNo;
private String vesselName;
private String vesselType;
private String locationName;
private String publishedDate;
}

파일 보기

@ -0,0 +1,21 @@
package com.snp.batch.jobs.event.batch.entity;
import com.snp.batch.common.batch.entity.BaseEntity;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;
@Data
@SuperBuilder
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode(callSuper = true)
public class HumanCasualtyEntity extends BaseEntity {
private Integer eventID;
private String scope;
private String type;
private String qualifier;
private Integer count;
}

파일 보기

@ -0,0 +1,23 @@
package com.snp.batch.jobs.event.batch.entity;
import com.snp.batch.common.batch.entity.BaseEntity;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;
@Data
@SuperBuilder
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode(callSuper = true)
public class RelationshipEntity extends BaseEntity {
private String incidentID;
private Integer eventID;
private String relationshipType;
private String relationshipTypeCode;
private Integer eventID2;
private String eventType;
private String eventTypeCode;
}

파일 보기

@ -0,0 +1,76 @@
package com.snp.batch.jobs.event.batch.processor;
import com.snp.batch.common.batch.processor.BaseProcessor;
import com.snp.batch.jobs.event.batch.dto.CargoDto;
import com.snp.batch.jobs.event.batch.dto.EventDetailDto;
import com.snp.batch.jobs.event.batch.dto.HumanCasualtyDto;
import com.snp.batch.jobs.event.batch.dto.RelationshipDto;
import com.snp.batch.jobs.event.batch.entity.EventDetailEntity;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.stream.Collectors;
@Slf4j
@Component
public class EventDataProcessor extends BaseProcessor<EventDetailDto, EventDetailEntity> {
@Override
protected EventDetailEntity processItem(EventDetailDto dto) throws Exception {
log.debug("Event 데이터 처리 시작: Event ID = {}", dto.getEventID());
EventDetailEntity entity = EventDetailEntity.builder()
.eventID(dto.getEventID())
.incidentID(dto.getIncidentID())
.eventTypeID(dto.getEventTypeID())
.eventType(dto.getEventType())
.significance(dto.getSignificance())
.headline(dto.getHeadline())
.ihslrOrImoShipNo(dto.getIhslrOrImoShipNo())
.vesselName(dto.getVesselName())
.vesselType(dto.getVesselType())
.vesselTypeDecode(dto.getVesselTypeDecode())
.vesselFlagCode(dto.getVesselFlagCode())
.vesselFlagDecode(dto.getVesselFlagDecode())
.cargoLoadingStatusCode(dto.getCargoLoadingStatusCode())
.vesselDWT(dto.getVesselDWT())
.vesselGT(dto.getVesselGT())
.ldtAtTime(dto.getLdtAtTime())
.dateOfBuild(dto.getDateOfBuild())
.registeredOwnerCodeAtTime(dto.getRegisteredOwnerCodeAtTime())
.registeredOwnerAtTime(dto.getRegisteredOwnerAtTime())
.registeredOwnerCountryCodeAtTime(dto.getRegisteredOwnerCountryCodeAtTime())
.registeredOwnerCountryAtTime(dto.getRegisteredOwnerCountryAtTime())
.weather(dto.getWeather())
.eventTypeDetail(dto.getEventTypeDetail())
.eventTypeDetailID(dto.getEventTypeDetailID())
.casualtyAction(dto.getCasualtyAction())
.locationName(dto.getLocationName())
.townName(dto.getTownName())
.marsdenGridReference(dto.getMarsdenGridReference())
.environmentLocation(dto.getEnvironmentLocation())
.casualtyZone(dto.getCasualtyZone())
.casualtyZoneCode(dto.getCasualtyZoneCode())
.countryCode(dto.getCountryCode())
.attemptedBoarding(dto.getAttemptedBoarding())
.description(dto.getDescription())
.pollutant(dto.getPollutant())
.pollutantUnit(dto.getPollutantUnit())
.pollutantQuantity(dto.getPollutantQuantity())
.publishedDate(dto.getPublishedDate())
.component2(dto.getComponent2())
.firedUpon(dto.getFiredUpon())
.eventStartDate(dto.getEventStartDate())
.eventEndDate(dto.getEventEndDate())
.cargoes(dto.getCargoes() != null ?
dto.getCargoes().stream().map(CargoDto::toEntity).collect(Collectors.toList()) : null)
.humanCasualties(dto.getHumanCasualties() != null ?
dto.getHumanCasualties().stream().map(HumanCasualtyDto::toEntity).collect(Collectors.toList()) : null)
.relationships(dto.getRelationships() != null ?
dto.getRelationships().stream().map(RelationshipDto::toEntity).collect(Collectors.toList()) : null)
.build();
log.debug("Event 데이터 처리 완료: Event ID = {}", dto.getEventID());
return entity;
}
}

파일 보기

@ -0,0 +1,239 @@
package com.snp.batch.jobs.event.batch.reader;
import com.snp.batch.common.batch.reader.BaseApiReader;
import com.snp.batch.jobs.event.batch.dto.*;
import com.snp.batch.service.BatchApiLogService;
import com.snp.batch.service.BatchDateService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpStatusCode;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
import java.time.LocalDateTime;
import java.util.*;
import java.util.stream.Collectors;
@Slf4j
public class EventDataReader extends BaseApiReader<EventDetailDto> {
private final BatchDateService batchDateService; // BatchDateService 필드 추가
private final BatchApiLogService batchApiLogService;
private final String maritimeApiUrl;
private Map<Long, EventPeriod> eventPeriodMap;
private final JdbcTemplate jdbcTemplate;
public EventDataReader(WebClient webClient, JdbcTemplate jdbcTemplate, BatchDateService batchDateService, BatchApiLogService batchApiLogService, String maritimeApiUrl) {
super(webClient);
this.jdbcTemplate = jdbcTemplate;
this.batchDateService = batchDateService;
this.batchApiLogService = batchApiLogService;
this.maritimeApiUrl = maritimeApiUrl;
enableChunkMode(); // Chunk 모드 활성화
}
@Override
protected String getReaderName() {
return "EventDataReader";
}
@Override
protected String getApiPath() {
return "/MaritimeWCF/MaritimeAndTradeEventsService.svc/RESTFul/GetEventListByEventChangeDateRange";
}
protected String getEventDetailApiPath() {
return "/MaritimeWCF/MaritimeAndTradeEventsService.svc/RESTFul/GetEventDataByEventID";
}
protected String getApiKey() {
return "EVENT_IMPORT_API";
}
// 배치 처리 상태
private List<Long> eventIds;
// DB 해시값을 저장할
private int currentBatchIndex = 0;
private final int batchSize = 1;
@Override
protected void resetCustomState() {
this.currentBatchIndex = 0;
this.eventIds = null;
this.eventPeriodMap = new HashMap<>();
}
@Override
protected void beforeFetch() {
// 1. 기간내 기록된 Event List 조회 (API 요청)
EventResponse response = callEventApiWithBatch();
// 2-1. Event List 에서 EventID List 추출
// 2-2. Event List 에서 Map<EventId,Map<StartDate,EndDate>> 추출
eventIds = extractEventIdList(response);
log.info("EvnetId List 추출 완료 : {} 개", eventIds.size());
eventPeriodMap = response.getMaritimeEvents().stream()
.filter(e -> e.getEventId() != null)
.collect(Collectors.toMap(
EventDto::getEventId,
e -> new EventPeriod(
e.getStartDate(),
e.getEndDate()
)
));
updateApiCallStats(eventIds.size(), 0);
}
@Override
protected List<EventDetailDto> fetchNextBatch() throws Exception {
// 3. EventID List Event Detail 조회 (API요청) : 청크단위 실행
// 모든 배치 처리 완료 확인
if (eventIds == null || currentBatchIndex >= eventIds.size()) {
return null; // Job 종료
}
// 현재 배치의 시작/ 인덱스 계산
int startIndex = currentBatchIndex;
int endIndex = Math.min(currentBatchIndex + batchSize, eventIds.size());
// 현재 배치의 IMO 번호 추출 (100개)
List<Long> currentBatch = eventIds.subList(startIndex, endIndex);
int currentBatchNumber = (currentBatchIndex / batchSize) + 1;
int totalBatches = (int) Math.ceil((double) eventIds.size() / batchSize);
try {
// API 호출
EventDetailResponse response = callEventDetailApiWithBatch(currentBatch.get(0));
// 다음 배치로 인덱스 이동
currentBatchIndex = endIndex;
List<EventDetailDto> eventDetailList = new ArrayList<>();
// 응답 처리
if (response != null && response.getEventDetailDto() != null) {
// TODO: getEventDetailDto에 Map<EventId,Map<StartDate,EndDate>> 데이터 세팅
EventDetailDto detailDto = response.getEventDetailDto();
Long eventId = detailDto.getEventID();
EventPeriod period = eventPeriodMap.get(eventId);
if (period != null) {
detailDto.setEventStartDate(period.getEventStartDate());
detailDto.setEventEndDate(period.getEventEndDate());
}
eventDetailList.add(response.getEventDetailDto());
log.info("[{}] 배치 {}/{} 완료: {} 건 조회",
getReaderName(), currentBatchNumber, totalBatches, eventDetailList.size());
// API 호출 통계 업데이트
updateApiCallStats(totalBatches, currentBatchNumber);
// API 과부하 방지 (다음 배치 1.0초 대기)
if (currentBatchIndex < eventIds.size()) {
Thread.sleep(1000);
}
return eventDetailList;
} else {
log.warn("[{}] 배치 {}/{} 응답 없음",
getReaderName(), currentBatchNumber, totalBatches);
// API 호출 통계 업데이트 (실패도 카운트)
updateApiCallStats(totalBatches, currentBatchNumber);
return Collections.emptyList();
}
} catch (Exception e) {
log.error("[{}] 배치 {}/{} 처리 중 오류: {}",
getReaderName(), currentBatchNumber, totalBatches, e.getMessage(), e);
// 오류 발생 시에도 다음 배치로 이동 (부분 실패 허용)
currentBatchIndex = endIndex;
// 리스트 반환 (Job 계속 진행)
return Collections.emptyList();
}
}
@Override
protected void afterFetch(List<EventDetailDto> data) {
int totalBatches = (int) Math.ceil((double) eventIds.size() / batchSize);
try {
if (data == null) {
log.info("[{}] 전체 {} 개 배치 처리 완료", getReaderName(), totalBatches);
log.info("[{}] 총 {} 개의 Event ID에 대한 API 호출 종료",
getReaderName(), eventIds.size());
}
} catch (Exception e) {
log.info("[{}] 전체 {} 개 배치 처리 실패", getReaderName(), totalBatches);
log.info("[{}] 총 {} 개의 Event ID에 대한 API 호출 종료",
getReaderName(), eventIds.size());
}
}
private List<Long> extractEventIdList(EventResponse response) {
if (response.getMaritimeEvents() == null) {
return Collections.emptyList();
}
return response.getMaritimeEvents().stream()
// ShipDto 객체에서 imoNumber 필드 (String 타입) 추출
.map(EventDto::getEventId)
// IMO 번호가 null이 아닌 경우만 필터링 (선택 사항이지만 안전성을 위해)
.filter(eventId -> eventId != null)
// 추출된 String imoNumber들을 List<String>으로 수집
.collect(Collectors.toList());
}
private EventResponse callEventApiWithBatch() {
Map<String, String> params = batchDateService.getDateRangeWithoutTimeParams(getApiKey());
return executeSingleApiCall(
maritimeApiUrl,
getApiPath(),
params,
new ParameterizedTypeReference<EventResponse>() {},
batchApiLogService,
res -> res.getMaritimeEvents() != null ? (long) res.getMaritimeEvents().size() : 0L // 람다 적용
);
}
private EventDetailResponse callEventDetailApiWithBatch(Long eventId) {
String url = getEventDetailApiPath();
return webClient.get()
.uri(url, uriBuilder -> uriBuilder
// 맵에서 파라미터 값을 동적으로 가져와 세팅
.queryParam("eventID", eventId)
.build())
.retrieve()
.onStatus(HttpStatusCode::isError, clientResponse ->
clientResponse.bodyToMono(String.class) // 에러 바디를 문자열로 읽음
.flatMap(errorBody -> {
// 2. 로그에 상태 코드와 에러 메세지 출력
log.error("[{}] API 호출 오류 발생!", getReaderName());
log.error("[{}] ERROR CODE: {}, REASON: {}",
getReaderName(),
clientResponse.statusCode(),
errorBody);
// 3. 상위로 예외 던지기 (배치 중단을 원할 경우)
return Mono.error(new RuntimeException(
String.format("API 호출 실패 (%s): %s", clientResponse.statusCode(), errorBody)
));
})
)
.bodyToMono(EventDetailResponse.class)
.block();
}
private LocalDateTime parseToLocalDate(String value) {
if (value == null || value.isBlank()) {
return null;
}
return LocalDateTime.parse(value);
}
}

파일 보기

@ -0,0 +1,15 @@
package com.snp.batch.jobs.event.batch.repository;
import com.snp.batch.jobs.event.batch.entity.CargoEntity;
import com.snp.batch.jobs.event.batch.entity.EventDetailEntity;
import com.snp.batch.jobs.event.batch.entity.HumanCasualtyEntity;
import com.snp.batch.jobs.event.batch.entity.RelationshipEntity;
import java.util.List;
public interface EventRepository {
void saveEventAll(List<EventDetailEntity> items);
void saveCargoAll(List<CargoEntity> items);
void saveHumanCasualtyAll(List<HumanCasualtyEntity> items);
void saveRelationshipAll(List<RelationshipEntity> items);
}

파일 보기

@ -0,0 +1,236 @@
package com.snp.batch.jobs.event.batch.repository;
import com.snp.batch.common.batch.repository.BaseJdbcRepository;
import com.snp.batch.jobs.event.batch.entity.CargoEntity;
import com.snp.batch.jobs.event.batch.entity.EventDetailEntity;
import com.snp.batch.jobs.event.batch.entity.HumanCasualtyEntity;
import com.snp.batch.jobs.event.batch.entity.RelationshipEntity;
import com.snp.batch.jobs.shipdetail.batch.entity.GroupBeneficialOwnerHistoryEntity;
import com.snp.batch.jobs.shipdetail.batch.repository.ShipDetailSql;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Repository;
import java.sql.PreparedStatement;
import java.sql.Types;
import java.util.List;
@Slf4j
@Repository("EventRepository")
public class EventRepositoryImpl extends BaseJdbcRepository<EventDetailEntity, Long> implements EventRepository {
public EventRepositoryImpl(JdbcTemplate jdbcTemplate) {
super(jdbcTemplate);
}
@Override
protected String getTableName() {
return null;
}
@Override
protected RowMapper<EventDetailEntity> getRowMapper() {
return null;
}
@Override
protected Long extractId(EventDetailEntity entity) {
return null;
}
@Override
protected String getInsertSql() {
return null;
}
@Override
protected String getUpdateSql() {
return null;
}
@Override
protected void setInsertParameters(PreparedStatement ps, EventDetailEntity entity) throws Exception {
}
@Override
protected String getEntityName() {
return "EventDetailEntity";
}
@Override
public void saveEventAll(List<EventDetailEntity> items) {
String entityName = "EventDetailEntity";
String sql = EventSql.getEventDetailUpdateSql();
jdbcTemplate.batchUpdate(sql, items, items.size(),
(ps, entity) -> {
try {
setUpdateParameters(ps, (EventDetailEntity) entity);
} catch (Exception e) {
log.error("배치 수정 파라미터 설정 실패", e);
throw new RuntimeException(e);
}
});
log.info("{} 배치 삽입 완료: {} 건", entityName, items.size());
}
@Override
public void saveCargoAll(List<CargoEntity> items) {
String entityName = "CargoEntity";
String sql = EventSql.getEventCargoSql();
jdbcTemplate.batchUpdate(sql, items, items.size(),
(ps, entity) -> {
try {
setCargoInsertParameters(ps, (CargoEntity) entity);
} catch (Exception e) {
log.error("배치 삽입 파라미터 설정 실패 - " + entityName, e);
throw new RuntimeException(e);
}
});
log.info("{} 배치 삽입 완료: {} 건", entityName, items.size());
}
@Override
public void saveHumanCasualtyAll(List<HumanCasualtyEntity> items) {
String entityName = "HumanCasualtyEntity";
String sql = EventSql.getEventHumanCasualtySql();
jdbcTemplate.batchUpdate(sql, items, items.size(),
(ps, entity) -> {
try {
setHumanCasualtyInsertParameters(ps, (HumanCasualtyEntity) entity);
} catch (Exception e) {
log.error("배치 삽입 파라미터 설정 실패 - " + entityName, e);
throw new RuntimeException(e);
}
});
log.info("{} 배치 삽입 완료: {} 건", entityName, items.size());
}
@Override
public void saveRelationshipAll(List<RelationshipEntity> items) {
String entityName = "RelationshipEntity";
String sql = EventSql.getEventRelationshipSql();
jdbcTemplate.batchUpdate(sql, items, items.size(),
(ps, entity) -> {
try {
setRelationshipInsertParameters(ps, (RelationshipEntity) entity);
} catch (Exception e) {
log.error("배치 삽입 파라미터 설정 실패 - " + entityName, e);
throw new RuntimeException(e);
}
});
log.info("{} 배치 삽입 완료: {} 건", entityName, items.size());
}
@Override
protected void setUpdateParameters(PreparedStatement ps, EventDetailEntity entity) throws Exception {
int idx = 1;
ps.setObject(idx++, entity.getEventID()); // event_id
ps.setObject(idx++, entity.getIncidentID()); // incident_id (누락됨)
ps.setObject(idx++, entity.getIhslrOrImoShipNo()); // ihslrorimoshipno (누락됨)
ps.setObject(idx++, entity.getPublishedDate()); // published_date (누락됨)
ps.setObject(idx++, entity.getEventStartDate()); // event_start_date
ps.setObject(idx++, entity.getEventEndDate()); // event_end_date
ps.setString(idx++, entity.getAttemptedBoarding()); // attempted_boarding
ps.setString(idx++, entity.getCargoLoadingStatusCode());// cargo_loading_status_code
ps.setString(idx++, entity.getCasualtyAction()); // casualty_action
ps.setString(idx++, entity.getCasualtyZone()); // casualty_zone
// 11~20
ps.setString(idx++, entity.getCasualtyZoneCode()); // casualty_zone_code
ps.setString(idx++, entity.getComponent2()); // component2
ps.setString(idx++, entity.getCountryCode()); // country_code
ps.setObject(idx++, entity.getDateOfBuild()); // date_of_build (Integer)
ps.setString(idx++, entity.getDescription()); // description
ps.setString(idx++, entity.getEnvironmentLocation()); // environment_location
ps.setString(idx++, entity.getLocationName()); // location_name (누락됨)
ps.setObject(idx++, entity.getMarsdenGridReference()); // marsden_grid_reference (Integer)
ps.setString(idx++, entity.getTownName()); // town_name
ps.setString(idx++, entity.getEventType()); // event_type (누락됨)
// 21~30
ps.setString(idx++, entity.getEventTypeDetail()); // event_type_detail
ps.setObject(idx++, entity.getEventTypeDetailID()); // event_type_detail_id (Integer)
ps.setObject(idx++, entity.getEventTypeID()); // event_type_id (Integer)
ps.setString(idx++, entity.getFiredUpon()); // fired_upon
ps.setString(idx++, entity.getHeadline()); // headline (누락됨)
ps.setObject(idx++, entity.getLdtAtTime()); // ldt_at_time (Integer)
ps.setString(idx++, entity.getSignificance()); // significance (누락됨)
ps.setString(idx++, entity.getWeather()); // weather
ps.setString(idx++, entity.getPollutant()); // pollutant
ps.setObject(idx++, entity.getPollutantQuantity()); // pollutant_quantity (Double)
// 31~42
ps.setString(idx++, entity.getPollutantUnit()); // pollutant_unit
ps.setString(idx++, entity.getRegisteredOwnerCodeAtTime()); // registered_owner_code_at_time
ps.setString(idx++, entity.getRegisteredOwnerAtTime()); // registered_owner_at_time
ps.setString(idx++, entity.getRegisteredOwnerCountryCodeAtTime()); // registered_owner_country_code_at_time
ps.setString(idx++, entity.getRegisteredOwnerCountryAtTime()); // registered_owner_country_at_time
ps.setObject(idx++, entity.getVesselDWT()); // vessel_dwt (Integer)
ps.setString(idx++, entity.getVesselFlagCode()); // vessel_flag_code
ps.setString(idx++, entity.getVesselFlagDecode()); // vessel_flag_decode (누락됨)
ps.setObject(idx++, entity.getVesselGT()); // vessel_gt (Integer)
ps.setString(idx++, entity.getVesselName()); // vessel_name (누락됨)
ps.setString(idx++, entity.getVesselType()); // vessel_type (누락됨)
ps.setString(idx++, entity.getVesselTypeDecode()); // vessel_type_decode
}
private void setCargoInsertParameters(PreparedStatement ps, CargoEntity entity)throws Exception{
int idx = 1;
// INSERT 필드
ps.setObject(idx++, entity.getEventID());
ps.setString(idx++, entity.getSequence());
ps.setString(idx++, entity.getIhslrOrImoShipNo());
ps.setString(idx++, entity.getType());
ps.setObject(idx++, entity.getQuantity()); // quantity 필드 (Entity에 없을 경우 null 처리)
ps.setString(idx++, entity.getUnitShort()); // unit_short 필드
ps.setString(idx++, entity.getUnit());
ps.setString(idx++, entity.getCargoDamage());
ps.setString(idx++, entity.getDangerous());
ps.setString(idx++, entity.getText());
}
private void setHumanCasualtyInsertParameters(PreparedStatement ps, HumanCasualtyEntity entity)throws Exception{
int idx = 1;
ps.setObject(idx++, entity.getEventID());
ps.setString(idx++, entity.getScope());
ps.setString(idx++, entity.getType());
ps.setString(idx++, entity.getQualifier());
ps.setObject(idx++, entity.getCount());
}
private void setRelationshipInsertParameters(PreparedStatement ps, RelationshipEntity entity)throws Exception{
int idx = 1;
ps.setString(idx++, entity.getIncidentID());
ps.setObject(idx++, entity.getEventID());
ps.setString(idx++, entity.getRelationshipType());
ps.setString(idx++, entity.getRelationshipTypeCode());
ps.setObject(idx++, entity.getEventID2());
ps.setString(idx++, entity.getEventType());
ps.setString(idx++, entity.getEventTypeCode());
}
private static void setStringOrNull(PreparedStatement ps, int index, String value) throws Exception {
if (value == null) {
ps.setNull(index, Types.VARCHAR);
} else {
ps.setString(index, value);
}
}
/**
* Double 값을 PreparedStatement에 설정 (null 처리 포함)
*/
private static void setDoubleOrNull(PreparedStatement ps, int index, Double value) throws Exception {
if (value == null) {
ps.setNull(index, Types.DOUBLE);
} else {
ps.setDouble(index, value);
}
}
}

파일 보기

@ -0,0 +1,122 @@
package com.snp.batch.jobs.event.batch.repository;
public class EventSql {
public static String getEventDetailUpdateSql(){
return """
INSERT INTO new_snp.event (
event_id, incident_id, ihslrorimoshipno, published_date, event_start_date, event_end_date,
attempted_boarding, cargo_loading_status_code, casualty_action,
casualty_zone, casualty_zone_code, component2, country_code,
date_of_build, description, environment_location, location_name,
marsden_grid_reference, town_name, event_type, event_type_detail,
event_type_detail_id, event_type_id, fired_upon, headline,
ldt_at_time, significance, weather, pollutant, pollutant_quantity,
pollutant_unit, registered_owner_code_at_time, registered_owner_at_time,
registered_owner_country_code_at_time, registered_owner_country_at_time,
vessel_dwt, vessel_flag_code, vessel_flag_decode, vessel_gt,
vessel_name, vessel_type, vessel_type_decode
)
VALUES (
?, ?, ?, ?::timestamptz,?::timestamptz,?::timestamptz, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?, ?, ?, ?
)
ON CONFLICT (event_id)
DO UPDATE SET
incident_id = EXCLUDED.incident_id,
ihslrorimoshipno = EXCLUDED.ihslrorimoshipno,
published_date = EXCLUDED.published_date,
event_start_date = EXCLUDED.event_start_date,
event_end_date = EXCLUDED.event_end_date,
attempted_boarding = EXCLUDED.attempted_boarding,
cargo_loading_status_code = EXCLUDED.cargo_loading_status_code,
casualty_action = EXCLUDED.casualty_action,
casualty_zone = EXCLUDED.casualty_zone,
casualty_zone_code = EXCLUDED.casualty_zone_code,
component2 = EXCLUDED.component2,
country_code = EXCLUDED.country_code,
date_of_build = EXCLUDED.date_of_build,
description = EXCLUDED.description,
environment_location = EXCLUDED.environment_location,
location_name = EXCLUDED.location_name,
marsden_grid_reference = EXCLUDED.marsden_grid_reference,
town_name = EXCLUDED.town_name,
event_type = EXCLUDED.event_type,
event_type_detail = EXCLUDED.event_type_detail,
event_type_detail_id = EXCLUDED.event_type_detail_id,
event_type_id = EXCLUDED.event_type_id,
fired_upon = EXCLUDED.fired_upon,
headline = EXCLUDED.headline,
ldt_at_time = EXCLUDED.ldt_at_time,
significance = EXCLUDED.significance,
weather = EXCLUDED.weather,
pollutant = EXCLUDED.pollutant,
pollutant_quantity = EXCLUDED.pollutant_quantity,
pollutant_unit = EXCLUDED.pollutant_unit,
registered_owner_code_at_time = EXCLUDED.registered_owner_code_at_time,
registered_owner_at_time = EXCLUDED.registered_owner_at_time,
registered_owner_country_code_at_time = EXCLUDED.registered_owner_country_code_at_time,
registered_owner_country_at_time = EXCLUDED.registered_owner_country_at_time,
vessel_dwt = EXCLUDED.vessel_dwt,
vessel_flag_code = EXCLUDED.vessel_flag_code,
vessel_flag_decode = EXCLUDED.vessel_flag_decode,
vessel_gt = EXCLUDED.vessel_gt,
vessel_name = EXCLUDED.vessel_name,
vessel_type = EXCLUDED.vessel_type,
vessel_type_decode = EXCLUDED.vessel_type_decode
""";
}
public static String getEventCargoSql(){
return """
INSERT INTO new_snp.event_cargo (
event_id, "sequence", ihslrorimoshipno, "type", quantity,
unit_short, unit, cargo_damage, dangerous, "text"
)
VALUES (
?, ?, ?, ?, ?,
?, ?, ?, ?, ?
)
ON CONFLICT (event_id, ihslrorimoshipno, "type", "sequence")
DO UPDATE SET
quantity = EXCLUDED.quantity,
unit_short = EXCLUDED.unit_short,
unit = EXCLUDED.unit,
cargo_damage = EXCLUDED.cargo_damage,
dangerous = EXCLUDED.dangerous,
"text" = EXCLUDED."text"
""";
}
public static String getEventRelationshipSql(){
return """
INSERT INTO new_snp.event_relationship (
incident_id, event_id, relationship_type, relationship_type_code,
event_id_2, event_type, event_type_code
)
VALUES (
?, ?, ?, ?,
?, ?, ?
)
ON CONFLICT (incident_id, event_id, event_id_2, event_type_code, relationship_type_code)
DO UPDATE SET
relationship_type = EXCLUDED.relationship_type,
event_type = EXCLUDED.event_type
""";
}
public static String getEventHumanCasualtySql(){
return """
INSERT INTO new_snp.event_humancasualty (
event_id, "scope", "type", qualifier, "count"
)
VALUES (
?, ?, ?, ?, ?
)
ON CONFLICT (event_id, "scope", "type", qualifier)
DO UPDATE SET
"count" = EXCLUDED."count"
""";
}
}

파일 보기

@ -0,0 +1,48 @@
package com.snp.batch.jobs.event.batch.writer;
import com.snp.batch.common.batch.writer.BaseWriter;
import com.snp.batch.jobs.event.batch.entity.EventDetailEntity;
import com.snp.batch.jobs.event.batch.repository.EventRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
import java.util.List;
@Slf4j
@Component
public class EventDataWriter extends BaseWriter<EventDetailEntity> {
private final EventRepository eventRepository;
public EventDataWriter(EventRepository eventRepository) {
super("EventRepository");
this.eventRepository = eventRepository;
}
@Override
protected void writeItems(List<EventDetailEntity> items) throws Exception {
if (CollectionUtils.isEmpty(items)) {
return;
}
// 1. EventDetail 메인 데이터 저장
eventRepository.saveEventAll(items);
for (EventDetailEntity event : items) {
// 2. CargoEntityList Save
if (!CollectionUtils.isEmpty(event.getCargoes())) {
eventRepository.saveCargoAll(event.getCargoes());
}
// 3. HumanCasualtyEntityList Save
if (!CollectionUtils.isEmpty(event.getHumanCasualties())) {
eventRepository.saveHumanCasualtyAll(event.getHumanCasualties());
}
// 4. RelationshipEntityList Save
if (!CollectionUtils.isEmpty(event.getRelationships())) {
eventRepository.saveRelationshipAll(event.getRelationships());
}
}
log.info("Batch Write 완료: {} 건의 Event 처리됨", items.size());
}
}

Some files were not shown because too many files have changed in this diff Show More