diff --git a/src/main/java/com/kamco/cd/training/config/SecurityConfig.java b/src/main/java/com/kamco/cd/training/config/SecurityConfig.java index 750cc34..efd625c 100644 --- a/src/main/java/com/kamco/cd/training/config/SecurityConfig.java +++ b/src/main/java/com/kamco/cd/training/config/SecurityConfig.java @@ -137,6 +137,6 @@ public class SecurityConfig { /** 완전 제외(필터 자체를 안 탐) */ @Bean public WebSecurityCustomizer webSecurityCustomizer() { - return (web) -> web.ignoring().requestMatchers("/api/mapsheet/**"); + return (web) -> web.ignoring().requestMatchers("/api/mapsheet/**", "/api/file-manager/**"); } } diff --git a/src/main/java/com/kamco/cd/training/filemanager/FileManagerApiController.java b/src/main/java/com/kamco/cd/training/filemanager/FileManagerApiController.java new file mode 100644 index 0000000..20a8d9b --- /dev/null +++ b/src/main/java/com/kamco/cd/training/filemanager/FileManagerApiController.java @@ -0,0 +1,125 @@ +package com.kamco.cd.training.filemanager; + +import com.kamco.cd.training.config.api.ApiResponseDto; +import com.kamco.cd.training.filemanager.dto.FileManagerDto; +import com.kamco.cd.training.filemanager.service.FileManagerService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Slf4j +@Tag(name = "파일 관리", description = "/data 디렉토리 파일 관리 API") +@RestController +@RequestMapping("/api/file-manager") +@RequiredArgsConstructor +public class FileManagerApiController { + + private final FileManagerService fileManagerService; + + @Operation( + summary = "파일 목록 조회", + description = "/data 디렉토리 내 파일 및 디렉토리 목록을 조회합니다. recursive=true로 설정하면 하위 디렉토리까지 조회합니다.") + @ApiResponses( + value = { + @ApiResponse( + responseCode = "200", + description = "조회 성공", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = FileManagerDto.ListFilesRes.class))), + @ApiResponse(responseCode = "400", description = "잘못된 요청 (유효하지 않은 경로)", content = @Content), + @ApiResponse(responseCode = "404", description = "디렉토리를 찾을 수 없음", content = @Content), + @ApiResponse(responseCode = "500", description = "서버 오류", content = @Content) + }) + @GetMapping("/files") + public ApiResponseDto listFiles( + @Parameter(description = "조회할 디렉토리 경로 (기본값: /data)", example = "/data/request") + @RequestParam(required = false) + String directoryPath, + @Parameter(description = "하위 디렉토리 포함 여부", example = "false") + @RequestParam(required = false, defaultValue = "false") + Boolean recursive) { + + FileManagerDto.ListFilesReq request = + FileManagerDto.ListFilesReq.builder() + .directoryPath(directoryPath) + .recursive(recursive) + .build(); + + FileManagerDto.ListFilesRes response = fileManagerService.listFiles(request); + return ApiResponseDto.ok(response); + } + + @Operation( + summary = "파일/디렉토리 삭제", + description = "지정된 파일 또는 디렉토리를 삭제합니다. recursive=true로 설정하면 디렉토리 내 모든 파일을 삭제합니다.", + requestBody = + @io.swagger.v3.oas.annotations.parameters.RequestBody( + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = FileManagerDto.DeleteFileReq.class), + examples = { + @ExampleObject( + name = "단일 파일 삭제", + value = + """ + { + "filePaths": ["/data/request/old_file.zip"], + "recursive": false + } + """), + @ExampleObject( + name = "여러 파일 삭제", + value = + """ + { + "filePaths": ["/data/file1.txt", "/data/file2.txt"], + "recursive": false + } + """), + @ExampleObject( + name = "디렉토리 전체 삭제", + value = + """ + { + "filePaths": ["/data/old_folder"], + "recursive": true + } + """) + }))) + @ApiResponses( + value = { + @ApiResponse( + responseCode = "200", + description = "삭제 성공", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = FileManagerDto.DeleteFileRes.class))), + @ApiResponse(responseCode = "400", description = "잘못된 요청 (유효하지 않은 경로)", content = @Content), + @ApiResponse(responseCode = "404", description = "파일을 찾을 수 없음", content = @Content), + @ApiResponse(responseCode = "500", description = "서버 오류", content = @Content) + }) + @DeleteMapping("/files") + public ApiResponseDto deleteFiles( + @RequestBody FileManagerDto.DeleteFileReq request) { + + FileManagerDto.DeleteFileRes response = fileManagerService.deleteFiles(request); + return ApiResponseDto.ok(response); + } +} diff --git a/src/main/java/com/kamco/cd/training/filemanager/dto/FileManagerDto.java b/src/main/java/com/kamco/cd/training/filemanager/dto/FileManagerDto.java new file mode 100644 index 0000000..87ec29f --- /dev/null +++ b/src/main/java/com/kamco/cd/training/filemanager/dto/FileManagerDto.java @@ -0,0 +1,134 @@ +package com.kamco.cd.training.filemanager.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +public class FileManagerDto { + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "파일 정보") + public static class FileInfo { + @Schema(description = "파일명", example = "dataset.zip") + private String fileName; + + @Schema(description = "파일 전체 경로", example = "/data/request/dataset.zip") + private String filePath; + + @Schema(description = "파일 크기 (bytes)", example = "1024000") + private Long fileSize; + + @Schema(description = "파일인지 디렉토리인지 여부", example = "true") + private Boolean isFile; + + @Schema(description = "디렉토리인지 여부", example = "false") + private Boolean isDirectory; + + @Schema(description = "마지막 수정 시간", example = "2026-04-06T15:30:00") + private LocalDateTime lastModified; + + @Schema(description = "읽기 권한", example = "true") + private Boolean readable; + + @Schema(description = "쓰기 권한", example = "true") + private Boolean writable; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "디렉토리 목록 조회 요청") + public static class ListFilesReq { + @Schema(description = "조회할 디렉토리 경로 (기본값: /data)", example = "/data/request") + private String directoryPath; + + @Schema(description = "하위 디렉토리 포함 여부", example = "false") + private Boolean recursive; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "디렉토리 목록 조회 응답") + public static class ListFilesRes { + @Schema(description = "조회된 디렉토리 경로", example = "/data/request") + private String directoryPath; + + @Schema(description = "파일 목록") + private List files; + + @Schema(description = "총 파일 개수", example = "10") + private Integer totalCount; + + @Schema(description = "총 파일 크기 (bytes)", example = "10240000") + private Long totalSize; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "파일 삭제 요청") + public static class DeleteFileReq { + @Schema( + description = "삭제할 파일 또는 디렉토리 경로 목록", + example = "[\"/data/request/old_file.zip\", \"/data/tmp/test_folder\"]") + private List filePaths; + + @Schema(description = "디렉토리일 경우 하위 파일 포함 삭제 여부", example = "true") + private Boolean recursive; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "파일 삭제 응답") + public static class DeleteFileRes { + @Schema(description = "삭제 성공한 파일 경로 목록") + private List deletedFiles; + + @Schema(description = "삭제 실패한 파일 경로 목록") + private List failedFiles; + + @Schema(description = "삭제 성공 개수", example = "5") + private Integer successCount; + + @Schema(description = "삭제 실패 개수", example = "0") + private Integer failureCount; + + @Schema(description = "전체 메시지", example = "5개 파일 삭제 성공") + private String message; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "디스크 사용량 조회 응답") + public static class DiskUsageRes { + @Schema(description = "디렉토리 경로", example = "/data") + private String directoryPath; + + @Schema(description = "총 용량 (bytes)", example = "1000000000000") + private Long totalSpace; + + @Schema(description = "사용 가능 용량 (bytes)", example = "500000000000") + private Long usableSpace; + + @Schema(description = "사용 중인 용량 (bytes)", example = "500000000000") + private Long usedSpace; + + @Schema(description = "사용률 (%)", example = "50.0") + private Double usagePercentage; + } +} diff --git a/src/main/java/com/kamco/cd/training/filemanager/service/FileManagerService.java b/src/main/java/com/kamco/cd/training/filemanager/service/FileManagerService.java new file mode 100644 index 0000000..1dd2a53 --- /dev/null +++ b/src/main/java/com/kamco/cd/training/filemanager/service/FileManagerService.java @@ -0,0 +1,234 @@ +package com.kamco.cd.training.filemanager.service; + +import com.kamco.cd.training.filemanager.dto.FileManagerDto; +import java.io.IOException; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Stream; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class FileManagerService { + + private static final String BASE_DATA_PATH = "/data"; + private static final long MAX_PATH_LENGTH = 500; + + /** + * 디렉토리 내 파일 목록 조회 + * + * @param request 조회 요청 정보 + * @return 파일 목록 응답 + */ + public FileManagerDto.ListFilesRes listFiles(FileManagerDto.ListFilesReq request) { + String targetPath = + request.getDirectoryPath() != null ? request.getDirectoryPath() : BASE_DATA_PATH; + + boolean recursive = request.getRecursive() != null && request.getRecursive(); + + validatePath(targetPath); + + Path directory = Paths.get(targetPath); + if (!Files.exists(directory)) { + throw new IllegalArgumentException("디렉토리가 존재하지 않습니다: " + targetPath); + } + + if (!Files.isDirectory(directory)) { + throw new IllegalArgumentException("디렉토리 경로가 아닙니다: " + targetPath); + } + + List files = new ArrayList<>(); + long totalSize = 0; + + try { + if (recursive) { + // 재귀적으로 모든 하위 파일 조회 + Files.walkFileTree( + directory, + new SimpleFileVisitor<>() { + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) { + files.add(createFileInfo(file)); + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) { + if (!dir.equals(directory)) { + files.add(createFileInfo(dir)); + } + return FileVisitResult.CONTINUE; + } + }); + } else { + // 현재 디렉토리의 파일만 조회 + try (Stream stream = Files.list(directory)) { + stream.forEach(path -> files.add(createFileInfo(path))); + } + } + + // 총 파일 크기 계산 + for (FileManagerDto.FileInfo file : files) { + if (file.getIsFile() && file.getFileSize() != null) { + totalSize += file.getFileSize(); + } + } + + } catch (IOException e) { + log.error("파일 목록 조회 중 오류 발생: {}", targetPath, e); + throw new RuntimeException("파일 목록 조회에 실패했습니다: " + e.getMessage()); + } + + return FileManagerDto.ListFilesRes.builder() + .directoryPath(targetPath) + .files(files) + .totalCount(files.size()) + .totalSize(totalSize) + .build(); + } + + /** + * 파일 또는 디렉토리 삭제 + * + * @param request 삭제 요청 정보 + * @return 삭제 결과 + */ + public FileManagerDto.DeleteFileRes deleteFiles(FileManagerDto.DeleteFileReq request) { + List deletedFiles = new ArrayList<>(); + List failedFiles = new ArrayList<>(); + + boolean recursive = request.getRecursive() != null && request.getRecursive(); + + for (String filePath : request.getFilePaths()) { + try { + validatePath(filePath); + Path path = Paths.get(filePath); + + if (!Files.exists(path)) { + log.warn("삭제하려는 파일이 존재하지 않습니다: {}", filePath); + failedFiles.add(filePath + " (파일이 존재하지 않음)"); + continue; + } + + if (Files.isDirectory(path)) { + if (recursive) { + // 디렉토리 및 하위 파일 모두 삭제 + deleteDirectoryRecursively(path); + deletedFiles.add(filePath); + } else { + // 빈 디렉토리만 삭제 + if (isDirectoryEmpty(path)) { + Files.delete(path); + deletedFiles.add(filePath); + } else { + failedFiles.add(filePath + " (디렉토리가 비어있지 않음)"); + } + } + } else { + // 파일 삭제 + Files.delete(path); + deletedFiles.add(filePath); + } + + log.info("파일 삭제 성공: {}", filePath); + + } catch (Exception e) { + log.error("파일 삭제 실패: {}", filePath, e); + failedFiles.add(filePath + " (" + e.getMessage() + ")"); + } + } + + String message = + String.format("%d개 파일 삭제 성공, %d개 파일 삭제 실패", deletedFiles.size(), failedFiles.size()); + + return FileManagerDto.DeleteFileRes.builder() + .deletedFiles(deletedFiles) + .failedFiles(failedFiles) + .successCount(deletedFiles.size()) + .failureCount(failedFiles.size()) + .message(message) + .build(); + } + + /** FileInfo 객체 생성 */ + private FileManagerDto.FileInfo createFileInfo(Path path) { + try { + BasicFileAttributes attrs = Files.readAttributes(path, BasicFileAttributes.class); + + return FileManagerDto.FileInfo.builder() + .fileName(path.getFileName().toString()) + .filePath(path.toString()) + .fileSize(attrs.isRegularFile() ? attrs.size() : null) + .isFile(attrs.isRegularFile()) + .isDirectory(attrs.isDirectory()) + .lastModified( + LocalDateTime.ofInstant( + Instant.ofEpochMilli(attrs.lastModifiedTime().toMillis()), + ZoneId.systemDefault())) + .readable(Files.isReadable(path)) + .writable(Files.isWritable(path)) + .build(); + } catch (IOException e) { + log.warn("파일 정보 조회 실패: {}", path, e); + return FileManagerDto.FileInfo.builder() + .fileName(path.getFileName().toString()) + .filePath(path.toString()) + .build(); + } + } + + /** 디렉토리 재귀 삭제 */ + private void deleteDirectoryRecursively(Path directory) throws IOException { + Files.walkFileTree( + directory, + new SimpleFileVisitor<>() { + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) + throws IOException { + Files.delete(file); + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException { + Files.delete(dir); + return FileVisitResult.CONTINUE; + } + }); + } + + /** 디렉토리가 비어있는지 확인 */ + private boolean isDirectoryEmpty(Path directory) throws IOException { + try (Stream stream = Files.list(directory)) { + return stream.findFirst().isEmpty(); + } + } + + /** 경로 검증 (보안) */ + private void validatePath(String path) { + if (path == null || path.trim().isEmpty()) { + throw new IllegalArgumentException("경로가 비어있습니다"); + } + + if (path.length() > MAX_PATH_LENGTH) { + throw new IllegalArgumentException("경로가 너무 깁니다"); + } + + // 경로 순회 공격 방지 - 상대경로 패턴만 제한 + if (path.contains("..")) { + throw new IllegalArgumentException("상대 경로(..)는 사용할 수 없습니다"); + } + } +} diff --git a/src/main/java/com/kamco/cd/training/model/service/ModelTrainMngService.java b/src/main/java/com/kamco/cd/training/model/service/ModelTrainMngService.java index 59f71b5..9cf0479 100644 --- a/src/main/java/com/kamco/cd/training/model/service/ModelTrainMngService.java +++ b/src/main/java/com/kamco/cd/training/model/service/ModelTrainMngService.java @@ -82,6 +82,10 @@ public class ModelTrainMngService { throw new CustomApiException("NOT_FOUND", HttpStatus.NOT_FOUND, "모델 없음"); } + if (model.getRequestPath() == null) { + throw new CustomApiException("NOT_FOUND", HttpStatus.NOT_FOUND, "임시파일 경로 없음"); + } + // ===== 2. 경로 생성 ===== Path tmpBase = Path.of(symbolicDir).toAbsolutePath().normalize(); Path tmp = tmpBase.resolve(model.getRequestPath()).normalize(); diff --git a/src/main/java/com/kamco/cd/training/postgres/core/ModelTestMetricsJobCoreService.java b/src/main/java/com/kamco/cd/training/postgres/core/ModelTestMetricsJobCoreService.java index a8d7b2c..3be3328 100644 --- a/src/main/java/com/kamco/cd/training/postgres/core/ModelTestMetricsJobCoreService.java +++ b/src/main/java/com/kamco/cd/training/postgres/core/ModelTestMetricsJobCoreService.java @@ -26,6 +26,10 @@ public class ModelTestMetricsJobCoreService { return modelTestMetricsJobRepository.getTestMetricSaveNotYetModelIds(); } + public ResponsePathDto getTestMetricSaveNotYetModelId(Long modelId) { + return modelTestMetricsJobRepository.getTestMetricSaveNotYetModelId(modelId); + } + public void insertModelMetricsTest(List batchArgs) { modelTestMetricsJobRepository.insertModelMetricsTest(batchArgs); } diff --git a/src/main/java/com/kamco/cd/training/postgres/core/ModelTrainJobCoreService.java b/src/main/java/com/kamco/cd/training/postgres/core/ModelTrainJobCoreService.java index 8cc1fe8..87321ff 100644 --- a/src/main/java/com/kamco/cd/training/postgres/core/ModelTrainJobCoreService.java +++ b/src/main/java/com/kamco/cd/training/postgres/core/ModelTrainJobCoreService.java @@ -80,6 +80,17 @@ public class ModelTrainJobCoreService { } } + /** 실행 시작 처리 수정 */ + @Transactional + public void updateJobStatus(Long jobId, String jobStatus) { + ModelTrainJobEntity job = + modelTrainJobRepository + .findById(jobId) + .orElseThrow(() -> new CustomApiException("NOT_FOUND_DATA", HttpStatus.NOT_FOUND)); + + job.setStatusCd(jobStatus); + } + /** * 성공 처리 * diff --git a/src/main/java/com/kamco/cd/training/postgres/core/ModelTrainMetricsJobCoreService.java b/src/main/java/com/kamco/cd/training/postgres/core/ModelTrainMetricsJobCoreService.java index 3f592d8..25c29cf 100644 --- a/src/main/java/com/kamco/cd/training/postgres/core/ModelTrainMetricsJobCoreService.java +++ b/src/main/java/com/kamco/cd/training/postgres/core/ModelTrainMetricsJobCoreService.java @@ -17,6 +17,10 @@ public class ModelTrainMetricsJobCoreService { return modelTrainMetricsJobRepository.getTrainMetricSaveNotYetModelIds(); } + public ResponsePathDto getTrainMetricSaveNotYetModelId(Long modelId) { + return modelTrainMetricsJobRepository.getTrainMetricSaveNotYetModelId(modelId); + } + public void insertModelMetricsTrain(List batchArgs) { modelTrainMetricsJobRepository.insertModelMetricsTrain(batchArgs); } diff --git a/src/main/java/com/kamco/cd/training/postgres/repository/train/ModelTestMetricsJobRepositoryCustom.java b/src/main/java/com/kamco/cd/training/postgres/repository/train/ModelTestMetricsJobRepositoryCustom.java index 4c55743..fd7a25f 100644 --- a/src/main/java/com/kamco/cd/training/postgres/repository/train/ModelTestMetricsJobRepositoryCustom.java +++ b/src/main/java/com/kamco/cd/training/postgres/repository/train/ModelTestMetricsJobRepositoryCustom.java @@ -12,6 +12,8 @@ public interface ModelTestMetricsJobRepositoryCustom { List getTestMetricSaveNotYetModelIds(); + ResponsePathDto getTestMetricSaveNotYetModelId(Long modelId); + void insertModelMetricsTest(List batchArgs); ModelMetricJsonDto getTestMetricPackingInfo(Long modelId); diff --git a/src/main/java/com/kamco/cd/training/postgres/repository/train/ModelTestMetricsJobRepositoryImpl.java b/src/main/java/com/kamco/cd/training/postgres/repository/train/ModelTestMetricsJobRepositoryImpl.java index fa28fb1..687dbfb 100644 --- a/src/main/java/com/kamco/cd/training/postgres/repository/train/ModelTestMetricsJobRepositoryImpl.java +++ b/src/main/java/com/kamco/cd/training/postgres/repository/train/ModelTestMetricsJobRepositoryImpl.java @@ -63,6 +63,25 @@ public class ModelTestMetricsJobRepositoryImpl extends QuerydslRepositorySupport .fetch(); } + @Override + public ResponsePathDto getTestMetricSaveNotYetModelId(Long modelId) { + return queryFactory + .select( + Projections.constructor( + ResponsePathDto.class, + modelMasterEntity.id, + modelMasterEntity.responsePath, + modelMasterEntity.uuid)) + .from(modelMasterEntity) + .where( + modelMasterEntity.id.eq(modelId), + modelMasterEntity + .step2MetricSaveYn + .isNull() + .or(modelMasterEntity.step2MetricSaveYn.isFalse())) + .fetchOne(); + } + @Override public void insertModelMetricsTest(List batchArgs) { // AS-IS diff --git a/src/main/java/com/kamco/cd/training/postgres/repository/train/ModelTrainMetricsJobRepositoryCustom.java b/src/main/java/com/kamco/cd/training/postgres/repository/train/ModelTrainMetricsJobRepositoryCustom.java index f4031bf..22cd2dc 100644 --- a/src/main/java/com/kamco/cd/training/postgres/repository/train/ModelTrainMetricsJobRepositoryCustom.java +++ b/src/main/java/com/kamco/cd/training/postgres/repository/train/ModelTrainMetricsJobRepositoryCustom.java @@ -7,6 +7,8 @@ public interface ModelTrainMetricsJobRepositoryCustom { List getTrainMetricSaveNotYetModelIds(); + ResponsePathDto getTrainMetricSaveNotYetModelId(Long modelId); + void insertModelMetricsTrain(List batchArgs); void updateModelMetricsTrainSaveYn(Long modelId, String stepNo); diff --git a/src/main/java/com/kamco/cd/training/postgres/repository/train/ModelTrainMetricsJobRepositoryImpl.java b/src/main/java/com/kamco/cd/training/postgres/repository/train/ModelTrainMetricsJobRepositoryImpl.java index 1323d40..d36586d 100644 --- a/src/main/java/com/kamco/cd/training/postgres/repository/train/ModelTrainMetricsJobRepositoryImpl.java +++ b/src/main/java/com/kamco/cd/training/postgres/repository/train/ModelTrainMetricsJobRepositoryImpl.java @@ -44,6 +44,25 @@ public class ModelTrainMetricsJobRepositoryImpl extends QuerydslRepositorySuppor .fetch(); } + @Override + public ResponsePathDto getTrainMetricSaveNotYetModelId(Long modelId) { + return queryFactory + .select( + Projections.constructor( + ResponsePathDto.class, + modelMasterEntity.id, + modelMasterEntity.responsePath, + modelMasterEntity.uuid)) + .from(modelMasterEntity) + .where( + modelMasterEntity.id.eq(modelId), + modelMasterEntity + .step1MetricSaveYn + .isNull() + .or(modelMasterEntity.step1MetricSaveYn.isFalse())) + .fetchOne(); + } + @Override public void insertModelMetricsTrain(List batchArgs) { String sql = diff --git a/src/main/java/com/kamco/cd/training/train/TrainApiController.java b/src/main/java/com/kamco/cd/training/train/TrainApiController.java index 1c5f9d4..e500a90 100644 --- a/src/main/java/com/kamco/cd/training/train/TrainApiController.java +++ b/src/main/java/com/kamco/cd/training/train/TrainApiController.java @@ -213,4 +213,27 @@ public class TrainApiController { Long modelId = trainJobService.getModelIdByUuid(uuid); return ApiResponseDto.ok(dataSetCountersService.getCount(modelId)); } + + @Operation(summary = "학습 상태 확인", description = "학습 상태 확인") + @ApiResponses( + value = { + @ApiResponse( + responseCode = "200", + description = "학습 상태 변경", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = String.class))), + @ApiResponse(responseCode = "400", description = "잘못된 검색 조건", content = @Content), + @ApiResponse(responseCode = "500", description = "서버 오류", content = @Content) + }) + @PostMapping(path = "/status/{uuid}", produces = MediaType.APPLICATION_JSON_VALUE) + public ApiResponseDto status( + @Parameter(description = "uuid", example = "e22181eb-2ac4-4100-9941-d06efce25c49") + @PathVariable + UUID uuid) { + Long modelId = trainJobService.getModelIdByUuid(uuid); + trainJobService.status(uuid, modelId); + return ApiResponseDto.ok("ok"); + } } diff --git a/src/main/java/com/kamco/cd/training/train/dto/DockerInspectState.java b/src/main/java/com/kamco/cd/training/train/dto/DockerInspectState.java new file mode 100644 index 0000000..fd93dad --- /dev/null +++ b/src/main/java/com/kamco/cd/training/train/dto/DockerInspectState.java @@ -0,0 +1,8 @@ +package com.kamco.cd.training.train.dto; + +public record DockerInspectState(boolean exists, boolean running, Integer exitCode, String status) { + + public static DockerInspectState missing() { + return new DockerInspectState(false, false, null, "missing"); + } +} diff --git a/src/main/java/com/kamco/cd/training/train/dto/OutputResult.java b/src/main/java/com/kamco/cd/training/train/dto/OutputResult.java new file mode 100644 index 0000000..23742ae --- /dev/null +++ b/src/main/java/com/kamco/cd/training/train/dto/OutputResult.java @@ -0,0 +1,20 @@ +package com.kamco.cd.training.train.dto; + +public class OutputResult { + + private final boolean completed; + private final String reason; + + public OutputResult(boolean completed, String reason) { + this.completed = completed; + this.reason = reason; + } + + public boolean completed() { + return completed; + } + + public String reason() { + return reason; + } +} diff --git a/src/main/java/com/kamco/cd/training/train/service/JobRecoveryOnStartupService.java b/src/main/java/com/kamco/cd/training/train/service/JobRecoveryOnStartupService.java index ffff705..395a33b 100644 --- a/src/main/java/com/kamco/cd/training/train/service/JobRecoveryOnStartupService.java +++ b/src/main/java/com/kamco/cd/training/train/service/JobRecoveryOnStartupService.java @@ -1,25 +1,16 @@ package com.kamco.cd.training.train.service; -import com.kamco.cd.training.model.dto.ModelTrainMngDto; import com.kamco.cd.training.postgres.core.ModelTrainJobCoreService; import com.kamco.cd.training.postgres.core.ModelTrainMngCoreService; +import com.kamco.cd.training.train.dto.DockerInspectState; import com.kamco.cd.training.train.dto.ModelTrainJobDto; -import java.io.BufferedReader; +import com.kamco.cd.training.train.dto.OutputResult; import java.io.IOException; -import java.io.InputStreamReader; -import java.nio.charset.StandardCharsets; -import java.nio.file.DirectoryStream; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; import java.util.List; import java.util.Map; -import java.util.Optional; import java.util.concurrent.TimeUnit; -import java.util.stream.Stream; import lombok.RequiredArgsConstructor; import lombok.extern.log4j.Log4j2; -import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.context.event.ApplicationReadyEvent; import org.springframework.context.annotation.Profile; import org.springframework.context.event.EventListener; @@ -44,14 +35,7 @@ public class JobRecoveryOnStartupService { private final ModelTrainJobCoreService modelTrainJobCoreService; private final ModelTrainMngCoreService modelTrainMngCoreService; private final ModelTrainMetricsJobService modelTrainMetricsJobService; - - /** - * Docker 컨테이너가 쓰는 response(산출물) 디렉토리의 "호스트 측" 베이스 경로. 예) /data/train/response - * - *

컨테이너가 --rm 으로 삭제된 경우에도 이 경로에 val.csv / *.pth 등이 남아있으면 정상 종료 여부를 "파일 기반"으로 판정합니다. - */ - @Value("${train.docker.response_dir}") - private String responseDir; + private final TrainUtilService trainUtilService; /** * 스프링 부팅 완료 시점(빈 생성/초기화 모두 끝난 뒤)에 복구 로직 실행. @@ -77,7 +61,7 @@ public class JobRecoveryOnStartupService { try { // 2-1) docker inspect로 컨테이너 상태 조회 - DockerInspectState state = inspectContainer(containerName); + DockerInspectState state = trainUtilService.inspectContainer(containerName); // 3) 컨테이너가 "없음" // - docker run --rm 로 실행한 컨테이너는 정상 종료 시 바로 삭제될 수 있음 @@ -88,7 +72,7 @@ public class JobRecoveryOnStartupService { containerName); // 3-1) 컨테이너가 없을 때는 산출물(responseDir)을 보고 완료 여부를 "추정" - OutputResult out = probeOutputs(job); + OutputResult out = trainUtilService.probeOutputs(job); // 3-2) 산출물이 충분하면 성공 처리 if (out.completed()) { @@ -109,11 +93,9 @@ public class JobRecoveryOnStartupService { job.getId(), out.reason()); - Integer modelId = job.getModelId() == null ? null : Math.toIntExact(job.getModelId()); - // PAUSED/STOP modelTrainJobCoreService.markPaused( - job.getId(), modelId, "SERVER_RESTART_CONTAINER_MISSING_OUTPUT_INCOMPLETE"); + job.getId(), -1, "SERVER_RESTART_CONTAINER_MISSING_OUTPUT_INCOMPLETE"); // 모델도 에러가 아니라 STOP으로 markStepStopByJobType( @@ -152,7 +134,7 @@ public class JobRecoveryOnStartupService { // ============================================================ // 2) kill 후 실제로 죽었는지 확인 // ============================================================ - DockerInspectState after = inspectContainer(containerName); + DockerInspectState after = trainUtilService.inspectContainer(containerName); if (after.exists() && after.running()) { throw new IOException("docker kill returned 0 but container still running"); } @@ -162,10 +144,8 @@ public class JobRecoveryOnStartupService { // ============================================================ // 3) job 상태를 PAUSED로 변경 (서버 재기동으로 강제 중단) // ============================================================ - Integer modelId = job.getModelId() == null ? null : Math.toIntExact(job.getModelId()); - modelTrainJobCoreService.markPaused( - job.getId(), modelId, "AUTO_KILLED_ON_SERVER_RESTART"); + modelTrainJobCoreService.markPaused(job.getId(), -1, "AUTO_KILLED_ON_SERVER_RESTART"); log.info("job = {}", job); markStepStopByJobType(job, "AUTO_KILLED_ON_SERVER_RESTART"); @@ -264,301 +244,4 @@ public class JobRecoveryOnStartupService { modelTrainMngCoreService.markError(job.getModelId(), msg); } } - - /** - * docker inspect를 사용해서 컨테이너 상태를 조회합니다. - * - *

사용하는 템플릿: {{.State.Status}} {{.State.Running}} {{.State.ExitCode}} - * - *

예상 출력 예: - "running true 0" - "exited false 0" - "exited false 137" - * - *

주의: - 컨테이너가 없거나 inspect 실패 시 exitCode != 0 또는 output이 비어서 missing() 반환 - 무한 대기 방지를 위해 5초 - * 타임아웃을 둠 - */ - private DockerInspectState inspectContainer(String containerName) - throws IOException, InterruptedException { - - ProcessBuilder pb = - new ProcessBuilder( - "docker", - "inspect", - "-f", - "{{.State.Status}} {{.State.Running}} {{.State.ExitCode}}", - containerName); - - // stderr를 stdout으로 합쳐서 한 스트림으로 읽기(에러 메시지도 함께 받음) - pb.redirectErrorStream(true); - - Process p = pb.start(); - - // inspect 출력은 1줄이면 충분하므로 readLine()만 수행 - String output; - try (BufferedReader br = new BufferedReader(new InputStreamReader(p.getInputStream()))) { - output = br.readLine(); - } - - // 무한대기 방지: 5초 내에 종료되지 않으면 강제 종료 - boolean finished = p.waitFor(5, TimeUnit.SECONDS); - if (!finished) { - p.destroyForcibly(); - throw new IOException("docker inspect timeout"); - } - - // docker inspect 자체의 프로세스 exit code - int code = p.exitValue(); - - // 실패(코드 !=0) 또는 출력이 없으면 "컨테이너 없음"으로 간주 - if (code != 0 || output == null || output.isBlank()) { - return DockerInspectState.missing(); - } - - // "status running exitCode" 형태로 split - String[] parts = output.trim().split("\\s+"); - - // status: running/exited/dead 등 - String status = parts.length > 0 ? parts[0] : "unknown"; - - // running: true/false - boolean running = parts.length > 1 && Boolean.parseBoolean(parts[1]); - - // exitCode: 정수 파싱(파싱 실패하면 null) - Integer exitCode = null; - if (parts.length > 2) { - try { - exitCode = Integer.parseInt(parts[2]); - } catch (Exception ignore) { - // ignore - } - } - - return new DockerInspectState(true, running, exitCode, status); - } - - /** - * docker inspect 결과를 담는 레코드. - * - *

exists: - true : docker inspect 성공 (컨테이너 존재) - false : 컨테이너 없음(또는 inspect 실패를 missing으로 간주) - */ - private record DockerInspectState( - boolean exists, boolean running, Integer exitCode, String status) { - static DockerInspectState missing() { - return new DockerInspectState(false, false, null, "missing"); - } - } - - // ============================================================================================ - // 컨테이너가 "없을 때" 파일 기반으로 완료/미완료를 판정하는 로직 - // ============================================================================================ - - /** - * 컨테이너가 없을 때(responseDir 산출물만 남아있는 상태) 완료 여부를 파일 기반으로 판정합니다. - * - *

판정 규칙(보수적으로 설계): 1) total_epoch가 paramsJson에 있어야 함 (없으면 완료 판단 불가) 2) val.csv 존재 + 헤더 제외 라인 수 - * >= total_epoch 이어야 함 3) *.pth 파일이 total_epoch 이상 존재하거나, best*.pth(또는 *best*.pth)가 존재해야 함 - * - *

왜 이렇게? - 어떤 학습은 epoch마다 pth를 남기고 - 어떤 학습은 best만 남기기도 해서 "pthCount >= total_epoch"만 쓰면 정상 종료를 - * 실패로 오판할 수 있음. - */ - private OutputResult probeOutputs(ModelTrainJobDto job) { - try { - - log.info( - "[RECOVERY] probeOutputs start. jobId={}, modelId={}", job.getId(), job.getModelId()); - - // 1) 출력 디렉토리 확인 - Path outDir = resolveOutputDir(job); - if (outDir == null || !Files.isDirectory(outDir)) { - log.warn("[RECOVERY] output directory missing. jobId={}, path={}", job.getId(), outDir); - return new OutputResult(false, "output-dir-missing"); - } - - log.info("[RECOVERY] output directory found. jobId={}, path={}", job.getId(), outDir); - - // 2) totalEpoch 확인 - Integer totalEpoch = extractTotalEpoch(job).orElse(null); - if (totalEpoch == null || totalEpoch <= 0) { - log.warn( - "[RECOVERY] totalEpoch missing or invalid. jobId={}, totalEpoch={}", - job.getId(), - totalEpoch); - return new OutputResult(false, "total-epoch-missing"); - } - - Integer valInterval = extractValInterval(job).orElse(null); - if (valInterval == null || valInterval <= 0) { - log.warn( - "[RECOVERY] valInterval missing or invalid. jobId={}, valInterval={}", - job.getId(), - valInterval); - return new OutputResult(false, "val-interval-missing"); - } - - log.info( - "[RECOVERY] totalEpoch={}. valInterval={}. jobId={}", - totalEpoch, - valInterval, - job.getId()); - - // 3) val.csv 존재 확인 - Path valCsv = outDir.resolve("val.csv"); - if (!Files.exists(valCsv)) { - log.warn("[RECOVERY] val.csv missing. jobId={}, path={}", job.getId(), valCsv); - return new OutputResult(false, "val.csv-missing"); - } - - // 4) val.csv 라인 수 확인 - long lines = countNonHeaderLines(valCsv); - - // expected = 실제 val 실행 횟수 - int expectedLines = totalEpoch / valInterval; - - log.info( - "[RECOVERY] val.csv lines counted. jobId={}, lines={}, expected={}", - job.getId(), - lines, - expectedLines); - - // 5) 완료 판정 - if (lines >= expectedLines) { - log.info("[RECOVERY] outputs look COMPLETE. jobId={}", job.getId()); - return new OutputResult(true, "ok"); - } - - log.warn( - "[RECOVERY] val.csv line mismatch. jobId={}, lines={}, expected={}", - job.getId(), - lines, - expectedLines); - - return new OutputResult( - false, "val.csv-lines-mismatch lines=" + lines + " expected=" + totalEpoch); - - } catch (Exception e) { - - log.error("[RECOVERY] probeOutputs error. jobId={}", job.getId(), e); - - return new OutputResult(false, "probe-error"); - } - } - - /** - * responseDir 아래에서 job 산출물 디렉토리를 찾습니다. - * - *

가장 중요한 커스터마이징 포인트: - 실제 운영 환경에서 산출물이 어떤 경로 규칙으로 저장되는지에 따라 여기만 수정하면 됩니다. - * - *

현재 기본 탐색 순서: 1) {responseDir}/{jobId} 2) {responseDir}/{modelId} 3) - * {responseDir}/{containerName} 4) 마지막 fallback: responseDir 자체 - * - *

