diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index d6534d8..794d220 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,5 +4,8 @@ ## [Unreleased] +### 추가 +- 자동 재수집 및 재수집 프로세스 전면 개선 (#30) + ### 기타 - 팀 워크플로우 v1.6.1 동기화 diff --git a/docs/recollection-process.md b/docs/recollection-process.md new file mode 100644 index 0000000..e9eb124 --- /dev/null +++ b/docs/recollection-process.md @@ -0,0 +1,205 @@ +# 재수집 프로세스 분석 및 테스트 시나리오 + +## 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 | +| `retryRecordKeys` | 있음 (콤마 구분) | 있음 (콤마 구분) | 없음 | +| `sourceStepExecutionId` | 있음 | 있음 | 없음 | +| `apiKey` | 있음 (ExecutionContext) | 없음 (resolveApiKey fallback) | 있음 (파라미터) | +| **Reader 모드** | retry (키 기반) | retry (키 기반) | 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.saveFailedRecords() ──→ [batch_failed_record] INSERT (FAILED) + │ └─ finally: ExecutionContext에 failedRecordKeys/stepId/apiKey 저장 + │ + ├─ [Job COMPLETED] + │ + ├─ AutoRetryJobExecutionListener.afterJob() + │ ├─ executionMode != "RECOLLECT" ✓ + │ ├─ status == COMPLETED ✓ + │ ├─ 모든 Step의 failedRecordKeys → LinkedHashSet 병합 + │ ├─ findExceededRetryKeys() → retryCount >= 3 인 키 제외 + │ └─ AutoRetryTriggerService.triggerRetryAsync() [autoRetryExecutor 스레드] + │ + ├─ [재수집 Job 시작 (executionMode=RECOLLECT, executor=AUTO_RETRY)] + │ + ├─ RecollectionJobExecutionListener.beforeJob() + │ ├─ last_success_date 저장 (ExecutionContext) + │ └─ recordStart() ──→ [batch_recollection_history] INSERT (STARTED, 날짜 NULL) + │ + ├─ Reader.beforeFetch() [retry 모드] + │ └─ allImoNumbers = retryRecordKeys (API 호출 없이 키 목록 직접 사용) + │ + ├─ Reader.afterFetch(null) [retry 모드] + │ ├─ resolveSuccessfulRetries() ──→ [batch_failed_record] UPDATE (RESOLVED) + │ └─ 재실패 건 saveFailedRecords() ──→ [batch_failed_record] INSERT (FAILED, 새 레코드) + │ + ├─ retryModeDecider → "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 + │ ?retryRecordKeys=IMO1,IMO2 + │ &sourceStepExecutionId=123 + │ &executionMode=RECOLLECT + │ &executor=MANUAL_RETRY + │ &reason=실패 건 수동 재수집 (N건) + │ + ├─ BatchController.executeJob() → BatchService.executeJob() + │ └─ jobLauncher.run(job, params) + │ + ├─ [이후 흐름은 AUTO_RETRY와 동일] + │ ├─ RecollectionJobExecutionListener.beforeJob() → 이력 INSERT + │ ├─ Reader [retry 모드] → 키 기반 재처리 + │ ├─ retryModeDecider → "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 | +|--------|-----------|--------------|-------------|-----------------| +| `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 자동) | - | + +--- + +## 4. 무한루프 방지 구조 + +``` +[이중 가드] + +Guard 1: AutoRetryJobExecutionListener + └─ executionMode == "RECOLLECT" → 즉시 리턴 (재수집 Job에서는 자동 재수집 트리거 안 함) + +Guard 2: findExceededRetryKeys() + └─ retryCount >= 3 인 키 → mergedKeys에서 제거 + └─ 남은 키 없으면 → 재수집 자체 스킵 + +[시나리오] + 일반 실행 → 실패 3건 → 자동 재수집(retryCount=3 저장) + → 재수집 Job은 executionMode=RECOLLECT → Guard 1에서 차단 + → 다음 정상 실행에서 같은 키 다시 실패해도 retryCount >= 3 → Guard 2에서 차단 +``` + +--- + +## 5. 프론트엔드 UI 진입점 + +| 화면 | 컴포넌트/영역 | 기능 | +|------|--------------|------| +| **Executions** | failedRecordCount 뱃지 | COMPLETED 상태 + 미해결 실패건 > 0 → amber 뱃지 | +| **ExecutionDetail** | FailedRecordsToggle | 실패 레코드 목록 + "재수집 실행" + "일괄 RESOLVED" 버튼 | +| **RecollectDetail** | FailedRecordsToggle | 재수집 상세 내 실패 레코드 + 동일 버튼 | +| **Recollects** | 이력 목록 + 수집기간 관리 | 검색/필터 + CSV 내보내기 + 재수집 실행 + 기간 편집 | +| **Dashboard** | 재수집 현황 위젯 | 통계 카드 5개 + 최근 5건 테이블 | + +--- + +## 6. 주요 파일 맵 + +### 백엔드 + +| 파일 | 역할 | +|------|------| +| `ShipDetailUpdateDataReader.java` | API 호출 + 실패 감지 + ExecutionContext 저장 | +| `ShipDetailUpdateJobConfig.java` | Job/Step 구성 + retryModeDecider + 리스너 등록 | +| `AutoRetryJobExecutionListener.java` | Job 완료 후 실패 키 병합 + retryCount 필터 + 트리거 | +| `AutoRetryTriggerService.java` | @Async 비동기 Job 재실행 (autoRetryExecutor) | +| `RecollectionJobExecutionListener.java` | 재수집 이력 기록 + last_success_date 백업/복원 | +| `RecollectionHistoryService.java` | 이력 CRUD + 통계 + 수집기간 관리 | +| `BatchFailedRecordService.java` | 실패 레코드 저장/RESOLVED 처리 | +| `BatchService.java` | Job 실행 + failedRecordCount 조회 | +| `BatchController.java` | REST API 엔드포인트 (실행/이력/CSV/RESOLVED) | + +### 프론트엔드 + +| 파일 | 역할 | +|------|------| +| `batchApi.ts` | API 클라이언트 (retryFailedRecords, resolveFailedRecords, 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 | + +--- diff --git a/frontend/src/api/batchApi.ts b/frontend/src/api/batchApi.ts index 9de8fa0..cfc95c1 100644 --- a/frontend/src/api/batchApi.ts +++ b/frontend/src/api/batchApi.ts @@ -73,6 +73,7 @@ export interface JobExecutionDto { endTime: string | null; exitCode: string | null; exitMessage: string | null; + failedRecordCount: number | null; } export interface ApiCallInfo { @@ -356,6 +357,9 @@ export const batchApi = { const qs = new URLSearchParams({ retryRecordKeys: recordKeys.join(','), sourceStepExecutionId: String(stepExecutionId), + executionMode: 'RECOLLECT', + executor: 'MANUAL_RETRY', + reason: `실패 건 수동 재수집 (${recordKeys.length}건)`, }); return postJson<{ success: boolean; message: string; executionId?: number }>( `${BASE}/jobs/${jobName}/execute?${qs.toString()}`); @@ -466,7 +470,7 @@ export const batchApi = { const qs = new URLSearchParams(); qs.set('page', String(params?.page ?? 0)); qs.set('size', String(params?.size ?? 50)); - if (params?.status) qs.set('status', params.status); + if (params?.status && params.status !== 'ALL') qs.set('status', params.status); return fetchJson( `${BASE}/steps/${stepExecutionId}/api-logs?${qs.toString()}`); }, @@ -489,4 +493,24 @@ export const batchApi = { // Last Collection Status getLastCollectionStatuses: () => fetchJson(`${BASE}/last-collections`), + + resolveFailedRecords: (ids: number[]) => + postJson<{ success: boolean; message: string; resolvedCount?: number }>( + `${BASE}/failed-records/resolve`, { ids }), + + exportRecollectionHistories: (params: { + apiKey?: string; + jobName?: string; + status?: string; + fromDate?: string; + toDate?: string; + }) => { + const qs = new URLSearchParams(); + if (params.apiKey) qs.set('apiKey', params.apiKey); + if (params.jobName) qs.set('jobName', params.jobName); + if (params.status) qs.set('status', params.status); + if (params.fromDate) qs.set('fromDate', params.fromDate); + if (params.toDate) qs.set('toDate', params.toDate); + window.open(`${BASE}/recollection-histories/export?${qs.toString()}`); + }, }; diff --git a/frontend/src/components/ApiLogSection.tsx b/frontend/src/components/ApiLogSection.tsx new file mode 100644 index 0000000..4da7cb3 --- /dev/null +++ b/frontend/src/components/ApiLogSection.tsx @@ -0,0 +1,170 @@ +import { useState, useCallback, useEffect } from 'react'; +import { batchApi, type ApiLogPageResponse, type ApiLogStatus } from '../api/batchApi'; +import { formatDateTime } from '../utils/formatters'; +import Pagination from './Pagination'; +import CopyButton from './CopyButton'; + +interface ApiLogSectionProps { + stepExecutionId: number; + summary: { totalCalls: number; successCount: number; errorCount: number }; +} + +export default function ApiLogSection({ stepExecutionId, summary }: ApiLogSectionProps) { + const [open, setOpen] = useState(false); + const [status, setStatus] = useState('ALL'); + const [page, setPage] = useState(0); + const [logData, setLogData] = useState(null); + const [loading, setLoading] = useState(false); + + const fetchLogs = useCallback(async (p: number, s: ApiLogStatus) => { + setLoading(true); + try { + const data = await batchApi.getStepApiLogs(stepExecutionId, { page: p, size: 10, status: s }); + setLogData(data); + } catch { + setLogData(null); + } finally { + setLoading(false); + } + }, [stepExecutionId]); + + useEffect(() => { + if (open) { + fetchLogs(page, status); + } + }, [open, page, status, fetchLogs]); + + const handleStatusChange = (s: ApiLogStatus) => { + setStatus(s); + setPage(0); + }; + + const filters: { key: ApiLogStatus; label: string; count: number }[] = [ + { key: 'ALL', label: '전체', count: summary.totalCalls }, + { key: 'SUCCESS', label: '성공', count: summary.successCount }, + { key: 'ERROR', label: '에러', count: summary.errorCount }, + ]; + + return ( +
+ + + {open && ( +
+ {/* 상태 필터 탭 */} +
+ {filters.map(({ key, label, count }) => ( + + ))} +
+ + {loading ? ( +
+
+ 로딩중... +
+ ) : logData && logData.content.length > 0 ? ( + <> +
+ + + + + + + + + + + + + + + {logData.content.map((log, idx) => { + const isError = (log.statusCode != null && log.statusCode >= 400) || log.errorMessage; + return ( + + + + + + + + + + + ); + })} + +
#URIMethod상태응답(ms)건수시간에러
{page * 10 + idx + 1} +
+ + {log.requestUri} + + +
+
{log.httpMethod} + + {log.statusCode ?? '-'} + + + {log.responseTimeMs?.toLocaleString() ?? '-'} + + {log.responseCount?.toLocaleString() ?? '-'} + + {formatDateTime(log.createdAt)} + + {log.errorMessage || '-'} +
+
+ + {/* 페이지네이션 */} + + + ) : ( +

조회된 로그가 없습니다.

+ )} +
+ )} +
+ ); +} diff --git a/frontend/src/components/CopyButton.tsx b/frontend/src/components/CopyButton.tsx new file mode 100644 index 0000000..617bf3e --- /dev/null +++ b/frontend/src/components/CopyButton.tsx @@ -0,0 +1,48 @@ +import { useState } from 'react'; + +interface CopyButtonProps { + text: string; +} + +export default function CopyButton({ text }: CopyButtonProps) { + const [copied, setCopied] = useState(false); + + const handleCopy = async (e: React.MouseEvent) => { + e.stopPropagation(); + try { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { + const textarea = document.createElement('textarea'); + textarea.value = text; + textarea.style.position = 'fixed'; + textarea.style.opacity = '0'; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand('copy'); + document.body.removeChild(textarea); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } + }; + + return ( + + ); +} diff --git a/frontend/src/components/DetailStatCard.tsx b/frontend/src/components/DetailStatCard.tsx new file mode 100644 index 0000000..13d0b11 --- /dev/null +++ b/frontend/src/components/DetailStatCard.tsx @@ -0,0 +1,22 @@ +interface DetailStatCardProps { + label: string; + value: number; + gradient: string; + icon: string; +} + +export default function DetailStatCard({ label, value, gradient, icon }: DetailStatCardProps) { + return ( +
+
+
+

{label}

+

+ {value.toLocaleString()} +

+
+ {icon} +
+
+ ); +} diff --git a/frontend/src/components/InfoItem.tsx b/frontend/src/components/InfoItem.tsx new file mode 100644 index 0000000..d8b400e --- /dev/null +++ b/frontend/src/components/InfoItem.tsx @@ -0,0 +1,15 @@ +interface InfoItemProps { + label: string; + value: string; +} + +export default function InfoItem({ label, value }: InfoItemProps) { + return ( +
+
+ {label} +
+
{value || '-'}
+
+ ); +} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 408c06d..b286f02 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -5,6 +5,7 @@ import { type DashboardResponse, type DashboardStats, type ExecutionStatisticsDto, + type RecollectionStatsResponse, } from '../api/batchApi'; import { usePoller } from '../hooks/usePoller'; import { useToastContext } from '../contexts/ToastContext'; @@ -54,6 +55,7 @@ export default function Dashboard() { const [stopDate, setStopDate] = useState(''); const [abandoning, setAbandoning] = useState(false); const [statistics, setStatistics] = useState(null); + const [recollectionStats, setRecollectionStats] = useState(null); const loadStatistics = useCallback(async () => { try { @@ -68,6 +70,19 @@ export default function Dashboard() { loadStatistics(); }, [loadStatistics]); + const loadRecollectionStats = useCallback(async () => { + try { + const data = await batchApi.getRecollectionStats(); + setRecollectionStats(data); + } catch { + /* 통계 로드 실패는 무시 */ + } + }, []); + + useEffect(() => { + loadRecollectionStats(); + }, [loadRecollectionStats]); + const loadDashboard = useCallback(async () => { try { const data = await batchApi.getDashboard(); @@ -391,6 +406,73 @@ export default function Dashboard() { )} + {/* 재수집 현황 */} + {recollectionStats && recollectionStats.totalCount > 0 && ( +
+
+

재수집 현황

+ + 전체 보기 → + +
+
+
+

{recollectionStats.totalCount}

+

전체

+
+
+

{recollectionStats.completedCount}

+

완료

+
+
+

{recollectionStats.failedCount}

+

실패

+
+
+

{recollectionStats.runningCount}

+

실행 중

+
+
+

{recollectionStats.overlapCount}

+

중복

+
+
+ {/* 최근 재수집 이력 (최대 5건) */} + {recollectionStats.recentHistories.length > 0 && ( +
+ + + + + + + + + + + + {recollectionStats.recentHistories.slice(0, 5).map((h) => ( + + + + + + + + ))} + +
이력 ID작업명실행자시작 시간상태
+ + #{h.historyId} + + {h.apiKeyName || h.jobName}{h.executor || '-'}{formatDateTime(h.executionStartTime)} + +
+
+ )} +
+ )} + {/* F8: Execution Statistics Chart */} {statistics && statistics.dailyStats.length > 0 && (
diff --git a/frontend/src/pages/ExecutionDetail.tsx b/frontend/src/pages/ExecutionDetail.tsx index f5f5482..3ca4255 100644 --- a/frontend/src/pages/ExecutionDetail.tsx +++ b/frontend/src/pages/ExecutionDetail.tsx @@ -1,248 +1,18 @@ -import { useState, useCallback, useEffect } from 'react'; +import { useState, useCallback } from 'react'; import { useParams, useSearchParams, useNavigate } from 'react-router-dom'; -import { batchApi, type JobExecutionDetailDto, type StepExecutionDto, type FailedRecordDto, type ApiLogPageResponse, type ApiLogStatus } from '../api/batchApi'; +import { batchApi, type JobExecutionDetailDto, type StepExecutionDto, type FailedRecordDto } from '../api/batchApi'; import { formatDateTime, formatDuration, calculateDuration } from '../utils/formatters'; import { usePoller } from '../hooks/usePoller'; import StatusBadge from '../components/StatusBadge'; import EmptyState from '../components/EmptyState'; import LoadingSpinner from '../components/LoadingSpinner'; import Pagination from '../components/Pagination'; +import DetailStatCard from '../components/DetailStatCard'; +import ApiLogSection from '../components/ApiLogSection'; +import InfoItem from '../components/InfoItem'; const POLLING_INTERVAL_MS = 5000; -interface StatCardProps { - label: string; - value: number; - gradient: string; - icon: string; -} - -function StatCard({ label, value, gradient, icon }: StatCardProps) { - return ( -
-
-
-

{label}

-

- {value.toLocaleString()} -

-
- {icon} -
-
- ); -} - -function CopyButton({ text }: { text: string }) { - const [copied, setCopied] = useState(false); - - const handleCopy = async (e: React.MouseEvent) => { - e.stopPropagation(); - try { - await navigator.clipboard.writeText(text); - setCopied(true); - setTimeout(() => setCopied(false), 1500); - } catch { - const textarea = document.createElement('textarea'); - textarea.value = text; - textarea.style.position = 'fixed'; - textarea.style.opacity = '0'; - document.body.appendChild(textarea); - textarea.select(); - document.execCommand('copy'); - document.body.removeChild(textarea); - setCopied(true); - setTimeout(() => setCopied(false), 1500); - } - }; - - return ( - - ); -} - -interface ApiLogSectionProps { - stepExecutionId: number; - summary: { totalCalls: number; successCount: number; errorCount: number }; -} - -function ApiLogSection({ stepExecutionId, summary }: ApiLogSectionProps) { - const [open, setOpen] = useState(false); - const [status, setStatus] = useState('ALL'); - const [page, setPage] = useState(0); - const [logData, setLogData] = useState(null); - const [loading, setLoading] = useState(false); - - const fetchLogs = useCallback(async (p: number, s: ApiLogStatus) => { - setLoading(true); - try { - const data = await batchApi.getStepApiLogs(stepExecutionId, { page: p, size: 10, status: s }); - setLogData(data); - } catch { - setLogData(null); - } finally { - setLoading(false); - } - }, [stepExecutionId]); - - useEffect(() => { - if (open) { - fetchLogs(page, status); - } - }, [open, page, status, fetchLogs]); - - const handleStatusChange = (s: ApiLogStatus) => { - setStatus(s); - setPage(0); - }; - - const filters: { key: ApiLogStatus; label: string; count: number }[] = [ - { key: 'ALL', label: '전체', count: summary.totalCalls }, - { key: 'SUCCESS', label: '성공', count: summary.successCount }, - { key: 'ERROR', label: '에러', count: summary.errorCount }, - ]; - - return ( -
- - - {open && ( -
- {/* 상태 필터 탭 */} -
- {filters.map(({ key, label, count }) => ( - - ))} -
- - {loading ? ( -
-
- 로딩중... -
- ) : logData && logData.content.length > 0 ? ( - <> -
- - - - - - - - - - - - - - - {logData.content.map((log, idx) => { - const isError = (log.statusCode != null && log.statusCode >= 400) || log.errorMessage; - return ( - - - - - - - - - - - ); - })} - -
#URIMethod상태응답(ms)건수시간에러
{page * 10 + idx + 1} -
- - {log.requestUri} - - -
-
{log.httpMethod} - - {log.statusCode ?? '-'} - - - {log.responseTimeMs?.toLocaleString() ?? '-'} - - {log.responseCount?.toLocaleString() ?? '-'} - - {formatDateTime(log.createdAt)} - - {log.errorMessage || '-'} -
-
- - {/* 페이지네이션 */} - - - ) : ( -

조회된 로그가 없습니다.

- )} -
- )} -
- ); -} - interface StepCardProps { step: StepExecutionDto; jobName: string; @@ -489,25 +259,25 @@ export default function ExecutionDetail() { {/* 실행 통계 카드 4개 */}
- - - - r.recordKey); const result = await batchApi.retryFailedRecords(jobName, keys, stepExecutionId); - if (result.success && result.executionId) { + if (result.success) { setShowConfirm(false); - navigate(`/executions/${result.executionId}`); + if (result.executionId) { + navigate(`/executions/${result.executionId}`); + } else { + alert(result.message || '재수집이 요청되었습니다.'); + } + } else { + alert(result.message || '재수집 실행에 실패했습니다.'); } } catch { alert('재수집 실행에 실패했습니다.'); @@ -612,6 +390,20 @@ function FailedRecordsToggle({ records, jobName, stepExecutionId }: { records: F } }; + const handleResolve = async () => { + setResolving(true); + try { + const ids = failedRecords.map((r) => r.id); + await batchApi.resolveFailedRecords(ids); + setShowResolveConfirm(false); + navigate(0); + } catch { + alert('일괄 RESOLVED 처리에 실패했습니다.'); + } finally { + setResolving(false); + } + }; + return (
@@ -625,19 +417,30 @@ function FailedRecordsToggle({ records, jobName, stepExecutionId }: { records: F > - 호출 실패 데이터 ({records.length.toLocaleString()}건) + 호출 실패 데이터 ({records.length.toLocaleString()}건, FAILED {failedRecords.length}건) {failedRecords.length > 0 && ( - +
+ + +
)}
@@ -692,7 +495,7 @@ function FailedRecordsToggle({ records, jobName, stepExecutionId }: { records: F
)} - {/* 확인 다이얼로그 */} + {/* 재수집 확인 다이얼로그 */} {showConfirm && (
@@ -740,17 +543,45 @@ function FailedRecordsToggle({ records, jobName, stepExecutionId }: { records: F
)} + + {/* 일괄 RESOLVED 확인 다이얼로그 */} + {showResolveConfirm && ( +
+
+

+ 일괄 RESOLVED 확인 +

+

+ FAILED 상태의 {failedRecords.length}건을 RESOLVED로 변경합니다. + 이 작업은 되돌릴 수 없습니다. +

+
+ + +
+
+
+ )}
); } -function InfoItem({ label, value }: { label: string; value: string }) { - return ( -
-
- {label} -
-
{value || '-'}
-
- ); -} diff --git a/frontend/src/pages/Executions.tsx b/frontend/src/pages/Executions.tsx index 12054b1..15bde23 100644 --- a/frontend/src/pages/Executions.tsx +++ b/frontend/src/pages/Executions.tsx @@ -410,18 +410,31 @@ export default function Executions() { {exec.jobName} - {/* F9: FAILED 상태 클릭 시 실패 로그 모달 */} - {exec.status === 'FAILED' ? ( - + ) : ( - - ) : ( - - )} + )} + {exec.status === 'COMPLETED' && exec.failedRecordCount != null && exec.failedRecordCount > 0 && ( + + + + + {exec.failedRecordCount} + + )} +
{formatDateTime(exec.startTime)} diff --git a/frontend/src/pages/RecollectDetail.tsx b/frontend/src/pages/RecollectDetail.tsx index eb6235a..79e885c 100644 --- a/frontend/src/pages/RecollectDetail.tsx +++ b/frontend/src/pages/RecollectDetail.tsx @@ -1,12 +1,10 @@ -import { useState, useCallback, useEffect } from 'react'; +import { useState, useCallback } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { batchApi, type RecollectionDetailResponse, type StepExecutionDto, type FailedRecordDto, - type ApiLogPageResponse, - type ApiLogStatus, } from '../api/batchApi'; import { formatDateTime, formatDuration, calculateDuration } from '../utils/formatters'; import { usePoller } from '../hooks/usePoller'; @@ -14,240 +12,12 @@ import StatusBadge from '../components/StatusBadge'; import EmptyState from '../components/EmptyState'; import LoadingSpinner from '../components/LoadingSpinner'; import Pagination from '../components/Pagination'; +import DetailStatCard from '../components/DetailStatCard'; +import ApiLogSection from '../components/ApiLogSection'; +import InfoItem from '../components/InfoItem'; const POLLING_INTERVAL_MS = 10_000; -interface StatCardProps { - label: string; - value: number; - gradient: string; - icon: string; -} - -function StatCard({ label, value, gradient, icon }: StatCardProps) { - return ( -
-
-
-

{label}

-

- {value.toLocaleString()} -

-
- {icon} -
-
- ); -} - -function CopyButton({ text }: { text: string }) { - const [copied, setCopied] = useState(false); - - const handleCopy = async (e: React.MouseEvent) => { - e.stopPropagation(); - try { - await navigator.clipboard.writeText(text); - setCopied(true); - setTimeout(() => setCopied(false), 1500); - } catch { - const textarea = document.createElement('textarea'); - textarea.value = text; - textarea.style.position = 'fixed'; - textarea.style.opacity = '0'; - document.body.appendChild(textarea); - textarea.select(); - document.execCommand('copy'); - document.body.removeChild(textarea); - setCopied(true); - setTimeout(() => setCopied(false), 1500); - } - }; - - return ( - - ); -} - -interface ApiLogSectionProps { - stepExecutionId: number; - summary: { totalCalls: number; successCount: number; errorCount: number }; -} - -function ApiLogSection({ stepExecutionId, summary }: ApiLogSectionProps) { - const [open, setOpen] = useState(false); - const [status, setStatus] = useState('ALL'); - const [page, setPage] = useState(0); - const [logData, setLogData] = useState(null); - const [loading, setLoading] = useState(false); - - const fetchLogs = useCallback(async (p: number, s: ApiLogStatus) => { - setLoading(true); - try { - const data = await batchApi.getStepApiLogs(stepExecutionId, { page: p, size: 10, status: s }); - setLogData(data); - } catch { - setLogData(null); - } finally { - setLoading(false); - } - }, [stepExecutionId]); - - useEffect(() => { - if (open) { - fetchLogs(page, status); - } - }, [open, page, status, fetchLogs]); - - const handleStatusChange = (s: ApiLogStatus) => { - setStatus(s); - setPage(0); - }; - - const filters: { key: ApiLogStatus; label: string; count: number }[] = [ - { key: 'ALL', label: '전체', count: summary.totalCalls }, - { key: 'SUCCESS', label: '성공', count: summary.successCount }, - { key: 'ERROR', label: '에러', count: summary.errorCount }, - ]; - - return ( -
- - - {open && ( -
- {/* 상태 필터 탭 */} -
- {filters.map(({ key, label, count }) => ( - - ))} -
- - {loading ? ( -
-
- 로딩중... -
- ) : logData && logData.content.length > 0 ? ( - <> -
- - - - - - - - - - - - - - - {logData.content.map((log, idx) => { - const isError = (log.statusCode != null && log.statusCode >= 400) || log.errorMessage; - return ( - - - - - - - - - - - ); - })} - -
#URIMethod상태응답(ms)건수시간에러
{page * 10 + idx + 1} -
- - {log.requestUri} - - -
-
{log.httpMethod} - - {log.statusCode ?? '-'} - - - {log.responseTimeMs?.toLocaleString() ?? '-'} - - {log.responseCount?.toLocaleString() ?? '-'} - - {formatDateTime(log.createdAt)} - - {log.errorMessage || '-'} -
-
- - {/* 페이지네이션 */} - - - ) : ( -

조회된 로그가 없습니다.

- )} -
- )} -
- ); -} - function StepCard({ step, jobName }: { step: StepExecutionDto; jobName: string }) { const stats = [ { label: '읽기', value: step.readCount }, @@ -471,25 +241,25 @@ export default function RecollectDetail() { {/* 처리 통계 카드 */}
- - - - r.recordKey); const result = await batchApi.retryFailedRecords(jobName, keys, stepExecutionId); - if (result.success && result.executionId) { + if (result.success) { setShowConfirm(false); - navigate(`/executions/${result.executionId}`); + if (result.executionId) { + navigate(`/executions/${result.executionId}`); + } else { + alert(result.message || '재수집이 요청되었습니다.'); + } + } else { + alert(result.message || '재수집 실행에 실패했습니다.'); } } catch { alert('재수집 실행에 실패했습니다.'); @@ -663,6 +441,20 @@ function FailedRecordsToggle({ records, jobName, stepExecutionId }: { records: F } }; + const handleResolve = async () => { + setResolving(true); + try { + const ids = failedRecords.map((r) => r.id); + await batchApi.resolveFailedRecords(ids); + setShowResolveConfirm(false); + navigate(0); + } catch { + alert('일괄 RESOLVED 처리에 실패했습니다.'); + } finally { + setResolving(false); + } + }; + return (
@@ -676,19 +468,30 @@ function FailedRecordsToggle({ records, jobName, stepExecutionId }: { records: F > - 호출 실패 데이터 ({records.length.toLocaleString()}건) + 호출 실패 데이터 ({records.length.toLocaleString()}건, FAILED {failedRecords.length}건) {failedRecords.length > 0 && ( - +
+ + +
)}
@@ -743,7 +546,7 @@ function FailedRecordsToggle({ records, jobName, stepExecutionId }: { records: F
)} - {/* 확인 다이얼로그 */} + {/* 재수집 확인 다이얼로그 */} {showConfirm && (
@@ -791,17 +594,44 @@ function FailedRecordsToggle({ records, jobName, stepExecutionId }: { records: F
)} -
- ); -} -function InfoItem({ label, value }: { label: string; value: string }) { - return ( -
-
- {label} -
-
{value || '-'}
+ {/* 일괄 RESOLVED 확인 다이얼로그 */} + {showResolveConfirm && ( +
+
+

+ 일괄 RESOLVED 확인 +

+

+ FAILED 상태의 {failedRecords.length}건을 RESOLVED로 변경합니다. + 이 작업은 되돌릴 수 없습니다. +

+
+ + +
+
+
+ )}
); } diff --git a/frontend/src/pages/Recollects.tsx b/frontend/src/pages/Recollects.tsx index 5967299..957c2f8 100644 --- a/frontend/src/pages/Recollects.tsx +++ b/frontend/src/pages/Recollects.tsx @@ -278,7 +278,7 @@ export default function Recollects() { const data = await batchApi.getLastCollectionStatuses(); setLastCollectionStatuses(data); } catch { - /* 수집 성공일시 로드 실패 무시 */ + /* 수집 성공일시 로드 실패 무시 (폴링 중 반복 에러 toast 방지) */ } }, []); @@ -287,10 +287,16 @@ export default function Recollects() { const data = await batchApi.getCollectionPeriods(); setPeriods(data); } catch { - /* 수집기간 로드 실패 무시 */ + /* 수집기간 로드 실패 무시 (폴링 중 반복 에러 toast 방지) */ } }, []); + const loadMetadata = useCallback(async () => { + await Promise.all([loadLastCollectionStatuses(), loadPeriods()]); + }, [loadLastCollectionStatuses, loadPeriods]); + + const [initialLoad, setInitialLoad] = useState(true); + const loadHistories = useCallback(async () => { try { const params: { @@ -315,17 +321,22 @@ export default function Recollects() { setTotalCount(data.totalElements); setFailedRecordCounts(data.failedRecordCounts ?? {}); if (!useSearch) setPage(data.number); - } catch { + setInitialLoad(false); + } catch (err) { + console.error('이력 조회 실패:', err); + /* 초기 로드 실패만 toast, 폴링 중 실패는 console.error만 */ + if (initialLoad) { + showToast('재수집 이력을 불러오지 못했습니다.', 'error'); + } setHistories([]); setTotalPages(0); setTotalCount(0); } finally { setLoading(false); } - }, [selectedApiKey, statusFilter, startDate, endDate, useSearch, page]); + }, [selectedApiKey, statusFilter, startDate, endDate, useSearch, page, showToast, initialLoad]); - usePoller(loadLastCollectionStatuses, 60_000, []); - usePoller(loadPeriods, 60_000, []); + usePoller(loadMetadata, 60_000, []); usePoller(loadHistories, POLLING_INTERVAL_MS, [selectedApiKey, statusFilter, useSearch, page]); const collectionStatusSummary = useMemo(() => { @@ -349,6 +360,7 @@ export default function Recollects() { setUseSearch(true); setPage(0); setLoading(true); + await loadHistories(); }; const handleResetSearch = () => { @@ -833,6 +845,17 @@ export default function Recollects() { 초기화 )} +
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 new file mode 100644 index 0000000..480e784 --- /dev/null +++ b/src/main/java/com/snp/batch/common/batch/listener/AutoRetryJobExecutionListener.java @@ -0,0 +1,120 @@ +package com.snp.batch.common.batch.listener; + +import com.snp.batch.global.repository.BatchFailedRecordRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.BatchStatus; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.JobExecutionListener; +import org.springframework.batch.core.StepExecution; +import org.springframework.stereotype.Component; + +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +/** + * 배치 Job 완료 후 실패 레코드가 있으면 자동으로 재수집을 트리거하는 리스너. + * + * 동작 조건: + * - Job 상태가 COMPLETED일 때만 실행 + * - executionMode가 RECOLLECT가 아닌 일반 모드일 때만 실행 (무한 루프 방지) + * - StepExecution의 ExecutionContext에 failedRecordKeys가 존재할 때만 실행 + * - 모든 Step의 failedRecordKeys를 Job 레벨에서 병합한 후 1회만 triggerRetryAsync 호출 + * - retryCount가 MAX_AUTO_RETRY_COUNT 이상인 키는 재수집에서 제외 (무한 루프 방지) + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class AutoRetryJobExecutionListener implements JobExecutionListener { + + private static final String FAILED_RECORD_KEYS = "failedRecordKeys"; + private static final String FAILED_STEP_EXECUTION_ID = "failedStepExecutionId"; + private static final String FAILED_API_KEY = "failedApiKey"; + private static final int MAX_AUTO_RETRY_COUNT = 3; + + private final AutoRetryTriggerService autoRetryTriggerService; + private final BatchFailedRecordRepository batchFailedRecordRepository; + + @Override + public void beforeJob(JobExecution jobExecution) { + // no-op + } + + @Override + public void afterJob(JobExecution jobExecution) { + String executionMode = jobExecution.getJobParameters() + .getString("executionMode", "NORMAL"); + + // 재수집 모드에서는 자동 재수집을 트리거하지 않음 (무한 루프 방지) + if ("RECOLLECT".equals(executionMode)) { + return; + } + + // Job이 정상 완료된 경우에만 재수집 트리거 + if (jobExecution.getStatus() != BatchStatus.COMPLETED) { + return; + } + + String jobName = jobExecution.getJobInstance().getJobName(); + + // 모든 Step의 failedRecordKeys를 Set으로 병합 (중복 제거) + Set mergedKeys = new LinkedHashSet<>(); + Long sourceStepExecutionId = null; + String apiKey = null; + + for (StepExecution stepExecution : jobExecution.getStepExecutions()) { + String failedKeys = stepExecution.getExecutionContext() + .getString(FAILED_RECORD_KEYS, null); + + if (failedKeys == null || failedKeys.isBlank()) { + continue; + } + + Arrays.stream(failedKeys.split(",")) + .map(String::trim) + .filter(key -> !key.isBlank()) + .forEach(mergedKeys::add); + + // sourceStepExecutionId: 마지막 Step 것 사용 + sourceStepExecutionId = stepExecution.getExecutionContext() + .containsKey(FAILED_STEP_EXECUTION_ID) + ? stepExecution.getExecutionContext().getLong(FAILED_STEP_EXECUTION_ID) + : stepExecution.getId(); + + // apiKey: non-null인 것 중 첫 번째 사용 + if (apiKey == null) { + apiKey = stepExecution.getExecutionContext() + .getString(FAILED_API_KEY, null); + } + } + + if (mergedKeys.isEmpty()) { + return; + } + + // retryCount가 MAX_AUTO_RETRY_COUNT 이상인 키 필터링 + List exceededKeys = batchFailedRecordRepository.findExceededRetryKeys( + jobName, List.copyOf(mergedKeys), MAX_AUTO_RETRY_COUNT); + + if (!exceededKeys.isEmpty()) { + log.warn("[AutoRetry] {} Job: 최대 재시도 횟수({})를 초과한 키 {}건 제외: {}", + jobName, MAX_AUTO_RETRY_COUNT, exceededKeys.size(), exceededKeys); + mergedKeys.removeAll(exceededKeys); + } + + if (mergedKeys.isEmpty()) { + log.warn("[AutoRetry] {} Job: 모든 실패 키가 최대 재시도 횟수를 초과하여 재수집을 건너뜁니다.", jobName); + return; + } + + String mergedFailedKeys = String.join(",", mergedKeys); + log.info("[AutoRetry] {} Job 완료 후 실패 건 {}건 감지 → 자동 재수집 트리거", + jobName, mergedKeys.size()); + + // 합산된 키로 1회만 triggerRetryAsync 호출 + autoRetryTriggerService.triggerRetryAsync( + jobName, mergedFailedKeys, 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 new file mode 100644 index 0000000..72d162e --- /dev/null +++ b/src/main/java/com/snp/batch/common/batch/listener/AutoRetryTriggerService.java @@ -0,0 +1,67 @@ +package com.snp.batch.common.batch.listener; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.core.launch.JobLauncher; +import org.springframework.context.annotation.Lazy; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +import java.util.Map; + +/** + * 자동 재수집 Job 비동기 트리거 서비스. + * JobExecutionListener 내부 self-invocation으로는 @Async 프록시가 동작하지 않으므로 + * 별도 빈으로 분리하여 프록시를 통한 비동기 호출을 보장합니다. + */ +@Slf4j +@Service +public class AutoRetryTriggerService { + + private final JobLauncher jobLauncher; + private final Map jobMap; + + public AutoRetryTriggerService(JobLauncher jobLauncher, @Lazy Map jobMap) { + this.jobLauncher = jobLauncher; + this.jobMap = jobMap; + } + + @Async("autoRetryExecutor") + public void triggerRetryAsync(String jobName, String failedKeys, + Long sourceStepExecutionId, String apiKey) { + try { + Job job = jobMap.get(jobName); + if (job == null) { + log.error("[AutoRetry] Job을 찾을 수 없습니다: {}", jobName); + return; + } + + JobParametersBuilder builder = new JobParametersBuilder() + .addLong("timestamp", System.currentTimeMillis()) + .addString("retryRecordKeys", failedKeys) + .addString("sourceStepExecutionId", String.valueOf(sourceStepExecutionId)) + .addString("executionMode", "RECOLLECT") + .addString("reason", "자동 재수집 (실패 건 자동 처리)") + .addString("executor", "AUTO_RETRY"); + + if (apiKey != null) { + builder.addString("apiKey", apiKey); + } + + JobParameters retryParams = builder.toJobParameters(); + + log.info("[AutoRetry] 재수집 Job 실행 시작: jobName={}, 실패건={}, sourceStepExecutionId={}", + jobName, failedKeys.split(",").length, sourceStepExecutionId); + + JobExecution retryExecution = jobLauncher.run(job, retryParams); + + log.info("[AutoRetry] 재수집 Job 실행 완료: jobName={}, executionId={}, status={}", + jobName, retryExecution.getId(), retryExecution.getStatus()); + } catch (Exception e) { + log.error("[AutoRetry] 재수집 Job 실행 실패: jobName={}, error={}", jobName, e.getMessage(), e); + } + } +} diff --git a/src/main/java/com/snp/batch/common/batch/listener/RecollectionJobExecutionListener.java b/src/main/java/com/snp/batch/common/batch/listener/RecollectionJobExecutionListener.java index b659cbe..da5ed39 100644 --- a/src/main/java/com/snp/batch/common/batch/listener/RecollectionJobExecutionListener.java +++ b/src/main/java/com/snp/batch/common/batch/listener/RecollectionJobExecutionListener.java @@ -30,7 +30,7 @@ public class RecollectionJobExecutionListener implements JobExecutionListener { Long jobExecutionId = jobExecution.getId(); String jobName = jobExecution.getJobInstance().getJobName(); - String apiKey = jobExecution.getJobParameters().getString("apiKey"); + String apiKey = resolveApiKey(jobExecution); String executor = jobExecution.getJobParameters().getString("executor", "SYSTEM"); String reason = jobExecution.getJobParameters().getString("reason"); @@ -65,7 +65,7 @@ public class RecollectionJobExecutionListener implements JobExecutionListener { Long jobExecutionId = jobExecution.getId(); String status = jobExecution.getStatus().name(); - String apiKey = jobExecution.getJobParameters().getString("apiKey"); + String apiKey = resolveApiKey(jobExecution); // Step별 통계 집계 long totalRead = 0; @@ -124,9 +124,16 @@ public class RecollectionJobExecutionListener implements JobExecutionListener { apiKey, originalDateStr); if (originalDateStr != null) { LocalDateTime originalDate = LocalDateTime.parse(originalDateStr); - recollectionHistoryService.restoreLastSuccessDate(apiKey, originalDate); - log.info("[RecollectionListener] last_success_date 복원 완료: apiKey={}, date={}", - apiKey, originalDate); + // 현재 DB 값이 원본보다 미래면(정상 수집 발생) 복원 스킵 + LocalDateTime currentDate = recollectionHistoryService.getLastSuccessDate(apiKey); + if (currentDate != null && currentDate.isAfter(originalDate)) { + log.info("[RecollectionListener] last_success_date가 이미 갱신됨 (정상 수집 발생), 복원 스킵: apiKey={}, original={}, current={}", + apiKey, originalDate, currentDate); + } else { + recollectionHistoryService.restoreLastSuccessDate(apiKey, originalDate); + log.info("[RecollectionListener] last_success_date 복원 완료: apiKey={}, date={}", + apiKey, originalDate); + } } else { log.warn("[RecollectionListener] originalLastSuccessDate가 ExecutionContext에 없음: apiKey={}", apiKey); @@ -137,4 +144,24 @@ public class RecollectionJobExecutionListener implements JobExecutionListener { apiKey, jobExecutionId, e); } } + + /** + * Job 파라미터에서 apiKey를 읽고, 없으면 jobName으로 BatchCollectionPeriod에서 조회합니다. + * 수동 재수집(UI 실패건 재수집)에서는 apiKey가 파라미터로 전달되지 않을 수 있으므로 + * jobName → apiKey 매핑을 fallback으로 사용합니다. + */ + private String resolveApiKey(JobExecution jobExecution) { + String apiKey = jobExecution.getJobParameters().getString("apiKey"); + if (apiKey != null) { + return apiKey; + } + + // fallback: jobName으로 BatchCollectionPeriod에서 apiKey 조회 + String jobName = jobExecution.getJobInstance().getJobName(); + apiKey = recollectionHistoryService.findApiKeyByJobName(jobName); + if (apiKey != null) { + log.info("[RecollectionListener] apiKey를 jobName에서 조회: jobName={}, apiKey={}", jobName, apiKey); + } + return apiKey; + } } diff --git a/src/main/java/com/snp/batch/global/config/AsyncConfig.java b/src/main/java/com/snp/batch/global/config/AsyncConfig.java index 62ba92b..636d5e9 100644 --- a/src/main/java/com/snp/batch/global/config/AsyncConfig.java +++ b/src/main/java/com/snp/batch/global/config/AsyncConfig.java @@ -21,4 +21,19 @@ public class AsyncConfig { executor.initialize(); return executor; } + + /** + * 자동 재수집 전용 Executor. + * 재수집 Job은 장시간 실행되므로 apiLogExecutor와 분리하여 별도 풀로 관리. + */ + @Bean(name = "autoRetryExecutor") + public Executor autoRetryExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(1); // 재수집은 순차적으로 충분 + executor.setMaxPoolSize(2); // 동시 최대 2개까지 허용 + executor.setQueueCapacity(10); // 대기 큐 (초과 시 CallerRunsPolicy) + executor.setThreadNamePrefix("AutoRetry-"); + executor.initialize(); + return executor; + } } \ No newline at end of file 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 7e03eef..5e823ce 100644 --- a/src/main/java/com/snp/batch/global/controller/BatchController.java +++ b/src/main/java/com/snp/batch/global/controller/BatchController.java @@ -3,6 +3,7 @@ package com.snp.batch.global.controller; import com.snp.batch.global.dto.*; import com.snp.batch.global.model.BatchCollectionPeriod; import com.snp.batch.global.model.BatchRecollectionHistory; +import com.snp.batch.service.BatchFailedRecordService; import com.snp.batch.service.BatchService; import com.snp.batch.service.RecollectionHistoryService; import com.snp.batch.service.ScheduleService; @@ -23,6 +24,10 @@ import org.springframework.web.bind.annotation.*; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; +import jakarta.servlet.http.HttpServletResponse; + +import java.io.IOException; +import java.io.PrintWriter; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.HashMap; @@ -40,6 +45,7 @@ public class BatchController { private final BatchService batchService; private final ScheduleService scheduleService; private final RecollectionHistoryService recollectionHistoryService; + private final BatchFailedRecordService batchFailedRecordService; @Operation(summary = "배치 작업 실행", description = "지정된 배치 작업을 즉시 실행합니다. 쿼리 파라미터로 Job Parameters 전달 가능") @ApiResponses(value = { @@ -612,4 +618,102 @@ public class BatchController { "message", "수집 기간 초기화 실패: " + e.getMessage())); } } + + // ── 실패 레코드 관리 API ────────────────────────────────────── + + @Operation(summary = "실패 레코드 일괄 RESOLVED 처리", description = "특정 Job의 FAILED 상태 레코드를 일괄 RESOLVED 처리합니다") + @PostMapping("/failed-records/resolve") + public ResponseEntity> resolveFailedRecords( + @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("Resolve failed records: ids count={}", ids.size()); + try { + int resolved = batchFailedRecordService.resolveByIds(ids); + return ResponseEntity.ok(Map.of( + "success", true, + "resolvedCount", resolved, + "message", resolved + "건의 실패 레코드가 RESOLVED 처리되었습니다")); + } catch (Exception e) { + log.error("Error resolving failed records", e); + return ResponseEntity.internalServerError().body(Map.of( + "success", false, + "message", "실패 레코드 RESOLVED 처리 실패: " + e.getMessage())); + } + } + + // ── 재수집 이력 CSV 내보내기 API ────────────────────────────── + + @Operation(summary = "재수집 이력 CSV 내보내기", description = "필터 조건으로 재수집 이력을 CSV 파일로 내보냅니다 (최대 10,000건)") + @GetMapping("/recollection-histories/export") + public void exportRecollectionHistories( + @Parameter(description = "API Key") @RequestParam(required = false) String apiKey, + @Parameter(description = "Job 이름") @RequestParam(required = false) String jobName, + @Parameter(description = "실행 상태") @RequestParam(required = false) String status, + @Parameter(description = "시작일 (yyyy-MM-dd'T'HH:mm:ss)") @RequestParam(required = false) String fromDate, + @Parameter(description = "종료일 (yyyy-MM-dd'T'HH:mm:ss)") @RequestParam(required = false) String toDate, + HttpServletResponse response) throws IOException { + log.info("Export recollection histories: apiKey={}, jobName={}, status={}", apiKey, jobName, status); + + LocalDateTime from = fromDate != null ? LocalDateTime.parse(fromDate) : null; + LocalDateTime to = toDate != null ? LocalDateTime.parse(toDate) : null; + + List histories = recollectionHistoryService + .getHistoriesForExport(apiKey, jobName, status, from, to); + + response.setContentType("text/csv; charset=UTF-8"); + response.setHeader("Content-Disposition", "attachment; filename=recollection-histories.csv"); + // BOM for Excel UTF-8 + response.getOutputStream().write(new byte[]{(byte) 0xEF, (byte) 0xBB, (byte) 0xBF}); + + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + PrintWriter writer = response.getWriter(); + writer.println("이력ID,API Key,작업명,Job실행ID,수집시작일,수집종료일,상태,실행시작,실행종료,소요시간(ms),읽기,쓰기,스킵,API호출,실행자,사유,실패사유,중복여부,생성일"); + + for (BatchRecollectionHistory h : histories) { + writer.println(String.join(",", + safeStr(h.getHistoryId()), + safeStr(h.getApiKey()), + safeStr(h.getJobName()), + safeStr(h.getJobExecutionId()), + h.getRangeFromDate() != null ? h.getRangeFromDate().format(formatter) : "", + h.getRangeToDate() != null ? h.getRangeToDate().format(formatter) : "", + safeStr(h.getExecutionStatus()), + h.getExecutionStartTime() != null ? h.getExecutionStartTime().format(formatter) : "", + h.getExecutionEndTime() != null ? h.getExecutionEndTime().format(formatter) : "", + safeStr(h.getDurationMs()), + safeStr(h.getReadCount()), + safeStr(h.getWriteCount()), + safeStr(h.getSkipCount()), + safeStr(h.getApiCallCount()), + escapeCsvField(h.getExecutor()), + escapeCsvField(h.getRecollectionReason()), + escapeCsvField(h.getFailureReason()), + h.getHasOverlap() != null ? (h.getHasOverlap() ? "Y" : "N") : "", + h.getCreatedAt() != null ? h.getCreatedAt().format(formatter) : "" + )); + } + writer.flush(); + } + + private String safeStr(Object value) { + return value != null ? value.toString() : ""; + } + + private String escapeCsvField(String value) { + if (value == null) { + return ""; + } + if (value.contains(",") || value.contains("\"") || value.contains("\n")) { + return "\"" + value.replace("\"", "\"\"") + "\""; + } + return value; + } } diff --git a/src/main/java/com/snp/batch/global/dto/JobExecutionDto.java b/src/main/java/com/snp/batch/global/dto/JobExecutionDto.java index 4f5d0c9..2bc6370 100644 --- a/src/main/java/com/snp/batch/global/dto/JobExecutionDto.java +++ b/src/main/java/com/snp/batch/global/dto/JobExecutionDto.java @@ -20,4 +20,5 @@ public class JobExecutionDto { private LocalDateTime endTime; private String exitCode; private String exitMessage; + private Long failedRecordCount; } diff --git a/src/main/java/com/snp/batch/global/model/BatchRecollectionHistory.java b/src/main/java/com/snp/batch/global/model/BatchRecollectionHistory.java index ddaea55..e27e276 100644 --- a/src/main/java/com/snp/batch/global/model/BatchRecollectionHistory.java +++ b/src/main/java/com/snp/batch/global/model/BatchRecollectionHistory.java @@ -35,10 +35,10 @@ public class BatchRecollectionHistory { @Column(name = "JOB_EXECUTION_ID") private Long jobExecutionId; - @Column(name = "RANGE_FROM_DATE", nullable = false) + @Column(name = "RANGE_FROM_DATE") private LocalDateTime rangeFromDate; - @Column(name = "RANGE_TO_DATE", nullable = false) + @Column(name = "RANGE_TO_DATE") private LocalDateTime rangeToDate; @Column(name = "EXECUTION_STATUS", length = 20, nullable = false) diff --git a/src/main/java/com/snp/batch/global/repository/BatchCollectionPeriodRepository.java b/src/main/java/com/snp/batch/global/repository/BatchCollectionPeriodRepository.java index c056434..b3b5ba8 100644 --- a/src/main/java/com/snp/batch/global/repository/BatchCollectionPeriodRepository.java +++ b/src/main/java/com/snp/batch/global/repository/BatchCollectionPeriodRepository.java @@ -5,9 +5,12 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; import java.util.List; +import java.util.Optional; @Repository public interface BatchCollectionPeriodRepository extends JpaRepository { List findAllByOrderByOrderSeqAsc(); + + Optional findByJobName(String jobName); } 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 55e9de8..1aea23e 100644 --- a/src/main/java/com/snp/batch/global/repository/BatchFailedRecordRepository.java +++ b/src/main/java/com/snp/batch/global/repository/BatchFailedRecordRepository.java @@ -28,6 +28,11 @@ public interface BatchFailedRecordRepository extends JpaRepository findByStepExecutionId(Long stepExecutionId); + /** + * 여러 Step에 대한 실패 레코드 일괄 조회 (N+1 방지) + */ + List findByStepExecutionIdIn(List stepExecutionIds); + /** * 실행별 실패 건수 */ @@ -53,4 +58,23 @@ public interface BatchFailedRecordRepository extends JpaRepository recordKeys, @Param("resolvedAt") LocalDateTime resolvedAt); + + /** + * ID 목록으로 FAILED 상태 레코드를 일괄 RESOLVED 처리 + */ + @Modifying + @Query("UPDATE BatchFailedRecord r SET r.status = 'RESOLVED', r.resolvedAt = :resolvedAt " + + "WHERE r.id IN :ids AND r.status = 'FAILED'") + int resolveByIds(@Param("ids") List ids, @Param("resolvedAt") LocalDateTime resolvedAt); + + /** + * 최대 재시도 횟수를 초과한 recordKey 목록 조회. + * 자동 재수집 무한 루프 방지에 사용. + */ + @Query("SELECT DISTINCT r.recordKey FROM BatchFailedRecord r " + + "WHERE r.jobName = :jobName AND r.recordKey IN :recordKeys " + + "AND r.status = 'FAILED' AND r.retryCount >= :maxRetryCount") + List findExceededRetryKeys(@Param("jobName") String jobName, + @Param("recordKeys") List recordKeys, + @Param("maxRetryCount") int maxRetryCount); } diff --git a/src/main/java/com/snp/batch/global/repository/BatchRecollectionHistoryRepository.java b/src/main/java/com/snp/batch/global/repository/BatchRecollectionHistoryRepository.java index eec8384..97f1a68 100644 --- a/src/main/java/com/snp/batch/global/repository/BatchRecollectionHistoryRepository.java +++ b/src/main/java/com/snp/batch/global/repository/BatchRecollectionHistoryRepository.java @@ -24,6 +24,8 @@ public interface BatchRecollectionHistoryRepository SELECT h FROM BatchRecollectionHistory h WHERE h.apiKey = :apiKey AND h.historyId != :excludeId + AND h.rangeFromDate IS NOT NULL + AND h.rangeToDate IS NOT NULL AND h.rangeFromDate < :toDate AND h.rangeToDate > :fromDate ORDER BY h.createdAt DESC 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 9813ddb..6bbf764 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 @@ -2,6 +2,7 @@ package com.snp.batch.jobs.shipdetail.batch.config; import com.fasterxml.jackson.databind.ObjectMapper; import com.snp.batch.common.batch.config.BaseMultiStepJobConfig; +import org.springframework.batch.core.JobExecutionListener; import com.snp.batch.jobs.shipdetail.batch.dto.ShipDetailDto; import com.snp.batch.jobs.shipdetail.batch.entity.ShipDetailEntity; import com.snp.batch.jobs.shipdetail.batch.processor.ShipDetailDataProcessor; @@ -48,10 +49,11 @@ public class ShipDetailUpdateJobConfig extends BaseMultiStepJobConfig { } else { log.info("[{}] 변경된 IMO 번호 조회 시작...", getReaderName()); ShipUpdateApiResponse response = callShipUpdateApi(); - allImoNumbers = extractUpdateImoNumbers(response); + List fullList = extractUpdateImoNumbers(response); + allImoNumbers = new ArrayList<>(fullList.subList(0, Math.min(60, fullList.size()))); // TODO: 임시 - 테스트용 100건 제한 log.info("[{}] 변경된 IMO 번호 수: {} 개", getReaderName(), response.getShipCount()); log.info("[{}] 총 {} 개의 변경된 IMO 번호 조회 완료", getReaderName(), allImoNumbers.size()); } @@ -331,9 +332,21 @@ public class ShipDetailUpdateDataReader extends BaseApiReader { } } } catch (Exception e) { - log.info("[{}] 전체 {} 개 배치 처리 실패", getReaderName(), totalBatches); - log.info("[{}] 총 {} 개의 IMO 번호에 대한 API 호출 종료", - getReaderName(), allImoNumbers.size()); + log.error("[{}] afterFetch 처리 중 예외 발생: {}", getReaderName(), e.getMessage(), e); + } finally { + // 일반 모드에서만: 실패 건이 있으면 ExecutionContext에 저장 (자동 재수집 트리거용) + if (!isRetryMode() && !failedImoNumbers.isEmpty() && stepExecution != null) { + try { + String failedKeys = String.join(",", failedImoNumbers); + stepExecution.getExecutionContext().putString("failedRecordKeys", failedKeys); + stepExecution.getExecutionContext().putLong("failedStepExecutionId", getStepExecutionId()); + stepExecution.getExecutionContext().putString("failedApiKey", getApiKey()); + log.info("[{}] 자동 재수집 대상 실패 키 {} 건 ExecutionContext에 저장", + getReaderName(), failedImoNumbers.size()); + } catch (Exception ex) { + log.error("[{}] ExecutionContext 저장 실패: {}", getReaderName(), ex.getMessage(), ex); + } + } } } } diff --git a/src/main/java/com/snp/batch/service/BatchFailedRecordService.java b/src/main/java/com/snp/batch/service/BatchFailedRecordService.java index c125c60..1c0d6dd 100644 --- a/src/main/java/com/snp/batch/service/BatchFailedRecordService.java +++ b/src/main/java/com/snp/batch/service/BatchFailedRecordService.java @@ -54,6 +54,19 @@ public class BatchFailedRecordService { } } + /** + * ID 목록으로 FAILED 상태 실패 레코드를 일괄 RESOLVED 처리합니다. + */ + @Transactional + public int resolveByIds(List ids) { + if (ids == null || ids.isEmpty()) { + return 0; + } + int resolved = batchFailedRecordRepository.resolveByIds(ids, LocalDateTime.now()); + log.info("실패 레코드 일괄 RESOLVED: {} 건", resolved); + return resolved; + } + /** * 재수집 성공 건을 RESOLVED로 처리합니다. * 원본 stepExecutionId로 범위를 제한하여 해당 Step의 실패 건만 RESOLVED 처리합니다. diff --git a/src/main/java/com/snp/batch/service/BatchService.java b/src/main/java/com/snp/batch/service/BatchService.java index b81442b..c329c26 100644 --- a/src/main/java/com/snp/batch/service/BatchService.java +++ b/src/main/java/com/snp/batch/service/BatchService.java @@ -120,18 +120,24 @@ public class BatchService { public List getJobExecutions(String jobName) { List jobInstances = jobExplorer.findJobInstancesByJobName(jobName, 0, 100); - return jobInstances.stream() + List executions = jobInstances.stream() .flatMap(instance -> jobExplorer.getJobExecutions(instance).stream()) .map(this::convertToDto) .sorted(Comparator.comparing(JobExecutionDto::getExecutionId).reversed()) .collect(Collectors.toList()); + + populateFailedRecordCounts(executions); + return executions; } public List getRecentExecutions(int limit) { List> recentData = timelineRepository.findRecentExecutions(limit); - return recentData.stream() + List executions = recentData.stream() .map(this::convertMapToDto) .collect(Collectors.toList()); + + populateFailedRecordCounts(executions); + return executions; } public JobExecutionDto getExecutionDetails(Long executionId) { @@ -797,6 +803,8 @@ public class BatchService { .map(this::convertMapToDto) .collect(Collectors.toList()); + populateFailedRecordCounts(executions); + return ExecutionSearchResponse.builder() .executions(executions) .totalCount(totalCount) @@ -897,6 +905,29 @@ public class BatchService { .build(); } + // ── 공통: 실패 레코드 건수 세팅 ──────────────────────────────── + + private void populateFailedRecordCounts(List executions) { + List executionIds = executions.stream() + .map(JobExecutionDto::getExecutionId) + .filter(java.util.Objects::nonNull) + .toList(); + + if (executionIds.isEmpty()) { + return; + } + + Map countMap = failedRecordRepository.countFailedByJobExecutionIds(executionIds) + .stream() + .collect(Collectors.toMap( + row -> ((Number) row[0]).longValue(), + row -> ((Number) row[1]).longValue() + )); + + executions.forEach(exec -> exec.setFailedRecordCount( + countMap.getOrDefault(exec.getExecutionId(), 0L))); + } + // ── 공통: Map → DTO 변환 헬퍼 ──────────────────────────────── private JobExecutionDto convertMapToDto(Map data) { diff --git a/src/main/java/com/snp/batch/service/RecollectionHistoryService.java b/src/main/java/com/snp/batch/service/RecollectionHistoryService.java index 0dbabc4..700e67c 100644 --- a/src/main/java/com/snp/batch/service/RecollectionHistoryService.java +++ b/src/main/java/com/snp/batch/service/RecollectionHistoryService.java @@ -54,28 +54,56 @@ public class RecollectionHistoryService { String executor, String reason) { - Optional period = periodRepository.findById(apiKey); - if (period.isEmpty()) { - log.warn("[RecollectionHistory] apiKey {} 에 대한 수집기간 없음, 이력 미생성", apiKey); + if (apiKey == null || apiKey.isBlank()) { + log.warn("[RecollectionHistory] apiKey가 null이므로 이력 미생성: jobName={}, executor={}", jobName, executor); return null; } - BatchCollectionPeriod cp = period.get(); - LocalDateTime rangeFrom = cp.getRangeFromDate(); - LocalDateTime rangeTo = cp.getRangeToDate(); + boolean isRetryByRecordKeys = "AUTO_RETRY".equals(executor) || "MANUAL_RETRY".equals(executor); - // 기간 중복 검출 - List overlaps = historyRepository - .findOverlappingHistories(apiKey, rangeFrom, rangeTo, -1L); - boolean hasOverlap = !overlaps.isEmpty(); - String overlapIds = overlaps.stream() - .map(h -> String.valueOf(h.getHistoryId())) - .collect(Collectors.joining(",")); + LocalDateTime rangeFrom = null; + LocalDateTime rangeTo = null; + String apiKeyName = null; + boolean hasOverlap = false; + String overlapIds = null; + + if (isRetryByRecordKeys) { + // 실패 건 재수집 (자동/수동): 날짜 범위가 아닌 실패 레코드 키 기반이므로 날짜 없이 이력 생성 + if (apiKey != null) { + apiKeyName = periodRepository.findById(apiKey) + .map(BatchCollectionPeriod::getApiKeyName) + .orElse(null); + } + log.info("[RecollectionHistory] 실패 건 재수집 이력 생성 (날짜 범위 없음): executor={}, apiKey={}, apiKeyName={}", executor, apiKey, apiKeyName); + } else { + // 수동 재수집: BatchCollectionPeriod에서 날짜 범위 조회 + Optional period = periodRepository.findById(apiKey); + if (period.isEmpty()) { + log.warn("[RecollectionHistory] apiKey {} 에 대한 수집기간 없음, 이력 미생성", apiKey); + return null; + } + + BatchCollectionPeriod cp = period.get(); + rangeFrom = cp.getRangeFromDate(); + rangeTo = cp.getRangeToDate(); + apiKeyName = cp.getApiKeyName(); + + // 기간 중복 검출 + List overlaps = historyRepository + .findOverlappingHistories(apiKey, rangeFrom, rangeTo, -1L); + hasOverlap = !overlaps.isEmpty(); + overlapIds = overlaps.stream() + .map(h -> String.valueOf(h.getHistoryId())) + .collect(Collectors.joining(",")); + if (overlapIds.length() > 490) { + overlapIds = overlapIds.substring(0, 490) + "..."; + } + } LocalDateTime now = LocalDateTime.now(); BatchRecollectionHistory history = BatchRecollectionHistory.builder() .apiKey(apiKey) - .apiKeyName(cp.getApiKeyName()) + .apiKeyName(apiKeyName) .jobName(jobName) .jobExecutionId(jobExecutionId) .rangeFromDate(rangeFrom) @@ -175,6 +203,40 @@ public class RecollectionHistoryService { return historyRepository.findAll(spec, pageable); } + /** + * CSV 내보내기용 전체 목록 조회 (최대 10,000건) + */ + @Transactional(readOnly = true) + public List getHistoriesForExport( + String apiKey, String jobName, String status, + LocalDateTime from, LocalDateTime to) { + + Specification spec = (root, query, cb) -> { + List predicates = new ArrayList<>(); + + if (apiKey != null && !apiKey.isEmpty()) { + predicates.add(cb.equal(root.get("apiKey"), apiKey)); + } + if (jobName != null && !jobName.isEmpty()) { + predicates.add(cb.equal(root.get("jobName"), jobName)); + } + if (status != null && !status.isEmpty()) { + predicates.add(cb.equal(root.get("executionStatus"), status)); + } + if (from != null) { + predicates.add(cb.greaterThanOrEqualTo(root.get("executionStartTime"), from)); + } + if (to != null) { + predicates.add(cb.lessThanOrEqualTo(root.get("executionStartTime"), to)); + } + + query.orderBy(cb.desc(root.get("createdAt"))); + return cb.and(predicates.toArray(new Predicate[0])); + }; + + return historyRepository.findAll(spec, org.springframework.data.domain.PageRequest.of(0, 10000)).getContent(); + } + /** * 상세 조회 (중복 이력 실시간 재검사 포함) */ @@ -184,10 +246,14 @@ public class RecollectionHistoryService { .orElseThrow(() -> new IllegalArgumentException("이력을 찾을 수 없습니다: " + historyId)); // 중복 이력 실시간 재검사 - List currentOverlaps = historyRepository - .findOverlappingHistories(history.getApiKey(), - history.getRangeFromDate(), history.getRangeToDate(), - history.getHistoryId()); + List currentOverlaps; + if (history.getRangeFromDate() != null && history.getRangeToDate() != null) { + currentOverlaps = historyRepository.findOverlappingHistories( + history.getApiKey(), history.getRangeFromDate(), history.getRangeToDate(), + history.getHistoryId()); + } else { + currentOverlaps = Collections.emptyList(); + } // API 응답시간 통계 Map apiStats = null; @@ -212,10 +278,14 @@ public class RecollectionHistoryService { .orElseThrow(() -> new IllegalArgumentException("이력을 찾을 수 없습니다: " + historyId)); // 중복 이력 실시간 재검사 - List currentOverlaps = historyRepository - .findOverlappingHistories(history.getApiKey(), - history.getRangeFromDate(), history.getRangeToDate(), - history.getHistoryId()); + List currentOverlaps; + if (history.getRangeFromDate() != null && history.getRangeToDate() != null) { + currentOverlaps = historyRepository.findOverlappingHistories( + history.getApiKey(), history.getRangeFromDate(), history.getRangeToDate(), + history.getHistoryId()); + } else { + currentOverlaps = Collections.emptyList(); + } // API 응답시간 통계 Map apiStats = null; @@ -232,8 +302,16 @@ public class RecollectionHistoryService { if (history.getJobExecutionId() != null) { JobExecution jobExecution = jobExplorer.getJobExecution(history.getJobExecutionId()); if (jobExecution != null) { + // N+1 방지: stepExecutionId 목록을 일괄 조회 후 Map으로 변환 + List stepIds = jobExecution.getStepExecutions().stream() + .map(StepExecution::getId) + .toList(); + Map> failedRecordsMap = failedRecordRepository + .findByStepExecutionIdIn(stepIds).stream() + .collect(Collectors.groupingBy(BatchFailedRecord::getStepExecutionId)); + stepExecutions = jobExecution.getStepExecutions().stream() - .map(this::convertStepToDto) + .map(step -> convertStepToDto(step, failedRecordsMap.getOrDefault(step.getId(), Collections.emptyList()))) .collect(Collectors.toList()); } } @@ -247,7 +325,8 @@ public class RecollectionHistoryService { return result; } - private JobExecutionDetailDto.StepExecutionDto convertStepToDto(StepExecution stepExecution) { + private JobExecutionDetailDto.StepExecutionDto convertStepToDto(StepExecution stepExecution, + List failedRecords) { Long duration = null; if (stepExecution.getStartTime() != null && stepExecution.getEndTime() != null) { duration = Duration.between(stepExecution.getStartTime(), stepExecution.getEndTime()).toMillis(); @@ -270,9 +349,8 @@ public class RecollectionHistoryService { JobExecutionDetailDto.StepApiLogSummary apiLogSummary = buildStepApiLogSummary(stepExecution.getId()); - // Step별 실패 레코드 조회 - List failedRecordDtos = - failedRecordRepository.findByStepExecutionId(stepExecution.getId()).stream() + // Step별 실패 레코드 DTO 변환 (사전 일괄 조회된 목록 사용) + List failedRecordDtos = failedRecords.stream() .map(record -> JobExecutionDetailDto.FailedRecordDto.builder() .id(record.getId()) .jobName(record.getJobName()) @@ -387,6 +465,16 @@ public class RecollectionHistoryService { return stats; } + /** + * jobName으로 BatchCollectionPeriod에서 apiKey를 조회합니다. + */ + @Transactional(readOnly = true) + public String findApiKeyByJobName(String jobName) { + return periodRepository.findByJobName(jobName) + .map(BatchCollectionPeriod::getApiKey) + .orElse(null); + } + /** * 재수집 실행 전: 현재 last_success_date 조회 (복원용) */