2 Commits

Author SHA1 Message Date
62398846d3 파일관리 기능 api 커밋 2026-04-06 17:36:40 +09:00
260569225c 파일관리 기능 api 커밋 2026-04-06 17:36:19 +09:00
4 changed files with 494 additions and 1 deletions

View File

@@ -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/**");
} }
} }

View File

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

View File

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

View File

@@ -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("상대 경로(..)는 사용할 수 없습니다");
}
}
}