추천: - 여러분 규칙이 "{responseDir}/{modelId}/{jobId}" 같은 형태라면 base.resolve(modelId).resolve(jobId) - * 형태를 1순위로 두세요. - */ - private Path resolveOutputDir(ModelTrainJobDto job) { - ModelTrainMngDto.Basic model = modelTrainMngCoreService.findModelById(job.getModelId()); - - Path base = Paths.get(responseDir, model.getUuid().toString(), "metrics"); - - return Files.isDirectory(base) ? base : null; - } - - /** - * paramsJson에서 total_epoch 값을 추출합니다. - * - *

키 후보: - "total_epoch" (snake_case) - "totalEpoch" (camelCase) - * - *

예: paramsJson = {"jobType":"TRAIN","total_epoch":50,...} - */ - private Optional extractTotalEpoch(ModelTrainJobDto job) { - Map params = job.getParamsJson(); - if (params == null) return Optional.empty(); - - Object v = params.get("total_epoch"); - if (v == null) v = params.get("totalEpoch"); - if (v == null) return Optional.empty(); - - try { - return Optional.of(Integer.parseInt(String.valueOf(v))); - } catch (Exception ignore) { - return Optional.empty(); - } - } - - /** - * CSV 파일에서 "헤더(첫 줄)"를 제외한 라인 수를 계산합니다. - * - *

가정: - val.csv 첫 줄은 헤더 - 이후 라인들이 epoch별 기록(또는 유사한 누적 기록) - * - *

주의: - 파일 인코딩은 UTF-8로 가정 - 빈 줄은 제외 - */ - private long countNonHeaderLines(Path csv) throws IOException { - try (Stream lines = Files.lines(csv, StandardCharsets.UTF_8)) { - return lines.skip(1).filter(s -> s != null && !s.isBlank()).count(); - } - } - - /** - * 디렉토리에서 glob 패턴에 맞는 파일 수를 셉니다. - * - *

예: - "*.pth" - "best*.pth" - */ - private long countFilesByGlob(Path dir, String glob) throws IOException { - try (DirectoryStream ds = Files.newDirectoryStream(dir, glob)) { - long cnt = 0; - for (Path p : ds) { - if (Files.isRegularFile(p)) cnt++; - } - return cnt; - } - } - - /** 디렉토리에서 glob 패턴에 맞는 파일이 "하나라도" 존재하는지 체크합니다. */ - private boolean existsByGlob(Path dir, String glob) throws IOException { - try (DirectoryStream ds = Files.newDirectoryStream(dir, glob)) { - return ds.iterator().hasNext(); - } - } - - // ============================================================================================ - // probeOutputs() 결과 객체 - // ============================================================================================ - - /** - * 컨테이너가 없을 때(responseDir 기반) 완료 여부 판정 결과. - * - *

