From e9ef8b9df5b2100b8990c7fa5e03c45b3a01ff3d Mon Sep 17 00:00:00 2001 From: HYOJIN Date: Thu, 12 Mar 2026 16:02:49 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix(batch):=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EC=9E=AC=EC=88=98=EC=A7=91=20=ED=8C=8C=EB=9D=BC=EB=AF=B8?= =?UTF-8?q?=ED=84=B0=20=EC=98=A4=EB=B2=84=ED=94=8C=EB=A1=9C=EC=9A=B0=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EB=B0=8F=20=EC=8B=A4=ED=8C=A8=20=EB=A0=88?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EA=B4=80=EB=A6=AC=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - retryRecordKeys JobParameter 제거 → DB 직접 조회 (VARCHAR 2500 제한 해결) - retryCount 세마틱 수정 (0부터 시작, 재수집 실패 시 +1) - 실패 레코드 Upsert로 중복 방지 (동일 키 1건만 유지) - 동기 저장으로 RECOLLECT 타이밍 경합 해결 - 재시도 초과 레코드 초기화 API/UI 추가 - 실행 확인 모달 시작/종료일시 항목 제거 Co-Authored-By: Claude Opus 4.6 --- docs/recollection-process.md | 146 ++++++++++++++---- frontend/src/api/batchApi.ts | 4 + frontend/src/pages/ExecutionDetail.tsx | 89 ++++++++++- frontend/src/pages/Jobs.tsx | 48 +----- frontend/src/pages/RecollectDetail.tsx | 89 ++++++++++- .../AutoRetryJobExecutionListener.java | 5 +- .../listener/AutoRetryTriggerService.java | 5 +- .../global/controller/BatchController.java | 27 ++++ .../BatchFailedRecordRepository.java | 56 +++++++ .../config/ShipDetailUpdateJobConfig.java | 38 +++-- .../reader/ShipDetailUpdateDataReader.java | 8 +- .../service/BatchFailedRecordService.java | 108 ++++++++++--- 12 files changed, 500 insertions(+), 123 deletions(-) diff --git a/docs/recollection-process.md b/docs/recollection-process.md index e9eb124..384ee6d 100644 --- a/docs/recollection-process.md +++ b/docs/recollection-process.md @@ -8,10 +8,9 @@ | **대상** | 실패한 record key | 실패한 record key | 날짜 범위 전체 | | `executionMode` | RECOLLECT | RECOLLECT | RECOLLECT | | `executor` | AUTO_RETRY | MANUAL_RETRY | MANUAL | -| `retryRecordKeys` | 있음 (콤마 구분) | 있음 (콤마 구분) | 없음 | | `sourceStepExecutionId` | 있음 | 있음 | 없음 | | `apiKey` | 있음 (ExecutionContext) | 없음 (resolveApiKey fallback) | 있음 (파라미터) | -| **Reader 모드** | retry (키 기반) | retry (키 기반) | normal (API 전체 호출) | +| **Reader 모드** | retry (DB에서 키 조회) | retry (DB에서 키 조회) | normal (API 전체 호출) | | **last_success_date** | Tasklet 스킵 → 불변 | Tasklet 스킵 → 불변 | Tasklet 실행 → 갱신 → 원복 | | **이력 날짜범위** | NULL / NULL | NULL / NULL | from / to | | **중복 검사** | 안함 | 안함 | 함 (findOverlappingHistories) | @@ -29,7 +28,9 @@ │ └─ failedImoNumbers에 누적 │ ├─ Reader.afterFetch(null) [일반 모드] - │ ├─ BatchFailedRecordService.saveFailedRecords() ──→ [batch_failed_record] INSERT (FAILED) + │ ├─ BatchFailedRecordService.saveFailedRecordsSync() [동기 저장] + │ │ ├─ 기존 FAILED 레코드 존재 → UPDATE (실행정보 갱신, retryCount 유지) + │ │ └─ 신규 키 → INSERT (retryCount=0, status=FAILED) │ └─ finally: ExecutionContext에 failedRecordKeys/stepId/apiKey 저장 │ ├─ [Job COMPLETED] @@ -39,7 +40,8 @@ │ ├─ status == COMPLETED ✓ │ ├─ 모든 Step의 failedRecordKeys → LinkedHashSet 병합 │ ├─ findExceededRetryKeys() → retryCount >= 3 인 키 제외 - │ └─ AutoRetryTriggerService.triggerRetryAsync() [autoRetryExecutor 스레드] + │ └─ AutoRetryTriggerService.triggerRetryAsync(jobName, failedCount, stepId, apiKey) + │ └─ JobParameter에 sourceStepExecutionId만 전달 (키 목록은 DB에서 조회) │ ├─ [재수집 Job 시작 (executionMode=RECOLLECT, executor=AUTO_RETRY)] │ @@ -47,14 +49,20 @@ │ ├─ last_success_date 저장 (ExecutionContext) │ └─ recordStart() ──→ [batch_recollection_history] INSERT (STARTED, 날짜 NULL) │ + ├─ ShipDetailUpdateJobConfig.shipDetailUpdateDataReader() [@StepScope 빈 초기화] + │ └─ BatchFailedRecordRepository.findFailedRecordKeysByStepExecutionId() → DB에서 실패 키 조회 + │ ├─ Reader.beforeFetch() [retry 모드] │ └─ allImoNumbers = retryRecordKeys (API 호출 없이 키 목록 직접 사용) │ ├─ Reader.afterFetch(null) [retry 모드] │ ├─ resolveSuccessfulRetries() ──→ [batch_failed_record] UPDATE (RESOLVED) - │ └─ 재실패 건 saveFailedRecords() ──→ [batch_failed_record] INSERT (FAILED, 새 레코드) + │ │ └─ sourceStepExecutionId 기준 (아직 원본 stepId 보유, resolve 먼저 실행) + │ └─ 재실패 건 saveFailedRecords() [비동기] + │ ├─ 기존 FAILED 레코드 → UPDATE (실행정보 갱신 + retryCount + 1) + │ └─ 신규 키 → INSERT (retryCount=1) │ - ├─ retryModeDecider → "RETRY" → lastExecutionUpdateStep 스킵 + ├─ retryModeDecider → executionMode="RECOLLECT" → "RETRY" → lastExecutionUpdateStep 스킵 │ ├─ RecollectionJobExecutionListener.afterJob() │ ├─ recordCompletion() ──→ [batch_recollection_history] UPDATE (COMPLETED/FAILED) @@ -73,8 +81,7 @@ │ ├─ batchApi.retryFailedRecords(jobName, recordKeys, stepExecutionId) │ └─ POST /api/batch/jobs/{jobName}/execute - │ ?retryRecordKeys=IMO1,IMO2 - │ &sourceStepExecutionId=123 + │ ?sourceStepExecutionId=123 │ &executionMode=RECOLLECT │ &executor=MANUAL_RETRY │ &reason=실패 건 수동 재수집 (N건) @@ -84,8 +91,9 @@ │ ├─ [이후 흐름은 AUTO_RETRY와 동일] │ ├─ RecollectionJobExecutionListener.beforeJob() → 이력 INSERT + │ ├─ Reader 빈 초기화 시 DB에서 실패 키 조회 │ ├─ Reader [retry 모드] → 키 기반 재처리 - │ ├─ retryModeDecider → "RETRY" → Tasklet 스킵 + │ ├─ retryModeDecider → executionMode="RECOLLECT" → "RETRY" → Tasklet 스킵 │ └─ RecollectionJobExecutionListener.afterJob() → 이력 UPDATE + 복원 │ └─ 성공 시 UI에서 새 실행 상세 화면으로 이동 @@ -124,20 +132,96 @@ ## 3. 테이블 영향 매트릭스 -| 테이블 | AUTO_RETRY | MANUAL_RETRY | MANUAL(날짜) | UI 일괄 RESOLVED | -|--------|-----------|--------------|-------------|-----------------| -| `batch_failed_record` | INSERT(FAILED) + UPDATE(RESOLVED) | INSERT(FAILED) + UPDATE(RESOLVED) | - | UPDATE(RESOLVED) | -| `batch_recollection_history` | INSERT + UPDATE | INSERT + UPDATE | INSERT + UPDATE | - | -| `batch_collection_period` | READ (apiKeyName 조회) | READ (apiKeyName 조회) | READ/WRITE (날짜범위) | - | -| `batch_last_execution` | READ (복원용) | READ (복원용) | READ → WRITE(갱신) → WRITE(복원) | - | -| `batch_job_execution` | INSERT (Spring Batch 자동) | INSERT (Spring Batch 자동) | INSERT (Spring Batch 자동) | - | +| 테이블 | AUTO_RETRY | MANUAL_RETRY | MANUAL(날짜) | UI 일괄 RESOLVED | UI 재시도 초기화 | +|--------|-----------|--------------|-------------|-----------------|-----------------| +| `batch_failed_record` | UPSERT(FAILED) + UPDATE(RESOLVED) | UPSERT(FAILED) + UPDATE(RESOLVED) | - | UPDATE(RESOLVED) | UPDATE(retryCount=0) | +| `batch_recollection_history` | INSERT + UPDATE | INSERT + UPDATE | INSERT + UPDATE | - | - | +| `batch_collection_period` | READ (apiKeyName 조회) | READ (apiKeyName 조회) | READ/WRITE (날짜범위) | - | - | +| `batch_last_execution` | READ (복원용) | READ (복원용) | READ → WRITE(갱신) → WRITE(복원) | - | - | +| `batch_job_execution` | INSERT (Spring Batch 자동) | INSERT (Spring Batch 자동) | INSERT (Spring Batch 자동) | - | - | --- -## 4. 무한루프 방지 구조 +## 4. 실패 레코드 관리 + +### retryCount 세마틱 + +`retryCount`는 해당 레코드가 재수집된 횟수를 나타냅니다. + +| 시점 | retryCount | 의미 | +|------|-----------|------| +| 최초 실패 | 0 | 아직 재시도하지 않음 | +| 1차 재수집 실패 | 1 | 1번 재시도했으나 재실패 | +| 2차 재수집 실패 | 2 | 2번 재시도했으나 재실패 | +| 3차 재수집 실패 | 3 | MAX_AUTO_RETRY_COUNT 도달 → 자동 재수집 제외 | + +### 중복 방지 (Upsert 패턴) + +동일 `(jobName, recordKey)`에 대해 FAILED 레코드는 **항상 1건만 유지**됩니다. ``` -[이중 가드] +[일반 모드 저장 시] + ├─ 기존 FAILED 레코드 없음 → INSERT (retryCount=0) + └─ 기존 FAILED 레코드 있음 → UPDATE (jobExecutionId, stepExecutionId, errorMessage 갱신, retryCount 유지) + +[재수집 모드 저장 시 (재실패)] + ├─ 기존 FAILED 레코드 없음 → INSERT (retryCount=1) + └─ 기존 FAILED 레코드 있음 → UPDATE (jobExecutionId, stepExecutionId, errorMessage 갱신, retryCount + 1) +``` + +중복 방지 효과: +- 배치 실행 1 → IMO 1234 실패 → INSERT (stepExecId=100, retryCount=0) +- 배치 실행 2 → IMO 1234 또 실패 → UPDATE (stepExecId=200, retryCount=0 유지) +- 재수집 트리거 → stepExecId=200 기준 조회 → 1건만 재수집 (중복 재수집 방지) + +### 동기/비동기 저장 구분 + +| 호출 시점 | 메서드 | 이유 | +|-----------|--------|------| +| 일반 모드 afterFetch | `saveFailedRecordsSync` (동기) | RECOLLECT Job의 Reader가 DB 조회 시 커밋 완료 보장 | +| 재수집 모드 afterFetch | `saveFailedRecords` (비동기) | 후속 RECOLLECT 없으므로 비동기 OK | + +### 재시도 초과 레코드 관리 + +retryCount >= MAX_AUTO_RETRY_COUNT(3)에 도달한 레코드는 자동 재수집에서 제외됩니다. +사용자는 UI에서 다음 3가지 방식으로 처리할 수 있습니다. + +| 액션 | 설명 | API | 효과 | +|------|------|-----|------| +| **재시도 초기화** | retryCount를 0으로 리셋 | `POST /api/batch/failed-records/reset-retry` | 다음 배치 실행 시 자동 재수집 대상에 다시 포함 | +| **수동 재수집** | 즉시 RECOLLECT Job 트리거 | `POST /api/batch/jobs/{jobName}/execute` | 선택한 키에 대해 재수집 즉시 실행 | +| **일괄 RESOLVED** | FAILED → RESOLVED 상태 변경 | `POST /api/batch/failed-records/resolve` | 해당 레코드 포기 (더 이상 재수집 안 함) | + +``` +[재시도 초과 레코드 처리 흐름] + + retryCount >= 3 도달 → 자동 재수집 제외 + │ + ├─ [재시도 초기화] → retryCount = 0 → 다음 배치 실행에서 자동 재수집 재개 + │ + ├─ [수동 재수집] → RECOLLECT Job 즉시 실행 → 성공 시 RESOLVED, 실패 시 retryCount + 1 + │ + └─ [일괄 RESOLVED] → status = RESOLVED → 재수집 대상에서 영구 제외 +``` + +### UI 재시도 상태 표시 + +FailedRecordsToggle 테이블의 "재시도" 컬럼에 retryCount 기반 상태 배지를 표시합니다. + +| retryCount | 배지 | 색상 | 의미 | +|-----------|------|------|------| +| 0 | `대기` | blue | 아직 재시도하지 않음, 자동 재수집 대상 | +| 1~2 | `재시도 N/3` | amber | 재수집 진행 중 | +| >= 3 | `재시도 초과` | red | 자동 재수집 제외, 사용자 조치 필요 | + +"재시도 초기화" 버튼은 `재시도 초과` 상태 레코드가 1건 이상 있을 때만 표시됩니다. + +--- + +## 5. 무한루프 방지 구조 + +``` +[삼중 가드] Guard 1: AutoRetryJobExecutionListener └─ executionMode == "RECOLLECT" → 즉시 리턴 (재수집 Job에서는 자동 재수집 트리거 안 함) @@ -146,47 +230,53 @@ Guard 2: findExceededRetryKeys() └─ retryCount >= 3 인 키 → mergedKeys에서 제거 └─ 남은 키 없으면 → 재수집 자체 스킵 +Guard 3: Reader afterFetch [retry 모드] + └─ ExecutionContext에 failedRecordKeys 미기록 → 리스너가 감지하지 못함 + [시나리오] - 일반 실행 → 실패 3건 → 자동 재수집(retryCount=3 저장) - → 재수집 Job은 executionMode=RECOLLECT → Guard 1에서 차단 + 일반 실행 → 실패 3건 → batch_failed_record INSERT (retryCount=0) + → 자동 재수집 1차 → 재실패 → retryCount=1 + → 자동 재수집 2차 → 재실패 → retryCount=2 + → 자동 재수집 3차 → 재실패 → retryCount=3 → 다음 정상 실행에서 같은 키 다시 실패해도 retryCount >= 3 → Guard 2에서 차단 ``` --- -## 5. 프론트엔드 UI 진입점 +## 6. 프론트엔드 UI 진입점 | 화면 | 컴포넌트/영역 | 기능 | |------|--------------|------| | **Executions** | failedRecordCount 뱃지 | COMPLETED 상태 + 미해결 실패건 > 0 → amber 뱃지 | -| **ExecutionDetail** | FailedRecordsToggle | 실패 레코드 목록 + "재수집 실행" + "일괄 RESOLVED" 버튼 | +| **ExecutionDetail** | FailedRecordsToggle | 실패 레코드 목록 + 재시도 상태 배지 + "재시도 초기화" / "재수집 실행" / "일괄 RESOLVED" 버튼 | | **RecollectDetail** | FailedRecordsToggle | 재수집 상세 내 실패 레코드 + 동일 버튼 | | **Recollects** | 이력 목록 + 수집기간 관리 | 검색/필터 + CSV 내보내기 + 재수집 실행 + 기간 편집 | | **Dashboard** | 재수집 현황 위젯 | 통계 카드 5개 + 최근 5건 테이블 | --- -## 6. 주요 파일 맵 +## 7. 주요 파일 맵 ### 백엔드 | 파일 | 역할 | |------|------| | `ShipDetailUpdateDataReader.java` | API 호출 + 실패 감지 + ExecutionContext 저장 | -| `ShipDetailUpdateJobConfig.java` | Job/Step 구성 + retryModeDecider + 리스너 등록 | +| `ShipDetailUpdateJobConfig.java` | Job/Step 구성 + retryModeDecider(executionMode 기반) + DB에서 실패 키 조회 | | `AutoRetryJobExecutionListener.java` | Job 완료 후 실패 키 병합 + retryCount 필터 + 트리거 | -| `AutoRetryTriggerService.java` | @Async 비동기 Job 재실행 (autoRetryExecutor) | +| `AutoRetryTriggerService.java` | @Async 비동기 Job 재실행 (sourceStepExecutionId만 전달) | | `RecollectionJobExecutionListener.java` | 재수집 이력 기록 + last_success_date 백업/복원 | | `RecollectionHistoryService.java` | 이력 CRUD + 통계 + 수집기간 관리 | -| `BatchFailedRecordService.java` | 실패 레코드 저장/RESOLVED 처리 | +| `BatchFailedRecordService.java` | 실패 레코드 Upsert(동기/비동기) + RESOLVED 처리 | +| `BatchFailedRecordRepository.java` | 실패 키 조회 + 벌크 UPDATE(retryCount 유지/증가/초기화) + RESOLVED | | `BatchService.java` | Job 실행 + failedRecordCount 조회 | -| `BatchController.java` | REST API 엔드포인트 (실행/이력/CSV/RESOLVED) | +| `BatchController.java` | REST API 엔드포인트 (실행/이력/CSV/RESOLVED/재시도 초기화) | ### 프론트엔드 | 파일 | 역할 | |------|------| -| `batchApi.ts` | API 클라이언트 (retryFailedRecords, resolveFailedRecords, exportCSV 등) | +| `batchApi.ts` | API 클라이언트 (retryFailedRecords, resolveFailedRecords, resetRetryCount, exportCSV 등) | | `Executions.tsx` | 실행 목록 + failedRecordCount amber 뱃지 | | `ExecutionDetail.tsx` | 실행 상세 + FailedRecordsToggle (재수집/RESOLVED) | | `RecollectDetail.tsx` | 재수집 상세 + FailedRecordsToggle (재수집/RESOLVED) | diff --git a/frontend/src/api/batchApi.ts b/frontend/src/api/batchApi.ts index cfc95c1..9ccb570 100644 --- a/frontend/src/api/batchApi.ts +++ b/frontend/src/api/batchApi.ts @@ -498,6 +498,10 @@ export const batchApi = { postJson<{ success: boolean; message: string; resolvedCount?: number }>( `${BASE}/failed-records/resolve`, { ids }), + resetRetryCount: (ids: number[]) => + postJson<{ success: boolean; message: string; resetCount?: number }>( + `${BASE}/failed-records/reset-retry`, { ids }), + exportRecollectionHistories: (params: { apiKey?: string; jobName?: string; diff --git a/frontend/src/pages/ExecutionDetail.tsx b/frontend/src/pages/ExecutionDetail.tsx index 3ca4255..33cf33c 100644 --- a/frontend/src/pages/ExecutionDetail.tsx +++ b/frontend/src/pages/ExecutionDetail.tsx @@ -353,6 +353,8 @@ function FailedRecordsToggle({ records, jobName, stepExecutionId }: { records: F const [showResolveConfirm, setShowResolveConfirm] = useState(false); const [retrying, setRetrying] = useState(false); const [resolving, setResolving] = useState(false); + const [showResetConfirm, setShowResetConfirm] = useState(false); + const [resetting, setResetting] = useState(false); const [page, setPage] = useState(0); const navigate = useNavigate(); @@ -368,6 +370,17 @@ function FailedRecordsToggle({ records, jobName, stepExecutionId }: { records: F } }; + const MAX_RETRY_COUNT = 3; + + const retryStatusLabel = (record: FailedRecordDto) => { + if (record.status !== 'FAILED') return null; + if (record.retryCount >= MAX_RETRY_COUNT) return { label: '재시도 초과', color: 'text-red-600 bg-red-100' }; + if (record.retryCount > 0) return { label: `재시도 ${record.retryCount}/${MAX_RETRY_COUNT}`, color: 'text-amber-600 bg-amber-100' }; + return { label: '대기', color: 'text-blue-600 bg-blue-100' }; + }; + + const exceededRecords = failedRecords.filter((r) => r.retryCount >= MAX_RETRY_COUNT); + const handleRetry = async () => { setRetrying(true); try { @@ -404,6 +417,20 @@ function FailedRecordsToggle({ records, jobName, stepExecutionId }: { records: F } }; + const handleResetRetry = async () => { + setResetting(true); + try { + const ids = exceededRecords.map((r) => r.id); + await batchApi.resetRetryCount(ids); + setShowResetConfirm(false); + navigate(0); + } catch { + alert('재시도 초기화에 실패했습니다.'); + } finally { + setResetting(false); + } + }; + return (
@@ -422,6 +449,17 @@ function FailedRecordsToggle({ records, jobName, stepExecutionId }: { records: F {failedRecords.length > 0 && (
+ {exceededRecords.length > 0 && ( + + )}
)} + + {/* 재시도 초기화 확인 다이얼로그 */} + {showResetConfirm && ( +
+
+

+ 재시도 초기화 확인 +

+

+ 재시도 횟수를 초과한 {exceededRecords.length}건의 retryCount를 0으로 초기화합니다. + 초기화 후 다음 배치 실행 시 자동 재수집 대상에 포함됩니다. +

+
+ + +
+
+
+ )}
); } diff --git a/frontend/src/pages/Jobs.tsx b/frontend/src/pages/Jobs.tsx index 3a35552..00cd8ed 100644 --- a/frontend/src/pages/Jobs.tsx +++ b/frontend/src/pages/Jobs.tsx @@ -60,8 +60,6 @@ export default function Jobs() { const [executeModalOpen, setExecuteModalOpen] = useState(false); const [targetJob, setTargetJob] = useState(''); const [executing, setExecuting] = useState(false); - const [startDate, setStartDate] = useState(''); - const [stopDate, setStopDate] = useState(''); const loadJobs = useCallback(async () => { @@ -121,8 +119,6 @@ export default function Jobs() { const handleExecuteClick = (jobName: string) => { setTargetJob(jobName); - setStartDate(''); - setStopDate(''); setExecuteModalOpen(true); }; @@ -130,14 +126,7 @@ export default function Jobs() { if (!targetJob) return; setExecuting(true); try { - const params: Record = {}; - if (startDate) params.startDate = startDate; - if (stopDate) params.stopDate = stopDate; - - const result = await batchApi.executeJob( - targetJob, - Object.keys(params).length > 0 ? params : undefined, - ); + const result = await batchApi.executeJob(targetJob); showToast( result.message || `${targetJob} 실행 요청 완료`, 'success', @@ -503,40 +492,7 @@ export default function Jobs() { "{targetJob}" 작업을 실행하시겠습니까?

- {/* Date parameters */} -
-
- - setStartDate(e.target.value)} - className="w-full px-3 py-2 border border-wing-border rounded-lg text-sm - focus:ring-2 focus:ring-wing-accent focus:border-wing-accent outline-none" - /> -
-
- - setStopDate(e.target.value)} - className="w-full px-3 py-2 border border-wing-border rounded-lg text-sm - focus:ring-2 focus:ring-wing-accent focus:border-wing-accent outline-none" - /> -
- {(startDate || stopDate) && ( -

- 날짜를 지정하면 해당 범위의 데이터를 수집합니다. -

- )} -
- -
+
+ )}
)} + + {/* 재시도 초기화 확인 다이얼로그 */} + {showResetConfirm && ( +
+
+

+ 재시도 초기화 확인 +

+

+ 재시도 횟수를 초과한 {exceededRecords.length}건의 retryCount를 0으로 초기화합니다. + 초기화 후 다음 배치 실행 시 자동 재수집 대상에 포함됩니다. +

+
+ + +
+
+
+ )} ); } diff --git a/src/main/java/com/snp/batch/common/batch/listener/AutoRetryJobExecutionListener.java b/src/main/java/com/snp/batch/common/batch/listener/AutoRetryJobExecutionListener.java index 480e784..cb0ee31 100644 --- a/src/main/java/com/snp/batch/common/batch/listener/AutoRetryJobExecutionListener.java +++ b/src/main/java/com/snp/batch/common/batch/listener/AutoRetryJobExecutionListener.java @@ -109,12 +109,11 @@ public class AutoRetryJobExecutionListener implements JobExecutionListener { return; } - String mergedFailedKeys = String.join(",", mergedKeys); log.info("[AutoRetry] {} Job 완료 후 실패 건 {}건 감지 → 자동 재수집 트리거", jobName, mergedKeys.size()); - // 합산된 키로 1회만 triggerRetryAsync 호출 + // sourceStepExecutionId 기반으로 1회만 triggerRetryAsync 호출 (실패 키는 DB에서 직접 조회) autoRetryTriggerService.triggerRetryAsync( - jobName, mergedFailedKeys, sourceStepExecutionId, apiKey); + jobName, mergedKeys.size(), sourceStepExecutionId, apiKey); } } diff --git a/src/main/java/com/snp/batch/common/batch/listener/AutoRetryTriggerService.java b/src/main/java/com/snp/batch/common/batch/listener/AutoRetryTriggerService.java index 72d162e..a6fa6c5 100644 --- a/src/main/java/com/snp/batch/common/batch/listener/AutoRetryTriggerService.java +++ b/src/main/java/com/snp/batch/common/batch/listener/AutoRetryTriggerService.java @@ -30,7 +30,7 @@ public class AutoRetryTriggerService { } @Async("autoRetryExecutor") - public void triggerRetryAsync(String jobName, String failedKeys, + public void triggerRetryAsync(String jobName, int failedCount, Long sourceStepExecutionId, String apiKey) { try { Job job = jobMap.get(jobName); @@ -41,7 +41,6 @@ public class AutoRetryTriggerService { JobParametersBuilder builder = new JobParametersBuilder() .addLong("timestamp", System.currentTimeMillis()) - .addString("retryRecordKeys", failedKeys) .addString("sourceStepExecutionId", String.valueOf(sourceStepExecutionId)) .addString("executionMode", "RECOLLECT") .addString("reason", "자동 재수집 (실패 건 자동 처리)") @@ -54,7 +53,7 @@ public class AutoRetryTriggerService { JobParameters retryParams = builder.toJobParameters(); log.info("[AutoRetry] 재수집 Job 실행 시작: jobName={}, 실패건={}, sourceStepExecutionId={}", - jobName, failedKeys.split(",").length, sourceStepExecutionId); + jobName, failedCount, sourceStepExecutionId); JobExecution retryExecution = jobLauncher.run(job, retryParams); diff --git a/src/main/java/com/snp/batch/global/controller/BatchController.java b/src/main/java/com/snp/batch/global/controller/BatchController.java index 5e823ce..a95aacf 100644 --- a/src/main/java/com/snp/batch/global/controller/BatchController.java +++ b/src/main/java/com/snp/batch/global/controller/BatchController.java @@ -648,6 +648,33 @@ public class BatchController { } } + @Operation(summary = "실패 레코드 재시도 횟수 초기화", description = "재시도 횟수를 초과한 FAILED 레코드의 retryCount를 0으로 초기화하여 자동 재수집 대상으로 복원합니다") + @PostMapping("/failed-records/reset-retry") + public ResponseEntity> resetRetryCount( + @RequestBody Map request) { + @SuppressWarnings("unchecked") + List rawIds = (List) request.get("ids"); + if (rawIds == null || rawIds.isEmpty()) { + return ResponseEntity.badRequest().body(Map.of( + "success", false, + "message", "ids는 필수이며 비어있을 수 없습니다")); + } + List ids = rawIds.stream().map(Integer::longValue).toList(); + log.info("Reset retry count: ids count={}", ids.size()); + try { + int reset = batchFailedRecordService.resetRetryCount(ids); + return ResponseEntity.ok(Map.of( + "success", true, + "resetCount", reset, + "message", reset + "건의 실패 레코드 재시도 횟수가 초기화되었습니다")); + } catch (Exception e) { + log.error("Error resetting retry count", e); + return ResponseEntity.internalServerError().body(Map.of( + "success", false, + "message", "재시도 횟수 초기화 실패: " + e.getMessage())); + } + } + // ── 재수집 이력 CSV 내보내기 API ────────────────────────────── @Operation(summary = "재수집 이력 CSV 내보내기", description = "필터 조건으로 재수집 이력을 CSV 파일로 내보냅니다 (최대 10,000건)") diff --git a/src/main/java/com/snp/batch/global/repository/BatchFailedRecordRepository.java b/src/main/java/com/snp/batch/global/repository/BatchFailedRecordRepository.java index 1aea23e..b775e73 100644 --- a/src/main/java/com/snp/batch/global/repository/BatchFailedRecordRepository.java +++ b/src/main/java/com/snp/batch/global/repository/BatchFailedRecordRepository.java @@ -77,4 +77,60 @@ public interface BatchFailedRecordRepository extends JpaRepository findExceededRetryKeys(@Param("jobName") String jobName, @Param("recordKeys") List recordKeys, @Param("maxRetryCount") int maxRetryCount); + + /** + * 특정 Step 실행의 미해결(FAILED) 실패 레코드 키 목록 조회. + * 자동 재수집 시 JobParameter 대신 DB에서 직접 조회하여 VARCHAR(2500) 제한을 우회. + */ + @Query("SELECT r.recordKey FROM BatchFailedRecord r " + + "WHERE r.jobName = :jobName AND r.stepExecutionId = :stepExecutionId " + + "AND r.status = 'FAILED'") + List findFailedRecordKeysByStepExecutionId( + @Param("jobName") String jobName, + @Param("stepExecutionId") Long stepExecutionId); + + /** + * 동일 Job에서 이미 FAILED 상태인 recordKey 목록 조회 (중복 방지용) + */ + @Query("SELECT r.recordKey FROM BatchFailedRecord r " + + "WHERE r.jobName = :jobName AND r.recordKey IN :recordKeys AND r.status = 'FAILED'") + List findExistingFailedKeys(@Param("jobName") String jobName, + @Param("recordKeys") List recordKeys); + + /** + * 기존 FAILED 레코드의 실행 정보를 벌크 업데이트 (retryCount 유지). + * 동일 키가 재실패할 때 최신 실행 정보로 갱신. + */ + @Modifying + @Query("UPDATE BatchFailedRecord r SET r.jobExecutionId = :jobExecutionId, " + + "r.stepExecutionId = :stepExecutionId, r.errorMessage = :errorMessage " + + "WHERE r.jobName = :jobName AND r.recordKey IN :recordKeys AND r.status = 'FAILED'") + int updateFailedRecordExecutions(@Param("jobName") String jobName, + @Param("recordKeys") List recordKeys, + @Param("jobExecutionId") Long jobExecutionId, + @Param("stepExecutionId") Long stepExecutionId, + @Param("errorMessage") String errorMessage); + + /** + * 기존 FAILED 레코드의 실행 정보를 벌크 업데이트하면서 retryCount를 1 증가. + * 재수집(RECOLLECT) 모드에서 재실패한 레코드에 사용. + */ + @Modifying + @Query("UPDATE BatchFailedRecord r SET r.jobExecutionId = :jobExecutionId, " + + "r.stepExecutionId = :stepExecutionId, r.errorMessage = :errorMessage, " + + "r.retryCount = r.retryCount + 1 " + + "WHERE r.jobName = :jobName AND r.recordKey IN :recordKeys AND r.status = 'FAILED'") + int incrementRetryAndUpdateFailedRecords(@Param("jobName") String jobName, + @Param("recordKeys") List recordKeys, + @Param("jobExecutionId") Long jobExecutionId, + @Param("stepExecutionId") Long stepExecutionId, + @Param("errorMessage") String errorMessage); + + /** + * FAILED 상태 레코드의 retryCount를 0으로 초기화 (재시도 초과 건 재활성화) + */ + @Modifying + @Query("UPDATE BatchFailedRecord r SET r.retryCount = 0 " + + "WHERE r.id IN :ids AND r.status = 'FAILED'") + int resetRetryCount(@Param("ids") List ids); } diff --git a/src/main/java/com/snp/batch/jobs/shipdetail/batch/config/ShipDetailUpdateJobConfig.java b/src/main/java/com/snp/batch/jobs/shipdetail/batch/config/ShipDetailUpdateJobConfig.java index 6bbf764..8a189b4 100644 --- a/src/main/java/com/snp/batch/jobs/shipdetail/batch/config/ShipDetailUpdateJobConfig.java +++ b/src/main/java/com/snp/batch/jobs/shipdetail/batch/config/ShipDetailUpdateJobConfig.java @@ -8,6 +8,7 @@ import com.snp.batch.jobs.shipdetail.batch.entity.ShipDetailEntity; import com.snp.batch.jobs.shipdetail.batch.processor.ShipDetailDataProcessor; import com.snp.batch.jobs.shipdetail.batch.reader.ShipDetailUpdateDataReader; import com.snp.batch.jobs.shipdetail.batch.writer.ShipDetailDataWriter; +import com.snp.batch.global.repository.BatchFailedRecordRepository; import com.snp.batch.service.BatchApiLogService; import com.snp.batch.service.BatchDateService; import com.snp.batch.service.BatchFailedRecordService; @@ -37,7 +38,6 @@ import org.springframework.web.reactive.function.client.WebClient; import java.sql.Timestamp; import java.time.LocalDateTime; -import java.util.Arrays; import java.util.List; @Slf4j @@ -53,6 +53,7 @@ public class ShipDetailUpdateJobConfig extends BaseMultiStepJobConfig { - String retryKeys = jobExecution.getJobParameters().getString("retryRecordKeys"); - if (retryKeys != null && !retryKeys.isBlank()) { + String executionMode = jobExecution.getJobParameters().getString("executionMode"); + if ("RECOLLECT".equals(executionMode)) { log.info("[ShipDetailUpdateJob] Decider: RETRY 모드 - LAST_EXECUTION 업데이트 스킵"); return new FlowExecutionStatus("RETRY"); } @@ -160,7 +163,7 @@ public class ShipDetailUpdateJobConfig extends BaseMultiStepJobConfig retryKeys = Arrays.stream(retryRecordKeysParam.split(",")) - .map(String::trim) - .filter(s -> !s.isEmpty()) - .toList(); - reader.setRetryRecordKeys(retryKeys); + // RECOLLECT 모드: DB에서 실패 키를 직접 조회하여 주입 + if ("RECOLLECT".equals(executionMode) && sourceStepExecutionIdParam != null && !sourceStepExecutionIdParam.isBlank()) { + Long sourceStepExecutionId = Long.parseLong(sourceStepExecutionIdParam); + List retryKeys = batchFailedRecordRepository.findFailedRecordKeysByStepExecutionId( + "ShipDetailUpdateJob", sourceStepExecutionId); - if (sourceStepExecutionIdParam != null && !sourceStepExecutionIdParam.isBlank()) { - reader.setSourceStepExecutionId(Long.parseLong(sourceStepExecutionIdParam)); + if (!retryKeys.isEmpty()) { + reader.setRetryRecordKeys(retryKeys); + reader.setSourceStepExecutionId(sourceStepExecutionId); + log.info("[ShipDetailUpdateJob] Retry 모드 활성화: DB에서 {} 건의 실패 키 조회, sourceStepExecutionId: {}", + retryKeys.size(), sourceStepExecutionId); + } else { + log.warn("[ShipDetailUpdateJob] RECOLLECT 모드이나 DB에서 실패 키를 찾을 수 없음 (sourceStepExecutionId: {})", + sourceStepExecutionId); } - - log.info("[ShipDetailUpdateJob] Retry 모드 활성화: {} 건의 IMO 대상, sourceStepExecutionId: {}", - retryKeys.size(), sourceStepExecutionIdParam); } return reader; diff --git a/src/main/java/com/snp/batch/jobs/shipdetail/batch/reader/ShipDetailUpdateDataReader.java b/src/main/java/com/snp/batch/jobs/shipdetail/batch/reader/ShipDetailUpdateDataReader.java index 712b47e..d6fa3ef 100644 --- a/src/main/java/com/snp/batch/jobs/shipdetail/batch/reader/ShipDetailUpdateDataReader.java +++ b/src/main/java/com/snp/batch/jobs/shipdetail/batch/reader/ShipDetailUpdateDataReader.java @@ -302,7 +302,7 @@ public class ShipDetailUpdateDataReader extends BaseApiReader { getJobExecutionId(), getStepExecutionId(), failedImoNumbers, - maxRetryCount, + true, lastErrorMessage ); } else { @@ -313,17 +313,17 @@ public class ShipDetailUpdateDataReader extends BaseApiReader { log.info("[{}] [RETRY MODE] 결과: 성공 {} 건, 재실패 {} 건", getReaderName(), successCount, failedImoNumbers.size()); } else { - // 일반 모드: 기존 로직 + // 일반 모드: 동기 저장 (자동 재수집 트리거 전에 커밋 보장) if (!failedImoNumbers.isEmpty()) { log.warn("[{}] 최종 실패 IMO 건수: {} 건", getReaderName(), failedImoNumbers.size()); log.warn("[{}] 실패 IMO 목록: {}", getReaderName(), failedImoNumbers); - batchFailedRecordService.saveFailedRecords( + batchFailedRecordService.saveFailedRecordsSync( "ShipDetailUpdateJob", getJobExecutionId(), getStepExecutionId(), failedImoNumbers, - maxRetryCount, + false, lastErrorMessage ); } else { diff --git a/src/main/java/com/snp/batch/service/BatchFailedRecordService.java b/src/main/java/com/snp/batch/service/BatchFailedRecordService.java index 1c0d6dd..7d0cc34 100644 --- a/src/main/java/com/snp/batch/service/BatchFailedRecordService.java +++ b/src/main/java/com/snp/batch/service/BatchFailedRecordService.java @@ -10,7 +10,9 @@ import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; +import java.util.HashSet; import java.util.List; +import java.util.Set; @Service @RequiredArgsConstructor @@ -20,8 +22,8 @@ public class BatchFailedRecordService { private final BatchFailedRecordRepository batchFailedRecordRepository; /** - * 실패한 레코드 목록을 비동기로 DB에 저장합니다. - * REQUIRES_NEW를 사용하여 메인 배치 트랜잭션과 독립적으로 저장합니다. + * 실패 레코드를 비동기로 저장/업데이트합니다. + * 동일 (jobName, recordKey)에 FAILED 레코드가 이미 존재하면 실행 정보만 갱신합니다. */ @Async("apiLogExecutor") @Transactional(propagation = Propagation.REQUIRES_NEW) @@ -30,27 +32,83 @@ public class BatchFailedRecordService { Long jobExecutionId, Long stepExecutionId, List failedRecordKeys, - int retryCount, + boolean isRetryMode, + String errorMessage + ) { + doSaveOrUpdateFailedRecords(jobName, jobExecutionId, stepExecutionId, + failedRecordKeys, isRetryMode, errorMessage); + } + + /** + * 실패 레코드를 동기적으로 저장/업데이트합니다. + * 자동 재수집 트리거 전에 실패 레코드가 반드시 커밋되어야 하는 경우 사용합니다. + */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void saveFailedRecordsSync( + String jobName, + Long jobExecutionId, + Long stepExecutionId, + List failedRecordKeys, + boolean isRetryMode, + String errorMessage + ) { + doSaveOrUpdateFailedRecords(jobName, jobExecutionId, stepExecutionId, + failedRecordKeys, isRetryMode, errorMessage); + } + + private void doSaveOrUpdateFailedRecords( + String jobName, + Long jobExecutionId, + Long stepExecutionId, + List failedRecordKeys, + boolean isRetryMode, String errorMessage ) { try { - List records = failedRecordKeys.stream() - .map(recordKey -> BatchFailedRecord.builder() - .jobName(jobName) - .jobExecutionId(jobExecutionId) - .stepExecutionId(stepExecutionId) - .recordKey(recordKey) - .errorMessage(errorMessage) - .retryCount(retryCount) - .status("FAILED") - .build()) - .toList(); + // 이미 FAILED 상태인 기존 레코드 키 조회 + Set existingKeys = new HashSet<>( + batchFailedRecordRepository.findExistingFailedKeys(jobName, failedRecordKeys)); - batchFailedRecordRepository.saveAll(records); - log.info("실패 레코드 {} 건 저장 완료 (job: {}, executionId: {})", - records.size(), jobName, jobExecutionId); + // 기존 레코드 업데이트 (실행 정보 갱신) + List keysToUpdate = failedRecordKeys.stream() + .filter(existingKeys::contains) + .toList(); + if (!keysToUpdate.isEmpty()) { + int updated; + if (isRetryMode) { + updated = batchFailedRecordRepository.incrementRetryAndUpdateFailedRecords( + jobName, keysToUpdate, jobExecutionId, stepExecutionId, errorMessage); + } else { + updated = batchFailedRecordRepository.updateFailedRecordExecutions( + jobName, keysToUpdate, jobExecutionId, stepExecutionId, errorMessage); + } + log.info("실패 레코드 {} 건 업데이트 완료 (job: {}, retryMode: {})", + updated, jobName, isRetryMode); + } + + // 신규 레코드 INSERT + List newKeys = failedRecordKeys.stream() + .filter(key -> !existingKeys.contains(key)) + .toList(); + if (!newKeys.isEmpty()) { + int initialRetryCount = isRetryMode ? 1 : 0; + List newRecords = newKeys.stream() + .map(recordKey -> BatchFailedRecord.builder() + .jobName(jobName) + .jobExecutionId(jobExecutionId) + .stepExecutionId(stepExecutionId) + .recordKey(recordKey) + .errorMessage(errorMessage) + .retryCount(initialRetryCount) + .status("FAILED") + .build()) + .toList(); + batchFailedRecordRepository.saveAll(newRecords); + log.info("실패 레코드 {} 건 신규 저장 완료 (job: {}, retryCount: {})", + newRecords.size(), jobName, initialRetryCount); + } } catch (Exception e) { - log.error("실패 레코드 저장 실패: {}", e.getMessage()); + log.error("실패 레코드 저장/업데이트 실패: {}", e.getMessage(), e); } } @@ -67,6 +125,20 @@ public class BatchFailedRecordService { return resolved; } + /** + * FAILED 상태 레코드의 retryCount를 0으로 초기화합니다. + * 재시도 횟수를 초과한 레코드를 다시 자동 재수집 대상으로 만듭니다. + */ + @Transactional + public int resetRetryCount(List ids) { + if (ids == null || ids.isEmpty()) { + return 0; + } + int reset = batchFailedRecordRepository.resetRetryCount(ids); + log.info("실패 레코드 retryCount 초기화: {} 건", reset); + return reset; + } + /** * 재수집 성공 건을 RESOLVED로 처리합니다. * 원본 stepExecutionId로 범위를 제한하여 해당 Step의 실패 건만 RESOLVED 처리합니다. -- 2.45.2 From fc5501fd5514ead986acca8eb94c917b26eaea17 Mon Sep 17 00:00:00 2001 From: HYOJIN Date: Thu, 12 Mar 2026 16:04:00 +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 | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 0f1c159..ca92665 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -26,8 +26,12 @@ - tb_ship_main_info, core20 테이블 mmsi 컬럼 업데이트 추가 (#28) - 자동 재수집 및 재수집 프로세스 전면 개선 (#30) - 배치 작업 목록 UX 개선: 상태 필터, 카드/테이블 뷰, 정렬, 실행 중 강조 (#33) +- 재시도 초과 레코드 초기화 API/UI 추가 ### 수정 +- 자동 재수집 JobParameter 오버플로우 수정 (VARCHAR 2500 제한 해결) +- retryCount 세마틱 오류 수정 (0부터 시작, 재수집 실패 시 증가) +- 실패 레코드 저장 타이밍 경합 해결 (동기 저장으로 변경) - ChnPrmShip 캐시 갱신 조건 완화 및 스케줄 이전 실행 시간 표시 (#3) - 재수집 관리 및 이력 추가 (#4) - 재수집 중복 실행 문제 해결 (#9) @@ -37,6 +41,8 @@ - 타임라인 상세 화면 이동 오류 수정 및 실행 중 작업 상세 버튼 추가 (#34) ### 변경 +- 실패 레코드 Upsert 패턴 적용 (동일 키 중복 방지) +- 재시도 상태 배지 표시 (대기/재시도 N/3/재시도 초과) - 미사용 Dead Code 정리 (~1,200 LOC 삭제) ### 기타 @@ -46,3 +52,4 @@ - CLAUDE_BOT_TOKEN 갱신 (#26) - 팀 글로벌 워크플로우 1.5.0 동기화 - 팀 워크플로우 v1.6.1 동기화 +- 실행 확인 모달 시작/종료일시 항목 제거 -- 2.45.2