diff --git a/src/main/java/com/interplug/qcast/batch/JobLauncherController.java b/src/main/java/com/interplug/qcast/batch/JobLauncherController.java index 189a7cf6..bcac883b 100644 --- a/src/main/java/com/interplug/qcast/batch/JobLauncherController.java +++ b/src/main/java/com/interplug/qcast/batch/JobLauncherController.java @@ -163,9 +163,11 @@ public class JobLauncherController { /** * BOM 아이템 동기화 배치 * - + * [2026-05-13] 04:20 으로 이동 (estimateSyncJob 과 시간 스왑). + * 사유: estimateSyncJob 이 만성 timeout 으로 길어질 수 있어, 새벽 마지막 슬롯(05:20)으로 옮김. + * BOM 은 GET 단일 호출(0.7초 수준)이라 슬롯 영향 無. */ - @Scheduled(cron = "0 20 05 * * *") + @Scheduled(cron = "0 20 04 * * *") public String bomJob() throws JobInstanceAlreadyCompleteException, JobExecutionAlreadyRunningException, JobParametersInvalidException, JobRestartException { return executeScheduledJob("bomJob"); @@ -246,9 +248,12 @@ public class JobLauncherController { /** * 견적서 전송 동기화 배치 * - + * [2026-05-13] 05:20 으로 이동 (bomJob 과 시간 스왑). + * 사유: 만성 QSP timeout 으로 chunk 분할 송신해도 길어질 수 있어, 새벽 마지막 슬롯에 배치. + * 다음 스케줄(23:50 storeAdditionalJob)까지 18시간 버퍼 확보 → 후속 job 침범 위험 제거. + * chunk 분할 로직은 EstimateSyncConfiguration 참고. */ - @Scheduled(cron = "0 20 04 * * *") + @Scheduled(cron = "0 20 05 * * *") public String estimateSyncJob() throws JobInstanceAlreadyCompleteException, JobExecutionAlreadyRunningException, JobParametersInvalidException, JobRestartException { return executeScheduledJob("estimateSyncJob"); diff --git a/src/main/java/com/interplug/qcast/batch/estimate/EstimateSyncConfiguration.java b/src/main/java/com/interplug/qcast/batch/estimate/EstimateSyncConfiguration.java index 801d0a73..d2fe4604 100644 --- a/src/main/java/com/interplug/qcast/batch/estimate/EstimateSyncConfiguration.java +++ b/src/main/java/com/interplug/qcast/batch/estimate/EstimateSyncConfiguration.java @@ -1,12 +1,15 @@ package com.interplug.qcast.batch.estimate; import com.interplug.qcast.biz.estimate.EstimateService; +import com.interplug.qcast.biz.estimate.dto.EstimateSendRequest; +import com.interplug.qcast.biz.estimate.dto.EstimateSendResponse; import com.interplug.qcast.biz.estimate.dto.EstimateSyncResponse; import com.interplug.qcast.biz.estimate.dto.PlanSyncResponse; import com.interplug.qcast.util.InterfaceQsp; import java.util.ArrayList; import java.util.Collections; import java.util.List; +import lombok.extern.slf4j.Slf4j; import org.springframework.batch.core.Job; import org.springframework.batch.core.JobExecutionListener; import org.springframework.batch.core.Step; @@ -24,17 +27,24 @@ import org.springframework.http.HttpMethod; import org.springframework.transaction.PlatformTransactionManager; /** Plan 확정 Item 마스터 동기화 배치 */ +@Slf4j @Configuration public class EstimateSyncConfiguration implements JobExecutionListener { private final EstimateService estimateService; private final InterfaceQsp interfaceQsp; - EstimateSyncResponse estimateSyncResponse; - @Value("${qsp.estimate-sync-batch-url}") private String qspInterfaceUrl; + // [2026-05-13] QSP 견적 동기화 chunk 크기 — 만성 120s timeout 회피 위해 분할 송신 + @Value("${qsp.estimate-sync-chunk-size:50}") + private int estimateSyncChunkSize; + + // [2026-05-13] 총 송신 시간 상한 (밀리초). 초과 시 남은 chunk 중단하여 다음 스케줄 job (04:50 materialJob) 침범 방지. + @Value("${qsp.estimate-sync-total-timeout-ms:1500000}") + private long estimateSyncTotalTimeoutMs; + public EstimateSyncConfiguration(EstimateService estimateService, InterfaceQsp interfaceQsp) { this.estimateService = estimateService; this.interfaceQsp = interfaceQsp; @@ -59,15 +69,94 @@ public class EstimateSyncConfiguration implements JobExecutionListener { @Bean @StepScope public ListItemReader estimateSyncReader() throws Exception { - this.estimateSyncResponse = - interfaceQsp.callApiData( - HttpMethod.POST, - qspInterfaceUrl, - estimateService.selectEstimateSyncFailList(), - EstimateSyncResponse.class); - return (estimateSyncResponse != null) - ? new ListItemReader<>(estimateSyncResponse.getSuccessList()) - : new ListItemReader<>(Collections.emptyList()); + // [2026-05-13] 만성 timeout 회피: 전체 fail list 를 한 번에 POST 하지 않고 chunk 단위로 분할 송신. + // 각 chunk 는 try/catch — 일부 chunk 실패해도 다음 chunk 진행. + // 모든 chunk 실패 시에만 throw 하여 Step FAILED. + // 총 elapsed 가 상한 초과 시 남은 chunk 중단 (다음 스케줄 job 침범 방지). + EstimateSendRequest fullRequest = estimateService.selectEstimateSyncFailList(); + List quoteList = + (fullRequest != null) ? fullRequest.getQuoteList() : null; + + if (quoteList == null || quoteList.isEmpty()) { + log.info("QSP 견적 동기화: 송신할 fail list 없음"); + return new ListItemReader<>(Collections.emptyList()); + } + + int chunkSize = Math.max(1, estimateSyncChunkSize); + int totalChunks = (quoteList.size() + chunkSize - 1) / chunkSize; + List mergedSuccessList = new ArrayList<>(); + int successChunks = 0; + int abortedChunks = 0; + long startAt = System.currentTimeMillis(); + + log.info( + "QSP 견적 동기화 분할 송신 시작: totalItems={}, chunkSize={}, totalChunks={}", + quoteList.size(), + chunkSize, + totalChunks); + + for (int i = 0; i < quoteList.size(); i += chunkSize) { + long elapsed = System.currentTimeMillis() - startAt; + if (elapsed > estimateSyncTotalTimeoutMs) { + abortedChunks = totalChunks - (i / chunkSize); + log.warn( + "QSP 견적 동기화 총 elapsed 상한 초과 — 남은 chunk 중단: elapsedMs={}, limitMs={}, abortedChunks={}", + elapsed, + estimateSyncTotalTimeoutMs, + abortedChunks); + break; + } + + int end = Math.min(i + chunkSize, quoteList.size()); + int chunkIndex = i / chunkSize; + List sub = new ArrayList<>(quoteList.subList(i, end)); + EstimateSendRequest subRequest = new EstimateSendRequest(); + subRequest.setQuoteList(sub); + + try { + EstimateSyncResponse chunkResponse = + interfaceQsp.callApiData( + HttpMethod.POST, qspInterfaceUrl, subRequest, EstimateSyncResponse.class); + if (chunkResponse != null && chunkResponse.getSuccessList() != null) { + mergedSuccessList.addAll(chunkResponse.getSuccessList()); + } + successChunks++; + log.info( + "QSP 견적 동기화 chunk 성공: chunkIndex={}/{}, range=[{},{}), accumulatedSuccess={}", + chunkIndex, + totalChunks, + i, + end, + mergedSuccessList.size()); + } catch (Exception e) { + log.error( + "QSP 견적 동기화 chunk 실패 — 다음 chunk 계속: chunkIndex={}/{}, range=[{},{})", + chunkIndex, + totalChunks, + i, + end, + e); + } + } + + long totalElapsed = System.currentTimeMillis() - startAt; + log.info( + "QSP 견적 동기화 분할 송신 완료: totalChunks={}, successChunks={}, abortedChunks={}, mergedSuccess={}, totalElapsedMs={}", + totalChunks, + successChunks, + abortedChunks, + mergedSuccessList.size(), + totalElapsed); + + if (successChunks == 0) { + throw new IllegalStateException( + "QSP 견적 동기화 모든 chunk 실패: totalChunks=" + + totalChunks + + ", quoteSize=" + + quoteList.size()); + } + + return new ListItemReader<>(mergedSuccessList); } @Bean