release: 2026-03-27.3 (7건 커밋) #121

병합
htlee develop 에서 main 로 7 commits 를 머지했습니다 2026-03-27 08:14:57 +09:00
10개의 변경된 파일85개의 추가작업 그리고 47개의 파일을 삭제

파일 보기

@ -4,6 +4,14 @@
## [Unreleased] ## [Unreleased]
## [2026-03-27.3]
### 추가
- 비정상 궤적 포함 저장 플래그 (`include-abnormal-in-tracks`) — 강화학습 데이터 수집용
### 수정
- REST API 경로 client_id 수집 누락 수정 — JWT 쿠키 파싱 공용 메서드 추출
## [2026-03-27.2] ## [2026-03-27.2]
### 수정 ### 수정

파일 보기

@ -62,6 +62,9 @@ public class DailyAggregationStepConfig {
@Value("${vessel.batch.chunk-size:5000}") @Value("${vessel.batch.chunk-size:5000}")
private int chunkSize; private int chunkSize;
@Value("${vessel.batch.track.include-abnormal-in-tracks:false}")
private boolean includeAbnormalInTracks;
@Bean @Bean
public Step mergeDailyTracksStep() { public Step mergeDailyTracksStep() {
log.info("Building mergeDailyTracksStep with cache-based in-memory merge"); log.info("Building mergeDailyTracksStep with cache-based in-memory merge");
@ -110,7 +113,9 @@ public class DailyAggregationStepConfig {
return new CompositeTrackWriter( return new CompositeTrackWriter(
vesselTrackBulkWriter, vesselTrackBulkWriter,
abnormalTrackWriter, abnormalTrackWriter,
"daily" "daily",
null,
includeAbnormalInTracks
); );
} }

파일 보기

@ -69,6 +69,9 @@ public class HourlyAggregationStepConfig {
@Value("${vessel.batch.chunk-size:5000}") @Value("${vessel.batch.chunk-size:5000}")
private int chunkSize; private int chunkSize;
@Value("${vessel.batch.track.include-abnormal-in-tracks:false}")
private boolean includeAbnormalInTracks;
// //
// Step 1: 5분 시간 병합 (인메모리 캐시 기반) // Step 1: 5분 시간 병합 (인메모리 캐시 기반)
// //
@ -122,7 +125,8 @@ public class HourlyAggregationStepConfig {
vesselTrackBulkWriter, vesselTrackBulkWriter,
abnormalTrackWriter, abnormalTrackWriter,
"hourly", "hourly",
hourlyTrackCache hourlyTrackCache,
includeAbnormalInTracks
); );
} }

파일 보기

@ -96,6 +96,9 @@ public class VesselTrackStepConfig {
@Value("${vessel.batch.chunk-size:1000}") @Value("${vessel.batch.chunk-size:1000}")
private int chunkSize; private int chunkSize;
@Value("${vessel.batch.track.include-abnormal-in-tracks:false}")
private boolean includeAbnormalInTracks;
@PostConstruct @PostConstruct
public void init() { public void init() {
// 5분 Job의 이름을 명시적으로 설정 // 5분 Job의 이름을 명시적으로 설정
@ -203,11 +206,15 @@ public class VesselTrackStepConfig {
log.warn("비정상 궤적 감지 [{}]: vessel={}, avg_speed={}, distance={}", log.warn("비정상 궤적 감지 [{}]: vessel={}, avg_speed={}, distance={}",
abnormalReason, track.getVesselKey(), track.getAvgSpeed(), track.getDistanceNm()); abnormalReason, track.getVesselKey(), track.getAvgSpeed(), track.getDistanceNm());
saveAbnormalTrack(track, abnormalReason); saveAbnormalTrack(track, abnormalReason);
if (includeAbnormalInTracks) {
filteredTracks.add(track); // 플래그 true 정상 테이블+캐시에도 포함
}
} else { } else {
filteredTracks.add(track); filteredTracks.add(track);
}
// 정상 궤적의 종료 위치 저장 (캐시 업데이트용) // 궤적의 종료 위치 저장 (캐시 업데이트용) 비정상 포함 시에도 위치 추적
if (track.getEndPosition() != null) { if (filteredTracks.contains(track) && track.getEndPosition() != null) {
currentBucketEndPositions.put(track.getMmsi(), VesselBucketPositionDto.builder() currentBucketEndPositions.put(track.getMmsi(), VesselBucketPositionDto.builder()
.mmsi(track.getMmsi()) .mmsi(track.getMmsi())
.endLon(track.getEndPosition().getLon()) .endLon(track.getEndPosition().getLon())
@ -216,7 +223,6 @@ public class VesselTrackStepConfig {
.build()); .build());
} }
} }
}
return filteredTracks.isEmpty() ? null : filteredTracks; return filteredTracks.isEmpty() ? null : filteredTracks;
}; };

파일 보기

