From e289aa1611d22d39bd0bfbb2ab1750f65f1cafce Mon Sep 17 00:00:00 2001 From: hyojin kim Date: Tue, 24 Feb 2026 14:58:30 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20API=20=ED=98=B8=EC=B6=9C=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=20=ED=8E=98=EC=9D=B4=EC=A7=95=20=EB=B0=8F=20=ED=95=84?= =?UTF-8?q?=ED=84=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/api/batchApi.ts | 22 +- frontend/src/pages/ExecutionDetail.tsx | 270 +++++++++++------ frontend/src/pages/RecollectDetail.tsx | 272 ++++++++++++------ .../global/controller/BatchController.java | 15 + .../global/dto/JobExecutionDetailDto.java | 17 +- .../repository/BatchApiLogRepository.java | 31 ++ .../com/snp/batch/service/BatchService.java | 52 +++- .../service/RecollectionHistoryService.java | 19 +- 8 files changed, 500 insertions(+), 198 deletions(-) diff --git a/frontend/src/api/batchApi.ts b/frontend/src/api/batchApi.ts index fb9e6b2..35b9e89 100644 --- a/frontend/src/api/batchApi.ts +++ b/frontend/src/api/batchApi.ts @@ -125,9 +125,18 @@ export interface StepApiLogSummary { minResponseMs: number; totalResponseMs: number; totalRecordCount: number; - logs: ApiLogEntryDto[]; } +export interface ApiLogPageResponse { + content: ApiLogEntryDto[]; + page: number; + size: number; + totalElements: number; + totalPages: number; +} + +export type ApiLogStatus = 'ALL' | 'SUCCESS' | 'ERROR'; + export interface JobExecutionDetailDto { executionId: number; jobName: string; @@ -420,6 +429,17 @@ export const batchApi = { return fetchJson(`${BASE}/recollection-histories?${qs.toString()}`); }, + getStepApiLogs: (stepExecutionId: number, params?: { + page?: number; size?: number; status?: ApiLogStatus; + }) => { + 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); + return fetchJson( + `${BASE}/steps/${stepExecutionId}/api-logs?${qs.toString()}`); + }, + getRecollectionDetail: (historyId: number) => fetchJson(`${BASE}/recollection-histories/${historyId}`), diff --git a/frontend/src/pages/ExecutionDetail.tsx b/frontend/src/pages/ExecutionDetail.tsx index afba8da..81b1eef 100644 --- a/frontend/src/pages/ExecutionDetail.tsx +++ b/frontend/src/pages/ExecutionDetail.tsx @@ -1,6 +1,6 @@ -import { useState, useCallback } from 'react'; +import { useState, useCallback, useEffect } from 'react'; import { useParams, useSearchParams, useNavigate } from 'react-router-dom'; -import { batchApi, type JobExecutionDetailDto, type StepExecutionDto } from '../api/batchApi'; +import { batchApi, type JobExecutionDetailDto, type StepExecutionDto, type ApiLogPageResponse, type ApiLogStatus } from '../api/batchApi'; import { formatDateTime, formatDuration, calculateDuration } from '../utils/formatters'; import { usePoller } from '../hooks/usePoller'; import StatusBadge from '../components/StatusBadge'; @@ -77,12 +77,196 @@ function CopyButton({ text }: { text: string }) { ); } +interface ApiLogSectionProps { + stepExecutionId: number; + summary: { totalCalls: number; successCount: number; errorCount: number }; +} + +function ApiLogSection({ stepExecutionId, summary }: ApiLogSectionProps) { + const [open, setOpen] = useState(false); + const [status, setStatus] = useState('ALL'); + const [page, setPage] = useState(0); + const [logData, setLogData] = useState(null); + const [loading, setLoading] = useState(false); + + const fetchLogs = useCallback(async (p: number, s: ApiLogStatus) => { + setLoading(true); + try { + const data = await batchApi.getStepApiLogs(stepExecutionId, { page: p, size: 50, status: s }); + setLogData(data); + } catch { + setLogData(null); + } finally { + setLoading(false); + } + }, [stepExecutionId]); + + useEffect(() => { + if (open) { + fetchLogs(page, status); + } + }, [open, page, status, fetchLogs]); + + const handleStatusChange = (s: ApiLogStatus) => { + setStatus(s); + setPage(0); + }; + + const filters: { key: ApiLogStatus; label: string; count: number }[] = [ + { key: 'ALL', label: '전체', count: summary.totalCalls }, + { key: 'SUCCESS', label: '성공', count: summary.successCount }, + { key: 'ERROR', label: '에러', count: summary.errorCount }, + ]; + + return ( +
+ + + {open && ( +
+ {/* 상태 필터 탭 */} +
+ {filters.map(({ key, label, count }) => ( + + ))} +
+ + {loading ? ( +
+
+ 로딩중... +
+ ) : logData && logData.content.length > 0 ? ( + <> +
+ + + + + + + + + + + + + + + {logData.content.map((log, idx) => { + const isError = (log.statusCode != null && log.statusCode >= 400) || log.errorMessage; + return ( + + + + + + + + + + + ); + })} + +
#URIMethod상태응답(ms)건수시간에러
{page * 50 + idx + 1} +
+ + {log.requestUri} + + +
+
{log.httpMethod} + + {log.statusCode ?? '-'} + + + {log.responseTimeMs?.toLocaleString() ?? '-'} + + {log.responseCount?.toLocaleString() ?? '-'} + + {formatDateTime(log.createdAt)} + + {log.errorMessage || '-'} +
+
+ + {/* 페이지네이션 */} + {logData.totalPages > 1 && ( +
+ + {logData.totalElements.toLocaleString()}건 중{' '} + {(page * 50 + 1).toLocaleString()}~{Math.min((page + 1) * 50, logData.totalElements).toLocaleString()} + +
+ + + {page + 1} / {logData.totalPages} + + +
+
+ )} + + ) : ( +

조회된 로그가 없습니다.

+ )} +
+ )} +
+ ); +} + interface StepCardProps { step: StepExecutionDto; } function StepCard({ step }: StepCardProps) { - const [logsOpen, setLogsOpen] = useState(false); const stats = [ { label: '읽기', value: step.readCount }, { label: '쓰기', value: step.writeCount }, @@ -166,84 +350,8 @@ function StepCard({ step }: StepCardProps) {
- {step.apiLogSummary.logs.length > 0 && ( -
- - - {logsOpen && ( -
- - - - - - - - - - - - - - - {step.apiLogSummary.logs.map((log, idx) => { - const isError = (log.statusCode != null && log.statusCode >= 400) || log.errorMessage; - return ( - - - - - - - - - - - ); - })} - -
#URIMethod상태응답(ms)건수시간에러
{idx + 1} -
- - {log.requestUri} - - -
-
{log.httpMethod} - - {log.statusCode ?? '-'} - - - {log.responseTimeMs?.toLocaleString() ?? '-'} - - {log.responseCount?.toLocaleString() ?? '-'} - - {formatDateTime(log.createdAt)} - - {log.errorMessage || '-'} -
-
- )} -
+ {step.apiLogSummary.totalCalls > 0 && ( + )} ) : step.apiCallInfo && ( diff --git a/frontend/src/pages/RecollectDetail.tsx b/frontend/src/pages/RecollectDetail.tsx index 3412a1e..07c7d6e 100644 --- a/frontend/src/pages/RecollectDetail.tsx +++ b/frontend/src/pages/RecollectDetail.tsx @@ -1,9 +1,11 @@ -import { useState, useCallback } from 'react'; +import { useState, useCallback, useEffect } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { batchApi, type RecollectionDetailResponse, type StepExecutionDto, + type ApiLogPageResponse, + type ApiLogStatus, } from '../api/batchApi'; import { formatDateTime, formatDuration, calculateDuration } from '../utils/formatters'; import { usePoller } from '../hooks/usePoller'; @@ -79,9 +81,192 @@ function CopyButton({ text }: { text: string }) { ); } -function StepCard({ step }: { step: StepExecutionDto }) { - const [logsOpen, setLogsOpen] = useState(false); +interface ApiLogSectionProps { + stepExecutionId: number; + summary: { totalCalls: number; successCount: number; errorCount: number }; +} +function ApiLogSection({ stepExecutionId, summary }: ApiLogSectionProps) { + const [open, setOpen] = useState(false); + const [status, setStatus] = useState('ALL'); + const [page, setPage] = useState(0); + const [logData, setLogData] = useState(null); + const [loading, setLoading] = useState(false); + + const fetchLogs = useCallback(async (p: number, s: ApiLogStatus) => { + setLoading(true); + try { + const data = await batchApi.getStepApiLogs(stepExecutionId, { page: p, size: 50, status: s }); + setLogData(data); + } catch { + setLogData(null); + } finally { + setLoading(false); + } + }, [stepExecutionId]); + + useEffect(() => { + if (open) { + fetchLogs(page, status); + } + }, [open, page, status, fetchLogs]); + + const handleStatusChange = (s: ApiLogStatus) => { + setStatus(s); + setPage(0); + }; + + const filters: { key: ApiLogStatus; label: string; count: number }[] = [ + { key: 'ALL', label: '전체', count: summary.totalCalls }, + { key: 'SUCCESS', label: '성공', count: summary.successCount }, + { key: 'ERROR', label: '에러', count: summary.errorCount }, + ]; + + return ( +
+ + + {open && ( +
+ {/* 상태 필터 탭 */} +
+ {filters.map(({ key, label, count }) => ( + + ))} +
+ + {loading ? ( +
+
+ 로딩중... +
+ ) : logData && logData.content.length > 0 ? ( + <> +
+ + + + + + + + + + + + + + + {logData.content.map((log, idx) => { + const isError = (log.statusCode != null && log.statusCode >= 400) || log.errorMessage; + return ( + + + + + + + + + + + ); + })} + +
#URIMethod상태응답(ms)건수시간에러
{page * 50 + idx + 1} +
+ + {log.requestUri} + + +
+
{log.httpMethod} + + {log.statusCode ?? '-'} + + + {log.responseTimeMs?.toLocaleString() ?? '-'} + + {log.responseCount?.toLocaleString() ?? '-'} + + {formatDateTime(log.createdAt)} + + {log.errorMessage || '-'} +
+
+ + {/* 페이지네이션 */} + {logData.totalPages > 1 && ( +
+ + {logData.totalElements.toLocaleString()}건 중{' '} + {(page * 50 + 1).toLocaleString()}~{Math.min((page + 1) * 50, logData.totalElements).toLocaleString()} + +
+ + + {page + 1} / {logData.totalPages} + + +
+
+ )} + + ) : ( +

조회된 로그가 없습니다.

+ )} +
+ )} +
+ ); +} + +function StepCard({ step }: { step: StepExecutionDto }) { const stats = [ { label: '읽기', value: step.readCount }, { label: '쓰기', value: step.writeCount }, @@ -167,85 +352,8 @@ function StepCard({ step }: { step: StepExecutionDto }) {
- {/* 펼침/접기 개별 로그 */} - {summary.logs.length > 0 && ( -
- - - {logsOpen && ( -
- - - - - - - - - - - - - - - {summary.logs.map((log, idx) => { - const isError = (log.statusCode != null && log.statusCode >= 400) || log.errorMessage; - return ( - - - - - - - - - - - ); - })} - -
#URIMethod상태응답(ms)건수시간에러
{idx + 1} -
- - {log.requestUri} - - -
-
{log.httpMethod} - - {log.statusCode ?? '-'} - - - {log.responseTimeMs?.toLocaleString() ?? '-'} - - {log.responseCount?.toLocaleString() ?? '-'} - - {formatDateTime(log.createdAt)} - - {log.errorMessage || '-'} -
-
- )} -
+ {summary.totalCalls > 0 && ( + )} )} 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 52cb2a6..64396e7 100644 --- a/src/main/java/com/snp/batch/global/controller/BatchController.java +++ b/src/main/java/com/snp/batch/global/controller/BatchController.java @@ -344,6 +344,21 @@ public class BatchController { } } + // ── Step API 로그 페이징 조회 ───────────────────────────── + + @Operation(summary = "Step API 호출 로그 페이징 조회", description = "Step 실행의 개별 API 호출 로그를 페이징 + 상태 필터로 조회합니다") + @GetMapping("/steps/{stepExecutionId}/api-logs") + public ResponseEntity getStepApiLogs( + @Parameter(description = "Step 실행 ID", required = true) @PathVariable Long stepExecutionId, + @Parameter(description = "페이지 번호 (0부터)") @RequestParam(defaultValue = "0") int page, + @Parameter(description = "페이지 크기") @RequestParam(defaultValue = "50") int size, + @Parameter(description = "상태 필터 (ALL, SUCCESS, ERROR)") @RequestParam(defaultValue = "ALL") String status) { + log.debug("Get step API logs: stepExecutionId={}, page={}, size={}, status={}", stepExecutionId, page, size, status); + JobExecutionDetailDto.ApiLogPageResponse response = batchService.getStepApiLogs( + stepExecutionId, status, PageRequest.of(page, size)); + return ResponseEntity.ok(response); + } + // ── F1: 강제 종료(Abandon) API ───────────────────────────── @Operation(summary = "오래된 실행 중 목록 조회", description = "지정된 시간(분) 이상 STARTED/STARTING 상태인 실행 목록을 조회합니다") diff --git a/src/main/java/com/snp/batch/global/dto/JobExecutionDetailDto.java b/src/main/java/com/snp/batch/global/dto/JobExecutionDetailDto.java index f9c0166..d45b44e 100644 --- a/src/main/java/com/snp/batch/global/dto/JobExecutionDetailDto.java +++ b/src/main/java/com/snp/batch/global/dto/JobExecutionDetailDto.java @@ -90,6 +90,7 @@ public class JobExecutionDetailDto { /** * Step별 API 로그 집계 요약 (batch_api_log 테이블 기반) + * 개별 로그 목록은 별도 API(/api/batch/steps/{id}/api-logs)로 페이징 조회 */ @Data @Builder @@ -104,7 +105,21 @@ public class JobExecutionDetailDto { private Long minResponseMs; // 최소 응답시간 private Long totalResponseMs; // 총 응답시간 private Long totalRecordCount; // 총 반환 건수 - private List logs; // 개별 로그 목록 + } + + /** + * API 호출 로그 페이징 응답 + */ + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class ApiLogPageResponse { + private List content; + private int page; + private int size; + private long totalElements; + private int totalPages; } /** diff --git a/src/main/java/com/snp/batch/global/repository/BatchApiLogRepository.java b/src/main/java/com/snp/batch/global/repository/BatchApiLogRepository.java index 8335cc3..96514ba 100644 --- a/src/main/java/com/snp/batch/global/repository/BatchApiLogRepository.java +++ b/src/main/java/com/snp/batch/global/repository/BatchApiLogRepository.java @@ -1,6 +1,8 @@ package com.snp.batch.global.repository; import com.snp.batch.global.model.BatchApiLog; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -43,4 +45,33 @@ public interface BatchApiLogRepository extends JpaRepository * Step별 개별 API 호출 로그 목록 */ List findByStepExecutionIdOrderByCreatedAtAsc(Long stepExecutionId); + + /** + * Step별 개별 API 호출 로그 - 페이징 (전체) + */ + Page findByStepExecutionIdOrderByCreatedAtAsc(Long stepExecutionId, Pageable pageable); + + /** + * Step별 성공 로그 - 페이징 (statusCode 200~299) + */ + @Query(""" + SELECT l FROM BatchApiLog l + WHERE l.stepExecutionId = :stepExecutionId + AND l.statusCode >= 200 AND l.statusCode < 300 + ORDER BY l.createdAt ASC + """) + Page findSuccessByStepExecutionId( + @Param("stepExecutionId") Long stepExecutionId, Pageable pageable); + + /** + * Step별 에러 로그 - 페이징 (statusCode >= 400 OR errorMessage 존재) + */ + @Query(""" + SELECT l FROM BatchApiLog l + WHERE l.stepExecutionId = :stepExecutionId + AND (l.statusCode >= 400 OR l.errorMessage IS NOT NULL) + ORDER BY l.createdAt ASC + """) + Page findErrorByStepExecutionId( + @Param("stepExecutionId") Long stepExecutionId, Pageable pageable); } \ No newline at end of file diff --git a/src/main/java/com/snp/batch/service/BatchService.java b/src/main/java/com/snp/batch/service/BatchService.java index a82f9e9..26a411c 100644 --- a/src/main/java/com/snp/batch/service/BatchService.java +++ b/src/main/java/com/snp/batch/service/BatchService.java @@ -18,6 +18,8 @@ import org.springframework.batch.core.launch.JobLauncher; import org.springframework.batch.core.launch.JobOperator; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Lazy; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -301,7 +303,7 @@ public class BatchService { } /** - * Step별 batch_api_log 집계 + 개별 로그 목록 조회 + * Step별 batch_api_log 통계 집계 (개별 로그는 별도 API로 페이징 조회) */ private com.snp.batch.global.dto.JobExecutionDetailDto.StepApiLogSummary buildStepApiLogSummary(Long stepExecutionId) { List stats = apiLogRepository.getApiStatsByStepExecutionId(stepExecutionId); @@ -311,11 +313,35 @@ public class BatchService { Object[] row = stats.get(0); - List logs = apiLogRepository - .findByStepExecutionIdOrderByCreatedAtAsc(stepExecutionId); + return com.snp.batch.global.dto.JobExecutionDetailDto.StepApiLogSummary.builder() + .totalCalls(((Number) row[0]).longValue()) + .successCount(((Number) row[1]).longValue()) + .errorCount(((Number) row[2]).longValue()) + .avgResponseMs(((Number) row[3]).doubleValue()) + .maxResponseMs(((Number) row[4]).longValue()) + .minResponseMs(((Number) row[5]).longValue()) + .totalResponseMs(((Number) row[6]).longValue()) + .totalRecordCount(((Number) row[7]).longValue()) + .build(); + } - List logEntries = logs.stream() - .map(apiLog -> com.snp.batch.global.dto.JobExecutionDetailDto.ApiLogEntryDto.builder() + /** + * Step별 API 호출 로그 페이징 조회 (상태 필터 지원) + * + * @param stepExecutionId Step 실행 ID + * @param status 필터: ALL(전체), SUCCESS(2xx), ERROR(4xx+/에러) + * @param pageable 페이징 정보 + */ + @Transactional(readOnly = true) + public JobExecutionDetailDto.ApiLogPageResponse getStepApiLogs(Long stepExecutionId, String status, Pageable pageable) { + Page page = switch (status) { + case "SUCCESS" -> apiLogRepository.findSuccessByStepExecutionId(stepExecutionId, pageable); + case "ERROR" -> apiLogRepository.findErrorByStepExecutionId(stepExecutionId, pageable); + default -> apiLogRepository.findByStepExecutionIdOrderByCreatedAtAsc(stepExecutionId, pageable); + }; + + List content = page.getContent().stream() + .map(apiLog -> JobExecutionDetailDto.ApiLogEntryDto.builder() .logId(apiLog.getLogId()) .requestUri(apiLog.getRequestUri()) .httpMethod(apiLog.getHttpMethod()) @@ -327,16 +353,12 @@ public class BatchService { .build()) .toList(); - return com.snp.batch.global.dto.JobExecutionDetailDto.StepApiLogSummary.builder() - .totalCalls(((Number) row[0]).longValue()) - .successCount(((Number) row[1]).longValue()) - .errorCount(((Number) row[2]).longValue()) - .avgResponseMs(((Number) row[3]).doubleValue()) - .maxResponseMs(((Number) row[4]).longValue()) - .minResponseMs(((Number) row[5]).longValue()) - .totalResponseMs(((Number) row[6]).longValue()) - .totalRecordCount(((Number) row[7]).longValue()) - .logs(logEntries) + return JobExecutionDetailDto.ApiLogPageResponse.builder() + .content(content) + .page(page.getNumber()) + .size(page.getSize()) + .totalElements(page.getTotalElements()) + .totalPages(page.getTotalPages()) .build(); } diff --git a/src/main/java/com/snp/batch/service/RecollectionHistoryService.java b/src/main/java/com/snp/batch/service/RecollectionHistoryService.java index 847059f..fd0a97b 100644 --- a/src/main/java/com/snp/batch/service/RecollectionHistoryService.java +++ b/src/main/java/com/snp/batch/service/RecollectionHistoryService.java @@ -290,7 +290,7 @@ public class RecollectionHistoryService { } /** - * Step별 batch_api_log 집계 + 개별 로그 목록 조회 + * Step별 batch_api_log 통계 집계 (개별 로그는 별도 API로 페이징 조회) */ private JobExecutionDetailDto.StepApiLogSummary buildStepApiLogSummary(Long stepExecutionId) { List stats = apiLogRepository.getApiStatsByStepExecutionId(stepExecutionId); @@ -300,22 +300,6 @@ public class RecollectionHistoryService { Object[] row = stats.get(0); - List logs = apiLogRepository - .findByStepExecutionIdOrderByCreatedAtAsc(stepExecutionId); - - List logEntries = logs.stream() - .map(apiLog -> JobExecutionDetailDto.ApiLogEntryDto.builder() - .logId(apiLog.getLogId()) - .requestUri(apiLog.getRequestUri()) - .httpMethod(apiLog.getHttpMethod()) - .statusCode(apiLog.getStatusCode()) - .responseTimeMs(apiLog.getResponseTimeMs()) - .responseCount(apiLog.getResponseCount()) - .errorMessage(apiLog.getErrorMessage()) - .createdAt(apiLog.getCreatedAt()) - .build()) - .toList(); - return JobExecutionDetailDto.StepApiLogSummary.builder() .totalCalls(((Number) row[0]).longValue()) .successCount(((Number) row[1]).longValue()) @@ -325,7 +309,6 @@ public class RecollectionHistoryService { .minResponseMs(((Number) row[5]).longValue()) .totalResponseMs(((Number) row[6]).longValue()) .totalRecordCount(((Number) row[7]).longValue()) - .logs(logEntries) .build(); } -- 2.45.2