본문 바로가기

Spring Boot

[Spring Boot] 파일 저장 시스템

파일 저장 시스템을 구현하기 전에 고려해야할 점

1. 파일 이름 중복

원본 파일 이름을 그대로 저장을 하게 된다면 파일 이름이 중복될 가능성이 존재합니다.

이러한 문제점을 해결하기 위해서는 별도의 새이름을 만들어 저장해야합니다.

이번 글에서 이 문제점을 UUID + _ + 원본 이름으로 유니크한 이름으로 저장하겠습니다.

 

2. 한 폴더에 저장할 수 있는 최대 파일 수

운영체제마다 다르지만 한 폴더에 저장할 수 있는 최대 파일 수의 한계가 있습니다.

  • 해시 기반 : 파일 이름이나 ID를 해시화하여 폴더를 나누는 방법입니다.
  • 날짜 기반 : 파일 업로드 날짜를 기준으로 폴더를 나누는 방법입니다.
  • UUID 기반 파일 이름 + 폴더화 : 파일 이름을 UUID로 변경하고, 그 일부를 폴더로 사용합니다

이번 글에서는 날짜 기반 파일 분할을 하도록 하겠습니다.

 

3. 파일 확장자 검사하기

공격자가 서버에 악성 파일을 업로드하고 실행할 수 있게 함으로써 공격자에게 서버에 대한 직접적인 접근 권한을 제공받을 수 있습니다.

파일 업로드 취약점에 대한 설명은 여기를 클릭하세요

 

4. 파일 용량

사용자가 엄청 큰 파일을 엄청 많이 요청을 보낼 경우 서버에 DoS 공격을 할 수 있습니다. 이를 방지하기 위해 파일 사이즈 제한을 두어야 합니다.

 

5. 중복 업로드

이름은 서로 중복되면 안되지만 내용이 같은 파일이 2개 이상 올라오면 서버는 부담이 생깁니다. Hash기반으로 내용을 분석하거나 간단하게 사용자, 업로드 파일이름 확인 등 다양한 방법으로 중복 업로드를 방지할 수 있습니다.

 

이번 글에서는 날짜 기반 파일 분할을 하도록 하겠습니다.

 

 

의존성

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-web'
	compileOnly 'org.projectlombok:lombok'
	runtimeOnly 'com.h2database:h2'
	annotationProcessor 'org.projectlombok:lombok'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
	implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
}

 

 

Application.yml

spring:
  application:
   name: file
  h2:
    console:
      enabled: true
      path: /h2-console
  datasource:
    url: jdbc:h2:~/local
    driver-class-name: org.h2.Driver
    username: sa
    password:

  jpa:
    properties:
      hibernate:
        dialect: org.hibernate.dialect.H2Dialect
        format_sql: true
    hibernate:
      ddl-auto: update
    show-sql: true


file:
  dir: C:\\sample_project\\files
  path: /uploads

file.dir : 실제 파일이 저장되는 물리적 경로

file.path : 웹 경로

 

 

FileConfig.class

@Getter
@Setter
@Configuration
@ConfigurationProperties(prefix = "file") // application에 정의된 file.로 시작하는 설정 값들을 자바 객체에 자동으로 바인딩
public class FileConfig {

    private String dir;

    private String path;

}

@Value로 dir, path를 넣어도 상관 없습니다.

 

 

FileEntity.class

@Entity
@Getter
@Setter
@Builder
public class FileEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long fileId;

    // 실제 파일 이름
    private String fileName;

    // 저장했을 때 파일 이름
    private String savedName;

    // 경로
    private String path;

    // 생성일
    private LocalDateTime createAt;

}

 

  • JPA 엔티티로, DB의 파일 정보를 저장하는 테이블과 매핑
  • 저장된 파일 이름(savedName), 원본 이름(fileName), 웹 경로(path), 생성 일시 등 보관
  • 파일 정보의 메타데이터를 담고 있음

 

 

 

FileItem.class

@Getter
@Setter
public class FileItem {

    private String fileName;

    private String url;

    private LocalDateTime createAt;

    public FileItem(FileEntity file) {
        this.fileName = file.getFileName();
        this.createAt = file.getCreateAt();
        this.url = file.getPath() + "/" + file.getSavedName() + "?date=" + this.createAt.toLocalDate().toString();
    }

}

 

  • 사용자에게 보여질 파일 정보 DTO (Data Transfer Object)
  • 파일 URL을 생성해서 index.html에 넘겨줄 때 사용
  • 내부적으로 FileEntity에서 값을 받아 가공하여 생성됨

 

 

FileResponse.class

@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class FileResponse {

    private String contentType;

    private byte[] bytes;

}

 

  • 파일 다운로드 시 응답에 담길 데이터 구조
  • byte[]로 파일 내용을 담고, contentType으로 MIME 타입을 전달
  • ResponseEntity<byte[]>에 감싸져서 실제로 클라이언트에 전송됨

 

 

 

 

 

Service

@Service
@RequiredArgsConstructor
public class FileService {

    private final FileConfig fileConfig;

    private static final long MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB

