diff --git a/frontend/src/pages/RecollectDetail.tsx b/frontend/src/pages/RecollectDetail.tsx
new file mode 100644
index 0000000..3412a1e
--- /dev/null
+++ b/frontend/src/pages/RecollectDetail.tsx
@@ -0,0 +1,543 @@
+import { useState, useCallback } from 'react';
+import { useParams, useNavigate } from 'react-router-dom';
+import {
+ batchApi,
+ type RecollectionDetailResponse,
+ type StepExecutionDto,
+} 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';
+
+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 (
+
+ );
+}
+
+function StepCard({ step }: { step: StepExecutionDto }) {
+ const [logsOpen, setLogsOpen] = useState(false);
+
+ const stats = [
+ { label: '읽기', value: step.readCount },
+ { label: '쓰기', value: step.writeCount },
+ { label: '커밋', value: step.commitCount },
+ { label: '롤백', value: step.rollbackCount },
+ { label: '읽기 건너뜀', value: step.readSkipCount },
+ { label: '처리 건너뜀', value: step.processSkipCount },
+ { label: '쓰기 건너뜀', value: step.writeSkipCount },
+ { label: '필터', value: step.filterCount },
+ ];
+
+ const summary = step.apiLogSummary;
+
+ return (
+
+
+
+
+ {step.stepName}
+
+
+
+
+ {step.duration != null
+ ? formatDuration(step.duration)
+ : calculateDuration(step.startTime, step.endTime)}
+
+
+
+
+
+ 시작: {formatDateTime(step.startTime)}
+
+
+ 종료: {formatDateTime(step.endTime)}
+
+
+
+
+ {stats.map(({ label, value }) => (
+
+
+ {value.toLocaleString()}
+
+
{label}
+
+ ))}
+
+
+ {/* API 호출 로그 요약 (batch_api_log 기반) */}
+ {summary && (
+
+
API 호출 정보
+
+
+
{summary.totalCalls.toLocaleString()}
+
총 호출
+
+
+
{summary.successCount.toLocaleString()}
+
성공
+
+
+
0 ? 'text-red-500' : 'text-wing-text'}`}>
+ {summary.errorCount.toLocaleString()}
+
+
에러
+
+
+
{Math.round(summary.avgResponseMs).toLocaleString()}
+
평균(ms)
+
+
+
{summary.maxResponseMs.toLocaleString()}
+
최대(ms)
+
+
+
{summary.minResponseMs.toLocaleString()}
+
최소(ms)
+
+
+
+ {/* 펼침/접기 개별 로그 */}
+ {summary.logs.length > 0 && (
+
+
+
+ {logsOpen && (
+
+
+
+
+ | # |
+ URI |
+ Method |
+ 상태 |
+ 응답(ms) |
+ 건수 |
+ 시간 |
+ 에러 |
+
+
+
+ {summary.logs.map((log, idx) => {
+ const isError = (log.statusCode != null && log.statusCode >= 400) || log.errorMessage;
+ return (
+
+ | {idx + 1} |
+
+
+
+ {log.requestUri}
+
+
+
+ |
+ {log.httpMethod} |
+
+
+ {log.statusCode ?? '-'}
+
+ |
+
+ {log.responseTimeMs?.toLocaleString() ?? '-'}
+ |
+
+ {log.responseCount?.toLocaleString() ?? '-'}
+ |
+
+ {formatDateTime(log.createdAt)}
+ |
+
+ {log.errorMessage || '-'}
+ |
+
+ );
+ })}
+
+
+
+ )}
+
+ )}
+
+ )}
+
+ {step.exitMessage && (
+
+
Exit Message
+
+ {step.exitMessage}
+
+
+ )}
+
+ );
+}
+
+export default function RecollectDetail() {
+ const { id: paramId } = useParams<{ id: string }>();
+ const navigate = useNavigate();
+
+ const historyId = paramId ? Number(paramId) : NaN;
+
+ const [data, setData] = useState
(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ const isRunning = data
+ ? data.history.executionStatus === 'STARTED'
+ : false;
+
+ const loadDetail = useCallback(async () => {
+ if (!historyId || isNaN(historyId)) {
+ setError('유효하지 않은 이력 ID입니다.');
+ setLoading(false);
+ return;
+ }
+ try {
+ const result = await batchApi.getRecollectionDetail(historyId);
+ setData(result);
+ setError(null);
+ } catch (err) {
+ setError(
+ err instanceof Error
+ ? err.message
+ : '재수집 상세 정보를 불러오지 못했습니다.',
+ );
+ } finally {
+ setLoading(false);
+ }
+ }, [historyId]);
+
+ usePoller(loadDetail, isRunning ? POLLING_INTERVAL_MS : 30_000, [historyId]);
+
+ if (loading) return ;
+
+ if (error || !data) {
+ return (
+
+
+
+
+ );
+ }
+
+ const { history, overlappingHistories, apiStats, stepExecutions } = data;
+
+ return (
+
+ {/* 상단 내비게이션 */}
+
+
+ {/* 기본 정보 카드 */}
+
+
+
+
+ 재수집 #{history.historyId}
+
+
+ {history.apiKeyName || history.apiKey} · {history.jobName}
+
+
+
+
+ {history.hasOverlap && (
+
+ 기간 중복
+
+ )}
+
+
+
+
+
+
+
+
+
+ {history.jobExecutionId && (
+
+ )}
+
+
+
+ {/* 수집 기간 정보 */}
+
+
+ {/* 처리 통계 카드 */}
+
+
+
+
+
+
+
+ {/* API 응답시간 통계 */}
+ {apiStats && (
+
+
+ API 응답시간 통계
+
+
+
+
+ {apiStats.callCount.toLocaleString()}
+
+
총 호출수
+
+
+
+ {apiStats.totalMs.toLocaleString()}
+
+
총 응답시간(ms)
+
+
+
+ {Math.round(apiStats.avgMs).toLocaleString()}
+
+
평균(ms)
+
+
+
+ {apiStats.maxMs.toLocaleString()}
+
+
최대(ms)
+
+
+
+ {apiStats.minMs.toLocaleString()}
+
+
최소(ms)
+
+
+
+ )}
+
+ {/* 실패 사유 */}
+ {history.executionStatus === 'FAILED' && history.failureReason && (
+
+
+ 실패 사유
+
+
+ {history.failureReason}
+
+
+ )}
+
+ {/* 기간 중복 이력 */}
+ {overlappingHistories.length > 0 && (
+
+
+ 기간 중복 이력
+
+ ({overlappingHistories.length}건)
+
+
+
+
+
+
+ | 이력 ID |
+ 작업명 |
+ 수집 시작일 |
+ 수집 종료일 |
+ 상태 |
+ 실행자 |
+
+
+
+ {overlappingHistories.map((oh) => (
+ navigate(`/recollects/${oh.historyId}`)}
+ >
+ |
+ #{oh.historyId}
+ |
+
+ {oh.apiKeyName || oh.apiKey}
+ |
+
+ {formatDateTime(oh.rangeFromDate)}
+ |
+
+ {formatDateTime(oh.rangeToDate)}
+ |
+
+
+ |
+
+ {oh.executor || '-'}
+ |
+
+ ))}
+
+
+
+
+ )}
+
+ {/* Step 실행 정보 */}
+
+
+ Step 실행 정보
+
+ ({stepExecutions.length}개)
+
+
+ {stepExecutions.length === 0 ? (
+
+ ) : (
+
+ {stepExecutions.map((step) => (
+
+ ))}
+
+ )}
+
+
+ );
+}
+
+function InfoItem({ label, value }: { label: string; value: string }) {
+ return (
+
+
+ {label}
+
+ {value || '-'}
+
+ );
+}
diff --git a/frontend/src/pages/Recollects.tsx b/frontend/src/pages/Recollects.tsx
new file mode 100644
index 0000000..6b2b898
--- /dev/null
+++ b/frontend/src/pages/Recollects.tsx
@@ -0,0 +1,809 @@
+import { useState, useMemo, useCallback } from 'react';
+import { useNavigate } from 'react-router-dom';
+import {
+ batchApi,
+ type RecollectionHistoryDto,
+ type RecollectionSearchResponse,
+ type CollectionPeriodDto,
+} from '../api/batchApi';
+import { formatDateTime, formatDuration } from '../utils/formatters';
+import { usePoller } from '../hooks/usePoller';
+import { useToastContext } from '../contexts/ToastContext';
+import StatusBadge from '../components/StatusBadge';
+import InfoModal from '../components/InfoModal';
+import EmptyState from '../components/EmptyState';
+import LoadingSpinner from '../components/LoadingSpinner';
+
+type StatusFilter = 'ALL' | 'COMPLETED' | 'FAILED' | 'STARTED';
+
+const STATUS_FILTERS: { value: StatusFilter; label: string }[] = [
+ { value: 'ALL', label: '전체' },
+ { value: 'COMPLETED', label: '완료' },
+ { value: 'FAILED', label: '실패' },
+ { value: 'STARTED', label: '실행중' },
+];
+
+const POLLING_INTERVAL_MS = 10_000;
+const PAGE_SIZE = 20;
+
+/** datetime 문자열에서 date input용 값 추출 (YYYY-MM-DD) */
+function toDateInput(dt: string | null): string {
+ if (!dt) return '';
+ return dt.substring(0, 10);
+}
+
+/** datetime 문자열에서 time input용 값 추출 (HH:mm) */
+function toTimeInput(dt: string | null): string {
+ if (!dt) return '00:00';
+ const t = dt.substring(11, 16);
+ return t || '00:00';
+}
+
+/** date + time을 ISO datetime 문자열로 결합 */
+function toIsoDateTime(date: string, time: string): string {
+ return `${date}T${time || '00:00'}:00`;
+}
+
+interface PeriodEdit {
+ fromDate: string;
+ fromTime: string;
+ toDate: string;
+ toTime: string;
+}
+
+/** 기간 프리셋 정의 (시간 단위) */
+const DURATION_PRESETS = [
+ { label: '6시간', hours: 6 },
+ { label: '12시간', hours: 12 },
+ { label: '하루', hours: 24 },
+ { label: '일주일', hours: 168 },
+] as const;
+
+/** 시작 날짜+시간에 시간(hours)을 더해 종료 날짜+시간을 반환 */
+function addHoursToDateTime(
+ date: string,
+ time: string,
+ hours: number,
+): { toDate: string; toTime: string } {
+ if (!date) return { toDate: '', toTime: '00:00' };
+ const dt = new Date(`${date}T${time || '00:00'}:00`);
+ dt.setTime(dt.getTime() + hours * 60 * 60 * 1000);
+ const y = dt.getFullYear();
+ const m = String(dt.getMonth() + 1).padStart(2, '0');
+ const d = String(dt.getDate()).padStart(2, '0');
+ const hh = String(dt.getHours()).padStart(2, '0');
+ const mm = String(dt.getMinutes()).padStart(2, '0');
+ return { toDate: `${y}-${m}-${d}`, toTime: `${hh}:${mm}` };
+}
+
+export default function Recollects() {
+ const navigate = useNavigate();
+ const { showToast } = useToastContext();
+ const [periods, setPeriods] = useState([]);
+ const [histories, setHistories] = useState([]);
+ const [selectedApiKey, setSelectedApiKey] = useState('');
+ const [apiDropdownOpen, setApiDropdownOpen] = useState(false);
+ const [statusFilter, setStatusFilter] = useState('ALL');
+ const [loading, setLoading] = useState(true);
+
+ // 날짜 범위 필터 + 페이지네이션
+ const [startDate, setStartDate] = useState('');
+ const [endDate, setEndDate] = useState('');
+ const [page, setPage] = useState(0);
+ const [totalPages, setTotalPages] = useState(0);
+ const [totalCount, setTotalCount] = useState(0);
+ const [useSearch, setUseSearch] = useState(false);
+
+ // 실패 로그 모달
+ const [failLogTarget, setFailLogTarget] = useState(null);
+
+ // 수집 기간 관리 패널
+ const [periodPanelOpen, setPeriodPanelOpen] = useState(false);
+ const [selectedPeriodKey, setSelectedPeriodKey] = useState('');
+ const [periodDropdownOpen, setPeriodDropdownOpen] = useState(false);
+ const [periodEdits, setPeriodEdits] = useState>({});
+ const [savingApiKey, setSavingApiKey] = useState(null);
+ const [executingApiKey, setExecutingApiKey] = useState(null);
+ const [manualToDate, setManualToDate] = useState>({});
+ const [selectedDuration, setSelectedDuration] = useState>({});
+
+ const getPeriodEdit = (p: CollectionPeriodDto): PeriodEdit => {
+ if (periodEdits[p.apiKey]) return periodEdits[p.apiKey];
+ return {
+ fromDate: toDateInput(p.rangeFromDate),
+ fromTime: toTimeInput(p.rangeFromDate),
+ toDate: toDateInput(p.rangeToDate),
+ toTime: toTimeInput(p.rangeToDate),
+ };
+ };
+
+ const updatePeriodEdit = (apiKey: string, field: keyof PeriodEdit, value: string) => {
+ const current = periodEdits[apiKey] || getPeriodEdit(periods.find((p) => p.apiKey === apiKey)!);
+ setPeriodEdits((prev) => ({ ...prev, [apiKey]: { ...current, [field]: value } }));
+ };
+
+ const applyDurationPreset = (apiKey: string, hours: number) => {
+ const p = periods.find((pp) => pp.apiKey === apiKey);
+ if (!p) return;
+ const edit = periodEdits[apiKey] || getPeriodEdit(p);
+ if (!edit.fromDate) {
+ showToast('재수집 시작일시를 먼저 선택해 주세요.', 'error');
+ return;
+ }
+ const { toDate, toTime } = addHoursToDateTime(edit.fromDate, edit.fromTime, hours);
+ setSelectedDuration((prev) => ({ ...prev, [apiKey]: hours }));
+ setPeriodEdits((prev) => ({
+ ...prev,
+ [apiKey]: { ...edit, toDate, toTime },
+ }));
+ };
+
+ const handleFromDateChange = (apiKey: string, field: 'fromDate' | 'fromTime', value: string) => {
+ const p = periods.find((pp) => pp.apiKey === apiKey);
+ if (!p) return;
+ const edit = periodEdits[apiKey] || getPeriodEdit(p);
+ const updated = { ...edit, [field]: value };
+ // 기간 프리셋이 선택된 상태면 종료일시도 자동 갱신
+ const dur = selectedDuration[apiKey];
+ if (dur != null && !manualToDate[apiKey]) {
+ const { toDate, toTime } = addHoursToDateTime(updated.fromDate, updated.fromTime, dur);
+ updated.toDate = toDate;
+ updated.toTime = toTime;
+ }
+ setPeriodEdits((prev) => ({ ...prev, [apiKey]: updated }));
+ };
+
+ const handleResetPeriod = async (p: CollectionPeriodDto) => {
+ setSavingApiKey(p.apiKey);
+ try {
+ await batchApi.resetCollectionPeriod(p.apiKey);
+ showToast(`${p.apiKeyName || p.apiKey} 수집 기간이 초기화되었습니다.`, 'success');
+ setPeriodEdits((prev) => { const next = { ...prev }; delete next[p.apiKey]; return next; });
+ setSelectedDuration((prev) => ({ ...prev, [p.apiKey]: null }));
+ setManualToDate((prev) => ({ ...prev, [p.apiKey]: false }));
+ await loadPeriods();
+ } catch (err) {
+ showToast(err instanceof Error ? err.message : '수집 기간 초기화에 실패했습니다.', 'error');
+ } finally {
+ setSavingApiKey(null);
+ }
+ };
+
+ const handleSavePeriod = async (p: CollectionPeriodDto) => {
+ const edit = getPeriodEdit(p);
+ if (!edit.fromDate || !edit.toDate) {
+ showToast('시작일과 종료일을 모두 입력해 주세요.', 'error');
+ return;
+ }
+ const from = toIsoDateTime(edit.fromDate, edit.fromTime);
+ const to = toIsoDateTime(edit.toDate, edit.toTime);
+ const now = new Date().toISOString().substring(0, 19);
+ if (from >= now) {
+ showToast('재수집 시작일시는 현재 시간보다 이전이어야 합니다.', 'error');
+ return;
+ }
+ if (to >= now) {
+ showToast('재수집 종료일시는 현재 시간보다 이전이어야 합니다.', 'error');
+ return;
+ }
+ if (from >= to) {
+ showToast('재수집 시작일시는 종료일시보다 이전이어야 합니다.', 'error');
+ return;
+ }
+ setSavingApiKey(p.apiKey);
+ try {
+ await batchApi.updateCollectionPeriod(p.apiKey, { rangeFromDate: from, rangeToDate: to });
+ showToast(`${p.apiKeyName || p.apiKey} 수집 기간이 저장되었습니다.`, 'success');
+ setPeriodEdits((prev) => { const next = { ...prev }; delete next[p.apiKey]; return next; });
+ await loadPeriods();
+ } catch (err) {
+ showToast(err instanceof Error ? err.message : '수집 기간 저장에 실패했습니다.', 'error');
+ } finally {
+ setSavingApiKey(null);
+ }
+ };
+
+ const handleExecuteRecollect = async (p: CollectionPeriodDto) => {
+ if (!p.jobName) {
+ showToast('연결된 Job이 없습니다.', 'error');
+ return;
+ }
+ setExecutingApiKey(p.apiKey);
+ try {
+ const result = await batchApi.executeJob(p.jobName, {
+ executionMode: 'RECOLLECT',
+ apiKey: p.apiKey,
+ executor: 'MANUAL',
+ reason: '수집 기간 관리 화면에서 수동 실행',
+ });
+ showToast(result.message || `${p.apiKeyName || p.apiKey} 재수집이 시작되었습니다.`, 'success');
+ setLoading(true);
+ } catch (err) {
+ showToast(err instanceof Error ? err.message : '재수집 실행에 실패했습니다.', 'error');
+ } finally {
+ setExecutingApiKey(null);
+ }
+ };
+
+ const loadPeriods = useCallback(async () => {
+ try {
+ const data = await batchApi.getCollectionPeriods();
+ setPeriods(data);
+ } catch {
+ /* 수집기간 로드 실패 무시 */
+ }
+ }, []);
+
+ const loadHistories = useCallback(async () => {
+ try {
+ const params: {
+ apiKey?: string;
+ status?: string;
+ fromDate?: string;
+ toDate?: string;
+ page?: number;
+ size?: number;
+ } = {
+ page: useSearch ? page : 0,
+ size: PAGE_SIZE,
+ };
+ if (selectedApiKey) params.apiKey = selectedApiKey;
+ if (statusFilter !== 'ALL') params.status = statusFilter;
+ if (useSearch && startDate) params.fromDate = `${startDate}T00:00:00`;
+ if (useSearch && endDate) params.toDate = `${endDate}T23:59:59`;
+
+ const data: RecollectionSearchResponse = await batchApi.searchRecollections(params);
+ setHistories(data.content);
+ setTotalPages(data.totalPages);
+ setTotalCount(data.totalElements);
+ if (!useSearch) setPage(data.number);
+ } catch {
+ setHistories([]);
+ setTotalPages(0);
+ setTotalCount(0);
+ } finally {
+ setLoading(false);
+ }
+ }, [selectedApiKey, statusFilter, startDate, endDate, useSearch, page]);
+
+ usePoller(loadPeriods, 60_000, []);
+ usePoller(loadHistories, POLLING_INTERVAL_MS, [selectedApiKey, statusFilter, useSearch, page]);
+
+ const filteredHistories = useMemo(() => {
+ if (useSearch) return histories;
+ if (statusFilter === 'ALL') return histories;
+ return histories.filter((h) => h.executionStatus === statusFilter);
+ }, [histories, statusFilter, useSearch]);
+
+ const handleSearch = async () => {
+ setUseSearch(true);
+ setPage(0);
+ setLoading(true);
+ };
+
+ const handleResetSearch = () => {
+ setUseSearch(false);
+ setStartDate('');
+ setEndDate('');
+ setPage(0);
+ setTotalPages(0);
+ setTotalCount(0);
+ setLoading(true);
+ };
+
+ const handlePageChange = (newPage: number) => {
+ if (newPage < 0 || newPage >= totalPages) return;
+ setPage(newPage);
+ setLoading(true);
+ };
+
+ const getApiLabel = (apiKey: string) => {
+ const p = periods.find((p) => p.apiKey === apiKey);
+ return p?.apiKeyName || apiKey;
+ };
+
+ return (
+
+ {/* 헤더 */}
+
+
재수집 이력
+
+ 배치 재수집 실행 이력을 조회하고 관리합니다.
+
+
+
+ {/* 수집 기간 관리 패널 */}
+
+
+
+ {periodPanelOpen && (
+
+ {periods.length === 0 ? (
+
+ 등록된 수집 기간이 없습니다.
+
+ ) : (
+ <>
+ {/* 작업 선택 드롭다운 */}
+
+
+
+
+ {periodDropdownOpen && (
+ <>
+
setPeriodDropdownOpen(false)} />
+
+ {periods.map((p) => (
+
+ ))}
+
+ >
+ )}
+
+
+
+ {/* 선택된 작업의 기간 편집 */}
+ {selectedPeriodKey && (() => {
+ const p = periods.find((pp) => pp.apiKey === selectedPeriodKey);
+ if (!p) return null;
+ const edit = getPeriodEdit(p);
+ const hasChange = !!periodEdits[p.apiKey];
+ const isSaving = savingApiKey === p.apiKey;
+ const isExecuting = executingApiKey === p.apiKey;
+ const isManual = !!manualToDate[p.apiKey];
+ const activeDur = selectedDuration[p.apiKey] ?? null;
+ return (
+
+
+ 작업명:
+ {p.jobName || '-'}
+
+
+ {/* Line 1: 재수집 시작일시 */}
+
+
+ {/* Line 2: 기간 선택 버튼 + 직접입력 토글 */}
+
+ {DURATION_PRESETS.map(({ label, hours }) => (
+
+ ))}
+
+ 직접입력
+
+
+
+
+ {/* Line 3: 재수집 종료일시 */}
+
+
+
+
+
+
+
+
+ );
+ })()}
+ >
+ )}
+
+ )}
+
+
+ {/* 필터 영역 */}
+
+
+ {/* API 선택 */}
+
+
+
+
+
+ {apiDropdownOpen && (
+ <>
+
setApiDropdownOpen(false)} />
+
+
+ {periods.map((p) => (
+
+ ))}
+
+ >
+ )}
+
+ {selectedApiKey && (
+
+ )}
+
+ {selectedApiKey && (
+
+
+ {getApiLabel(selectedApiKey)}
+
+
+
+ )}
+
+
+ {/* 상태 필터 버튼 그룹 */}
+
+ {STATUS_FILTERS.map(({ value, label }) => (
+
+ ))}
+
+
+
+ {/* 날짜 범위 필터 */}
+
+
+
+ setStartDate(e.target.value)}
+ className="block rounded-lg border border-wing-border bg-wing-surface px-3 py-2 text-sm shadow-sm focus:border-wing-accent focus:ring-1 focus:ring-wing-accent"
+ />
+ ~
+ setEndDate(e.target.value)}
+ className="block rounded-lg border border-wing-border bg-wing-surface px-3 py-2 text-sm shadow-sm focus:border-wing-accent focus:ring-1 focus:ring-wing-accent"
+ />
+
+
+
+ {useSearch && (
+
+ )}
+
+
+
+
+ {/* 재수집 이력 테이블 */}
+
+ {loading ? (
+
+ ) : filteredHistories.length === 0 ? (
+
+ ) : (
+
+
+
+
+ | 재수집 ID |
+ 작업명 |
+ 상태 |
+ 재수집 시작일시 |
+ 재수집 종료일시 |
+ 소요시간 |
+ 액션 |
+
+
+
+ {filteredHistories.map((hist) => (
+
+ |
+ #{hist.historyId}
+ |
+
+
+ {hist.apiKeyName || hist.apiKey}
+
+ |
+
+ {hist.executionStatus === 'FAILED' ? (
+
+ ) : (
+
+ )}
+ {hist.hasOverlap && (
+
+ !
+ )}
+ |
+
+ {formatDateTime(hist.rangeFromDate)}
+ |
+
+ {formatDateTime(hist.rangeToDate)}
+ |
+
+ {formatDuration(hist.durationMs)}
+ |
+
+
+ |
+
+ ))}
+
+
+
+ )}
+
+ {/* 결과 건수 + 페이지네이션 */}
+ {!loading && filteredHistories.length > 0 && (
+
+
+ 총 {totalCount}건
+
+ {totalPages > 1 && (
+
+
+
+ {page + 1} / {totalPages}
+
+
+
+ )}
+
+ )}
+
+
+ {/* 실패 로그 뷰어 모달 */}
+
setFailLogTarget(null)}
+ >
+ {failLogTarget && (
+
+
+
+ 실행 상태
+
+
+ {failLogTarget.executionStatus}
+
+
+
+
+ 실패 사유
+
+
+ {failLogTarget.failureReason || '실패 사유 없음'}
+
+
+
+ )}
+
+
+ );
+}
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..55ae0c7
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,6 @@
+{
+ "name": "snp-batch-validation",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {}
+}
diff --git a/pom.xml b/pom.xml
index 14e0c6c..dce40cd 100644
--- a/pom.xml
+++ b/pom.xml
@@ -143,6 +143,12 @@
spring-batch-test
test
+
+
+ com.google.code.findbugs
+ jsr305
+ 3.0.2
+
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
new file mode 100644
index 0000000..b659cbe
--- /dev/null
+++ b/src/main/java/com/snp/batch/common/batch/listener/RecollectionJobExecutionListener.java
@@ -0,0 +1,140 @@
+package com.snp.batch.common.batch.listener;
+
+import com.snp.batch.service.RecollectionHistoryService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+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.time.LocalDateTime;
+
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class RecollectionJobExecutionListener implements JobExecutionListener {
+
+ private static final String ORIGINAL_LAST_SUCCESS_DATE_KEY = "originalLastSuccessDate";
+
+ private final RecollectionHistoryService recollectionHistoryService;
+
+ @Override
+ public void beforeJob(JobExecution jobExecution) {
+ String executionMode = jobExecution.getJobParameters()
+ .getString("executionMode", "NORMAL");
+
+ if (!"RECOLLECT".equals(executionMode)) {
+ return;
+ }
+
+ Long jobExecutionId = jobExecution.getId();
+ String jobName = jobExecution.getJobInstance().getJobName();
+ String apiKey = jobExecution.getJobParameters().getString("apiKey");
+ String executor = jobExecution.getJobParameters().getString("executor", "SYSTEM");
+ String reason = jobExecution.getJobParameters().getString("reason");
+
+ try {
+ // 1. 현재 last_success_date를 JobExecutionContext에 저장 (afterJob에서 복원용)
+ if (apiKey != null) {
+ LocalDateTime originalDate = recollectionHistoryService.getLastSuccessDate(apiKey);
+ if (originalDate != null) {
+ jobExecution.getExecutionContext()
+ .putString(ORIGINAL_LAST_SUCCESS_DATE_KEY, originalDate.toString());
+ log.info("[RecollectionListener] 원본 last_success_date 저장: apiKey={}, date={}",
+ apiKey, originalDate);
+ }
+ }
+
+ // 2. 재수집 이력 기록
+ recollectionHistoryService.recordStart(
+ jobName, jobExecutionId, apiKey, executor, reason);
+ } catch (Exception e) {
+ log.error("[RecollectionListener] beforeJob 처리 실패: jobExecutionId={}", jobExecutionId, e);
+ }
+ }
+
+ @Override
+ public void afterJob(JobExecution jobExecution) {
+ String executionMode = jobExecution.getJobParameters()
+ .getString("executionMode", "NORMAL");
+
+ if (!"RECOLLECT".equals(executionMode)) {
+ return;
+ }
+
+ Long jobExecutionId = jobExecution.getId();
+ String status = jobExecution.getStatus().name();
+ String apiKey = jobExecution.getJobParameters().getString("apiKey");
+
+ // Step별 통계 집계
+ long totalRead = 0;
+ long totalWrite = 0;
+ long totalSkip = 0;
+ int totalApiCalls = 0;
+
+ for (StepExecution step : jobExecution.getStepExecutions()) {
+ totalRead += step.getReadCount();
+ totalWrite += step.getWriteCount();
+ totalSkip += step.getReadSkipCount()
+ + step.getProcessSkipCount()
+ + step.getWriteSkipCount();
+
+ if (step.getExecutionContext().containsKey("totalApiCalls")) {
+ totalApiCalls += step.getExecutionContext().getInt("totalApiCalls", 0);
+ }
+ }
+
+ // 실패 사유 추출
+ String failureReason = null;
+ if ("FAILED".equals(status)) {
+ failureReason = jobExecution.getExitStatus().getExitDescription();
+ if (failureReason == null || failureReason.isEmpty()) {
+ failureReason = jobExecution.getStepExecutions().stream()
+ .filter(s -> "FAILED".equals(s.getStatus().name()))
+ .map(s -> s.getExitStatus().getExitDescription())
+ .filter(desc -> desc != null && !desc.isEmpty())
+ .findFirst()
+ .orElse("Unknown error");
+ }
+ if (failureReason != null && failureReason.length() > 2000) {
+ failureReason = failureReason.substring(0, 2000) + "...";
+ }
+ }
+
+ // 1. 재수집 이력 완료 기록
+ try {
+ recollectionHistoryService.recordCompletion(
+ jobExecutionId, status,
+ totalRead, totalWrite, totalSkip,
+ totalApiCalls, null,
+ failureReason);
+ } catch (Exception e) {
+ log.error("[RecollectionListener] 재수집 이력 완료 기록 실패: jobExecutionId={}", jobExecutionId, e);
+ }
+
+ // 2. last_success_date 복원 (Tasklet이 NOW()로 업데이트한 것을 되돌림)
+ // 재수집은 과거 데이터 재처리이므로 last_success_date를 변경하면 안 됨
+ // recordCompletion 실패와 무관하게 반드시 실행되어야 함
+ try {
+ if (apiKey != null) {
+ String originalDateStr = jobExecution.getExecutionContext()
+ .getString(ORIGINAL_LAST_SUCCESS_DATE_KEY, null);
+ log.info("[RecollectionListener] last_success_date 복원 시도: apiKey={}, originalDateStr={}",
+ apiKey, originalDateStr);
+ if (originalDateStr != null) {
+ LocalDateTime originalDate = LocalDateTime.parse(originalDateStr);
+ recollectionHistoryService.restoreLastSuccessDate(apiKey, originalDate);
+ log.info("[RecollectionListener] last_success_date 복원 완료: apiKey={}, date={}",
+ apiKey, originalDate);
+ } else {
+ log.warn("[RecollectionListener] originalLastSuccessDate가 ExecutionContext에 없음: apiKey={}",
+ apiKey);
+ }
+ }
+ } catch (Exception e) {
+ log.error("[RecollectionListener] last_success_date 복원 실패: apiKey={}, jobExecutionId={}",
+ apiKey, jobExecutionId, e);
+ }
+ }
+}
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 596ab4a..e112227 100644
--- a/src/main/java/com/snp/batch/global/controller/BatchController.java
+++ b/src/main/java/com/snp/batch/global/controller/BatchController.java
@@ -1,7 +1,10 @@
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.BatchService;
+import com.snp.batch.service.RecollectionHistoryService;
import com.snp.batch.service.ScheduleService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
@@ -17,6 +20,9 @@ import org.springdoc.core.annotations.ParameterObject;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageRequest;
+
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
@@ -32,6 +38,7 @@ public class BatchController {
private final BatchService batchService;
private final ScheduleService scheduleService;
+ private final RecollectionHistoryService recollectionHistoryService;
@Operation(summary = "배치 작업 실행", description = "지정된 배치 작업을 즉시 실행합니다. 쿼리 파라미터로 Job Parameters 전달 가능")
@ApiResponses(value = {
@@ -453,4 +460,120 @@ public class BatchController {
ExecutionStatisticsDto stats = batchService.getJobStatistics(jobName, days);
return ResponseEntity.ok(stats);
}
+
+ // ── 재수집 이력 관리 API ─────────────────────────────────────
+
+ @Operation(summary = "재수집 이력 목록 조회", description = "필터 조건으로 재수집 이력을 페이징 조회합니다")
+ @GetMapping("/recollection-histories")
+ public ResponseEntity