feat(recollection): 자동 재수집 및 재수집 프로세스 전면 개선 (#30) #31

병합
HYOJIN feature/ISSUE-30-auto-retry-failed-records 에서 develop 로 2 commits 를 머지했습니다 2026-03-10 17:30:06 +09:00
26개의 변경된 파일1363개의 추가작업 그리고 583개의 파일을 삭제
Showing only changes of commit 2bc2f1fc32 - Show all commits

파일 보기

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

파일 보기

@ -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<ApiLogPageResponse>(
`${BASE}/steps/${stepExecutionId}/api-logs?${qs.toString()}`);
},
@ -489,4 +493,24 @@ export const batchApi = {
// Last Collection Status
getLastCollectionStatuses: () =>
fetchJson<LastCollectionStatusDto[]>(`${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()}`);
},
};

파일 보기

@ -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<ApiLogStatus>('ALL');
const [page, setPage] = useState(0);
const [logData, setLogData] = useState<ApiLogPageResponse | null>(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 (
<div className="mt-2">
<button
onClick={() => setOpen((v) => !v)}
className="inline-flex items-center gap-1 text-xs font-medium text-blue-600 hover:text-blue-800 transition-colors"
>
<svg
className={`w-3 h-3 transition-transform ${open ? 'rotate-90' : ''}`}
fill="none" viewBox="0 0 24 24" stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
({summary.totalCalls.toLocaleString()})
</button>
{open && (
<div className="mt-2">
{/* 상태 필터 탭 */}
<div className="flex gap-1 mb-2">
{filters.map(({ key, label, count }) => (
<button
key={key}
onClick={() => handleStatusChange(key)}
className={`px-2.5 py-1 text-xs rounded-full font-medium transition-colors ${
status === key
? key === 'ERROR'
? 'bg-red-100 text-red-700'
: key === 'SUCCESS'
? 'bg-emerald-100 text-emerald-700'
: 'bg-blue-100 text-blue-700'
: 'bg-gray-100 text-gray-500 hover:bg-gray-200'
}`}
>
{label} ({count.toLocaleString()})
</button>
))}
</div>
{loading ? (
<div className="flex items-center justify-center py-6">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
<span className="ml-2 text-xs text-blue-500">...</span>
</div>
) : logData && logData.content.length > 0 ? (
<>
<div className="overflow-x-auto">
<table className="w-full text-xs text-left">
<thead className="bg-blue-100 text-blue-700 sticky top-0">
<tr>
<th className="px-2 py-1.5 font-medium">#</th>
<th className="px-2 py-1.5 font-medium">URI</th>
<th className="px-2 py-1.5 font-medium">Method</th>
<th className="px-2 py-1.5 font-medium"></th>
<th className="px-2 py-1.5 font-medium text-right">(ms)</th>
<th className="px-2 py-1.5 font-medium text-right"></th>
<th className="px-2 py-1.5 font-medium"></th>
<th className="px-2 py-1.5 font-medium"></th>
</tr>
</thead>
<tbody className="divide-y divide-blue-100">
{logData.content.map((log, idx) => {
const isError = (log.statusCode != null && log.statusCode >= 400) || log.errorMessage;
return (
<tr
key={log.logId}
className={isError ? 'bg-red-50' : 'bg-white hover:bg-blue-50'}
>
<td className="px-2 py-1.5 text-blue-500">{page * 10 + idx + 1}</td>
<td className="px-2 py-1.5 max-w-[200px]">
<div className="flex items-center gap-0.5">
<span className="font-mono text-blue-900 truncate" title={log.requestUri}>
{log.requestUri}
</span>
<CopyButton text={log.requestUri} />
</div>
</td>
<td className="px-2 py-1.5 font-semibold text-blue-900">{log.httpMethod}</td>
<td className="px-2 py-1.5">
<span className={`font-semibold ${
log.statusCode == null ? 'text-gray-400'
: log.statusCode < 300 ? 'text-emerald-600'
: log.statusCode < 400 ? 'text-amber-600'
: 'text-red-600'
}`}>
{log.statusCode ?? '-'}
</span>
</td>
<td className="px-2 py-1.5 text-right text-blue-900">
{log.responseTimeMs?.toLocaleString() ?? '-'}
</td>
<td className="px-2 py-1.5 text-right text-blue-900">
{log.responseCount?.toLocaleString() ?? '-'}
</td>
<td className="px-2 py-1.5 text-blue-600 whitespace-nowrap">
{formatDateTime(log.createdAt)}
</td>
<td className="px-2 py-1.5 text-red-500 max-w-[150px] truncate" title={log.errorMessage || ''}>
{log.errorMessage || '-'}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* 페이지네이션 */}
<Pagination
page={page}
totalPages={logData.totalPages}
totalElements={logData.totalElements}
pageSize={10}
onPageChange={setPage}
/>
</>
) : (
<p className="text-xs text-wing-muted py-3 text-center"> .</p>
)}
</div>
)}
</div>
);
}

파일 보기

@ -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 (
<button
onClick={handleCopy}
title={copied ? '복사됨!' : 'URI 복사'}
className="inline-flex items-center p-0.5 rounded hover:bg-blue-200 transition-colors shrink-0"
>
{copied ? (
<svg className="w-3.5 h-3.5 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
) : (
<svg className="w-3.5 h-3.5 text-blue-400 hover:text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
)}
</button>
);
}

파일 보기

@ -0,0 +1,22 @@
interface DetailStatCardProps {
label: string;
value: number;
gradient: string;
icon: string;
}
export default function DetailStatCard({ label, value, gradient, icon }: DetailStatCardProps) {
return (
<div className={`rounded-xl p-5 text-white shadow-md ${gradient}`}>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-white/80">{label}</p>
<p className="mt-1 text-3xl font-bold">
{value.toLocaleString()}
</p>
</div>
<span className="text-3xl opacity-80">{icon}</span>
</div>
</div>
);
}

파일 보기

@ -0,0 +1,15 @@
interface InfoItemProps {
label: string;
value: string;
}
export default function InfoItem({ label, value }: InfoItemProps) {
return (
<div>
<dt className="text-xs font-medium text-wing-muted uppercase tracking-wide">
{label}
</dt>
<dd className="mt-1 text-sm text-wing-text break-words">{value || '-'}</dd>
</div>
);
}

파일 보기

@ -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<ExecutionStatisticsDto | null>(null);
const [recollectionStats, setRecollectionStats] = useState<RecollectionStatsResponse | null>(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() {
</section>
)}
{/* 재수집 현황 */}
{recollectionStats && recollectionStats.totalCount > 0 && (
<section className="bg-wing-surface rounded-xl shadow-md p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-wing-text"> </h2>
<Link to="/recollects" className="text-sm text-wing-accent hover:text-wing-accent no-underline">
&rarr;
</Link>
</div>
<div className="grid grid-cols-2 sm:grid-cols-5 gap-3 mb-4">
<div className="bg-blue-50 rounded-lg p-3 text-center">
<p className="text-2xl font-bold text-blue-700">{recollectionStats.totalCount}</p>
<p className="text-xs text-blue-500 mt-1"></p>
</div>
<div className="bg-emerald-50 rounded-lg p-3 text-center">
<p className="text-2xl font-bold text-emerald-700">{recollectionStats.completedCount}</p>
<p className="text-xs text-emerald-500 mt-1"></p>
</div>
<div className="bg-red-50 rounded-lg p-3 text-center">
<p className="text-2xl font-bold text-red-700">{recollectionStats.failedCount}</p>
<p className="text-xs text-red-500 mt-1"></p>
</div>
<div className="bg-amber-50 rounded-lg p-3 text-center">
<p className="text-2xl font-bold text-amber-700">{recollectionStats.runningCount}</p>
<p className="text-xs text-amber-500 mt-1"> </p>
</div>
<div className="bg-violet-50 rounded-lg p-3 text-center">
<p className="text-2xl font-bold text-violet-700">{recollectionStats.overlapCount}</p>
<p className="text-xs text-violet-500 mt-1"></p>
</div>
</div>
{/* 최근 재수집 이력 (최대 5건) */}
{recollectionStats.recentHistories.length > 0 && (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-wing-border text-left text-wing-muted">
<th className="pb-2 font-medium"> ID</th>
<th className="pb-2 font-medium"></th>
<th className="pb-2 font-medium"></th>
<th className="pb-2 font-medium"> </th>
<th className="pb-2 font-medium"></th>
</tr>
</thead>
<tbody>
{recollectionStats.recentHistories.slice(0, 5).map((h) => (
<tr key={h.historyId} className="border-b border-wing-border/50">
<td className="py-3">
<Link to={`/recollects/${h.historyId}`} className="text-wing-accent hover:text-wing-accent no-underline font-medium">
#{h.historyId}
</Link>
</td>
<td className="py-3 text-wing-text">{h.apiKeyName || h.jobName}</td>
<td className="py-3 text-wing-muted">{h.executor || '-'}</td>
<td className="py-3 text-wing-muted">{formatDateTime(h.executionStartTime)}</td>
<td className="py-3">
<StatusBadge status={h.executionStatus} />
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
)}
{/* F8: Execution Statistics Chart */}
{statistics && statistics.dailyStats.length > 0 && (
<section className="bg-wing-surface rounded-xl shadow-md p-6">

파일 보기

@ -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 (
<div
className={`rounded-xl p-5 text-white shadow-md ${gradient}`}
>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-white/80">{label}</p>
<p className="mt-1 text-3xl font-bold">
{value.toLocaleString()}
</p>
</div>
<span className="text-3xl opacity-80">{icon}</span>
</div>
</div>
);
}
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 (
<button
onClick={handleCopy}
title={copied ? '복사됨!' : 'URI 복사'}
className="inline-flex items-center p-0.5 rounded hover:bg-blue-200 transition-colors shrink-0"
>
{copied ? (
<svg className="w-3.5 h-3.5 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
) : (
<svg className="w-3.5 h-3.5 text-blue-400 hover:text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
)}
</button>
);
}
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<ApiLogStatus>('ALL');
const [page, setPage] = useState(0);
const [logData, setLogData] = useState<ApiLogPageResponse | null>(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 (
<div className="mt-2">
<button
onClick={() => setOpen((v) => !v)}
className="inline-flex items-center gap-1 text-xs font-medium text-blue-600 hover:text-blue-800 transition-colors"
>
<svg
className={`w-3 h-3 transition-transform ${open ? 'rotate-90' : ''}`}
fill="none" viewBox="0 0 24 24" stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
({summary.totalCalls.toLocaleString()})
</button>
{open && (
<div className="mt-2">
{/* 상태 필터 탭 */}
<div className="flex gap-1 mb-2">
{filters.map(({ key, label, count }) => (
<button
key={key}
onClick={() => handleStatusChange(key)}
className={`px-2.5 py-1 text-xs rounded-full font-medium transition-colors ${
status === key
? key === 'ERROR'
? 'bg-red-100 text-red-700'
: key === 'SUCCESS'
? 'bg-emerald-100 text-emerald-700'
: 'bg-blue-100 text-blue-700'
: 'bg-gray-100 text-gray-500 hover:bg-gray-200'
}`}
>
{label} ({count.toLocaleString()})
</button>
))}
</div>
{loading ? (
<div className="flex items-center justify-center py-6">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
<span className="ml-2 text-xs text-blue-500">...</span>
</div>
) : logData && logData.content.length > 0 ? (
<>
<div className="overflow-x-auto">
<table className="w-full text-xs text-left">
<thead className="bg-blue-100 text-blue-700 sticky top-0">
<tr>
<th className="px-2 py-1.5 font-medium">#</th>
<th className="px-2 py-1.5 font-medium">URI</th>
<th className="px-2 py-1.5 font-medium">Method</th>
<th className="px-2 py-1.5 font-medium"></th>
<th className="px-2 py-1.5 font-medium text-right">(ms)</th>
<th className="px-2 py-1.5 font-medium text-right"></th>
<th className="px-2 py-1.5 font-medium"></th>
<th className="px-2 py-1.5 font-medium"></th>
</tr>
</thead>
<tbody className="divide-y divide-blue-100">
{logData.content.map((log, idx) => {
const isError = (log.statusCode != null && log.statusCode >= 400) || log.errorMessage;
return (
<tr
key={log.logId}
className={isError ? 'bg-red-50' : 'bg-white hover:bg-blue-50'}
>
<td className="px-2 py-1.5 text-blue-500">{page * 10 + idx + 1}</td>
<td className="px-2 py-1.5 max-w-[200px]">
<div className="flex items-center gap-0.5">
<span className="font-mono text-blue-900 truncate" title={log.requestUri}>
{log.requestUri}
</span>
<CopyButton text={log.requestUri} />
</div>
</td>
<td className="px-2 py-1.5 font-semibold text-blue-900">{log.httpMethod}</td>
<td className="px-2 py-1.5">
<span className={`font-semibold ${
log.statusCode == null ? 'text-gray-400'
: log.statusCode < 300 ? 'text-emerald-600'
: log.statusCode < 400 ? 'text-amber-600'
: 'text-red-600'
}`}>
{log.statusCode ?? '-'}
</span>
</td>
<td className="px-2 py-1.5 text-right text-blue-900">
{log.responseTimeMs?.toLocaleString() ?? '-'}
</td>
<td className="px-2 py-1.5 text-right text-blue-900">
{log.responseCount?.toLocaleString() ?? '-'}
</td>
<td className="px-2 py-1.5 text-blue-600 whitespace-nowrap">
{formatDateTime(log.createdAt)}
</td>
<td className="px-2 py-1.5 text-red-500 max-w-[150px] truncate" title={log.errorMessage || ''}>
{log.errorMessage || '-'}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* 페이지네이션 */}
<Pagination
page={page}
totalPages={logData.totalPages}
totalElements={logData.totalElements}
pageSize={10}
onPageChange={setPage}
/>
</>
) : (
<p className="text-xs text-wing-muted py-3 text-center"> .</p>
)}
</div>
)}
</div>
);
}
interface StepCardProps {
step: StepExecutionDto;
jobName: string;
@ -489,25 +259,25 @@ export default function ExecutionDetail() {
{/* 실행 통계 카드 4개 */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard
<DetailStatCard
label="읽기 (Read)"
value={detail.readCount}
gradient="bg-gradient-to-br from-blue-500 to-blue-600"
icon="&#x1F4E5;"
/>
<StatCard
<DetailStatCard
label="쓰기 (Write)"
value={detail.writeCount}
gradient="bg-gradient-to-br from-emerald-500 to-emerald-600"
icon="&#x1F4E4;"
/>
<StatCard
<DetailStatCard
label="건너뜀 (Skip)"
value={detail.skipCount}
gradient="bg-gradient-to-br from-amber-500 to-amber-600"
icon="&#x23ED;"
/>
<StatCard
<DetailStatCard
label="필터 (Filter)"
value={detail.filterCount}
gradient="bg-gradient-to-br from-purple-500 to-purple-600"
@ -580,7 +350,9 @@ const FAILED_PAGE_SIZE = 10;
function FailedRecordsToggle({ records, jobName, stepExecutionId }: { records: FailedRecordDto[]; jobName: string; stepExecutionId: number }) {
const [open, setOpen] = useState(false);
const [showConfirm, setShowConfirm] = useState(false);
const [showResolveConfirm, setShowResolveConfirm] = useState(false);
const [retrying, setRetrying] = useState(false);
const [resolving, setResolving] = useState(false);
const [page, setPage] = useState(0);
const navigate = useNavigate();
@ -601,9 +373,15 @@ function FailedRecordsToggle({ records, jobName, stepExecutionId }: { records: F
try {
const keys = failedRecords.map((r) => 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 (
<div className="mt-4 rounded-lg bg-red-50 border border-red-200 p-3">
<div className="flex items-center justify-between">
@ -625,19 +417,30 @@ function FailedRecordsToggle({ records, jobName, stepExecutionId }: { records: F
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
({records.length.toLocaleString()})
({records.length.toLocaleString()}, FAILED {failedRecords.length})
</button>
{failedRecords.length > 0 && (
<button
onClick={() => setShowConfirm(true)}
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium text-white bg-red-500 hover:bg-red-600 rounded-md transition-colors"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
({failedRecords.length})
</button>
<div className="flex items-center gap-1.5">
<button
onClick={() => setShowResolveConfirm(true)}
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium text-emerald-700 bg-emerald-50 hover:bg-emerald-100 border border-emerald-200 rounded-md transition-colors"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
RESOLVED ({failedRecords.length})
</button>
<button
onClick={() => setShowConfirm(true)}
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium text-white bg-red-500 hover:bg-red-600 rounded-md transition-colors"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
({failedRecords.length})
</button>
</div>
)}
</div>
@ -692,7 +495,7 @@ function FailedRecordsToggle({ records, jobName, stepExecutionId }: { records: F
</div>
)}
{/* 확인 다이얼로그 */}
{/* 재수집 확인 다이얼로그 */}
{showConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white rounded-xl shadow-2xl p-6 w-full max-w-md mx-4">
@ -740,17 +543,45 @@ function FailedRecordsToggle({ records, jobName, stepExecutionId }: { records: F
</div>
</div>
)}
{/* 일괄 RESOLVED 확인 다이얼로그 */}
{showResolveConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white rounded-xl shadow-2xl p-6 w-full max-w-md mx-4">
<h3 className="text-lg font-semibold text-wing-text mb-2">
RESOLVED
</h3>
<p className="text-sm text-wing-muted mb-4">
FAILED {failedRecords.length} RESOLVED로 .
.
</p>
<div className="flex justify-end gap-2">
<button
onClick={() => setShowResolveConfirm(false)}
disabled={resolving}
className="px-4 py-2 text-sm font-medium text-wing-muted bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors disabled:opacity-50"
>
</button>
<button
onClick={handleResolve}
disabled={resolving}
className="px-4 py-2 text-sm font-medium text-white bg-emerald-500 hover:bg-emerald-600 rounded-lg transition-colors disabled:opacity-50 inline-flex items-center gap-1"
>
{resolving ? (
<>
<div className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-white border-t-transparent" />
...
</>
) : (
'RESOLVED 처리'
)}
</button>
</div>
</div>
</div>
)}
</div>
);
}
function InfoItem({ label, value }: { label: string; value: string }) {
return (
<div>
<dt className="text-xs font-medium text-wing-muted uppercase tracking-wide">
{label}
</dt>
<dd className="mt-1 text-sm text-wing-text break-words">{value || '-'}</dd>
</div>
);
}

파일 보기

@ -410,18 +410,31 @@ export default function Executions() {
{exec.jobName}
</td>
<td className="px-6 py-4">
{/* F9: FAILED 상태 클릭 시 실패 로그 모달 */}
{exec.status === 'FAILED' ? (
<button
onClick={() => setFailLogTarget(exec)}
className="cursor-pointer"
title="클릭하여 실패 로그 확인"
>
<div className="flex items-center gap-1.5">
{/* F9: FAILED 상태 클릭 시 실패 로그 모달 */}
{exec.status === 'FAILED' ? (
<button
onClick={() => setFailLogTarget(exec)}
className="cursor-pointer"
title="클릭하여 실패 로그 확인"
>
<StatusBadge status={exec.status} />
</button>
) : (
<StatusBadge status={exec.status} />
</button>
) : (
<StatusBadge status={exec.status} />
)}
)}
{exec.status === 'COMPLETED' && exec.failedRecordCount != null && exec.failedRecordCount > 0 && (
<span
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] font-semibold text-amber-700 bg-amber-50 border border-amber-200 rounded-full"
title={`미해결 실패 레코드 ${exec.failedRecordCount}`}
>
<svg className="w-2.5 h-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{exec.failedRecordCount}
</span>
)}
</div>
</td>
<td className="px-6 py-4 text-wing-muted whitespace-nowrap">
{formatDateTime(exec.startTime)}

파일 보기

@ -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 (
<div className={`rounded-xl p-5 text-white shadow-md ${gradient}`}>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-white/80">{label}</p>
<p className="mt-1 text-3xl font-bold">
{value.toLocaleString()}
</p>
</div>
<span className="text-3xl opacity-80">{icon}</span>
</div>
</div>
);
}
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 (
<button
onClick={handleCopy}
title={copied ? '복사됨!' : 'URI 복사'}
className="inline-flex items-center p-0.5 rounded hover:bg-blue-200 transition-colors shrink-0"
>
{copied ? (
<svg className="w-3.5 h-3.5 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
) : (
<svg className="w-3.5 h-3.5 text-blue-400 hover:text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
)}
</button>
);
}
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<ApiLogStatus>('ALL');
const [page, setPage] = useState(0);
const [logData, setLogData] = useState<ApiLogPageResponse | null>(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 (
<div className="mt-2">
<button
onClick={() => setOpen((v) => !v)}
className="inline-flex items-center gap-1 text-xs font-medium text-blue-600 hover:text-blue-800 transition-colors"
>
<svg
className={`w-3 h-3 transition-transform ${open ? 'rotate-90' : ''}`}
fill="none" viewBox="0 0 24 24" stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
({summary.totalCalls.toLocaleString()})
</button>
{open && (
<div className="mt-2">
{/* 상태 필터 탭 */}
<div className="flex gap-1 mb-2">
{filters.map(({ key, label, count }) => (
<button
key={key}
onClick={() => handleStatusChange(key)}
className={`px-2.5 py-1 text-xs rounded-full font-medium transition-colors ${
status === key
? key === 'ERROR'
? 'bg-red-100 text-red-700'
: key === 'SUCCESS'
? 'bg-emerald-100 text-emerald-700'
: 'bg-blue-100 text-blue-700'
: 'bg-gray-100 text-gray-500 hover:bg-gray-200'
}`}
>
{label} ({count.toLocaleString()})
</button>
))}
</div>
{loading ? (
<div className="flex items-center justify-center py-6">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
<span className="ml-2 text-xs text-blue-500">...</span>
</div>
) : logData && logData.content.length > 0 ? (
<>
<div className="overflow-x-auto">
<table className="w-full text-xs text-left">
<thead className="bg-blue-100 text-blue-700 sticky top-0">
<tr>
<th className="px-2 py-1.5 font-medium">#</th>
<th className="px-2 py-1.5 font-medium">URI</th>
<th className="px-2 py-1.5 font-medium">Method</th>
<th className="px-2 py-1.5 font-medium"></th>
<th className="px-2 py-1.5 font-medium text-right">(ms)</th>
<th className="px-2 py-1.5 font-medium text-right"></th>
<th className="px-2 py-1.5 font-medium"></th>
<th className="px-2 py-1.5 font-medium"></th>
</tr>
</thead>
<tbody className="divide-y divide-blue-100">
{logData.content.map((log, idx) => {
const isError = (log.statusCode != null && log.statusCode >= 400) || log.errorMessage;
return (
<tr
key={log.logId}
className={isError ? 'bg-red-50' : 'bg-white hover:bg-blue-50'}
>
<td className="px-2 py-1.5 text-blue-500">{page * 10 + idx + 1}</td>
<td className="px-2 py-1.5 max-w-[200px]">
<div className="flex items-center gap-0.5">
<span className="font-mono text-blue-900 truncate" title={log.requestUri}>
{log.requestUri}
</span>
<CopyButton text={log.requestUri} />
</div>
</td>
<td className="px-2 py-1.5 font-semibold text-blue-900">{log.httpMethod}</td>
<td className="px-2 py-1.5">
<span className={`font-semibold ${
log.statusCode == null ? 'text-gray-400'
: log.statusCode < 300 ? 'text-emerald-600'
: log.statusCode < 400 ? 'text-amber-600'
: 'text-red-600'
}`}>
{log.statusCode ?? '-'}
</span>
</td>
<td className="px-2 py-1.5 text-right text-blue-900">
{log.responseTimeMs?.toLocaleString() ?? '-'}
</td>
<td className="px-2 py-1.5 text-right text-blue-900">
{log.responseCount?.toLocaleString() ?? '-'}
</td>
<td className="px-2 py-1.5 text-blue-600 whitespace-nowrap">
{formatDateTime(log.createdAt)}
</td>
<td className="px-2 py-1.5 text-red-500 max-w-[150px] truncate" title={log.errorMessage || ''}>
{log.errorMessage || '-'}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* 페이지네이션 */}
<Pagination
page={page}
totalPages={logData.totalPages}
totalElements={logData.totalElements}
pageSize={10}
onPageChange={setPage}
/>
</>
) : (
<p className="text-xs text-wing-muted py-3 text-center"> .</p>
)}
</div>
)}
</div>
);
}
function StepCard({ step, jobName }: { step: StepExecutionDto; jobName: string }) {
const stats = [
{ label: '읽기', value: step.readCount },
@ -471,25 +241,25 @@ export default function RecollectDetail() {
{/* 처리 통계 카드 */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard
<DetailStatCard
label="읽기 (Read)"
value={history.readCount ?? 0}
gradient="bg-gradient-to-br from-blue-500 to-blue-600"
icon="&#x1F4E5;"
/>
<StatCard
<DetailStatCard
label="쓰기 (Write)"
value={history.writeCount ?? 0}
gradient="bg-gradient-to-br from-emerald-500 to-emerald-600"
icon="&#x1F4E4;"
/>
<StatCard
<DetailStatCard
label="건너뜀 (Skip)"
value={history.skipCount ?? 0}
gradient="bg-gradient-to-br from-amber-500 to-amber-600"
icon="&#x23ED;"
/>
<StatCard
<DetailStatCard
label="API 호출"
value={history.apiCallCount ?? 0}
gradient="bg-gradient-to-br from-purple-500 to-purple-600"
@ -631,7 +401,9 @@ const FAILED_PAGE_SIZE = 10;
function FailedRecordsToggle({ records, jobName, stepExecutionId }: { records: FailedRecordDto[]; jobName: string; stepExecutionId: number }) {
const [open, setOpen] = useState(false);
const [showConfirm, setShowConfirm] = useState(false);
const [showResolveConfirm, setShowResolveConfirm] = useState(false);
const [retrying, setRetrying] = useState(false);
const [resolving, setResolving] = useState(false);
const [page, setPage] = useState(0);
const navigate = useNavigate();
@ -652,9 +424,15 @@ function FailedRecordsToggle({ records, jobName, stepExecutionId }: { records: F
try {
const keys = failedRecords.map((r) => 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 (
<div className="mt-4 rounded-lg bg-red-50 border border-red-200 p-3">
<div className="flex items-center justify-between">
@ -676,19 +468,30 @@ function FailedRecordsToggle({ records, jobName, stepExecutionId }: { records: F
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
({records.length.toLocaleString()})
({records.length.toLocaleString()}, FAILED {failedRecords.length})
</button>
{failedRecords.length > 0 && (
<button
onClick={() => setShowConfirm(true)}
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium text-white bg-red-500 hover:bg-red-600 rounded-md transition-colors"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
({failedRecords.length})
</button>
<div className="flex items-center gap-1.5">
<button
onClick={() => setShowResolveConfirm(true)}
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium text-emerald-700 bg-emerald-50 hover:bg-emerald-100 border border-emerald-200 rounded-md transition-colors"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
RESOLVED ({failedRecords.length})
</button>
<button
onClick={() => setShowConfirm(true)}
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium text-white bg-red-500 hover:bg-red-600 rounded-md transition-colors"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
({failedRecords.length})
</button>
</div>
)}
</div>
@ -743,7 +546,7 @@ function FailedRecordsToggle({ records, jobName, stepExecutionId }: { records: F
</div>
)}
{/* 확인 다이얼로그 */}
{/* 재수집 확인 다이얼로그 */}
{showConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white rounded-xl shadow-2xl p-6 w-full max-w-md mx-4">
@ -791,17 +594,44 @@ function FailedRecordsToggle({ records, jobName, stepExecutionId }: { records: F
</div>
</div>
)}
</div>
);
}
function InfoItem({ label, value }: { label: string; value: string }) {
return (
<div>
<dt className="text-xs font-medium text-wing-muted uppercase tracking-wide">
{label}
</dt>
<dd className="mt-1 text-sm text-wing-text break-words">{value || '-'}</dd>
{/* 일괄 RESOLVED 확인 다이얼로그 */}
{showResolveConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white rounded-xl shadow-2xl p-6 w-full max-w-md mx-4">
<h3 className="text-lg font-semibold text-wing-text mb-2">
RESOLVED
</h3>
<p className="text-sm text-wing-muted mb-4">
FAILED {failedRecords.length} RESOLVED로 .
.
</p>
<div className="flex justify-end gap-2">
<button
onClick={() => setShowResolveConfirm(false)}
disabled={resolving}
className="px-4 py-2 text-sm font-medium text-wing-muted bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors disabled:opacity-50"
>
</button>
<button
onClick={handleResolve}
disabled={resolving}
className="px-4 py-2 text-sm font-medium text-white bg-emerald-500 hover:bg-emerald-600 rounded-lg transition-colors disabled:opacity-50 inline-flex items-center gap-1"
>
{resolving ? (
<>
<div className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-white border-t-transparent" />
...
</>
) : (
'RESOLVED 처리'
)}
</button>
</div>
</div>
</div>
)}
</div>
);
}

파일 보기

@ -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() {
</button>
)}
<button
onClick={() => batchApi.exportRecollectionHistories({
apiKey: selectedApiKey || undefined,
status: statusFilter !== 'ALL' ? statusFilter : undefined,
fromDate: useSearch && startDate ? `${startDate}T00:00:00` : undefined,
toDate: useSearch && endDate ? `${endDate}T23:59:59` : undefined,
})}
className="px-4 py-2 text-sm font-medium text-wing-text border border-wing-border rounded-lg hover:bg-wing-hover transition-colors"
>
CSV
</button>
</div>
</div>
</div>

파일 보기

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

파일 보기

@ -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<String, Job> jobMap;
public AutoRetryTriggerService(JobLauncher jobLauncher, @Lazy Map<String, Job> 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);
}
}
}

파일 보기

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

파일 보기

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

파일 보기

@ -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<Map<String, Object>> resolveFailedRecords(
@RequestBody Map<String, Object> request) {
@SuppressWarnings("unchecked")
List<Integer> rawIds = (List<Integer>) request.get("ids");
if (rawIds == null || rawIds.isEmpty()) {
return ResponseEntity.badRequest().body(Map.of(
"success", false,
"message", "ids는 필수이며 비어있을 수 없습니다"));
}
List<Long> ids = rawIds.stream().map(Integer::longValue).toList();
log.info("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<BatchRecollectionHistory> 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;
}
}

파일 보기

@ -20,4 +20,5 @@ public class JobExecutionDto {
private LocalDateTime endTime;
private String exitCode;
private String exitMessage;
private Long failedRecordCount;
}

파일 보기

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

파일 보기

@ -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<BatchCollectionPeriod, String> {
List<BatchCollectionPeriod> findAllByOrderByOrderSeqAsc();
Optional<BatchCollectionPeriod> findByJobName(String jobName);
}

파일 보기

@ -28,6 +28,11 @@ public interface BatchFailedRecordRepository extends JpaRepository<BatchFailedRe
*/
List<BatchFailedRecord> findByStepExecutionId(Long stepExecutionId);
/**
* 여러 Step에 대한 실패 레코드 일괄 조회 (N+1 방지)
*/
List<BatchFailedRecord> findByStepExecutionIdIn(List<Long> stepExecutionIds);
/**
* 실행별 실패 건수
*/
@ -53,4 +58,23 @@ public interface BatchFailedRecordRepository extends JpaRepository<BatchFailedRe
@Param("stepExecutionId") Long stepExecutionId,
@Param("recordKeys") List<String> 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<Long> 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<String> findExceededRetryKeys(@Param("jobName") String jobName,
@Param("recordKeys") List<String> recordKeys,
@Param("maxRetryCount") int maxRetryCount);
}

파일 보기

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

파일 보기

@ -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<ShipDetail
private final ShipDetailUpdateDataReader shipDetailUpdateDataReader;
private final JdbcTemplate jdbcTemplate;
private final WebClient maritimeApiWebClient;
private final ObjectMapper objectMapper; // ObjectMapper 주입 추가
private final ObjectMapper objectMapper;
private final BatchDateService batchDateService;
private final BatchApiLogService batchApiLogService;
private final BatchFailedRecordService batchFailedRecordService;
private final JobExecutionListener autoRetryJobExecutionListener;
@Value("${app.batch.ship-api.url}")
private String maritimeApiUrl;
@ -88,7 +90,8 @@ public class ShipDetailUpdateJobConfig extends BaseMultiStepJobConfig<ShipDetail
ObjectMapper objectMapper,
BatchDateService batchDateService,
BatchApiLogService batchApiLogService,
BatchFailedRecordService batchFailedRecordService) {
BatchFailedRecordService batchFailedRecordService,
@Qualifier("autoRetryJobExecutionListener") JobExecutionListener autoRetryJobExecutionListener) {
super(jobRepository, transactionManager);
this.shipDetailDataProcessor = shipDetailDataProcessor;
this.shipDetailDataWriter = shipDetailDataWriter;
@ -99,6 +102,12 @@ public class ShipDetailUpdateJobConfig extends BaseMultiStepJobConfig<ShipDetail
this.batchDateService = batchDateService;
this.batchApiLogService = batchApiLogService;
this.batchFailedRecordService = batchFailedRecordService;
this.autoRetryJobExecutionListener = autoRetryJobExecutionListener;
}
@Override
protected void configureJob(JobBuilder jobBuilder) {
jobBuilder.listener(autoRetryJobExecutionListener);
}
@Override

파일 보기

@ -122,7 +122,8 @@ public class ShipDetailUpdateDataReader extends BaseApiReader<ShipDetailDto> {
} else {
log.info("[{}] 변경된 IMO 번호 조회 시작...", getReaderName());
ShipUpdateApiResponse response = callShipUpdateApi();
allImoNumbers = extractUpdateImoNumbers(response);
List<String> 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<ShipDetailDto> {
}
}
} 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);
}
}
}
}
}

파일 보기

@ -54,6 +54,19 @@ public class BatchFailedRecordService {
}
}
/**
* ID 목록으로 FAILED 상태 실패 레코드를 일괄 RESOLVED 처리합니다.
*/
@Transactional
public int resolveByIds(List<Long> 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 처리합니다.

파일 보기

@ -120,18 +120,24 @@ public class BatchService {
public List<JobExecutionDto> getJobExecutions(String jobName) {
List<JobInstance> jobInstances = jobExplorer.findJobInstancesByJobName(jobName, 0, 100);
return jobInstances.stream()
List<JobExecutionDto> 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<JobExecutionDto> getRecentExecutions(int limit) {
List<Map<String, Object>> recentData = timelineRepository.findRecentExecutions(limit);
return recentData.stream()
List<JobExecutionDto> 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<JobExecutionDto> executions) {
List<Long> executionIds = executions.stream()
.map(JobExecutionDto::getExecutionId)
.filter(java.util.Objects::nonNull)
.toList();
if (executionIds.isEmpty()) {
return;
}
Map<Long, Long> 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<String, Object> data) {

파일 보기

@ -54,28 +54,56 @@ public class RecollectionHistoryService {
String executor,
String reason) {
Optional<BatchCollectionPeriod> 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<BatchRecollectionHistory> 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<BatchCollectionPeriod> 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<BatchRecollectionHistory> 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<BatchRecollectionHistory> getHistoriesForExport(
String apiKey, String jobName, String status,
LocalDateTime from, LocalDateTime to) {
Specification<BatchRecollectionHistory> spec = (root, query, cb) -> {
List<Predicate> 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<BatchRecollectionHistory> currentOverlaps = historyRepository
.findOverlappingHistories(history.getApiKey(),
history.getRangeFromDate(), history.getRangeToDate(),
history.getHistoryId());
List<BatchRecollectionHistory> 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<String, Object> apiStats = null;
@ -212,10 +278,14 @@ public class RecollectionHistoryService {
.orElseThrow(() -> new IllegalArgumentException("이력을 찾을 수 없습니다: " + historyId));
// 중복 이력 실시간 재검사
List<BatchRecollectionHistory> currentOverlaps = historyRepository
.findOverlappingHistories(history.getApiKey(),
history.getRangeFromDate(), history.getRangeToDate(),
history.getHistoryId());
List<BatchRecollectionHistory> 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<String, Object> 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<Long> stepIds = jobExecution.getStepExecutions().stream()
.map(StepExecution::getId)
.toList();
Map<Long, List<BatchFailedRecord>> 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<BatchFailedRecord> 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<JobExecutionDetailDto.FailedRecordDto> failedRecordDtos =
failedRecordRepository.findByStepExecutionId(stepExecution.getId()).stream()
// Step별 실패 레코드 DTO 변환 (사전 일괄 조회된 목록 사용)
List<JobExecutionDetailDto.FailedRecordDto> 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 조회 (복원용)
*/