fix(batch): 자동 재수집 파라미터 오버플로우 수정 및 실패 레코드 관리 개선 #38

병합
HYOJIN fix/auto-retry-parameter-overflow 에서 develop 로 2 commits 를 머지했습니다 2026-03-12 16:07:11 +09:00
12개의 변경된 파일500개의 추가작업 그리고 123개의 파일을 삭제
Showing only changes of commit e9ef8b9df5 - Show all commits

파일 보기

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

파일 보기

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

파일 보기

@ -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 (
<div className="mt-4 rounded-lg bg-red-50 border border-red-200 p-3">
<div className="flex items-center justify-between">
@ -422,6 +449,17 @@ function FailedRecordsToggle({ records, jobName, stepExecutionId }: { records: F
{failedRecords.length > 0 && (
<div className="flex items-center gap-1.5">
{exceededRecords.length > 0 && (
<button
onClick={() => setShowResetConfirm(true)}
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium text-amber-700 bg-amber-50 hover:bg-amber-100 border border-amber-200 rounded-md transition-colors"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
({exceededRecords.length})
</button>
)}
<button
onClick={() => setShowResolveConfirm(true)}
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium text-emerald-700 bg-emerald-50 hover:bg-emerald-100 border border-emerald-200 rounded-md transition-colors"
@ -469,8 +507,17 @@ function FailedRecordsToggle({ records, jobName, stepExecutionId }: { records: F
<td className="px-2 py-1.5 text-red-600 max-w-[200px] truncate" title={record.errorMessage || ''}>
{record.errorMessage || '-'}
</td>
<td className="px-2 py-1.5 text-center text-red-900">
{record.retryCount}
<td className="px-2 py-1.5 text-center">
{(() => {
const info = retryStatusLabel(record);
return info ? (
<span className={`inline-flex px-1.5 py-0.5 text-[10px] font-medium rounded-full ${info.color}`}>
{info.label}
</span>
) : (
<span className="text-wing-muted">-</span>
);
})()}
</td>
<td className="px-2 py-1.5 text-center">
<span className={`inline-flex px-1.5 py-0.5 text-[10px] font-medium rounded-full ${statusColor(record.status)}`}>
@ -581,6 +628,44 @@ function FailedRecordsToggle({ records, jobName, stepExecutionId }: { records: F
</div>
</div>
)}
{/* 재시도 초기화 확인 다이얼로그 */}
{showResetConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white rounded-xl shadow-2xl p-6 w-full max-w-md mx-4">
<h3 className="text-lg font-semibold text-wing-text mb-2">
</h3>
<p className="text-sm text-wing-muted mb-4">
{exceededRecords.length} retryCount를 0 .
.
</p>
<div className="flex justify-end gap-2">
<button
onClick={() => setShowResetConfirm(false)}
disabled={resetting}
className="px-4 py-2 text-sm font-medium text-wing-muted bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors disabled:opacity-50"
>
</button>
<button
onClick={handleResetRetry}
disabled={resetting}
className="px-4 py-2 text-sm font-medium text-white bg-amber-500 hover:bg-amber-600 rounded-lg transition-colors disabled:opacity-50 inline-flex items-center gap-1"
>
{resetting ? (
<>
<div className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-white border-t-transparent" />
...
</>
) : (
'초기화 실행'
)}
</button>
</div>
</div>
</div>
)}
</div>
);
}

파일 보기

@ -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<string, string> = {};
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() {
&quot;{targetJob}&quot; ?
</p>
{/* Date parameters */}
<div className="space-y-3 mb-6">
<div>
<label className="block text-xs font-medium text-wing-text mb-1">
()
</label>
<input
type="datetime-local"
value={startDate}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-xs font-medium text-wing-text mb-1">
()
</label>
<input
type="datetime-local"
value={stopDate}
onChange={(e) => 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"
/>
</div>
{(startDate || stopDate) && (
<p className="text-xs text-wing-muted">
.
</p>
)}
</div>
<div className="flex justify-end gap-3">
<div className="flex justify-end gap-3 mt-6">
<button
onClick={() => setExecuteModalOpen(false)}
className="px-4 py-2 text-sm font-medium text-wing-text bg-wing-card rounded-lg

파일 보기

@ -404,6 +404,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();
@ -419,6 +421,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 {
@ -455,6 +468,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 (
<div className="mt-4 rounded-lg bg-red-50 border border-red-200 p-3">
<div className="flex items-center justify-between">
@ -473,6 +500,17 @@ function FailedRecordsToggle({ records, jobName, stepExecutionId }: { records: F
{failedRecords.length > 0 && (
<div className="flex items-center gap-1.5">
{exceededRecords.length > 0 && (
<button
onClick={() => setShowResetConfirm(true)}
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium text-amber-700 bg-amber-50 hover:bg-amber-100 border border-amber-200 rounded-md transition-colors"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
({exceededRecords.length})
</button>
)}
<button
onClick={() => setShowResolveConfirm(true)}
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium text-emerald-700 bg-emerald-50 hover:bg-emerald-100 border border-emerald-200 rounded-md transition-colors"
@ -520,8 +558,17 @@ function FailedRecordsToggle({ records, jobName, stepExecutionId }: { records: F
<td className="px-2 py-1.5 text-red-600 max-w-[200px] truncate" title={record.errorMessage || ''}>
{record.errorMessage || '-'}
</td>
<td className="px-2 py-1.5 text-center text-red-900">
{record.retryCount}
<td className="px-2 py-1.5 text-center">
{(() => {
const info = retryStatusLabel(record);
return info ? (
<span className={`inline-flex px-1.5 py-0.5 text-[10px] font-medium rounded-full ${info.color}`}>
{info.label}
</span>
) : (
<span className="text-wing-muted">-</span>
);
})()}
</td>
<td className="px-2 py-1.5 text-center">
<span className={`inline-flex px-1.5 py-0.5 text-[10px] font-medium rounded-full ${statusColor(record.status)}`}>
@ -632,6 +679,44 @@ function FailedRecordsToggle({ records, jobName, stepExecutionId }: { records: F
</div>
</div>
)}
{/* 재시도 초기화 확인 다이얼로그 */}
{showResetConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white rounded-xl shadow-2xl p-6 w-full max-w-md mx-4">
<h3 className="text-lg font-semibold text-wing-text mb-2">
</h3>
<p className="text-sm text-wing-muted mb-4">
{exceededRecords.length} retryCount를 0 .
.
</p>
<div className="flex justify-end gap-2">
<button
onClick={() => setShowResetConfirm(false)}
disabled={resetting}
className="px-4 py-2 text-sm font-medium text-wing-muted bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors disabled:opacity-50"
>
</button>
<button
onClick={handleResetRetry}
disabled={resetting}
className="px-4 py-2 text-sm font-medium text-white bg-amber-500 hover:bg-amber-600 rounded-lg transition-colors disabled:opacity-50 inline-flex items-center gap-1"
>
{resetting ? (
<>
<div className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-white border-t-transparent" />
...
</>
) : (
'초기화 실행'
)}
</button>
</div>
</div>
</div>
)}
</div>
);
}

파일 보기

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

파일 보기

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

파일 보기

@ -648,6 +648,33 @@ public class BatchController {
}
}
@Operation(summary = "실패 레코드 재시도 횟수 초기화", description = "재시도 횟수를 초과한 FAILED 레코드의 retryCount를 0으로 초기화하여 자동 재수집 대상으로 복원합니다")
@PostMapping("/failed-records/reset-retry")
public ResponseEntity<Map<String, Object>> resetRetryCount(
@RequestBody Map<String, Object> request) {
@SuppressWarnings("unchecked")
List<Integer> rawIds = (List<Integer>) request.get("ids");
if (rawIds == null || rawIds.isEmpty()) {
return ResponseEntity.badRequest().body(Map.of(
"success", false,
"message", "ids는 필수이며 비어있을 수 없습니다"));
}
List<Long> 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건)")

파일 보기

@ -77,4 +77,60 @@ public interface BatchFailedRecordRepository extends JpaRepository<BatchFailedRe
List<String> findExceededRetryKeys(@Param("jobName") String jobName,
@Param("recordKeys") List<String> 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<String> 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<String> findExistingFailedKeys(@Param("jobName") String jobName,
@Param("recordKeys") List<String> 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<String> 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<String> 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<Long> ids);
}

파일 보기

@ -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<ShipDetail
private final BatchDateService batchDateService;
private final BatchApiLogService batchApiLogService;
private final BatchFailedRecordService batchFailedRecordService;
private final BatchFailedRecordRepository batchFailedRecordRepository;
private final JobExecutionListener autoRetryJobExecutionListener;
@Value("${app.batch.ship-api.url}")
@ -91,6 +92,7 @@ public class ShipDetailUpdateJobConfig extends BaseMultiStepJobConfig<ShipDetail
BatchDateService batchDateService,
BatchApiLogService batchApiLogService,
BatchFailedRecordService batchFailedRecordService,
BatchFailedRecordRepository batchFailedRecordRepository,
@Qualifier("autoRetryJobExecutionListener") JobExecutionListener autoRetryJobExecutionListener) {
super(jobRepository, transactionManager);
this.shipDetailDataProcessor = shipDetailDataProcessor;
@ -102,6 +104,7 @@ public class ShipDetailUpdateJobConfig extends BaseMultiStepJobConfig<ShipDetail
this.batchDateService = batchDateService;
this.batchApiLogService = batchApiLogService;
this.batchFailedRecordService = batchFailedRecordService;
this.batchFailedRecordRepository = batchFailedRecordRepository;
this.autoRetryJobExecutionListener = autoRetryJobExecutionListener;
}
@ -134,13 +137,13 @@ public class ShipDetailUpdateJobConfig extends BaseMultiStepJobConfig<ShipDetail
/**
* Retry 모드 판별 Decider
* retryRecordKeys 파라미터가 존재하 RETRY, 없으면 NORMAL 반환
* executionMode가 RECOLLECT이 RETRY, 없으면 NORMAL 반환
*/
@Bean
public JobExecutionDecider retryModeDecider() {
return (jobExecution, stepExecution) -> {
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<ShipDetail
public ShipDetailUpdateDataReader shipDetailUpdateDataReader(
@Value("#{stepExecution.jobExecution.id}") Long jobExecutionId,
@Value("#{stepExecution.id}") Long stepExecutionId,
@Value("#{jobParameters['retryRecordKeys']}") String retryRecordKeysParam,
@Value("#{jobParameters['executionMode']}") String executionMode,
@Value("#{jobParameters['sourceStepExecutionId']}") String sourceStepExecutionIdParam
) {
ShipDetailUpdateDataReader reader = new ShipDetailUpdateDataReader(
@ -170,20 +173,21 @@ public class ShipDetailUpdateJobConfig extends BaseMultiStepJobConfig<ShipDetail
);
reader.setExecutionIds(jobExecutionId, stepExecutionId);
// Retry 모드: retryRecordKeys 파라미터가 있으면 주입
if (retryRecordKeysParam != null && !retryRecordKeysParam.isBlank()) {
List<String> 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<String> 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;

파일 보기

@ -302,7 +302,7 @@ public class ShipDetailUpdateDataReader extends BaseApiReader<ShipDetailDto> {
getJobExecutionId(),
getStepExecutionId(),
failedImoNumbers,
maxRetryCount,
true,
lastErrorMessage
);
} else {
@ -313,17 +313,17 @@ public class ShipDetailUpdateDataReader extends BaseApiReader<ShipDetailDto> {
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 {

파일 보기

@ -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<String> 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<String> failedRecordKeys,
boolean isRetryMode,
String errorMessage
) {
doSaveOrUpdateFailedRecords(jobName, jobExecutionId, stepExecutionId,
failedRecordKeys, isRetryMode, errorMessage);
}
private void doSaveOrUpdateFailedRecords(
String jobName,
Long jobExecutionId,
Long stepExecutionId,
List<String> failedRecordKeys,
boolean isRetryMode,
String errorMessage
) {
try {
List<BatchFailedRecord> 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<String> existingKeys = new HashSet<>(
batchFailedRecordRepository.findExistingFailedKeys(jobName, failedRecordKeys));
batchFailedRecordRepository.saveAll(records);
log.info("실패 레코드 {} 건 저장 완료 (job: {}, executionId: {})",
records.size(), jobName, jobExecutionId);
// 기존 레코드 업데이트 (실행 정보 갱신)
List<String> 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<String> newKeys = failedRecordKeys.stream()
.filter(key -> !existingKeys.contains(key))
.toList();
if (!newKeys.isEmpty()) {
int initialRetryCount = isRetryMode ? 1 : 0;
List<BatchFailedRecord> 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<Long> ids) {
if (ids == null || ids.isEmpty()) {
return 0;
}
int reset = batchFailedRecordRepository.resetRetryCount(ids);
log.info("실패 레코드 retryCount 초기화: {} 건", reset);
return reset;
}
/**
* 재수집 성공 건을 RESOLVED로 처리합니다.
* 원본 stepExecutionId로 범위를 제한하여 해당 Step의 실패 건만 RESOLVED 처리합니다.