# 재수집 프로세스 분석 및 테스트 시나리오 ## 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 | ---