completed: - true : 산출물이 완료로 보임(성공 처리 가능) - false : 산출물이 부족/불명확(실패 또는 유예 판단) - * - *

reason: - 실패/미완료 사유(로그/DB 메시지로 남기기 용도) - */ - private static final class OutputResult { - - private final boolean completed; - private final String reason; - - private OutputResult(boolean completed, String reason) { - this.completed = completed; - this.reason = reason; - } - - boolean completed() { - return completed; - } - - String reason() { - return reason; - } - } - - /** paramsJson에서 valInterval 추출 */ - private Optional extractValInterval(ModelTrainJobDto job) { - Map params = job.getParamsJson(); - if (params == null) return Optional.empty(); - - Object v = params.get("valInterval"); - if (v == null) return Optional.empty(); - - try { - return Optional.of(Integer.parseInt(String.valueOf(v))); - } catch (Exception ignore) { - return Optional.empty(); - } - } } diff --git a/src/main/java/com/kamco/cd/training/train/service/ModelTestMetricsJobService.java b/src/main/java/com/kamco/cd/training/train/service/ModelTestMetricsJobService.java index 22c2d58..a0c1447 100644 --- a/src/main/java/com/kamco/cd/training/train/service/ModelTestMetricsJobService.java +++ b/src/main/java/com/kamco/cd/training/train/service/ModelTestMetricsJobService.java @@ -59,119 +59,133 @@ public class ModelTestMetricsJobService { } for (ResponsePathDto modelInfo : modelIds) { + createFile(modelInfo); + } + } - String testPath = responseDir + "/" + modelInfo.getUuid() + "/metrics/test.csv"; - try (BufferedReader reader = - Files.newBufferedReader(Paths.get(testPath), StandardCharsets.UTF_8); ) { + /** 단건 결과 csv 파일 정보 등록 */ + public void testValidMetricCsvFiles(Long modelId) { - CSVParser parser = CSVFormat.DEFAULT.withFirstRecordAsHeader().parse(reader); + ResponsePathDto model = modelTestMetricsJobCoreService.getTestMetricSaveNotYetModelId(modelId); - List batchArgs = new ArrayList<>(); + if (model == null) { + return; + } - for (CSVRecord record : parser) { + createFile(model); + } - String model = record.get("model"); - long TP = Long.parseLong(record.get("TP")); - long FP = Long.parseLong(record.get("FP")); - long FN = Long.parseLong(record.get("FN")); - float precision = Float.parseFloat(record.get("precision")); - float recall = Float.parseFloat(record.get("recall")); - float f1_score = Float.parseFloat(record.get("f1_score")); - float accuracy = Float.parseFloat(record.get("accuracy")); - float iou = Float.parseFloat(record.get("iou")); - long detection_count = Long.parseLong(record.get("detection_count")); - long gt_count = Long.parseLong(record.get("gt_count")); + /** + * 베스트 에폭 zip파일 생성, 테스트결과 db등록 + * + * @param modelInfo + */ + private void createFile(ResponsePathDto modelInfo) { - batchArgs.add( - new Object[] { - modelInfo.getModelId(), - model, - TP, - FP, - FN, - precision, - recall, - f1_score, - accuracy, - iou, - detection_count, - gt_count - }); - } + String testPath = responseDir + "/" + modelInfo.getUuid() + "/metrics/test.csv"; + try (BufferedReader reader = + Files.newBufferedReader(Paths.get(testPath), StandardCharsets.UTF_8); ) { - modelTestMetricsJobCoreService.insertModelMetricsTest(batchArgs); + CSVParser parser = CSVFormat.DEFAULT.withFirstRecordAsHeader().parse(reader); - // test.csv 파일 읽어서 저장한 여부로만 사용하기 - modelTestMetricsJobCoreService.updateModelMetricsTrainSaveYn( - modelInfo.getModelId(), "step2"); + List batchArgs = new ArrayList<>(); - } catch (IOException e) { - throw new RuntimeException(e); + for (CSVRecord record : parser) { + + String model = record.get("model"); + long TP = Long.parseLong(record.get("TP")); + long FP = Long.parseLong(record.get("FP")); + long FN = Long.parseLong(record.get("FN")); + float precision = Float.parseFloat(record.get("precision")); + float recall = Float.parseFloat(record.get("recall")); + float f1_score = Float.parseFloat(record.get("f1_score")); + float accuracy = Float.parseFloat(record.get("accuracy")); + float iou = Float.parseFloat(record.get("iou")); + long detection_count = Long.parseLong(record.get("detection_count")); + long gt_count = Long.parseLong(record.get("gt_count")); + + batchArgs.add( + new Object[] { + modelInfo.getModelId(), + model, + TP, + FP, + FN, + precision, + recall, + f1_score, + accuracy, + iou, + detection_count, + gt_count + }); } - // 패키징할 파일 만들기 - modelTestMetricsJobCoreService.updatePackingStart( - modelInfo.getModelId(), ZonedDateTime.now()); + modelTestMetricsJobCoreService.insertModelMetricsTest(batchArgs); - ModelMetricJsonDto jsonDto = - modelTestMetricsJobCoreService.getTestMetricPackingInfo(modelInfo.getModelId()); - try { - writeJsonFile( - jsonDto, - Paths.get( - responseDir - + "/" - + modelInfo.getUuid() - + "/" - + jsonDto.getModelVersion() - + ".json")); - } catch (IOException e) { - throw new RuntimeException(e); - } + // test.csv 파일 읽어서 저장한 여부로만 사용하기 + modelTestMetricsJobCoreService.updateModelMetricsTrainSaveYn(modelInfo.getModelId(), "step2"); - Path responsePath = Paths.get(responseDir + "/" + modelInfo.getUuid()); + } catch (IOException e) { + throw new RuntimeException(e); + } - ModelTestFileName fileInfo = - modelTestMetricsJobCoreService.findModelTestFileNames(modelInfo.getModelId()); + // 패키징할 파일 만들기 + modelTestMetricsJobCoreService.updatePackingStart(modelInfo.getModelId(), ZonedDateTime.now()); - Path zipPath = + ModelMetricJsonDto jsonDto = + modelTestMetricsJobCoreService.getTestMetricPackingInfo(modelInfo.getModelId()); + try { + writeJsonFile( + jsonDto, Paths.get( - responseDir + "/" + modelInfo.getUuid() + "/" + fileInfo.getModelVersion() + ".zip"); - Set targetNames = - Set.of( - "model_config.py", - fileInfo.getBestEpochFileName() + ".pth", - fileInfo.getModelVersion() + ".json"); + responseDir + "/" + modelInfo.getUuid() + "/" + jsonDto.getModelVersion() + ".json")); + } catch (IOException e) { + throw new RuntimeException(e); + } - List files = new ArrayList<>(); - try (Stream s = Files.list(responsePath)) { - files.addAll( - s.filter(Files::isRegularFile) - .filter(p -> targetNames.contains(p.getFileName().toString())) - .collect(Collectors.toList())); - } catch (IOException e) { - throw new RuntimeException(e); - } + Path responsePath = Paths.get(responseDir + "/" + modelInfo.getUuid()); - try (Stream s = Files.list(Path.of(ptPathDir))) { - files.addAll( - s.filter(Files::isRegularFile) - .limit(1) // yolov8_6th-6m.pt 파일 1개만 - .collect(Collectors.toList())); - } catch (IOException e) { - throw new RuntimeException(e); - } + ModelTestFileName fileInfo = + modelTestMetricsJobCoreService.findModelTestFileNames(modelInfo.getModelId()); - try { - zipFiles(files, zipPath); + Path zipPath = + Paths.get( + responseDir + "/" + modelInfo.getUuid() + "/" + fileInfo.getModelVersion() + ".zip"); + Set targetNames = + Set.of( + "model_config.py", + fileInfo.getBestEpochFileName() + ".pth", + fileInfo.getModelVersion() + ".json"); - modelTestMetricsJobCoreService.updatePackingEnd( - modelInfo.getModelId(), ZonedDateTime.now(), TrainStatusType.COMPLETED.getId()); - } catch (IOException e) { - modelTestMetricsJobCoreService.updatePackingEnd( - modelInfo.getModelId(), ZonedDateTime.now(), TrainStatusType.ERROR.getId()); - throw new RuntimeException(e); - } + List files = new ArrayList<>(); + try (Stream s = Files.list(responsePath)) { + files.addAll( + s.filter(Files::isRegularFile) + .filter(p -> targetNames.contains(p.getFileName().toString())) + .collect(Collectors.toList())); + } catch (IOException e) { + throw new RuntimeException(e); + } + + try (Stream s = Files.list(Path.of(ptPathDir))) { + files.addAll( + s.filter(Files::isRegularFile) + .limit(1) // yolov8_6th-6m.pt 파일 1개만 + .collect(Collectors.toList())); + } catch (IOException e) { + throw new RuntimeException(e); + } + + try { + zipFiles(files, zipPath); + + modelTestMetricsJobCoreService.updatePackingEnd( + modelInfo.getModelId(), ZonedDateTime.now(), TrainStatusType.COMPLETED.getId()); + } catch (IOException e) { + modelTestMetricsJobCoreService.updatePackingEnd( + modelInfo.getModelId(), ZonedDateTime.now(), TrainStatusType.ERROR.getId()); + throw new RuntimeException(e); } } diff --git a/src/main/java/com/kamco/cd/training/train/service/ModelTrainMetricsJobService.java b/src/main/java/com/kamco/cd/training/train/service/ModelTrainMetricsJobService.java index 6566cf7..3a5181d 100644 --- a/src/main/java/com/kamco/cd/training/train/service/ModelTrainMetricsJobService.java +++ b/src/main/java/com/kamco/cd/training/train/service/ModelTrainMetricsJobService.java @@ -48,115 +48,135 @@ public class ModelTrainMetricsJobService { for (ResponsePathDto modelInfo : modelIds) { - String trainPath = responseDir + "/" + modelInfo.getUuid() + "/metrics/train.csv"; - try (BufferedReader reader = - Files.newBufferedReader(Paths.get(trainPath), StandardCharsets.UTF_8); ) { - - CSVParser parser = CSVFormat.DEFAULT.withFirstRecordAsHeader().parse(reader); - - List batchArgs = new ArrayList<>(); - - for (CSVRecord record : parser) { - - int epoch = Integer.parseInt(record.get("Epoch")); - long iteration = Long.parseLong(record.get("Iteration")); - double Loss = Double.parseDouble(record.get("Loss")); - double LR = Double.parseDouble(record.get("LR")); - float time = Float.parseFloat(record.get("Time")); - - batchArgs.add(new Object[] {modelInfo.getModelId(), epoch, iteration, Loss, LR, time}); - } - - modelTrainMetricsJobCoreService.insertModelMetricsTrain(batchArgs); - - } catch (IOException e) { - throw new RuntimeException(e); - } - - String validationPath = responseDir + "/" + modelInfo.getUuid() + "/metrics/val.csv"; - try (BufferedReader reader = - Files.newBufferedReader(Paths.get(validationPath), StandardCharsets.UTF_8); ) { - - CSVParser parser = CSVFormat.DEFAULT.withFirstRecordAsHeader().parse(reader); - - List batchArgs = new ArrayList<>(); - - for (CSVRecord record : parser) { - - int epoch = Integer.parseInt(record.get("Epoch")); - - Float aAcc = parseFloatSafe(record.get("aAcc")); - Float mFscore = parseFloatSafe(record.get("mFscore")); - Float mPrecision = parseFloatSafe(record.get("mPrecision")); - Float mRecall = parseFloatSafe(record.get("mRecall")); - Float mIoU = parseFloatSafe(record.get("mIoU")); - Float mAcc = parseFloatSafe(record.get("mAcc")); - - Float changed_fscore = parseFloatSafe(record.get("changed_fscore")); - Float changed_precision = parseFloatSafe(record.get("changed_precision")); - Float changed_recall = parseFloatSafe(record.get("changed_recall")); - - Float unchanged_fscore = parseFloatSafe(record.get("unchanged_fscore")); - Float unchanged_precision = parseFloatSafe(record.get("unchanged_precision")); - Float unchanged_recall = parseFloatSafe(record.get("unchanged_recall")); - - batchArgs.add( - new Object[] { - modelInfo.getModelId(), - epoch, - aAcc, - mFscore, - mPrecision, - mRecall, - mIoU, - mAcc, - changed_fscore, - changed_precision, - changed_recall, - unchanged_fscore, - unchanged_precision, - unchanged_recall - }); - } - - modelTrainMetricsJobCoreService.insertModelMetricsValidation(batchArgs); - - } catch (IOException e) { - throw new RuntimeException(e); - } - - Path responsePath = Paths.get(responseDir + "/" + modelInfo.getUuid()); - Integer epoch = null; - boolean exists; - Pattern pattern = Pattern.compile("best_changed_fscore_epoch_(\\d+)\\.pth"); - - try (Stream s = Files.list(responsePath)) { - epoch = - s.filter(Files::isRegularFile) - .map( - p -> { - Matcher matcher = pattern.matcher(p.getFileName().toString()); - if (matcher.matches()) { - return Integer.parseInt(matcher.group(1)); // ← 숫자 부분 추출 - } - return null; - }) - .filter(Objects::nonNull) - .findFirst() - .orElse(null); - - } catch (IOException e) { - throw new RuntimeException(e); - } - - // best_changed_fscore_epoch_숫자.pth -> 숫자 값 가지고 와서 베스트 에폭에 업데이트 하기 - modelTrainMetricsJobCoreService.updateModelSelectedBestEpoch(modelInfo.getModelId(), epoch); - - modelTrainMetricsJobCoreService.updateModelMetricsTrainSaveYn( - modelInfo.getModelId(), "step1"); + createFile(modelInfo); } } + /** 단건 결과 csv 파일 정보 등록 */ + public void trainValidMetricCsvFile(Long modelId) { + + ResponsePathDto modelInfo = + modelTrainMetricsJobCoreService.getTrainMetricSaveNotYetModelId(modelId); + + if (modelInfo == null) { + return; + } + createFile(modelInfo); + } + + /** + * 학습 csv 파일 db 등록 + * + * @param modelInfo + */ + private void createFile(ResponsePathDto modelInfo) { + String trainPath = responseDir + "/" + modelInfo.getUuid() + "/metrics/train.csv"; + try (BufferedReader reader = + Files.newBufferedReader(Paths.get(trainPath), StandardCharsets.UTF_8); ) { + + CSVParser parser = CSVFormat.DEFAULT.withFirstRecordAsHeader().parse(reader); + + List batchArgs = new ArrayList<>(); + + for (CSVRecord record : parser) { + + int epoch = Integer.parseInt(record.get("Epoch")); + long iteration = Long.parseLong(record.get("Iteration")); + double Loss = Double.parseDouble(record.get("Loss")); + double LR = Double.parseDouble(record.get("LR")); + float time = Float.parseFloat(record.get("Time")); + + batchArgs.add(new Object[] {modelInfo.getModelId(), epoch, iteration, Loss, LR, time}); + } + + modelTrainMetricsJobCoreService.insertModelMetricsTrain(batchArgs); + + } catch (IOException e) { + throw new RuntimeException(e); + } + + String validationPath = responseDir + "/" + modelInfo.getUuid() + "/metrics/val.csv"; + try (BufferedReader reader = + Files.newBufferedReader(Paths.get(validationPath), StandardCharsets.UTF_8); ) { + + CSVParser parser = CSVFormat.DEFAULT.withFirstRecordAsHeader().parse(reader); + + List batchArgs = new ArrayList<>(); + + for (CSVRecord record : parser) { + + int epoch = Integer.parseInt(record.get("Epoch")); + + Float aAcc = parseFloatSafe(record.get("aAcc")); + Float mFscore = parseFloatSafe(record.get("mFscore")); + Float mPrecision = parseFloatSafe(record.get("mPrecision")); + Float mRecall = parseFloatSafe(record.get("mRecall")); + Float mIoU = parseFloatSafe(record.get("mIoU")); + Float mAcc = parseFloatSafe(record.get("mAcc")); + + Float changed_fscore = parseFloatSafe(record.get("changed_fscore")); + Float changed_precision = parseFloatSafe(record.get("changed_precision")); + Float changed_recall = parseFloatSafe(record.get("changed_recall")); + + Float unchanged_fscore = parseFloatSafe(record.get("unchanged_fscore")); + Float unchanged_precision = parseFloatSafe(record.get("unchanged_precision")); + Float unchanged_recall = parseFloatSafe(record.get("unchanged_recall")); + + batchArgs.add( + new Object[] { + modelInfo.getModelId(), + epoch, + aAcc, + mFscore, + mPrecision, + mRecall, + mIoU, + mAcc, + changed_fscore, + changed_precision, + changed_recall, + unchanged_fscore, + unchanged_precision, + unchanged_recall + }); + } + + modelTrainMetricsJobCoreService.insertModelMetricsValidation(batchArgs); + + } catch (IOException e) { + throw new RuntimeException(e); + } + + Path responsePath = Paths.get(responseDir + "/" + modelInfo.getUuid()); + Integer epoch = null; + boolean exists; + Pattern pattern = Pattern.compile("best_changed_fscore_epoch_(\\d+)\\.pth"); + + try (Stream s = Files.list(responsePath)) { + epoch = + s.filter(Files::isRegularFile) + .map( + p -> { + Matcher matcher = pattern.matcher(p.getFileName().toString()); + if (matcher.matches()) { + return Integer.parseInt(matcher.group(1)); // ← 숫자 부분 추출 + } + return null; + }) + .filter(Objects::nonNull) + .findFirst() + .orElse(null); + + } catch (IOException e) { + throw new RuntimeException(e); + } + + // best_changed_fscore_epoch_숫자.pth -> 숫자 값 가지고 와서 베스트 에폭에 업데이트 하기 + modelTrainMetricsJobCoreService.updateModelSelectedBestEpoch(modelInfo.getModelId(), epoch); + + modelTrainMetricsJobCoreService.updateModelMetricsTrainSaveYn(modelInfo.getModelId(), "step1"); + } + private Float parseFloatSafe(String value) { try { if (value == null) return null; diff --git a/src/main/java/com/kamco/cd/training/train/service/TrainJobService.java b/src/main/java/com/kamco/cd/training/train/service/TrainJobService.java index 020f265..697bde9 100644 --- a/src/main/java/com/kamco/cd/training/train/service/TrainJobService.java +++ b/src/main/java/com/kamco/cd/training/train/service/TrainJobService.java @@ -1,13 +1,17 @@ package com.kamco.cd.training.train.service; import com.fasterxml.jackson.databind.ObjectMapper; +import com.kamco.cd.training.common.enums.JobStatusType; +import com.kamco.cd.training.common.enums.TrainStatusType; import com.kamco.cd.training.common.exception.CustomApiException; import com.kamco.cd.training.model.dto.ModelTrainMngDto; import com.kamco.cd.training.postgres.core.ModelTrainJobCoreService; import com.kamco.cd.training.postgres.core.ModelTrainMngCoreService; +import com.kamco.cd.training.train.dto.DockerInspectState; import com.kamco.cd.training.train.dto.ModelTrainJobDto; import com.kamco.cd.training.train.dto.ModelTrainJobQueuedEvent; import com.kamco.cd.training.train.dto.ModelTrainLinkDto; +import com.kamco.cd.training.train.dto.OutputResult; import com.kamco.cd.training.train.dto.TrainRunRequest; import java.io.IOException; import java.nio.file.Files; @@ -16,6 +20,7 @@ import java.nio.file.Paths; import java.time.ZonedDateTime; import java.util.List; import java.util.Map; +import java.util.NoSuchElementException; import java.util.UUID; import lombok.RequiredArgsConstructor; import lombok.extern.log4j.Log4j2; @@ -33,11 +38,14 @@ public class TrainJobService { private final ModelTrainJobCoreService modelTrainJobCoreService; private final ModelTrainMngCoreService modelTrainMngCoreService; + private final ModelTrainMetricsJobService modelTrainMetricsJobService; + private final ModelTestMetricsJobService modelTestMetricsJobService; private final DockerTrainService dockerTrainService; private final ObjectMapper objectMapper; private final ApplicationEventPublisher eventPublisher; private final TmpDatasetService tmpDatasetService; private final DataSetCountersService dataSetCounters; + private final TrainUtilService trainUtilService; // 학습 결과가 저장될 호스트 디렉토리 @Value("${train.docker.response_dir}") @@ -309,4 +317,133 @@ public class TrainJobService { } return modelUuid; } + + /** + * 동작되고잇는 pid을 확인하고 pid 목록에 있으면 진행중, 없으면 종료(결과 csv, zip 파일 확인하여 완료 여부 체크) + * + * @param uuid + * @param modelId + */ + public void status(UUID uuid, Long modelId) { + + ModelTrainJobDto job = + modelTrainJobCoreService + .findLatestByModelId(modelId) + .orElseThrow(() -> new NoSuchElementException("job not found")); + + ModelTrainMngDto.Basic model = modelTrainMngCoreService.findModelById(modelId); + + // TODO 실행중 상태인것만 변경해야하면 주석 해제 + // if(job.getStatusCd().equals(JobStatusType.RUNNING.getId())) { + // return; + // } + + String containerName = job.getContainerName(); + + try { + // docker inspect로 컨테이너 상태 조회 + DockerInspectState state = trainUtilService.inspectContainer(containerName); + + // 컨테이너가 "없음" + // - docker run --rm 로 실행한 컨테이너는 정상 종료 시 바로 삭제될 수 있음 + // - 즉 "컨테이너 없음"이 무조건 실패는 아님 + if (!state.exists()) { + log.warn("container missing. try file-based reconcile. container={}", containerName); + + // 컨테이너가 없을 때는 산출물(responseDir)을 보고 완료 여부를 "추정" + OutputResult out = trainUtilService.probeOutputs(job); + + // 산출물이 충분하면 성공 처리 + if (out.completed()) { + + // 테스트 완료인지 zip파일로 확인 + if (trainUtilService.existsZipFile(uuid)) { + // 테스트 완료일때 + log.info("outputs look completed. mark SUCCESS. jobId={}", job.getId()); + + // job 완료처리 + modelTrainJobCoreService.markSuccess(job.getId(), 0); + + // 학습 완료가 아니면 완료 업데이트 + if (!model.getStep1Status().equals(TrainStatusType.COMPLETED.getId())) { + + // model 상태 변경 (학습) + modelTrainMngCoreService.markStep1Success(job.getModelId()); + // 학습 결과 csv 파일 정보 등록 + modelTrainMetricsJobService.trainValidMetricCsvFile(modelId); + } + + // 테스트 완료가 아니면 완료 업데이트 + if (!model.getStep2Status().equals(TrainStatusType.COMPLETED.getId())) { + // model 상태 변경 (테스트) + modelTrainMngCoreService.markStep2Success(job.getModelId()); + // 테스트 결과 csv 파일 정보 등록 + modelTestMetricsJobService.testValidMetricCsvFiles(modelId); + } + + } else { + // 학습 완료일때 + log.info("outputs look completed. mark SUCCESS. jobId={}", job.getId()); + modelTrainJobCoreService.markSuccess(job.getId(), 0); + // 학습 완료가 아니면 완료 업데이트 + if (!model.getStep1Status().equals(TrainStatusType.COMPLETED.getId())) { + + // model 상태 변경 (학습) + modelTrainMngCoreService.markStep1Success(job.getModelId()); + // 학습 결과 csv 파일 정보 등록 + modelTrainMetricsJobService.trainValidMetricCsvFile(modelId); + } + } + + } else { + + // 산출물이 부족하면 중단처리 + // 산출물이 부족하면 "중단/보류"로 처리 + // 운영자가 재시작 할 수 있게 한다. + log.warn( + "outputs incomplete. mark PAUSED/STOP for restart. jobId={} reason={}", + job.getId(), + out.reason()); + + // PAUSED/STOP + modelTrainJobCoreService.markPaused( + job.getId(), -1, "SERVER_RESTART_CONTAINER_MISSING_OUTPUT_INCOMPLETE"); + + // STOP으로 변경 + markStepStopByJobType( + job, "SERVER_RESTART_CONTAINER_MISSING_OUTPUT_INCOMPLETE: " + out.reason()); + } + } else { + // 컨테이너가 있으면 진행중처리 + Map params = job.getParamsJson(); + boolean isEval = params != null && "EVAL".equals(String.valueOf(params.get("jobType"))); + if (isEval) { + // 테스트 진행중 상태로 업데이트 + modelTrainMngCoreService.markStep2InProgress(job.getModelId(), job.getId()); + } else { + // 학습 진행중 상태로 업데이트 + modelTrainMngCoreService.markStep1InProgress(job.getModelId(), job.getId()); + } + // job 테이블 진행중으로 업데이트 + modelTrainJobCoreService.updateJobStatus(job.getId(), JobStatusType.RUNNING.getId()); + } + } catch (Exception e) { + log.error("container inspect failed. container={}", containerName, e); + } + } + + /** + * jobType에 따라 학습 관리 테이블의 "에러 단계"를 업데이트. + * + *

예: - jobType == "EVAL" → step2(평가 단계) 에러 - 그 외 → step1 혹은 전체 에러 + */ + private void markStepStopByJobType(ModelTrainJobDto job, String msg) { + Map params = job.getParamsJson(); + boolean isEval = params != null && "EVAL".equals(String.valueOf(params.get("jobType"))); + if (isEval) { + modelTrainMngCoreService.markStep2Stop(job.getModelId(), msg); + } else { + modelTrainMngCoreService.markStep1Stop(job.getModelId(), msg); + } + } } diff --git a/src/main/java/com/kamco/cd/training/train/service/TrainUtilService.java b/src/main/java/com/kamco/cd/training/train/service/TrainUtilService.java new file mode 100644 index 0000000..2be678c --- /dev/null +++ b/src/main/java/com/kamco/cd/training/train/service/TrainUtilService.java @@ -0,0 +1,228 @@ +package com.kamco.cd.training.train.service; + +import com.kamco.cd.training.model.dto.ModelTrainMngDto; +import com.kamco.cd.training.postgres.core.ModelTrainMngCoreService; +import com.kamco.cd.training.train.dto.DockerInspectState; +import com.kamco.cd.training.train.dto.ModelTrainJobDto; +import com.kamco.cd.training.train.dto.OutputResult; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import java.util.stream.Stream; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class TrainUtilService { + + private final ModelTrainMngCoreService modelTrainMngCoreService; + + /** + * Docker 컨테이너가 쓰는 response(산출물) 디렉토리의 "호스트 측" 베이스 경로. 예) /data/train/response + * + *

컨테이너가 --rm 으로 삭제된 경우에도 이 경로에 val.csv / *.pth 등이 남아있으면 정상 종료 여부를 "파일 기반"으로 판정합니다. + */ + @Value("${train.docker.response_dir}") + private String responseDir; + + /** + * docker inspect를 사용해서 컨테이너 상태를 조회합니다. + * + *

사용하는 템플릿: {{.State.Status}} {{.State.Running}} {{.State.ExitCode}} + * + *

예상 출력 예: - "running true 0" - "exited false 0" - "exited false 137" + * + *

주의: - 컨테이너가 없거나 inspect 실패 시 exitCode != 0 또는 output이 비어서 missing() 반환 - 무한 대기 방지를 위해 5초 + * 타임아웃을 둠 + */ + public DockerInspectState inspectContainer(String containerName) + throws IOException, InterruptedException { + + ProcessBuilder pb = + new ProcessBuilder( + "docker", + "inspect", + "-f", + "{{.State.Status}} {{.State.Running}} {{.State.ExitCode}}", + containerName); + + pb.redirectErrorStream(true); + + Process p = pb.start(); + + String output; + try (BufferedReader br = new BufferedReader(new InputStreamReader(p.getInputStream()))) { + output = br.readLine(); + } + + boolean finished = p.waitFor(5, TimeUnit.SECONDS); + if (!finished) { + p.destroyForcibly(); + throw new IOException("docker inspect timeout"); + } + + int code = p.exitValue(); + + if (code != 0 || output == null || output.isBlank()) { + return DockerInspectState.missing(); + } + + String[] parts = output.trim().split("\\s+"); + + String status = parts.length > 0 ? parts[0] : "unknown"; + boolean running = parts.length > 1 && Boolean.parseBoolean(parts[1]); + + Integer exitCode = null; + if (parts.length > 2) { + try { + exitCode = Integer.parseInt(parts[2]); + } catch (Exception ignore) { + } + } + + return new DockerInspectState(true, running, exitCode, status); + } + + /** + * 컨테이너가 없을 때(responseDir 산출물만 남아있는 상태) 완료 여부를 파일 기반으로 판정합니다. + * + *

판정 규칙(보수적으로 설계): 1) total_epoch가 paramsJson에 있어야 함 (없으면 완료 판단 불가) 2) val.csv 존재 + 헤더 제외 라인 수 + * >= total_epoch 이어야 함 3) *.pth 파일이 total_epoch 이상 존재하거나, best*.pth(또는 *best*.pth)가 존재해야 함 + * + *

왜 이렇게? - 어떤 학습은 epoch마다 pth를 남기고 - 어떤 학습은 best만 남기기도 해서 "pthCount >= total_epoch"만 쓰면 정상 종료를 + * 실패로 오판할 수 있음. + */ + public OutputResult probeOutputs(ModelTrainJobDto job) { + + try { + Path outDir = resolveOutputDir(job); + if (outDir == null || !Files.isDirectory(outDir)) { + return new OutputResult(false, "output-dir-missing"); + } + + Integer totalEpoch = extractTotalEpoch(job).orElse(null); + if (totalEpoch == null || totalEpoch <= 0) { + return new OutputResult(false, "total-epoch-missing"); + } + + Integer valInterval = extractValInterval(job).orElse(null); + if (valInterval == null || valInterval <= 0) { + return new OutputResult(false, "val-interval-missing"); + } + + Path valCsv = outDir.resolve("val.csv"); + if (!Files.exists(valCsv)) { + return new OutputResult(false, "val.csv-missing"); + } + + long lines = countNonHeaderLines(valCsv); + int expectedLines = totalEpoch / valInterval; + + if (lines >= expectedLines) { + return new OutputResult(true, "ok"); + } + + return new OutputResult(false, "val.csv-lines-mismatch"); + + } catch (Exception e) { + return new OutputResult(false, "probe-error"); + } + } + + /** + * 테스트 완료후 zip 파일 있는지 확인 + * + * @param uuid + * @return + */ + public boolean existsZipFile(UUID uuid) { + Path path = Paths.get(responseDir, uuid.toString()); + + if (!Files.isDirectory(path)) { + return false; + } + + String pattern = "*" + uuid + "*.zip"; + + try (DirectoryStream stream = Files.newDirectoryStream(path, pattern)) { + return stream.iterator().hasNext(); + } catch (IOException e) { + return false; + } + } + + /** + * responseDir 아래에서 job 산출물 디렉토리를 찾습니다. + * + *

가장 중요한 커스터마이징 포인트: - 실제 운영 환경에서 산출물이 어떤 경로 규칙으로 저장되는지에 따라 여기만 수정하면 됩니다. + * + *

현재 기본 탐색 순서: 1) {responseDir}/{jobId} 2) {responseDir}/{modelId} 3) + * {responseDir}/{containerName} 4) 마지막 fallback: responseDir 자체 + * + *

추천: - 여러분 규칙이 "{responseDir}/{modelId}/{jobId}" 같은 형태라면 base.resolve(modelId).resolve(jobId) + * 형태를 1순위로 두세요. + */ + private Path resolveOutputDir(ModelTrainJobDto job) { + ModelTrainMngDto.Basic model = modelTrainMngCoreService.findModelById(job.getModelId()); + + Path base = Paths.get(responseDir, model.getUuid().toString(), "metrics"); + + return Files.isDirectory(base) ? base : null; + } + + /** + * paramsJson에서 total_epoch 값을 추출합니다. + * + *

키 후보: - "total_epoch" (snake_case) - "totalEpoch" (camelCase) + * + *

예: paramsJson = {"jobType":"TRAIN","total_epoch":50,...} + */ + private Optional extractTotalEpoch(ModelTrainJobDto job) { + Map params = job.getParamsJson(); + if (params == null) return Optional.empty(); + + Object v = params.get("total_epoch"); + if (v == null) v = params.get("totalEpoch"); + + try { + return v == null ? Optional.empty() : Optional.of(Integer.parseInt(String.valueOf(v))); + } catch (Exception e) { + return Optional.empty(); + } + } + + /** paramsJson에서 valInterval 추출 */ + private Optional extractValInterval(ModelTrainJobDto job) { + Map params = job.getParamsJson(); + if (params == null) return Optional.empty(); + + Object v = params.get("valInterval"); + + try { + return v == null ? Optional.empty() : Optional.of(Integer.parseInt(String.valueOf(v))); + } catch (Exception e) { + return Optional.empty(); + } + } + + /** + * CSV 파일에서 "헤더(첫 줄)"를 제외한 라인 수를 계산합니다. + * + *

가정: - val.csv 첫 줄은 헤더 - 이후 라인들이 epoch별 기록(또는 유사한 누적 기록) + * + *

주의: - 파일 인코딩은 UTF-8로 가정 - 빈 줄은 제외 + */ + private long countNonHeaderLines(Path csv) throws IOException { + try (Stream lines = Files.lines(csv, StandardCharsets.UTF_8)) { + return lines.skip(1).filter(s -> s != null && !s.isBlank()).count(); + } + } +} diff --git a/상태전의 STATUS 가이드.md b/상태전의 STATUS 가이드.md new file mode 100644 index 0000000..80a5214 --- /dev/null +++ b/상태전의 STATUS 가이드.md @@ -0,0 +1,964 @@ +# POST /api/train/status/{uuid} - 상태 전이 다이어그램 완전 가이드 + +> **프론트엔드 표시 방법 및 상태 제어 API 포함** + +--- + +## 📊 1. DB 테이블 상태 필드 + +### 1.1 **Job 테이블** (`tb_model_train_job`) +| 필드명 | 설명 | 가능한 값 | UI 표시명 | +|--------|------|-----------|-----------| +| `status_cd` | Job 실행 상태 | `QUEUED`, `RUNNING`, `SUCCESS`, `FAILED`, `STOPPED`, `CANCELED` | 대기중, 실행중, 성공, 실패, 중단됨, 취소 | +| `exit_code` | 종료 코드 | 정수 (0: 정상, -1: 중단, 기타: 에러) | - | +| `error_message` | 에러 메시지 | 문자열 | 에러 상세 내용 | +| `finished_dttm` | 완료 시간 | timestamp | YYYY-MM-DD HH:mm:ss | + +### 1.2 **Model Master 테이블** (`tb_model_master`) +| 필드명 | 설명 | 가능한 값 | UI 표시명 | +|--------|------|-----------|-----------| +| `status_cd` | 전체 모델 상태 | `READY`, `IN_PROGRESS`, `COMPLETED`, `STOPPED`, `ERROR` | 대기, 진행중, 완료, 중단됨, 오류 | +| `step1_state` | Step1(학습) 상태 | `READY`, `IN_PROGRESS`, `COMPLETED`, `STOPPED`, `ERROR` | 대기, 진행중, 완료, 중단됨, 오류 | +| `step2_state` | Step2(테스트) 상태 | `READY`, `IN_PROGRESS`, `COMPLETED`, `STOPPED`, `ERROR` | 대기, 진행중, 완료, 중단됨, 오류 | +| `step1_strt_dttm` | Step1 시작 시간 | timestamp | YYYY-MM-DD HH:mm:ss | +| `step1_end_dttm` | Step1 종료 시간 | timestamp | YYYY-MM-DD HH:mm:ss | +| `step2_strt_dttm` | Step2 시작 시간 | timestamp | YYYY-MM-DD HH:mm:ss | +| `step2_end_dttm` | Step2 종료 시간 | timestamp | YYYY-MM-DD HH:mm:ss | +| `step1_metric_save_yn` | Step1 메트릭 저장 여부 | `true`, `false` | - | +| `step2_metric_save_yn` | Step2 메트릭 저장 여부 | `true`, `false` | - | +| `current_attempt_id` | 현재 실행 중인 Job ID | Long | - | +| `best_epoch` | 최적 에폭 | Integer | Best Epoch: {값} | +| `last_error` | 마지막 에러 메시지 | 문자열 | 에러 상세 내용 | + +--- + +## 🎨 2. 프론트엔드 API 응답 및 표시 + +### 2.1 모델 목록 조회 API + +**엔드포인트**: `GET /api/models/list` + +**요청 파라미터**: +```json +{ + "status": "IN_PROGRESS", // "", "IN_PROGRESS", "COMPLETED" + "modelNo": "G1", // "G1", "G2", "G3", "G4" + "page": 0, + "size": 20 +} +``` + +**응답 예시**: +```json +{ + "success": true, + "data": { + "content": [ + { + "id": 123, + "uuid": "e22181eb-2ac4-4100-9941-d06efce25c49", + "modelVer": "v1.0.0", + "statusCd": "IN_PROGRESS", + "statusName": "진행중", + "step1Status": "COMPLETED", + "step1StatusName": "완료", + "step2Status": "IN_PROGRESS", + "step2StatusName": "진행중", + "step1StrtDttm": "2026-04-05 10:30:00", + "step1EndDttm": "2026-04-05 12:45:00", + "step1Duration": "2시간 15분 0초", + "step2StrtDttm": "2026-04-05 12:50:00", + "step2EndDttm": null, + "step2Duration": null, + "modelNo": "G1", + "trainType": "GENERAL", + "trainTypeName": "일반", + "currentAttemptId": 456, + "memo": "테스트 학습" + } + ], + "totalElements": 100, + "totalPages": 5 + } +} +``` + +### 2.2 프론트엔드 표시 예시 + +#### 📋 **모델 목록 테이블** + +| 모델 번호 | 버전 | 전체 상태 | 학습(Step1) | 테스트(Step2) | 학습 시간 | 테스트 시간 | 작업 | +|----------|------|----------|------------|------------|----------|----------|------| +| G1 | v1.0.0 | 🟢 진행중 | ✅ 완료 (2시간 15분) | 🔄 진행중 | 2026-04-05 10:30 ~ 12:45 | 2026-04-05 12:50 ~ | [취소] | +| G2 | v1.0.1 | ✅ 완료 | ✅ 완료 (1시간 30분) | ✅ 완료 (45분) | 2026-04-04 14:00 ~ 15:30 | 2026-04-04 15:35 ~ 16:20 | [결과보기] | +| G3 | v1.0.2 | ⚠️ 중단됨 | ⚠️ 중단됨 | - | 2026-04-03 09:00 ~ | - | [재시작] [이어하기] | +| G4 | v1.0.3 | ❌ 오류 | ❌ 오류 | - | 2026-04-02 11:00 ~ | - | [재시작] | + +#### 🎯 **상태별 UI 컬러 가이드** + +```javascript +// 상태별 배지 색상 매핑 +const STATUS_COLORS = { + 'READY': { bg: '#e3f2fd', text: '#1976d2', icon: '⏸️' }, // 파란색 - 대기 + 'IN_PROGRESS': { bg: '#e8f5e9', text: '#388e3c', icon: '🔄' }, // 녹색 - 진행중 + 'COMPLETED': { bg: '#f1f8e9', text: '#689f38', icon: '✅' }, // 연두색 - 완료 + 'STOPPED': { bg: '#fff3e0', text: '#f57c00', icon: '⚠️' }, // 주황색 - 중단됨 + 'ERROR': { bg: '#ffebee', text: '#d32f2f', icon: '❌' } // 빨간색 - 오류 +}; + +// 상태명 한글 변환 +const STATUS_NAMES = { + 'READY': '대기', + 'IN_PROGRESS': '진행중', + 'COMPLETED': '완료', + 'STOPPED': '중단됨', + 'ERROR': '오류' +}; + +// React 컴포넌트 예시 +function StatusBadge({ statusCd }) { + const { bg, text, icon } = STATUS_COLORS[statusCd] || {}; + const name = STATUS_NAMES[statusCd] || statusCd; + + return ( + + {icon} {name} + + ); +} +``` + +#### 📊 **상세 진행 상황 표시** + +```javascript +// Step별 진행률 계산 +function ModelProgressBar({ model }) { + const steps = [ + { + name: '학습 (Step1)', + status: model.step1Status, + statusName: model.step1StatusName, + startTime: model.step1StrtDttm, + endTime: model.step1EndDttm, + duration: model.step1Duration + }, + { + name: '테스트 (Step2)', + status: model.step2Status, + statusName: model.step2StatusName, + startTime: model.step2StrtDttm, + endTime: model.step2EndDttm, + duration: model.step2Duration + } + ]; + + return ( +

+ {steps.map((step, index) => ( +
+
+ {step.name} + +
+
+ {step.startTime && ( + <> + 시작: {step.startTime} + {step.endTime && ~ 종료: {step.endTime}} + {step.duration && (소요시간: {step.duration})} + + )} +
+ {step.status === 'IN_PROGRESS' && ( +
+
+
+ )} +
+ ))} +
+ ); +} +``` + +--- + +## 🔄 3. 상태 전이 플로우차트 (상태값 명시) + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ POST /api/train/status/{uuid} API 호출 │ +└────────────────────────────┬────────────────────────────────────┘ + ↓ + ┌────────────────────┐ + │ UUID → ModelID │ + │ 조회 및 Job 조회 │ + └────────┬───────────┘ + ↓ + ┌────────────────────┐ + │ Docker Inspect 실행 │ + │ (containerName) │ + └────────┬───────────┘ + ↓ + ┌──────────────┴──────────────┐ + │ │ + [exists=false] [exists=true] + 컨테이너 없음 컨테이너 존재 + ↓ ↓ + ┌─────────────────────┐ ┌──────────────────────┐ + │ TrainUtilService │ │ jobType 확인 │ + │ .probeOutputs() │ │ (paramsJson) │ + │ │ └──────────┬───────────┘ + │ 1. total_epoch 추출 │ │ + │ 2. valInterval 추출 │ ┌──────────┴──────────┐ + │ 3. val.csv 존재확인 │ │ │ + │ 4. 라인수 검증 │ [TRAIN] [EVAL] + └─────────┬───────────┘ │ │ + │ ↓ ↓ + ┌─────────┴─────────┐ ┌──────────────┐ ┌──────────────┐ + │ │ │ Step1 진행중 │ │ Step2 진행중 │ + [completed=true] [completed=false] └──────────────┘ └──────────────┘ + │ │ │ │ + │ │ ┌────▼─────────────────────▼────────┐ + │ │ │ ✅ tb_model_train_job │ + │ │ │ status_cd = "RUNNING" │ + │ │ │ │ + │ │ │ ✅ tb_model_master │ + │ │ │ status_cd = "IN_PROGRESS" │ + │ │ │ step1_state = "IN_PROGRESS" │ [TRAIN] + │ │ │ step1_strt_dttm = now() │ + │ │ │ OR │ + │ │ │ step2_state = "IN_PROGRESS" │ [EVAL] + │ │ │ step2_strt_dttm = now() │ + │ │ │ current_attempt_id = jobId │ + │ │ └──────────────────────────────────┘ + │ │ 【프론트 표시】 + │ │ 🔄 진행중 - 학습 중... / 테스트 중... + │ │ + │ ↓ + │ ┌─────────────────────┐ + │ │ ⚠️ 산출물 부족 │ + │ │ (val.csv 부족 등) │ + │ └─────────┬───────────┘ + │ │ + │ ┌─────────▼────────────────────────────────┐ + │ │ ⚠️ tb_model_train_job │ + │ │ status_cd = "STOPPED" │ + │ │ exit_code = -1 │ + │ │ error_message = "SERVER_RESTART_..." │ + │ │ │ + │ │ ⚠️ tb_model_master │ + │ │ status_cd = "STOPPED" │ + │ │ step1_state = "STOPPED" [TRAIN] │ + │ │ OR │ + │ │ step2_state = "STOPPED" [EVAL] │ + │ │ last_error = "컨테이너 없음, 산출물 부족" │ + │ └──────────────────────────────────────────┘ + │ 【프론트 표시】 + │ ⚠️ 중단됨 - 산출물 부족으로 중단됨 + │ [재시작] [이어하기] 버튼 활성화 + │ + ↓ +┌──────────────────┐ +│ existsZipFile() │ +│ ZIP 파일 존재? │ +└────┬────────┬────┘ + │ │ + [YES] [NO] + │ │ + │ ↓ + │ ┌──────────────────────────────────────────────┐ + │ │ 📁 학습 완료 (Step1만) │ + │ └─────────┬────────────────────────────────────┘ + │ │ + │ ┌─────────▼──────────────────────────────────┐ + │ │ ✅ tb_model_train_job │ + │ │ status_cd = "SUCCESS" │ + │ │ exit_code = 0 │ + │ │ finished_dttm = now() │ + │ │ │ + │ │ ✅ tb_model_master (Step1 미완료 시) │ + │ │ status_cd = "COMPLETED" │ + │ │ step1_state = "COMPLETED" │ + │ │ step1_end_dttm = now() │ + │ │ step1_metric_save_yn = true │ + │ │ │ + │ │ 📊 Metrics 저장 │ + │ │ - train.csv → tb_model_metrics_train │ + │ │ - val.csv → tb_model_metrics_validation │ + │ └────────────────────────────────────────────┘ + │ 【프론트 표시】 + │ ✅ 완료 - 학습 완료 (테스트 대기) + │ [테스트 실행] 버튼 활성화 + │ + ↓ +┌──────────────────────────────────────────────┐ +│ 📦 학습+테스트 완료 (Step1 + Step2) │ +└─────────┬────────────────────────────────────┘ + │ +┌─────────▼──────────────────────────────────┐ +│ ✅ tb_model_train_job │ +│ status_cd = "SUCCESS" │ +│ exit_code = 0 │ +│ finished_dttm = now() │ +│ │ +│ ✅ tb_model_master (Step1 미완료 시) │ +│ status_cd = "COMPLETED" │ +│ step1_state = "COMPLETED" │ +│ step1_end_dttm = now() │ +│ step1_metric_save_yn = true │ +│ │ +│ ✅ tb_model_master (Step2 미완료 시) │ +│ step2_state = "COMPLETED" │ +│ step2_end_dttm = now() │ +│ step2_metric_save_yn = true │ +│ best_epoch = 최적값 │ +│ │ +│ 📊 Metrics 저장 │ +│ - train.csv → tb_model_metrics_train │ +│ - val.csv → tb_model_metrics_validation │ +│ - test.csv → 테스트 메트릭 테이블 │ +│ - *.zip → 테스트 결과 파일 생성 │ +└────────────────────────────────────────────┘ +【프론트 표시】 +✅ 완료 - 모든 학습 및 테스트 완료 +[결과 보기] [다운로드] 버튼 활성화 +``` + +--- + +## 🛠️ 4. 상태 제어 API 목록 + +### 4.1 학습 실행 제어 API + +| API | HTTP | 엔드포인트 | 설명 | 상태 전환 | 프론트 버튼 표시 조건 | +|-----|------|-----------|------|----------|---------------------| +| **학습 실행** | POST | `/api/train/run/{uuid}` | 최초 학습 시작 | `READY` → `IN_PROGRESS` | `statusCd == 'READY'` | +| **학습 재실행** | POST | `/api/train/restart/{uuid}` | 중단/오류 후 처음부터 재실행 | `STOPPED/ERROR` → `IN_PROGRESS` | `statusCd == 'STOPPED' \|\| statusCd == 'ERROR'` | +| **학습 이어하기** | POST | `/api/train/resume/{uuid}` | 중단된 지점부터 계속 실행 | `STOPPED` → `IN_PROGRESS` | `statusCd == 'STOPPED'` | +| **학습 취소** | POST | `/api/train/cancel/{uuid}` | 실행 중인 학습 중단 | `IN_PROGRESS` → `STOPPED` | `statusCd == 'IN_PROGRESS' && step1Status == 'IN_PROGRESS'` | +| **학습 상태 확인** | POST | `/api/train/status/{uuid}` | 현재 상태 동기화 | 현재 상태 유지/갱신 | 주기적 호출 (폴링) | + +### 4.2 테스트 실행 제어 API + +| API | HTTP | 엔드포인트 | 설명 | 상태 전환 | 프론트 버튼 표시 조건 | +|-----|------|-----------|------|----------|---------------------| +| **테스트 실행** | POST | `/api/train/test/run/{epoch}/{uuid}` | 특정 에폭으로 테스트 실행 | `step2_state: READY` → `IN_PROGRESS` | `step1Status == 'COMPLETED' && step2Status != 'IN_PROGRESS'` | +| **테스트 취소** | POST | `/api/train/test/cancel/{uuid}` | 실행 중인 테스트 중단 | `step2_state: IN_PROGRESS` → `STOPPED` | `step2Status == 'IN_PROGRESS'` | + +### 4.3 기타 API + +| API | HTTP | 엔드포인트 | 설명 | +|-----|------|-----------|------| +| **데이터셋 임시 파일 생성** | POST | `/api/train/create-tmp/{uuid}` | 학습용 임시 데이터셋 생성 | +| **데이터셋 카운트 조회** | GET | `/api/train/counts/{uuid}` | 데이터셋 통계 정보 조회 | + +--- + +## 📋 5. 상태별 프론트엔드 액션 매트릭스 + +### 5.1 전체 상태 (`statusCd`) 기준 + +| 상태 코드 | 상태명 | 활성화 버튼 | 비활성화 버튼 | 표시 메시지 | UI 색상 | +|-----------|-------|------------|--------------|------------|---------| +| `READY` | 대기 | [학습 실행] | [취소] [재실행] [이어하기] | "학습 준비 완료" | 파란색 | +| `IN_PROGRESS` | 진행중 | [취소] | [학습 실행] [재실행] [이어하기] | "학습 진행 중..." | 녹색 | +| `COMPLETED` | 완료 | [결과 보기] [다운로드] | [학습 실행] [취소] | "학습 완료" | 연두색 | +| `STOPPED` | 중단됨 | [재실행] [이어하기] | [취소] | "학습 중단됨 - 재시작 가능" | 주황색 | +| `ERROR` | 오류 | [재실행] | [취소] [이어하기] | "오류 발생 - 재시작 필요" | 빨간색 | + +### 5.2 Step1 상태 (`step1Status`) 기준 + +| 상태 코드 | 버튼/액션 | 조건 | 설명 | +|-----------|----------|------|------| +| `READY` | [학습 실행] | `statusCd == 'READY'` | 최초 학습 시작 | +| `IN_PROGRESS` | [학습 취소] | `statusCd == 'IN_PROGRESS'` | 진행 중인 학습 중단 | +| `COMPLETED` | [테스트 실행] | `step2Status == 'READY'` | 학습 완료 후 테스트 가능 | +| `STOPPED` | [재실행] [이어하기] | `statusCd == 'STOPPED'` | 중단된 학습 복구 | +| `ERROR` | [재실행] | `statusCd == 'ERROR'` | 오류 발생 후 재시도 | + +### 5.3 Step2 상태 (`step2Status`) 기준 + +| 상태 코드 | 버튼/액션 | 조건 | 설명 | +|-----------|----------|------|------| +| `READY` | [테스트 실행] | `step1Status == 'COMPLETED'` | 학습 완료 후 테스트 가능 | +| `IN_PROGRESS` | [테스트 취소] | `statusCd == 'IN_PROGRESS'` | 진행 중인 테스트 중단 | +| `COMPLETED` | [결과 보기] [다운로드] | 항상 | 테스트 완료 후 결과 확인 | +| `STOPPED` | [테스트 재실행] | `step1Status == 'COMPLETED'` | 중단된 테스트 재시작 | + +--- + +## 💻 6. 프론트엔드 구현 예시 + +### 6.1 상태별 버튼 렌더링 (React) + +```javascript +function ModelActionButtons({ model }) { + const { uuid, statusCd, step1Status, step2Status } = model; + + // 학습 실행 버튼 + const canRun = statusCd === 'READY'; + + // 학습 취소 버튼 + const canCancel = statusCd === 'IN_PROGRESS' && step1Status === 'IN_PROGRESS'; + + // 재실행 버튼 + const canRestart = ['STOPPED', 'ERROR'].includes(statusCd); + + // 이어하기 버튼 + const canResume = statusCd === 'STOPPED'; + + // 테스트 실행 버튼 + const canTest = step1Status === 'COMPLETED' && + step2Status !== 'IN_PROGRESS' && + step2Status !== 'COMPLETED'; + + // 테스트 취소 버튼 + const canCancelTest = step2Status === 'IN_PROGRESS'; + + // 결과 보기 버튼 + const canViewResult = statusCd === 'COMPLETED' && + step1Status === 'COMPLETED' && + step2Status === 'COMPLETED'; + + return ( +
+ {canRun && ( + + )} + + {canCancel && ( + + )} + + {canRestart && ( + + )} + + {canResume && ( + + )} + + {canTest && ( + + )} + + {canCancelTest && ( + + )} + + {canViewResult && ( + + )} +
+ ); +} +``` + +### 6.2 API 호출 함수 + +```javascript +import axios from 'axios'; + +const API_BASE = '/api/train'; + +// 학습 실행 +async function runTrain(uuid) { + try { + const response = await axios.post(`${API_BASE}/run/${uuid}`); + if (response.data.success) { + alert('학습이 시작되었습니다.'); + refreshModelList(); // 목록 갱신 + } + } catch (error) { + alert('학습 실행 실패: ' + error.message); + } +} + +// 학습 취소 +async function cancelTrain(uuid) { + if (!confirm('학습을 취소하시겠습니까?')) return; + + try { + const response = await axios.post(`${API_BASE}/cancel/${uuid}`); + if (response.data.success) { + alert('학습이 취소되었습니다.'); + refreshModelList(); + } + } catch (error) { + alert('학습 취소 실패: ' + error.message); + } +} + +// 학습 재실행 +async function restartTrain(uuid) { + if (!confirm('처음부터 다시 학습을 시작하시겠습니까?')) return; + + try { + const response = await axios.post(`${API_BASE}/restart/${uuid}`); + if (response.data.success) { + alert('학습이 재시작되었습니다.'); + refreshModelList(); + } + } catch (error) { + alert('학습 재시작 실패: ' + error.message); + } +} + +// 학습 이어하기 +async function resumeTrain(uuid) { + if (!confirm('중단된 지점부터 학습을 이어가시겠습니까?')) return; + + try { + const response = await axios.post(`${API_BASE}/resume/${uuid}`); + if (response.data.success) { + alert('학습이 재개되었습니다.'); + refreshModelList(); + } + } catch (error) { + alert('학습 이어하기 실패: ' + error.message); + } +} + +// 테스트 실행 +async function runTest(uuid, epoch) { + if (!confirm(`Epoch ${epoch}으로 테스트를 실행하시겠습니까?`)) return; + + try { + const response = await axios.post(`${API_BASE}/test/run/${epoch}/${uuid}`); + if (response.data.success) { + alert('테스트가 시작되었습니다.'); + refreshModelList(); + } + } catch (error) { + alert('테스트 실행 실패: ' + error.message); + } +} + +// 테스트 취소 +async function cancelTest(uuid) { + if (!confirm('테스트를 취소하시겠습니까?')) return; + + try { + const response = await axios.post(`${API_BASE}/test/cancel/${uuid}`); + if (response.data.success) { + alert('테스트가 취소되었습니다.'); + refreshModelList(); + } + } catch (error) { + alert('테스트 취소 실패: ' + error.message); + } +} + +// 학습 상태 확인 (폴링용) +async function checkTrainStatus(uuid) { + try { + const response = await axios.post(`${API_BASE}/status/${uuid}`); + return response.data.success; + } catch (error) { + console.error('상태 확인 실패:', error); + return false; + } +} +``` + +### 6.3 주기적 상태 업데이트 (폴링) + +```javascript +import { useEffect, useState } from 'react'; + +function ModelMonitor({ uuid }) { + const [isPolling, setIsPolling] = useState(false); + + useEffect(() => { + let intervalId; + + // 진행 중인 모델만 폴링 + if (isPolling) { + intervalId = setInterval(async () => { + const success = await checkTrainStatus(uuid); + if (success) { + refreshModelList(); // 상태 업데이트 후 목록 갱신 + } + }, 10000); // 10초마다 상태 확인 + } + + return () => { + if (intervalId) clearInterval(intervalId); + }; + }, [uuid, isPolling]); + + // 모델 상태에 따라 폴링 활성화/비활성화 + useEffect(() => { + const shouldPoll = model.statusCd === 'IN_PROGRESS'; + setIsPolling(shouldPoll); + }, [model.statusCd]); + + return null; // 백그라운드 작업 +} +``` + +--- + +## 🎯 7. 상태 수정 시나리오별 가이드 + +### 시나리오 1: 학습 중단 후 재시작 + +**현재 상태**: +```json +{ + "statusCd": "STOPPED", + "step1Status": "STOPPED", + "lastError": "SERVER_RESTART_CONTAINER_MISSING_OUTPUT_INCOMPLETE" +} +``` + +**프론트 표시**: +- ⚠️ 중단됨 - 산출물 부족으로 중단됨 +- 버튼: [재실행] [이어하기] + +**액션**: + +**옵션 A) 재실행** (`POST /api/train/restart/{uuid}`): +```javascript +// 처음부터 다시 시작 +await axios.post(`/api/train/restart/${uuid}`); + +// 결과 상태 +{ + "statusCd": "IN_PROGRESS", + "step1Status": "IN_PROGRESS", + "currentAttemptId": 새로운JobId +} +``` + +**옵션 B) 이어하기** (`POST /api/train/resume/{uuid}`): +```javascript +// 중단된 지점부터 계속 +await axios.post(`/api/train/resume/${uuid}`); + +// 결과 상태 +{ + "statusCd": "IN_PROGRESS", + "step1Status": "IN_PROGRESS", + "currentAttemptId": 새로운JobId, + // paramsJson에 resumeFrom 설정됨 +} +``` + +--- + +### 시나리오 2: 학습 완료 후 테스트 실행 + +**현재 상태**: +```json +{ + "statusCd": "COMPLETED", + "step1Status": "COMPLETED", + "step2Status": "READY", + "bestEpoch": 45 +} +``` + +**프론트 표시**: +- ✅ 완료 - 학습 완료 (테스트 대기) +- 버튼: [테스트 실행] + +**액션**: +```javascript +// 테스트 실행 (bestEpoch 사용) +await axios.post(`/api/train/test/run/45/${uuid}`); + +// 결과 상태 +{ + "statusCd": "IN_PROGRESS", + "step1Status": "COMPLETED", + "step2Status": "IN_PROGRESS", + "step2StrtDttm": "2026-04-06 14:30:00" +} +``` + +**프론트 표시 변경**: +- 🔄 진행중 - 테스트 진행 중... +- 버튼: [테스트 취소] + +--- + +### 시나리오 3: 실행 중인 학습 취소 + +**현재 상태**: +```json +{ + "statusCd": "IN_PROGRESS", + "step1Status": "IN_PROGRESS", + "currentAttemptId": 123 +} +``` + +**프론트 표시**: +- 🔄 진행중 - 학습 진행 중... +- 버튼: [학습 취소] + +**액션**: +```javascript +// 학습 취소 +await axios.post(`/api/train/cancel/${uuid}`); + +// 결과 상태 +{ + "statusCd": "STOPPED", + "step1Status": "STOPPED", + "currentAttemptId": 123 +} +``` + +**프론트 표시 변경**: +- ⚠️ 중단됨 - 사용자가 학습을 취소함 +- 버튼: [재실행] [이어하기] + +--- + +### 시나리오 4: 서버 재시작 후 자동 상태 복구 + +**서버 재시작 전**: +```json +{ + "statusCd": "IN_PROGRESS", + "step1Status": "IN_PROGRESS" +} +``` + +**서버 재시작 후 API 호출**: +```javascript +// 주기적 폴링 또는 수동 호출 +await axios.post(`/api/train/status/${uuid}`); +``` + +**케이스 A) 학습 완료된 경우**: +```json +{ + "statusCd": "COMPLETED", + "step1Status": "COMPLETED", + "step1EndDttm": "2026-04-06 15:00:00", + "step1MetricSaveYn": true +} +``` +- ✅ 완료 - 학습 완료 +- 버튼: [테스트 실행] [결과 보기] + +**케이스 B) 산출물 부족한 경우**: +```json +{ + "statusCd": "STOPPED", + "step1Status": "STOPPED", + "lastError": "SERVER_RESTART_CONTAINER_MISSING_OUTPUT_INCOMPLETE: val.csv-lines-mismatch" +} +``` +- ⚠️ 중단됨 - 산출물 부족으로 중단됨 +- 버튼: [재실행] [이어하기] + +**케이스 C) 여전히 실행 중인 경우**: +```json +{ + "statusCd": "IN_PROGRESS", + "step1Status": "IN_PROGRESS" +} +``` +- 🔄 진행중 - 학습 진행 중... +- 버튼: [학습 취소] + +--- + +## 📊 8. 에러 메시지 처리 + +### 8.1 `last_error` 필드 해석 + +| 에러 메시지 | 의미 | 프론트 표시 | 권장 액션 | +|------------|------|-----------|----------| +| `SERVER_RESTART_CONTAINER_MISSING_OUTPUT_INCOMPLETE` | 서버 재시작 후 컨테이너 없음 + 산출물 부족 | "서버 재시작으로 인한 중단 - 산출물 확인 필요" | [재실행] | +| `val.csv-lines-mismatch` | val.csv 라인 수 부족 | "검증 데이터 부족 - 학습이 완료되지 않음" | [재실행] [이어하기] | +| `val.csv-missing` | val.csv 파일 없음 | "검증 파일 없음 - 학습 실패" | [재실행] | +| `total-epoch-missing` | paramsJson에 total_epoch 없음 | "설정 오류 - 에폭 정보 없음" | [재실행] | +| `output-dir-missing` | 산출물 디렉토리 없음 | "산출물 디렉토리 없음 - 학습 실패" | [재실행] | + +### 8.2 에러 표시 컴포넌트 + +```javascript +function ErrorMessage({ lastError }) { + if (!lastError) return null; + + const errorMessages = { + 'SERVER_RESTART_CONTAINER_MISSING_OUTPUT_INCOMPLETE': { + icon: '⚠️', + title: '서버 재시작으로 인한 중단', + message: '서버가 재시작되면서 학습이 중단되었습니다. 산출물을 확인하여 학습 상태를 복구합니다.', + severity: 'warning' + }, + 'val.csv-lines-mismatch': { + icon: '📊', + title: '검증 데이터 부족', + message: '검증 데이터 라인 수가 예상보다 적습니다. 학습이 완료되지 않았을 가능성이 있습니다.', + severity: 'warning' + }, + 'val.csv-missing': { + icon: '❌', + title: '검증 파일 없음', + message: 'val.csv 파일을 찾을 수 없습니다. 학습이 실패했을 가능성이 높습니다.', + severity: 'error' + } + }; + + // 에러 메시지 파싱 + const errorKey = Object.keys(errorMessages).find(key => lastError.includes(key)); + const errorInfo = errorMessages[errorKey] || { + icon: '⚠️', + title: '오류 발생', + message: lastError, + severity: 'error' + }; + + return ( +
+
+ {errorInfo.icon} + {errorInfo.title} +
+