    public String uploadFile(MultipartFile file) throws IOException {
        if (file.isEmpty()) {
            throw new IOException();
        }

        String contentType = file.getContentType();
        assert contentType != null;
        if (!contentType.startsWith("image/") && !contentType.equals("application/pdf")) {
            throw new IllegalArgumentException("허용되지 않는 파일 타입입니다.");
        }

        if (file.getSize() > MAX_FILE_SIZE) {
            throw new IllegalArgumentException("최대 10MB까지만 업로드 가능합니다.");
        }


        String uuid = UUID.randomUUID().toString();
        // 실제로 저장할 파일 이름(UNIQUE 저장을 위해)
        String savedName = uuid + "_" + file.getOriginalFilename();

        // 해당 날짜의 파일이 존재하지 않을 때 파일 생성
        String dir = fileConfig.getDir() + "\\" + LocalDate.now();
        File fileDir = new File(dir);
        if (!fileDir.exists()) {
            fileDir.mkdirs();
        }

        // 파일 저장하기
        File dest = new File(dir, savedName);
        file.transferTo(dest);

        return savedName;
    }

    public FileResponse getFile(LocalDate date, String name) throws IOException {
        File file = new File(fileConfig.getDir() + "\\" + date, name);

        return FileResponse.builder()
                .bytes(FileCopyUtils.copyToByteArray(file))
                .contentType(Files.probeContentType(file.toPath()))
                .build()
                ;
    }
}
  • 물리적 파일 저장 및 조회 처리
  • 저장할 파일 이름을 UUID_원본이름으로 변환하여 충돌 방지
  • 날짜 기준으로 폴더를 자동 생성하여 파일을 분산 저장
  • 파일을 읽어서 FileResponse로 반환

핵심 역할

  • 파일 시스템의 저장 처리
  • 날짜 기반 경로 생성 및 저장
  • 요청된 파일 반환 (파일 바이트 및 MIME 타입 포함)

 

 

 

@Slf4j
@Service
@RequiredArgsConstructor
public class WebService {

    private final FileRepository fileRepository;
    private final FileConfig fileConfig;
    private final FileService fileService;

    // 파일 업로드
    public void uploadFile(MultipartFile file) throws IOException {
        // 폴더에 파일을 저장하고 저장한 이름을 반환
        String savedName = fileService.uploadFile(file);
        // 파일의 실제 이름 반환(사용자에게 보여주기 위해서)
        String fileName = file.getOriginalFilename();

        // Entity 저장하기
        FileEntity entity = FileEntity.builder()
                .path(fileConfig.getPath())
                .fileName(fileName)
                .savedName(savedName)
                .createAt(LocalDateTime.now())
                .build()
                ;
        fileRepository.save(entity);
    }

    // 모든 파일을 반환하며 LocalDate 기준으로 그룹
    public Map<LocalDate, List<FileItem>> getFiles() {
        List<FileEntity> fileEntities = fileRepository.findAll();

        return fileEntities.stream()
                .map(FileItem::new)
                .collect(Collectors.groupingBy(item -> item.getCreateAt().toLocalDate()));
    }
}
  • 사용자의 요청을 실제로 처리하는 비즈니스 로직 계층
  • FileService를 통해 물리적으로 파일을 저장
  • FileEntity로 DB에 파일 정보 저장
  • 저장된 파일들을 날짜 기준으로 그룹화해서 화면에 보여줌

핵심 역할

  • 파일 업로드 시: 디스크에 저장 → DB에 정보 저장
  • 파일 목록 조회 시: LocalDate 기준으로 그룹핑 후 반환

 

Controller

@Controller
@RequiredArgsConstructor
public class WebController {

    private final WebService webService;
    private final FileService fileService;

    @GetMapping
    public String index(Model model) {
        model.addAttribute("fileMap", webService.getFiles());
        return "index";
    }

    @PostMapping
    public String addFile(
            @RequestParam MultipartFile file
    ) throws IOException {
       webService.uploadFile(file);
       return "redirect:/";
    }

    @GetMapping("/uploads/{fileName}")
    @ResponseBody
    public ResponseEntity<byte[]> getFile(
            @PathVariable String fileName,
            @RequestParam LocalDate date
    ) throws IOException {
        FileResponse response = fileService.getFile(date, fileName);
        HttpHeaders header = new HttpHeaders();
        header.add("Content-Type", response.getContentType());

        return new ResponseEntity<>(response.getBytes(), header, HttpStatus.OK);
    }

}

 

 

 

index.html

<!DOCTYPE html>
<html xmlns:th="http://www.w3.org/1999/xhtml">
<head>
    <meta charset="UTF-8">
    <title>Insert title here</title>
</head>
<body>
<form th:action="@{/}" method="post" enctype="multipart/form-data">
    <input type="file" name="file">
    <input type="submit" value="upload">
</form>
<ul th:each="entry : ${fileMap}">
    <li><strong th:text="${entry.key}">날짜</strong></li>
    <ul>
        <li th:each="file : ${entry.value}">
            <a th:href="${file.url}" th:text="${file.fileName}"></a>
        </li>
    </ul>
</ul>
</body>
</html>

 

'Spring Boot' 카테고리의 다른 글

[Spring Boot] QR Code 발급하기  (0) 2025.05.17
[Spring Boot] Scheduler  (0) 2025.05.01
[Spring Boot] Optional  (0) 2025.04.16
[Spring boot] 프록시 객체(Proxy Object)  (1) 2025.03.29
[Spring Boot] 상속관계 매핑 전략  (0) 2025.03.15