재수집 프로세스 분석 및 테스트 시나리오
1. 트리거 경로 3가지 비교
| 항목 |
자동 재수집 (AUTO_RETRY) |
수동 실패건 재수집 (MANUAL_RETRY) |
수동 날짜범위 재수집 (MANUAL) |
| 트리거 |
Job COMPLETED 후 리스너 자동 |
UI "실패 건 재수집" 버튼 |
UI "재수집 실행" 버튼 |
| 대상 |
실패한 record key |
실패한 record key |
날짜 범위 전체 |
executionMode |
RECOLLECT |
RECOLLECT |
RECOLLECT |
executor |
AUTO_RETRY |
MANUAL_RETRY |
MANUAL |
sourceStepExecutionId |
있음 |
있음 |
없음 |
apiKey |
있음 (ExecutionContext) |
없음 (resolveApiKey fallback) |
있음 (파라미터) |
| Reader 모드 |
retry (DB에서 키 조회) |
retry (DB에서 키 조회) |
normal (API 전체 호출) |
| last_success_date |
Tasklet 스킵 → 불변 |
Tasklet 스킵 → 불변 |
Tasklet 실행 → 갱신 → 원복 |
| 이력 날짜범위 |
NULL / NULL |
NULL / NULL |
from / to |
| 중복 검사 |
안함 |
안함 |
함 (findOverlappingHistories) |
2. 데이터 흐름 다이어그램
자동 재수집 흐름 (AUTO_RETRY)
[정상 배치 실행]
│
├─ Reader.fetchNextBatch() → API 호출 실패 (3회 재시도 소진)
│ └─ failedImoNumbers에 누적
│
├─ Reader.afterFetch(null) [일반 모드]
│ ├─ BatchFailedRecordService.saveFailedRecordsSync() [동기 저장]
│ │ ├─ 기존 FAILED 레코드 존재 → UPDATE (실행정보 갱신, retryCount 유지)
│ │ └─ 신규 키 → INSERT (retryCount=0, status=FAILED)
│ └─ finally: ExecutionContext에 failedRecordKeys/stepId/apiKey 저장
│
├─ [Job COMPLETED]
│
├─ AutoRetryJobExecutionListener.afterJob()
│ ├─ executionMode != "RECOLLECT" ✓
│ ├─ status == COMPLETED ✓
│ ├─ 모든 Step의 failedRecordKeys → LinkedHashSet 병합
│ ├─ findExceededRetryKeys() → retryCount >= 3 인 키 제외
│ └─ AutoRetryTriggerService.triggerRetryAsync(jobName, failedCount, stepId, apiKey)
│ └─ JobParameter에 sourceStepExecutionId만 전달 (키 목록은 DB에서 조회)
│
├─ [재수집 Job 시작 (executionMode=RECOLLECT, executor=AUTO_RETRY)]
│
├─ RecollectionJobExecutionListener.beforeJob()
│ ├─ 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)
│ │ └─ sourceStepExecutionId 기준 (아직 원본 stepId 보유, resolve 먼저 실행)
│ └─ 재실패 건 saveFailedRecords() [비동기]
│ ├─ 기존 FAILED 레코드 → UPDATE (실행정보 갱신 + retryCount + 1)
│ └─ 신규 키 → INSERT (retryCount=1)
│
├─ retryModeDecider → executionMode="RECOLLECT" → "RETRY" → lastExecutionUpdateStep 스킵
│
├─ RecollectionJobExecutionListener.afterJob()
│ ├─ recordCompletion() ──→ [batch_recollection_history] UPDATE (COMPLETED/FAILED)
│ └─ last_success_date 복원 검사 (DB 현재값 > 원본 → 스킵)
│ └─ restoreLastSuccessDate() ──→ [batch_last_execution] UPDATE
│
└─ [완료]
수동 실패건 재수집 흐름 (MANUAL_RETRY)
[프론트엔드 ExecutionDetail / RecollectDetail]
│
├─ "실패 건 재수집" 버튼 클릭 → 확인 다이얼로그
│
├─ batchApi.retryFailedRecords(jobName, recordKeys, stepExecutionId)
│ └─ POST /api/batch/jobs/{jobName}/execute
│ ?sourceStepExecutionId=123
│ &executionMode=RECOLLECT
│ &executor=MANUAL_RETRY
│ &reason=실패 건 수동 재수집 (N건)
│
├─ BatchController.executeJob() → BatchService.executeJob()
│ └─ jobLauncher.run(job, params)
│
├─ [이후 흐름은 AUTO_RETRY와 동일]
│ ├─ RecollectionJobExecutionListener.beforeJob() → 이력 INSERT
│ ├─ Reader 빈 초기화 시 DB에서 실패 키 조회
│ ├─ Reader [retry 모드] → 키 기반 재처리
│ ├─ retryModeDecider → executionMode="RECOLLECT" → "RETRY" → Tasklet 스킵
│ └─ RecollectionJobExecutionListener.afterJob() → 이력 UPDATE + 복원
│
└─ 성공 시 UI에서 새 실행 상세 화면으로 이동
수동 날짜범위 재수집 흐름 (MANUAL)
[프론트엔드 Recollects.tsx]
│
├─ updateCollectionPeriod(apiKey, from, to) ──→ [batch_collection_period] UPDATE
│
├─ executeJob(jobName, {executionMode, apiKey, executor, reason})
│ └─ POST /api/batch/jobs/{jobName}/execute
│ ?executionMode=RECOLLECT&apiKey=...&executor=MANUAL&reason=...
│
├─ RecollectionJobExecutionListener.beforeJob()
│ ├─ last_success_date 저장
│ ├─ periodRepository.findById(apiKey) → 날짜범위 조회
│ ├─ findOverlappingHistories() → 중복 검사
│ └─ recordStart() ──→ [batch_recollection_history] INSERT (날짜범위 포함)
│
├─ Reader [일반 모드] → API 전체 호출
│
├─ retryModeDecider → "NORMAL" → lastExecutionUpdateStep 실행
│ └─ Tasklet ──→ [batch_last_execution] UPDATE (NOW())
│
├─ RecollectionJobExecutionListener.afterJob()
│ ├─ recordCompletion() ──→ [batch_recollection_history] UPDATE
│ └─ last_success_date 복원 ──→ [batch_last_execution] UPDATE (원래 값 복원)
│
└─ [완료]
3. 테이블 영향 매트릭스
| 테이블 |
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. 실패 레코드 관리
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에서는 자동 재수집 트리거 안 함)
Guard 2: findExceededRetryKeys()
└─ retryCount >= 3 인 키 → mergedKeys에서 제거
└─ 남은 키 없으면 → 재수집 자체 스킵
Guard 3: Reader afterFetch [retry 모드]
└─ ExecutionContext에 failedRecordKeys 미기록 → 리스너가 감지하지 못함
[시나리오]
일반 실행 → 실패 3건 → batch_failed_record INSERT (retryCount=0)
→ 자동 재수집 1차 → 재실패 → retryCount=1
→ 자동 재수집 2차 → 재실패 → retryCount=2
→ 자동 재수집 3차 → 재실패 → retryCount=3
→ 다음 정상 실행에서 같은 키 다시 실패해도 retryCount >= 3 → Guard 2에서 차단
6. 프론트엔드 UI 진입점
| 화면 |
컴포넌트/영역 |
기능 |
| Executions |
failedRecordCount 뱃지 |
COMPLETED 상태 + 미해결 실패건 > 0 → amber 뱃지 |
| ExecutionDetail |
FailedRecordsToggle |
실패 레코드 목록 + 재시도 상태 배지 + "재시도 초기화" / "재수집 실행" / "일괄 RESOLVED" 버튼 |
| RecollectDetail |
FailedRecordsToggle |
재수집 상세 내 실패 레코드 + 동일 버튼 |
| Recollects |
이력 목록 + 수집기간 관리 |
검색/필터 + CSV 내보내기 + 재수집 실행 + 기간 편집 |
| Dashboard |
재수집 현황 위젯 |
통계 카드 5개 + 최근 5건 테이블 |
7. 주요 파일 맵
백엔드
| 파일 |
역할 |
ShipDetailUpdateDataReader.java |
API 호출 + 실패 감지 + ExecutionContext 저장 |
ShipDetailUpdateJobConfig.java |
Job/Step 구성 + retryModeDecider(executionMode 기반) + DB에서 실패 키 조회 |
AutoRetryJobExecutionListener.java |
Job 완료 후 실패 키 병합 + retryCount 필터 + 트리거 |
AutoRetryTriggerService.java |
@Async 비동기 Job 재실행 (sourceStepExecutionId만 전달) |
RecollectionJobExecutionListener.java |
재수집 이력 기록 + last_success_date 백업/복원 |
RecollectionHistoryService.java |
이력 CRUD + 통계 + 수집기간 관리 |
BatchFailedRecordService.java |
실패 레코드 Upsert(동기/비동기) + RESOLVED 처리 |
BatchFailedRecordRepository.java |
실패 키 조회 + 벌크 UPDATE(retryCount 유지/증가/초기화) + RESOLVED |
BatchService.java |
Job 실행 + failedRecordCount 조회 |
BatchController.java |
REST API 엔드포인트 (실행/이력/CSV/RESOLVED/재시도 초기화) |
프론트엔드
| 파일 |
역할 |
batchApi.ts |
API 클라이언트 (retryFailedRecords, resolveFailedRecords, resetRetryCount, exportCSV 등) |
Executions.tsx |
실행 목록 + failedRecordCount amber 뱃지 |
ExecutionDetail.tsx |
실행 상세 + FailedRecordsToggle (재수집/RESOLVED) |
RecollectDetail.tsx |
재수집 상세 + FailedRecordsToggle (재수집/RESOLVED) |
Recollects.tsx |
재수집 이력 목록 + 수집기간 관리 + CSV 내보내기 |
Dashboard.tsx |
대시보드 재수집 현황 위젯 |
공유 컴포넌트
| 파일 |
사용처 |
components/CopyButton.tsx |
ApiLogSection 내부 |
components/DetailStatCard.tsx |
ExecutionDetail, RecollectDetail |
components/ApiLogSection.tsx |
ExecutionDetail, RecollectDetail |
components/InfoItem.tsx |
ExecutionDetail, RecollectDetail |