@ -25,21 +25,24 @@ public class CompositeTrackWriter implements ItemWriter<AbnormalDetectionResult>
private final AbnormalTrackWriter abnormalTrackWriter; private final AbnormalTrackWriter abnormalTrackWriter;
private final String targetTable; private final String targetTable;
private final HourlyTrackCache hourlyTrackCache; // nullable (daily writer는 미사용) private final HourlyTrackCache hourlyTrackCache; // nullable (daily writer는 미사용)
private final boolean includeAbnormalInTracks;
public CompositeTrackWriter(VesselTrackBulkWriter vesselTrackBulkWriter, public CompositeTrackWriter(VesselTrackBulkWriter vesselTrackBulkWriter,
AbnormalTrackWriter abnormalTrackWriter, AbnormalTrackWriter abnormalTrackWriter,
String targetTable, String targetTable,
HourlyTrackCache hourlyTrackCache) { HourlyTrackCache hourlyTrackCache,
boolean includeAbnormalInTracks) {
this.vesselTrackBulkWriter = vesselTrackBulkWriter; this.vesselTrackBulkWriter = vesselTrackBulkWriter;
this.abnormalTrackWriter = abnormalTrackWriter; this.abnormalTrackWriter = abnormalTrackWriter;
this.targetTable = targetTable; this.targetTable = targetTable;
this.hourlyTrackCache = hourlyTrackCache; this.hourlyTrackCache = hourlyTrackCache;
this.includeAbnormalInTracks = includeAbnormalInTracks;
} }
public CompositeTrackWriter(VesselTrackBulkWriter vesselTrackBulkWriter, public CompositeTrackWriter(VesselTrackBulkWriter vesselTrackBulkWriter,
AbnormalTrackWriter abnormalTrackWriter, AbnormalTrackWriter abnormalTrackWriter,
String targetTable) { String targetTable) {
this(vesselTrackBulkWriter, abnormalTrackWriter, targetTable, null); this(vesselTrackBulkWriter, abnormalTrackWriter, targetTable, null, false);
} }
@BeforeStep @BeforeStep
@ -66,9 +69,11 @@ public class CompositeTrackWriter implements ItemWriter<AbnormalDetectionResult>
abnormalResults.add(result); abnormalResults.add(result);
// 정정된 궤적이 있으면 정상 궤적으로 저장 // 정정된 궤적이 있으면 정상 궤적으로 저장
// null이면 전체 궤적이 비정상이므로 제외 // null이면 전체 궤적이 비정상이므로 제외 (플래그 true면 원본 포함)
if (result.getCorrectedTrack() != null) { if (result.getCorrectedTrack() != null) {
normalTracks.add(result.getCorrectedTrack()); normalTracks.add(result.getCorrectedTrack());
} else if (includeAbnormalInTracks) {
normalTracks.add(result.getOriginalTrack());
} else { } else {
log.debug("비정상 궤적 전체 제외: vessel={}", log.debug("비정상 궤적 전체 제외: vessel={}",
result.getOriginalTrack().getVesselKey()); result.getOriginalTrack().getVesselKey());

파일 보기

@ -191,7 +191,11 @@ public class GisControllerV2 {
) )
@RequestBody VesselTracksRequest request, @RequestBody VesselTracksRequest request,
HttpServletRequest httpRequest) { HttpServletRequest httpRequest) {
return gisServiceV2.getVesselTracksV2(request, getClientIp(httpRequest)); return gisServiceV2.getVesselTracksV2(request, getClientIp(httpRequest), getClientId(httpRequest));
}
private String getClientId(HttpServletRequest request) {
return gc.mda.signal_batch.global.config.WebSocketStompConfig.extractClientIdFromRequest(request);
} }
private String getClientIp(HttpServletRequest request) { private String getClientIp(HttpServletRequest request) {

파일 보기

@ -285,7 +285,7 @@ public class GisServiceV2 {
/** /**
* 선박별 항적 조회 V2 (캐시 + Semaphore + 간소화 + ChnPrmShip enrichment) * 선박별 항적 조회 V2 (캐시 + Semaphore + 간소화 + ChnPrmShip enrichment)
*/ */
public List<CompactVesselTrack> getVesselTracksV2(VesselTracksRequest request, String clientIp) { public List<CompactVesselTrack> getVesselTracksV2(VesselTracksRequest request, String clientIp, String clientId) {
String queryId = "rest-vessels-" + UUID.randomUUID().toString().substring(0, 8); String queryId = "rest-vessels-" + UUID.randomUUID().toString().substring(0, 8);
long startMs = System.currentTimeMillis(); long startMs = System.currentTimeMillis();
boolean slotAcquired = false; boolean slotAcquired = false;
@ -329,7 +329,7 @@ public class GisServiceV2 {
result.size(), request.getVessels().size(), result.size(), request.getVessels().size(),
dailyTrackCacheManager.isEnabled(), request.isIncludeChnPrmShip()); dailyTrackCacheManager.isEnabled(), request.isIncludeChnPrmShip());
enqueueRestMetric(queryId, request, result, startMs, clientIp); enqueueRestMetric(queryId, request, result, startMs, clientIp, clientId);
return result; return result;
@ -347,7 +347,7 @@ public class GisServiceV2 {
} }
private void enqueueRestMetric(String queryId, VesselTracksRequest request, private void enqueueRestMetric(String queryId, VesselTracksRequest request,
List<CompactVesselTrack> result, long startMs, String clientIp) { List<CompactVesselTrack> result, long startMs, String clientIp, String clientId) {
try { try {
int totalPoints = result.stream().mapToInt(CompactVesselTrack::getPointCount).sum(); int totalPoints = result.stream().mapToInt(CompactVesselTrack::getPointCount).sum();
long responseBytes = (long) result.size() * 200 + (long) totalPoints * 40; long responseBytes = (long) result.size() * 200 + (long) totalPoints * 40;
@ -367,6 +367,7 @@ public class GisServiceV2 {
.elapsedMs(System.currentTimeMillis() - startMs) .elapsedMs(System.currentTimeMillis() - startMs)
.status("COMPLETED") .status("COMPLETED")
.clientIp(clientIp) .clientIp(clientIp)
.clientId(clientId)
.build()); .build());
} catch (Exception e) { } catch (Exception e) {
log.debug("Failed to enqueue REST metric: {}", e.getMessage()); log.debug("Failed to enqueue REST metric: {}", e.getMessage());

파일 보기

@ -235,11 +235,16 @@ public class WebSocketStompConfig implements WebSocketMessageBrokerConfigurer {
return "unknown"; return "unknown";
} }
private String extractEmailFromJwtCookie(HttpServletRequest request) {
return extractClientIdFromRequest(request);
}
}
/** /**
* GC_SESSION 쿠키에서 JWT payload의 email 클레임 추출. * GC_SESSION 쿠키에서 JWT payload의 email 클레임 추출 (REST/WebSocket 공용).
* JWT 검증은 nginx auth_request에서 이미 완료 여기서는 payload 디코딩만 수행. * JWT 검증은 nginx auth_request에서 이미 완료 여기서는 payload 디코딩만 수행.
*/ */
private String extractEmailFromJwtCookie(HttpServletRequest request) { public static String extractClientIdFromRequest(HttpServletRequest request) {
Cookie[] cookies = request.getCookies(); Cookie[] cookies = request.getCookies();
if (cookies == null) return null; if (cookies == null) return null;
@ -253,12 +258,10 @@ public class WebSocketStompConfig implements WebSocketMessageBrokerConfigurer {
if (token == null || token.isEmpty()) return null; if (token == null || token.isEmpty()) return null;
try { try {
// JWT: header.payload.signature payload만 Base64URL 디코딩
String[] parts = token.split("\\."); String[] parts = token.split("\\.");
if (parts.length < 2) return null; if (parts.length < 2) return null;
String payload = new String(Base64.getUrlDecoder().decode(parts[1])); String payload = new String(Base64.getUrlDecoder().decode(parts[1]));
// 간단한 JSON 파싱 (Jackson 의존 없이): "email":"value" 추출
int emailIdx = payload.indexOf("\"email\""); int emailIdx = payload.indexOf("\"email\"");
if (emailIdx < 0) return null; if (emailIdx < 0) return null;
@ -272,5 +275,4 @@ public class WebSocketStompConfig implements WebSocketMessageBrokerConfigurer {
return null; return null;
} }
} }
}
} }

파일 보기

@ -173,6 +173,7 @@ vessel: # spring 하위가 아닌 최상위 레벨
# 궤적 비정상 검출 설정 # 궤적 비정상 검출 설정
track: track:
include-abnormal-in-tracks: true # 비정상 궤적도 정상 테이블+캐시에 포함 (강화학습 데이터 수집용)
abnormal-detection: abnormal-detection:
large-gap-threshold-hours: 4 # 이 시간 이상 gap은 연결 안함 large-gap-threshold-hours: 4 # 이 시간 이상 gap은 연결 안함
extreme-speed-threshold: 1000 # 이 속도 이상은 무조건 비정상 (knots) extreme-speed-threshold: 1000 # 이 속도 이상은 무조건 비정상 (knots)

파일 보기

@ -159,6 +159,8 @@ vessel:
page-size: ${BATCH_PAGE_SIZE:10000} page-size: ${BATCH_PAGE_SIZE:10000}
partition-size: ${BATCH_PARTITION_SIZE:24} partition-size: ${BATCH_PARTITION_SIZE:24}
skip-limit: 100 skip-limit: 100
track:
include-abnormal-in-tracks: false # true: 비정상 궤적도 정상 테이블+캐시에 포함 (강화학습 데이터 수집용)
retry-limit: 3 retry-limit: 3
# Reader 설정 # Reader 설정
use-cursor-reader: true # Cursor Reader 사용 여부 use-cursor-reader: true # Cursor Reader 사용 여부