상태변경 추가 #191
@@ -137,6 +137,6 @@ public class SecurityConfig {
|
|||||||
/** 완전 제외(필터 자체를 안 탐) */
|
/** 완전 제외(필터 자체를 안 탐) */
|
||||||
@Bean
|
@Bean
|
||||||
public WebSecurityCustomizer webSecurityCustomizer() {
|
public WebSecurityCustomizer webSecurityCustomizer() {
|
||||||
return (web) -> web.ignoring().requestMatchers("/api/mapsheet/**");
|
return (web) -> web.ignoring().requestMatchers("/api/mapsheet/**", "/api/file-manager/**");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<FileManagerDto.ListFilesRes> 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<FileManagerDto.DeleteFileRes> deleteFiles(
|
||||||
|
@RequestBody FileManagerDto.DeleteFileReq request) {
|
||||||
|
|
||||||
|
FileManagerDto.DeleteFileRes response = fileManagerService.deleteFiles(request);
|
||||||
|
return ApiResponseDto.ok(response);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<FileInfo> 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<String> filePaths;
|
||||||
|
|
||||||
|
@Schema(description = "디렉토리일 경우 하위 파일 포함 삭제 여부", example = "true")
|
||||||
|
private Boolean recursive;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Schema(description = "파일 삭제 응답")
|
||||||
|
public static class DeleteFileRes {
|
||||||
|
@Schema(description = "삭제 성공한 파일 경로 목록")
|
||||||
|
private List<String> deletedFiles;
|
||||||
|
|
||||||
|
@Schema(description = "삭제 실패한 파일 경로 목록")
|
||||||
|
private List<String> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<FileManagerDto.FileInfo> 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<Path> 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<String> deletedFiles = new ArrayList<>();
|
||||||
|
List<String> 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<Path> 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("상대 경로(..)는 사용할 수 없습니다");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -82,6 +82,10 @@ public class ModelTrainMngService {
|
|||||||
throw new CustomApiException("NOT_FOUND", HttpStatus.NOT_FOUND, "모델 없음");
|
throw new CustomApiException("NOT_FOUND", HttpStatus.NOT_FOUND, "모델 없음");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (model.getRequestPath() == null) {
|
||||||
|
throw new CustomApiException("NOT_FOUND", HttpStatus.NOT_FOUND, "임시파일 경로 없음");
|
||||||
|
}
|
||||||
|
|
||||||
// ===== 2. 경로 생성 =====
|
// ===== 2. 경로 생성 =====
|
||||||
Path tmpBase = Path.of(symbolicDir).toAbsolutePath().normalize();
|
Path tmpBase = Path.of(symbolicDir).toAbsolutePath().normalize();
|
||||||
Path tmp = tmpBase.resolve(model.getRequestPath()).normalize();
|
Path tmp = tmpBase.resolve(model.getRequestPath()).normalize();
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ public class ModelTestMetricsJobCoreService {
|
|||||||
return modelTestMetricsJobRepository.getTestMetricSaveNotYetModelIds();
|
return modelTestMetricsJobRepository.getTestMetricSaveNotYetModelIds();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public ResponsePathDto getTestMetricSaveNotYetModelId(Long modelId) {
|
||||||
|
return modelTestMetricsJobRepository.getTestMetricSaveNotYetModelId(modelId);
|
||||||
|
}
|
||||||
|
|
||||||
public void insertModelMetricsTest(List<Object[]> batchArgs) {
|
public void insertModelMetricsTest(List<Object[]> batchArgs) {
|
||||||
modelTestMetricsJobRepository.insertModelMetricsTest(batchArgs);
|
modelTestMetricsJobRepository.insertModelMetricsTest(batchArgs);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 성공 처리
|
* 성공 처리
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ public class ModelTrainMetricsJobCoreService {
|
|||||||
return modelTrainMetricsJobRepository.getTrainMetricSaveNotYetModelIds();
|
return modelTrainMetricsJobRepository.getTrainMetricSaveNotYetModelIds();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public ResponsePathDto getTrainMetricSaveNotYetModelId(Long modelId) {
|
||||||
|
return modelTrainMetricsJobRepository.getTrainMetricSaveNotYetModelId(modelId);
|
||||||
|
}
|
||||||
|
|
||||||
public void insertModelMetricsTrain(List<Object[]> batchArgs) {
|
public void insertModelMetricsTrain(List<Object[]> batchArgs) {
|
||||||
modelTrainMetricsJobRepository.insertModelMetricsTrain(batchArgs);
|
modelTrainMetricsJobRepository.insertModelMetricsTrain(batchArgs);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ public interface ModelTestMetricsJobRepositoryCustom {
|
|||||||
|
|
||||||
List<ResponsePathDto> getTestMetricSaveNotYetModelIds();
|
List<ResponsePathDto> getTestMetricSaveNotYetModelIds();
|
||||||
|
|
||||||
|
ResponsePathDto getTestMetricSaveNotYetModelId(Long modelId);
|
||||||
|
|
||||||
void insertModelMetricsTest(List<Object[]> batchArgs);
|
void insertModelMetricsTest(List<Object[]> batchArgs);
|
||||||
|
|
||||||
ModelMetricJsonDto getTestMetricPackingInfo(Long modelId);
|
ModelMetricJsonDto getTestMetricPackingInfo(Long modelId);
|
||||||
|
|||||||
@@ -63,6 +63,25 @@ public class ModelTestMetricsJobRepositoryImpl extends QuerydslRepositorySupport
|
|||||||
.fetch();
|
.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
|
@Override
|
||||||
public void insertModelMetricsTest(List<Object[]> batchArgs) {
|
public void insertModelMetricsTest(List<Object[]> batchArgs) {
|
||||||
// AS-IS
|
// AS-IS
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ public interface ModelTrainMetricsJobRepositoryCustom {
|
|||||||
|
|
||||||
List<ResponsePathDto> getTrainMetricSaveNotYetModelIds();
|
List<ResponsePathDto> getTrainMetricSaveNotYetModelIds();
|
||||||
|
|
||||||
|
ResponsePathDto getTrainMetricSaveNotYetModelId(Long modelId);
|
||||||
|
|
||||||
void insertModelMetricsTrain(List<Object[]> batchArgs);
|
void insertModelMetricsTrain(List<Object[]> batchArgs);
|
||||||
|
|
||||||
void updateModelMetricsTrainSaveYn(Long modelId, String stepNo);
|
void updateModelMetricsTrainSaveYn(Long modelId, String stepNo);
|
||||||
|
|||||||
@@ -44,6 +44,25 @@ public class ModelTrainMetricsJobRepositoryImpl extends QuerydslRepositorySuppor
|
|||||||
.fetch();
|
.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
|
@Override
|
||||||
public void insertModelMetricsTrain(List<Object[]> batchArgs) {
|
public void insertModelMetricsTrain(List<Object[]> batchArgs) {
|
||||||
String sql =
|
String sql =
|
||||||
|
|||||||
@@ -213,4 +213,27 @@ public class TrainApiController {
|
|||||||
Long modelId = trainJobService.getModelIdByUuid(uuid);
|
Long modelId = trainJobService.getModelIdByUuid(uuid);
|
||||||
return ApiResponseDto.ok(dataSetCountersService.getCount(modelId));
|
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<String> 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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,25 +1,16 @@
|
|||||||
package com.kamco.cd.training.train.service;
|
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.ModelTrainJobCoreService;
|
||||||
import com.kamco.cd.training.postgres.core.ModelTrainMngCoreService;
|
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.ModelTrainJobDto;
|
||||||
import java.io.BufferedReader;
|
import com.kamco.cd.training.train.dto.OutputResult;
|
||||||
import java.io.IOException;
|
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.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
import java.util.stream.Stream;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.log4j.Log4j2;
|
import lombok.extern.log4j.Log4j2;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
|
||||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||||
import org.springframework.context.annotation.Profile;
|
import org.springframework.context.annotation.Profile;
|
||||||
import org.springframework.context.event.EventListener;
|
import org.springframework.context.event.EventListener;
|
||||||
@@ -44,14 +35,7 @@ public class JobRecoveryOnStartupService {
|
|||||||
private final ModelTrainJobCoreService modelTrainJobCoreService;
|
private final ModelTrainJobCoreService modelTrainJobCoreService;
|
||||||
private final ModelTrainMngCoreService modelTrainMngCoreService;
|
private final ModelTrainMngCoreService modelTrainMngCoreService;
|
||||||
private final ModelTrainMetricsJobService modelTrainMetricsJobService;
|
private final ModelTrainMetricsJobService modelTrainMetricsJobService;
|
||||||
|
private final TrainUtilService trainUtilService;
|
||||||
/**
|
|
||||||
* Docker 컨테이너가 쓰는 response(산출물) 디렉토리의 "호스트 측" 베이스 경로. 예) /data/train/response
|
|
||||||
*
|
|
||||||
* <p>컨테이너가 --rm 으로 삭제된 경우에도 이 경로에 val.csv / *.pth 등이 남아있으면 정상 종료 여부를 "파일 기반"으로 판정합니다.
|
|
||||||
*/
|
|
||||||
@Value("${train.docker.response_dir}")
|
|
||||||
private String responseDir;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 스프링 부팅 완료 시점(빈 생성/초기화 모두 끝난 뒤)에 복구 로직 실행.
|
* 스프링 부팅 완료 시점(빈 생성/초기화 모두 끝난 뒤)에 복구 로직 실행.
|
||||||
@@ -77,7 +61,7 @@ public class JobRecoveryOnStartupService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// 2-1) docker inspect로 컨테이너 상태 조회
|
// 2-1) docker inspect로 컨테이너 상태 조회
|
||||||
DockerInspectState state = inspectContainer(containerName);
|
DockerInspectState state = trainUtilService.inspectContainer(containerName);
|
||||||
|
|
||||||
// 3) 컨테이너가 "없음"
|
// 3) 컨테이너가 "없음"
|
||||||
// - docker run --rm 로 실행한 컨테이너는 정상 종료 시 바로 삭제될 수 있음
|
// - docker run --rm 로 실행한 컨테이너는 정상 종료 시 바로 삭제될 수 있음
|
||||||
@@ -88,7 +72,7 @@ public class JobRecoveryOnStartupService {
|
|||||||
containerName);
|
containerName);
|
||||||
|
|
||||||
// 3-1) 컨테이너가 없을 때는 산출물(responseDir)을 보고 완료 여부를 "추정"
|
// 3-1) 컨테이너가 없을 때는 산출물(responseDir)을 보고 완료 여부를 "추정"
|
||||||
OutputResult out = probeOutputs(job);
|
OutputResult out = trainUtilService.probeOutputs(job);
|
||||||
|
|
||||||
// 3-2) 산출물이 충분하면 성공 처리
|
// 3-2) 산출물이 충분하면 성공 처리
|
||||||
if (out.completed()) {
|
if (out.completed()) {
|
||||||
@@ -109,11 +93,9 @@ public class JobRecoveryOnStartupService {
|
|||||||
job.getId(),
|
job.getId(),
|
||||||
out.reason());
|
out.reason());
|
||||||
|
|
||||||
Integer modelId = job.getModelId() == null ? null : Math.toIntExact(job.getModelId());
|
|
||||||
|
|
||||||
// PAUSED/STOP
|
// PAUSED/STOP
|
||||||
modelTrainJobCoreService.markPaused(
|
modelTrainJobCoreService.markPaused(
|
||||||
job.getId(), modelId, "SERVER_RESTART_CONTAINER_MISSING_OUTPUT_INCOMPLETE");
|
job.getId(), -1, "SERVER_RESTART_CONTAINER_MISSING_OUTPUT_INCOMPLETE");
|
||||||
|
|
||||||
// 모델도 에러가 아니라 STOP으로
|
// 모델도 에러가 아니라 STOP으로
|
||||||
markStepStopByJobType(
|
markStepStopByJobType(
|
||||||
@@ -152,7 +134,7 @@ public class JobRecoveryOnStartupService {
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
// 2) kill 후 실제로 죽었는지 확인
|
// 2) kill 후 실제로 죽었는지 확인
|
||||||
// ============================================================
|
// ============================================================
|
||||||
DockerInspectState after = inspectContainer(containerName);
|
DockerInspectState after = trainUtilService.inspectContainer(containerName);
|
||||||
if (after.exists() && after.running()) {
|
if (after.exists() && after.running()) {
|
||||||
throw new IOException("docker kill returned 0 but container still running");
|
throw new IOException("docker kill returned 0 but container still running");
|
||||||
}
|
}
|
||||||
@@ -162,10 +144,8 @@ public class JobRecoveryOnStartupService {
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
// 3) job 상태를 PAUSED로 변경 (서버 재기동으로 강제 중단)
|
// 3) job 상태를 PAUSED로 변경 (서버 재기동으로 강제 중단)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
Integer modelId = job.getModelId() == null ? null : Math.toIntExact(job.getModelId());
|
|
||||||
|
|
||||||
modelTrainJobCoreService.markPaused(
|
modelTrainJobCoreService.markPaused(job.getId(), -1, "AUTO_KILLED_ON_SERVER_RESTART");
|
||||||
job.getId(), modelId, "AUTO_KILLED_ON_SERVER_RESTART");
|
|
||||||
|
|
||||||
log.info("job = {}", job);
|
log.info("job = {}", job);
|
||||||
markStepStopByJobType(job, "AUTO_KILLED_ON_SERVER_RESTART");
|
markStepStopByJobType(job, "AUTO_KILLED_ON_SERVER_RESTART");
|
||||||
@@ -264,301 +244,4 @@ public class JobRecoveryOnStartupService {
|
|||||||
modelTrainMngCoreService.markError(job.getModelId(), msg);
|
modelTrainMngCoreService.markError(job.getModelId(), msg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* docker inspect를 사용해서 컨테이너 상태를 조회합니다.
|
|
||||||
*
|
|
||||||
* <p>사용하는 템플릿: {{.State.Status}} {{.State.Running}} {{.State.ExitCode}}
|
|
||||||
*
|
|
||||||
* <p>예상 출력 예: - "running true 0" - "exited false 0" - "exited false 137"
|
|
||||||
*
|
|
||||||
* <p>주의: - 컨테이너가 없거나 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 결과를 담는 레코드.
|
|
||||||
*
|
|
||||||
* <p>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 산출물만 남아있는 상태) 완료 여부를 파일 기반으로 판정합니다.
|
|
||||||
*
|
|
||||||
* <p>판정 규칙(보수적으로 설계): 1) total_epoch가 paramsJson에 있어야 함 (없으면 완료 판단 불가) 2) val.csv 존재 + 헤더 제외 라인 수
|
|
||||||
* >= total_epoch 이어야 함 3) *.pth 파일이 total_epoch 이상 존재하거나, best*.pth(또는 *best*.pth)가 존재해야 함
|
|
||||||
*
|
|
||||||
* <p>왜 이렇게? - 어떤 학습은 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 산출물 디렉토리를 찾습니다.
|
|
||||||
*
|
|
||||||
* <p>가장 중요한 커스터마이징 포인트: - 실제 운영 환경에서 산출물이 어떤 경로 규칙으로 저장되는지에 따라 여기만 수정하면 됩니다.
|
|
||||||
*
|
|
||||||
* <p>현재 기본 탐색 순서: 1) {responseDir}/{jobId} 2) {responseDir}/{modelId} 3)
|
|
||||||
* {responseDir}/{containerName} 4) 마지막 fallback: responseDir 자체
|
|
||||||
*
|
|
||||||
* <p>추천: - 여러분 규칙이 "{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 값을 추출합니다.
|
|
||||||
*
|
|
||||||
* <p>키 후보: - "total_epoch" (snake_case) - "totalEpoch" (camelCase)
|
|
||||||
*
|
|
||||||
* <p>예: paramsJson = {"jobType":"TRAIN","total_epoch":50,...}
|
|
||||||
*/
|
|
||||||
private Optional<Integer> extractTotalEpoch(ModelTrainJobDto job) {
|
|
||||||
Map<String, Object> 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 파일에서 "헤더(첫 줄)"를 제외한 라인 수를 계산합니다.
|
|
||||||
*
|
|
||||||
* <p>가정: - val.csv 첫 줄은 헤더 - 이후 라인들이 epoch별 기록(또는 유사한 누적 기록)
|
|
||||||
*
|
|
||||||
* <p>주의: - 파일 인코딩은 UTF-8로 가정 - 빈 줄은 제외
|
|
||||||
*/
|
|
||||||
private long countNonHeaderLines(Path csv) throws IOException {
|
|
||||||
try (Stream<String> lines = Files.lines(csv, StandardCharsets.UTF_8)) {
|
|
||||||
return lines.skip(1).filter(s -> s != null && !s.isBlank()).count();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 디렉토리에서 glob 패턴에 맞는 파일 수를 셉니다.
|
|
||||||
*
|
|
||||||
* <p>예: - "*.pth" - "best*.pth"
|
|
||||||
*/
|
|
||||||
private long countFilesByGlob(Path dir, String glob) throws IOException {
|
|
||||||
try (DirectoryStream<Path> 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<Path> ds = Files.newDirectoryStream(dir, glob)) {
|
|
||||||
return ds.iterator().hasNext();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================================
|
|
||||||
// probeOutputs() 결과 객체
|
|
||||||
// ============================================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 컨테이너가 없을 때(responseDir 기반) 완료 여부 판정 결과.
|
|
||||||
*
|
|
||||||
* <p>completed: - true : 산출물이 완료로 보임(성공 처리 가능) - false : 산출물이 부족/불명확(실패 또는 유예 판단)
|
|
||||||
*
|
|
||||||
* <p>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<Integer> extractValInterval(ModelTrainJobDto job) {
|
|
||||||
Map<String, Object> 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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,28 @@ public class ModelTestMetricsJobService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (ResponsePathDto modelInfo : modelIds) {
|
for (ResponsePathDto modelInfo : modelIds) {
|
||||||
|
createFile(modelInfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 단건 결과 csv 파일 정보 등록 */
|
||||||
|
public void testValidMetricCsvFiles(Long modelId) {
|
||||||
|
|
||||||
|
ResponsePathDto model = modelTestMetricsJobCoreService.getTestMetricSaveNotYetModelId(modelId);
|
||||||
|
|
||||||
|
if (model == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
createFile(model);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 베스트 에폭 zip파일 생성, 테스트결과 db등록
|
||||||
|
*
|
||||||
|
* @param modelInfo
|
||||||
|
*/
|
||||||
|
private void createFile(ResponsePathDto modelInfo) {
|
||||||
|
|
||||||
String testPath = responseDir + "/" + modelInfo.getUuid() + "/metrics/test.csv";
|
String testPath = responseDir + "/" + modelInfo.getUuid() + "/metrics/test.csv";
|
||||||
try (BufferedReader reader =
|
try (BufferedReader reader =
|
||||||
@@ -102,16 +124,14 @@ public class ModelTestMetricsJobService {
|
|||||||
modelTestMetricsJobCoreService.insertModelMetricsTest(batchArgs);
|
modelTestMetricsJobCoreService.insertModelMetricsTest(batchArgs);
|
||||||
|
|
||||||
// test.csv 파일 읽어서 저장한 여부로만 사용하기
|
// test.csv 파일 읽어서 저장한 여부로만 사용하기
|
||||||
modelTestMetricsJobCoreService.updateModelMetricsTrainSaveYn(
|
modelTestMetricsJobCoreService.updateModelMetricsTrainSaveYn(modelInfo.getModelId(), "step2");
|
||||||
modelInfo.getModelId(), "step2");
|
|
||||||
|
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
throw new RuntimeException(e);
|
throw new RuntimeException(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 패키징할 파일 만들기
|
// 패키징할 파일 만들기
|
||||||
modelTestMetricsJobCoreService.updatePackingStart(
|
modelTestMetricsJobCoreService.updatePackingStart(modelInfo.getModelId(), ZonedDateTime.now());
|
||||||
modelInfo.getModelId(), ZonedDateTime.now());
|
|
||||||
|
|
||||||
ModelMetricJsonDto jsonDto =
|
ModelMetricJsonDto jsonDto =
|
||||||
modelTestMetricsJobCoreService.getTestMetricPackingInfo(modelInfo.getModelId());
|
modelTestMetricsJobCoreService.getTestMetricPackingInfo(modelInfo.getModelId());
|
||||||
@@ -119,12 +139,7 @@ public class ModelTestMetricsJobService {
|
|||||||
writeJsonFile(
|
writeJsonFile(
|
||||||
jsonDto,
|
jsonDto,
|
||||||
Paths.get(
|
Paths.get(
|
||||||
responseDir
|
responseDir + "/" + modelInfo.getUuid() + "/" + jsonDto.getModelVersion() + ".json"));
|
||||||
+ "/"
|
|
||||||
+ modelInfo.getUuid()
|
|
||||||
+ "/"
|
|
||||||
+ jsonDto.getModelVersion()
|
|
||||||
+ ".json"));
|
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
throw new RuntimeException(e);
|
throw new RuntimeException(e);
|
||||||
}
|
}
|
||||||
@@ -173,7 +188,6 @@ public class ModelTestMetricsJobService {
|
|||||||
throw new RuntimeException(e);
|
throw new RuntimeException(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private void writeJsonFile(Object data, Path outputPath) throws IOException {
|
private void writeJsonFile(Object data, Path outputPath) throws IOException {
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,28 @@ public class ModelTrainMetricsJobService {
|
|||||||
|
|
||||||
for (ResponsePathDto modelInfo : modelIds) {
|
for (ResponsePathDto modelInfo : modelIds) {
|
||||||
|
|
||||||
|
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";
|
String trainPath = responseDir + "/" + modelInfo.getUuid() + "/metrics/train.csv";
|
||||||
try (BufferedReader reader =
|
try (BufferedReader reader =
|
||||||
Files.newBufferedReader(Paths.get(trainPath), StandardCharsets.UTF_8); ) {
|
Files.newBufferedReader(Paths.get(trainPath), StandardCharsets.UTF_8); ) {
|
||||||
@@ -152,9 +174,7 @@ public class ModelTrainMetricsJobService {
|
|||||||
// best_changed_fscore_epoch_숫자.pth -> 숫자 값 가지고 와서 베스트 에폭에 업데이트 하기
|
// best_changed_fscore_epoch_숫자.pth -> 숫자 값 가지고 와서 베스트 에폭에 업데이트 하기
|
||||||
modelTrainMetricsJobCoreService.updateModelSelectedBestEpoch(modelInfo.getModelId(), epoch);
|
modelTrainMetricsJobCoreService.updateModelSelectedBestEpoch(modelInfo.getModelId(), epoch);
|
||||||
|
|
||||||
modelTrainMetricsJobCoreService.updateModelMetricsTrainSaveYn(
|
modelTrainMetricsJobCoreService.updateModelMetricsTrainSaveYn(modelInfo.getModelId(), "step1");
|
||||||
modelInfo.getModelId(), "step1");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private Float parseFloatSafe(String value) {
|
private Float parseFloatSafe(String value) {
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
package com.kamco.cd.training.train.service;
|
package com.kamco.cd.training.train.service;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
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.common.exception.CustomApiException;
|
||||||
import com.kamco.cd.training.model.dto.ModelTrainMngDto;
|
import com.kamco.cd.training.model.dto.ModelTrainMngDto;
|
||||||
import com.kamco.cd.training.postgres.core.ModelTrainJobCoreService;
|
import com.kamco.cd.training.postgres.core.ModelTrainJobCoreService;
|
||||||
import com.kamco.cd.training.postgres.core.ModelTrainMngCoreService;
|
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.ModelTrainJobDto;
|
||||||
import com.kamco.cd.training.train.dto.ModelTrainJobQueuedEvent;
|
import com.kamco.cd.training.train.dto.ModelTrainJobQueuedEvent;
|
||||||
import com.kamco.cd.training.train.dto.ModelTrainLinkDto;
|
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 com.kamco.cd.training.train.dto.TrainRunRequest;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
@@ -16,6 +20,7 @@ import java.nio.file.Paths;
|
|||||||
import java.time.ZonedDateTime;
|
import java.time.ZonedDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.NoSuchElementException;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.log4j.Log4j2;
|
import lombok.extern.log4j.Log4j2;
|
||||||
@@ -33,11 +38,14 @@ public class TrainJobService {
|
|||||||
|
|
||||||
private final ModelTrainJobCoreService modelTrainJobCoreService;
|
private final ModelTrainJobCoreService modelTrainJobCoreService;
|
||||||
private final ModelTrainMngCoreService modelTrainMngCoreService;
|
private final ModelTrainMngCoreService modelTrainMngCoreService;
|
||||||
|
private final ModelTrainMetricsJobService modelTrainMetricsJobService;
|
||||||
|
private final ModelTestMetricsJobService modelTestMetricsJobService;
|
||||||
private final DockerTrainService dockerTrainService;
|
private final DockerTrainService dockerTrainService;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private final ApplicationEventPublisher eventPublisher;
|
private final ApplicationEventPublisher eventPublisher;
|
||||||
private final TmpDatasetService tmpDatasetService;
|
private final TmpDatasetService tmpDatasetService;
|
||||||
private final DataSetCountersService dataSetCounters;
|
private final DataSetCountersService dataSetCounters;
|
||||||
|
private final TrainUtilService trainUtilService;
|
||||||
|
|
||||||
// 학습 결과가 저장될 호스트 디렉토리
|
// 학습 결과가 저장될 호스트 디렉토리
|
||||||
@Value("${train.docker.response_dir}")
|
@Value("${train.docker.response_dir}")
|
||||||
@@ -309,4 +317,133 @@ public class TrainJobService {
|
|||||||
}
|
}
|
||||||
return modelUuid;
|
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<String, Object> 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에 따라 학습 관리 테이블의 "에러 단계"를 업데이트.
|
||||||
|
*
|
||||||
|
* <p>예: - jobType == "EVAL" → step2(평가 단계) 에러 - 그 외 → step1 혹은 전체 에러
|
||||||
|
*/
|
||||||
|
private void markStepStopByJobType(ModelTrainJobDto job, String msg) {
|
||||||
|
Map<String, Object> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
*
|
||||||
|
* <p>컨테이너가 --rm 으로 삭제된 경우에도 이 경로에 val.csv / *.pth 등이 남아있으면 정상 종료 여부를 "파일 기반"으로 판정합니다.
|
||||||
|
*/
|
||||||
|
@Value("${train.docker.response_dir}")
|
||||||
|
private String responseDir;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* docker inspect를 사용해서 컨테이너 상태를 조회합니다.
|
||||||
|
*
|
||||||
|
* <p>사용하는 템플릿: {{.State.Status}} {{.State.Running}} {{.State.ExitCode}}
|
||||||
|
*
|
||||||
|
* <p>예상 출력 예: - "running true 0" - "exited false 0" - "exited false 137"
|
||||||
|
*
|
||||||
|
* <p>주의: - 컨테이너가 없거나 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 산출물만 남아있는 상태) 완료 여부를 파일 기반으로 판정합니다.
|
||||||
|
*
|
||||||
|
* <p>판정 규칙(보수적으로 설계): 1) total_epoch가 paramsJson에 있어야 함 (없으면 완료 판단 불가) 2) val.csv 존재 + 헤더 제외 라인 수
|
||||||
|
* >= total_epoch 이어야 함 3) *.pth 파일이 total_epoch 이상 존재하거나, best*.pth(또는 *best*.pth)가 존재해야 함
|
||||||
|
*
|
||||||
|
* <p>왜 이렇게? - 어떤 학습은 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<Path> stream = Files.newDirectoryStream(path, pattern)) {
|
||||||
|
return stream.iterator().hasNext();
|
||||||
|
} catch (IOException e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* responseDir 아래에서 job 산출물 디렉토리를 찾습니다.
|
||||||
|
*
|
||||||
|
* <p>가장 중요한 커스터마이징 포인트: - 실제 운영 환경에서 산출물이 어떤 경로 규칙으로 저장되는지에 따라 여기만 수정하면 됩니다.
|
||||||
|
*
|
||||||
|
* <p>현재 기본 탐색 순서: 1) {responseDir}/{jobId} 2) {responseDir}/{modelId} 3)
|
||||||
|
* {responseDir}/{containerName} 4) 마지막 fallback: responseDir 자체
|
||||||
|
*
|
||||||
|
* <p>추천: - 여러분 규칙이 "{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 값을 추출합니다.
|
||||||
|
*
|
||||||
|
* <p>키 후보: - "total_epoch" (snake_case) - "totalEpoch" (camelCase)
|
||||||
|
*
|
||||||
|
* <p>예: paramsJson = {"jobType":"TRAIN","total_epoch":50,...}
|
||||||
|
*/
|
||||||
|
private Optional<Integer> extractTotalEpoch(ModelTrainJobDto job) {
|
||||||
|
Map<String, Object> 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<Integer> extractValInterval(ModelTrainJobDto job) {
|
||||||
|
Map<String, Object> 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 파일에서 "헤더(첫 줄)"를 제외한 라인 수를 계산합니다.
|
||||||
|
*
|
||||||
|
* <p>가정: - val.csv 첫 줄은 헤더 - 이후 라인들이 epoch별 기록(또는 유사한 누적 기록)
|
||||||
|
*
|
||||||
|
* <p>주의: - 파일 인코딩은 UTF-8로 가정 - 빈 줄은 제외
|
||||||
|
*/
|
||||||
|
private long countNonHeaderLines(Path csv) throws IOException {
|
||||||
|
try (Stream<String> lines = Files.lines(csv, StandardCharsets.UTF_8)) {
|
||||||
|
return lines.skip(1).filter(s -> s != null && !s.isBlank()).count();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
964
상태전의 STATUS 가이드.md
Normal file
964
상태전의 STATUS 가이드.md
Normal file
@@ -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 (
|
||||||
|
<span style={{ backgroundColor: bg, color: text, padding: '4px 12px', borderRadius: '12px' }}>
|
||||||
|
{icon} {name}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 📊 **상세 진행 상황 표시**
|
||||||
|
|
||||||
|
```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 (
|
||||||
|
<div className="progress-container">
|
||||||
|
{steps.map((step, index) => (
|
||||||
|
<div key={index} className="step-item">
|
||||||
|
<div className="step-header">
|
||||||
|
<span className="step-name">{step.name}</span>
|
||||||
|
<StatusBadge statusCd={step.status} />
|
||||||
|
</div>
|
||||||
|
<div className="step-time">
|
||||||
|
{step.startTime && (
|
||||||
|
<>
|
||||||
|
<span>시작: {step.startTime}</span>
|
||||||
|
{step.endTime && <span> ~ 종료: {step.endTime}</span>}
|
||||||
|
{step.duration && <span> (소요시간: {step.duration})</span>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{step.status === 'IN_PROGRESS' && (
|
||||||
|
<div className="loading-bar">
|
||||||
|
<div className="loading-animation"></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 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 (
|
||||||
|
<div className="action-buttons">
|
||||||
|
{canRun && (
|
||||||
|
<button onClick={() => runTrain(uuid)} className="btn-primary">
|
||||||
|
🚀 학습 실행
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canCancel && (
|
||||||
|
<button onClick={() => cancelTrain(uuid)} className="btn-danger">
|
||||||
|
⏹️ 학습 취소
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canRestart && (
|
||||||
|
<button onClick={() => restartTrain(uuid)} className="btn-warning">
|
||||||
|
🔄 재실행
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canResume && (
|
||||||
|
<button onClick={() => resumeTrain(uuid)} className="btn-info">
|
||||||
|
▶️ 이어하기
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canTest && (
|
||||||
|
<button onClick={() => runTest(uuid, model.bestEpoch)} className="btn-success">
|
||||||
|
🧪 테스트 실행
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canCancelTest && (
|
||||||
|
<button onClick={() => cancelTest(uuid)} className="btn-danger">
|
||||||
|
⏹️ 테스트 취소
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canViewResult && (
|
||||||
|
<button onClick={() => viewResult(uuid)} className="btn-primary">
|
||||||
|
📊 결과 보기
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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 (
|
||||||
|
<div className={`error-message severity-${errorInfo.severity}`}>
|
||||||
|
<div className="error-header">
|
||||||
|
<span className="error-icon">{errorInfo.icon}</span>
|
||||||
|
<span className="error-title">{errorInfo.title}</span>
|
||||||
|
</div>
|
||||||
|
<p className="error-description">{errorInfo.message}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 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초 간격 폴링
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user