[견적배치] estimateSyncJob 만성 QSP timeout — chunk 분할 송신 + 스케줄 슬롯 04:20→05:20 (bomJob 과 스왑) #499

Merged
ysCha merged 1 commits from dev into prd-deploy 2026-05-13 13:25:03 +09:00
2 changed files with 109 additions and 15 deletions

View File

@ -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");

View File

@ -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<PlanSyncResponse> 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<EstimateSendResponse> 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<PlanSyncResponse> 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<EstimateSendResponse> 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