{errorInfo.message}

+
+ ); +} +``` + +--- + +## 🔍 9. 상태 일관성 체크리스트 + +### 프론트엔드 개발 시 확인사항 + +#### ✅ 상태 표시 +- [ ] `statusCd`를 UI에 정확히 매핑 (READY, IN_PROGRESS, COMPLETED, STOPPED, ERROR) +- [ ] `step1Status`, `step2Status` 개별 표시 +- [ ] 한글 상태명 표시 (statusName, step1StatusName, step2StatusName 활용) +- [ ] 상태별 적절한 색상/아이콘 사용 + +#### ✅ 버튼 활성화/비활성화 +- [ ] `statusCd`에 따른 버튼 조건 확인 +- [ ] `step1Status`, `step2Status` 조합 고려 +- [ ] 중복 실행 방지 (disabled 속성) + +#### ✅ 시간 정보 표시 +- [ ] `step1StrtDttm`, `step1EndDttm` 표시 +- [ ] `step2StrtDttm`, `step2EndDttm` 표시 +- [ ] `step1Duration`, `step2Duration` 계산 표시 + +#### ✅ 에러 처리 +- [ ] `lastError` 메시지 파싱 및 표시 +- [ ] 사용자 친화적 에러 메시지 변환 +- [ ] 에러 발생 시 복구 방법 안내 + +#### ✅ 주기적 업데이트 +- [ ] 진행 중인 모델 폴링 (10초 간격 권장) +- [ ] `POST /api/train/status/{uuid}` 주기 호출 +- [ ] 목록 자동 갱신 + +--- + +## 💡 10. 베스트 프랙티스 + +### 10.1 상태 동기화 + +```javascript +// ❌ 나쁜 예: 프론트에서 임의로 상태 변경 +function handleCancel() { + model.statusCd = 'STOPPED'; // 직접 변경 X + updateUI(); +} + +// ✅ 좋은 예: API 호출 후 서버 응답 기준으로 업데이트 +async function handleCancel() { + await axios.post(`/api/train/cancel/${uuid}`); + const updated = await axios.get(`/api/models/list`); + setModels(updated.data.content); +} +``` + +### 10.2 낙관적 UI 업데이트 + +```javascript +// ✅ 사용자 경험 향상: 즉시 UI 업데이트 + 백그라운드 검증 +async function handleRun() { + // 1. 즉시 UI 업데이트 (낙관적) + setModel(prev => ({ ...prev, statusCd: 'IN_PROGRESS' })); + + try { + // 2. API 호출 + await axios.post(`/api/train/run/${uuid}`); + + // 3. 실제 상태 확인 + setTimeout(() => checkTrainStatus(uuid), 2000); + } catch (error) { + // 4. 실패 시 롤백 + setModel(prev => ({ ...prev, statusCd: 'READY' })); + alert('실행 실패: ' + error.message); + } +} +``` + +### 10.3 상태별 UI 일관성 + +```css +/* 상태별 일관된 색상 스타일 */ +.status-badge.ready { background: #e3f2fd; color: #1976d2; } +.status-badge.in-progress { background: #e8f5e9; color: #388e3c; } +.status-badge.completed { background: #f1f8e9; color: #689f38; } +.status-badge.stopped { background: #fff3e0; color: #f57c00; } +.status-badge.error { background: #ffebee; color: #d32f2f; } + +/* 버튼 스타일 */ +.btn-primary { background: #1976d2; color: white; } +.btn-success { background: #388e3c; color: white; } +.btn-warning { background: #f57c00; color: white; } +.btn-danger { background: #d32f2f; color: white; } +.btn-info { background: #0288d1; color: white; } +``` + +--- + +## 📝 요약 + +### 상태 확인 방법 +1. **API 호출**: `POST /api/train/status/{uuid}` +2. **응답 확인**: `statusCd`, `step1Status`, `step2Status` +3. **UI 반영**: 상태별 배지/아이콘 표시 + +### 상태 변경 방법 +1. **적절한 제어 API 호출**: + - 실행: `POST /api/train/run/{uuid}` + - 취소: `POST /api/train/cancel/{uuid}` + - 재실행: `POST /api/train/restart/{uuid}` + - 이어하기: `POST /api/train/resume/{uuid}` + +2. **버튼 조건부 표시**: + - `statusCd`, `step1Status`, `step2Status` 조합 확인 + +3. **주기적 폴링**: + - 진행 중(`IN_PROGRESS`) 상태일 때만 10초 간격 폴링 + +--- + + +