From ae110bd91a4138edde5d15f813797c532462a734 Mon Sep 17 00:00:00 2001 From: HYOJIN Date: Wed, 18 Mar 2026 15:02:29 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(ais):=20AIS=20=EC=9D=91=EB=8B=B5=20?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EB=A6=AC=EB=B0=8D=20=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?=EB=B0=8F=20=EC=BA=90=EC=8B=9C=20=EB=A1=9C=EA=B7=B8=20=EB=AA=85?= =?UTF-8?q?=ED=99=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../config/AisTargetImportJobConfig.java | 10 ++- .../batch/reader/AisTargetDataReader.java | 78 ++++++++++++++++--- .../batch/writer/AisTargetDataWriter.java | 4 +- 3 files changed, 78 insertions(+), 14 deletions(-) diff --git a/src/main/java/com/snp/batch/jobs/aistarget/batch/config/AisTargetImportJobConfig.java b/src/main/java/com/snp/batch/jobs/aistarget/batch/config/AisTargetImportJobConfig.java index d5f7ccf..025b84c 100644 --- a/src/main/java/com/snp/batch/jobs/aistarget/batch/config/AisTargetImportJobConfig.java +++ b/src/main/java/com/snp/batch/jobs/aistarget/batch/config/AisTargetImportJobConfig.java @@ -1,5 +1,6 @@ package com.snp.batch.jobs.aistarget.batch.config; +import com.fasterxml.jackson.databind.ObjectMapper; import com.snp.batch.common.batch.config.BaseJobConfig; import com.snp.batch.jobs.aistarget.batch.dto.AisTargetDto; import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity; @@ -44,6 +45,7 @@ public class AisTargetImportJobConfig extends BaseJobConfig createReader() { - return new AisTargetDataReader(maritimeAisApiWebClient, sinceSeconds); + return new AisTargetDataReader(maritimeAisApiWebClient, objectMapper, sinceSeconds); } @Override @@ -104,7 +108,7 @@ public class AisTargetImportJobConfig extends BaseJobConfig { private final int sinceSeconds; + private final ObjectMapper objectMapper; - public AisTargetDataReader(WebClient webClient, int sinceSeconds) { + public AisTargetDataReader(WebClient webClient, ObjectMapper objectMapper, int sinceSeconds) { super(webClient); + this.objectMapper = objectMapper; this.sinceSeconds = sinceSeconds; } @@ -63,15 +75,18 @@ public class AisTargetDataReader extends BaseApiReader { @Override protected List fetchDataFromApi() { + Path tempFile = null; try { - log.info("[{}] API 호출 시작: {} {}", getReaderName(), getHttpMethod(), getApiPath()); + log.info("[{}] API 호출 시작 (스트리밍 모드): {} {}", getReaderName(), getHttpMethod(), getApiPath()); - AisTargetApiResponse response = webClient.post() - .uri(getApiPath()) - .bodyValue(getRequestBody()) - .retrieve() - .bodyToMono(AisTargetApiResponse.class) - .block(); + tempFile = Files.createTempFile("ais-response-", ".json"); + + // 응답을 DataBuffer 스트림으로 받아 임시 파일에 기록 + long bytesWritten = streamResponseToFile(tempFile); + log.info("[{}] 응답 스트리밍 완료: {} bytes → {}", getReaderName(), bytesWritten, tempFile.getFileName()); + + // 임시 파일에서 JSON 파싱 + AisTargetApiResponse response = parseResponseFromFile(tempFile); if (response != null && response.getTargetArr() != null) { List targets = response.getTargetArr(); @@ -86,6 +101,51 @@ public class AisTargetDataReader extends BaseApiReader { } catch (Exception e) { log.error("[{}] API 호출 실패: {}", getReaderName(), e.getMessage(), e); return handleApiError(e); + } finally { + deleteTempFile(tempFile); + } + } + + /** + * WebClient 응답을 DataBuffer 스트림으로 받아 임시 파일에 기록한다. + * bodyToMono()와 달리 메모리 버퍼 제한(maxInMemorySize)의 영향을 받지 않는다. + */ + private long streamResponseToFile(Path tempFile) throws IOException { + try (OutputStream os = Files.newOutputStream(tempFile, StandardOpenOption.WRITE)) { + webClient.post() + .uri(getApiPath()) + .bodyValue(getRequestBody()) + .retrieve() + .bodyToFlux(DataBuffer.class) + .doOnNext(dataBuffer -> { + try { + byte[] bytes = new byte[dataBuffer.readableByteCount()]; + dataBuffer.read(bytes); + os.write(bytes); + } catch (IOException e) { + throw new RuntimeException("임시 파일 쓰기 실패", e); + } finally { + DataBufferUtils.release(dataBuffer); + } + }) + .blockLast(); + } + return Files.size(tempFile); + } + + private AisTargetApiResponse parseResponseFromFile(Path tempFile) throws IOException { + try (InputStream is = Files.newInputStream(tempFile)) { + return objectMapper.readValue(is, AisTargetApiResponse.class); + } + } + + private void deleteTempFile(Path tempFile) { + if (tempFile != null) { + try { + Files.deleteIfExists(tempFile); + } catch (IOException e) { + log.warn("[{}] 임시 파일 삭제 실패: {}", getReaderName(), tempFile, e); + } } } diff --git a/src/main/java/com/snp/batch/jobs/aistarget/batch/writer/AisTargetDataWriter.java b/src/main/java/com/snp/batch/jobs/aistarget/batch/writer/AisTargetDataWriter.java index 0c4db30..d0e1d2b 100644 --- a/src/main/java/com/snp/batch/jobs/aistarget/batch/writer/AisTargetDataWriter.java +++ b/src/main/java/com/snp/batch/jobs/aistarget/batch/writer/AisTargetDataWriter.java @@ -52,7 +52,7 @@ public class AisTargetDataWriter extends BaseWriter { @Override protected void writeItems(List items) throws Exception { - log.debug("AIS Target 캐시 업데이트 시작: {} 건", items.size()); + log.info("AIS Target 캐시 업데이트 시작: {} 건", items.size()); // 1. ClassType 분류 (캐시 저장 전에 분류) // - Core20 캐시의 IMO와 매칭하여 classType(A/B), core20Mmsi 설정 @@ -67,7 +67,7 @@ public class AisTargetDataWriter extends BaseWriter { // 3. 캐시 업데이트 (classType, core20Mmsi, signalKindCode 포함) cacheManager.putAll(items); - log.debug("AIS Target 캐시 업데이트 완료: {} 건 (캐시 크기: {})", + log.info("AIS Target 캐시 업데이트 완료: {} 건 (캐시 크기: {})", items.size(), cacheManager.size()); // 4. ChnPrmShip 전용 캐시 업데이트 (대상 MMSI만 필터) From ea958a43cae5efc75e87b4cc9920237152fb014d Mon Sep 17 00:00:00 2001 From: HYOJIN Date: Wed, 18 Mar 2026 15:03:16 +0900 Subject: [PATCH 2/2] =?UTF-8?q?docs:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/RELEASE-NOTES.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index df1afac..0d1783a 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -34,6 +34,7 @@ - 각 화면별 사용자 가이드 추가 (#41) - 스케줄 화면 검색/정렬/필터 기능 추가 및 UI 구조 개선 (#54) - 재수집 이력 화면 개선: 배치 실행일시 추가, 작업명 잘림 해소, CSV 내보내기 제거 (#55) +- AIS API 응답 스트리밍 처리로 메모리 버퍼 제한 우회 (DataBufferLimitException 근본 해결) ### 수정 - 자동 재수집 JobParameter 오버플로우 수정 (VARCHAR 2500 제한 해결) @@ -59,6 +60,7 @@ - 미사용 Dead Code 정리 (~1,200 LOC 삭제) - 미사용 배치 작업 13개 제거 (~4,000 LOC 삭제) (#40) - API 인증정보 공통화(api-auth) 및 환경별 중복 설정 제거 (#59) +- AIS Import Job 로그에 캐시 적재 흐름 명시 (`API → 캐시`) ### 기타 - Gitea 팀 프로젝트 워크플로우 구조 적용