5단계 Uploading Files

Back·2026년 7월 27일

회원가입, 게시판, 프로필 사진 등 웹 서비스를 만들다 보면 언젠가는 반드시 마주치게 되는 기능이 있습니다. 바로 파일 업로드입니다. 이번 글에서는 Spring Boot로 서버에 파일을 업로드하고, 업로드된 파일 목록을 조회·다운로드하는 기능을 만드는 과정을 정리해봤습니다.

무엇을 만들 것인가

  • 파일을 업로드할 수 있는 웹 애플리케이션
  • 업로드된 파일 목록을 보여주고, 각 파일을 다운로드할 수 있는 화면

사전 준비물

  • Java 17 이상
  • Gradle 7.5+ 또는 Maven 3.5+
  • 소요 시간 약 15분

프로젝트 시작하기

Spring Initializr에서 프로젝트를 생성하면서 다음 의존성을 추가합니다.

  • Spring Web
  • Thymeleaf

1. 애플리케이션 클래스

@SpringBootApplication 어노테이션 하나만 있으면 됩니다. Spring Boot가 클래스패스에서 spring-webmvc를 감지하면, 파일 업로드에 필요한 MultipartConfigElement(서블릿 컨테이너에서 멀티파트 요청을 처리하기 위한 설정)를 자동으로 구성해주기 때문입니다. 별도의 web.xml 설정은 필요 없습니다.

package com.example.uploadingfiles;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class UploadingFilesApplication {

  public static void main(String[] args) {
    SpringApplication.run(UploadingFilesApplication.class, args);
  }

}

2. 저장 로직을 담당할 StorageService

파일을 어디에, 어떻게 저장할지는 컨트롤러가 아니라 별도의 서비스 계층으로 분리하는 게 좋습니다.

package com.example.uploadingfiles.storage;

import org.springframework.core.io.Resource;
import org.springframework.web.multipart.MultipartFile;

import java.nio.file.Path;
import java.util.stream.Stream;

public interface StorageService {

  void init();

  void store(MultipartFile file);

  Stream<Path> loadAll();

  Path load(String filename);

  Resource loadAsResource(String filename);

  void deleteAll();

}

이 인터페이스를 구현하는 FileSystemStorageService는 로컬 디스크에 파일을 저장합니다. 이때 눈여겨볼 부분은 보안 체크입니다. 저장 경로가 지정된 루트 디렉토리를 벗어나지 않는지 검증해서, 악의적인 파일명(../../etc/passwd 같은)으로 다른 경로에 파일이 쓰이는 걸 막아줍니다.

package com.example.uploadingfiles.storage;

import java.io.IOException;
import java.io.InputStream;
import java.net.MalformedURLException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.util.stream.Stream;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import org.springframework.stereotype.Service;
import org.springframework.util.FileSystemUtils;
import org.springframework.web.multipart.MultipartFile;

@Service
public class FileSystemStorageService implements StorageService {

  private final Path rootLocation;

  @Autowired
  public FileSystemStorageService(StorageProperties properties) {

        if(properties.getLocation().trim().length() == 0){
            throw new StorageException("File upload location can not be Empty.");
        }

    this.rootLocation = Paths.get(properties.getLocation());
  }

  @Override
  public void store(MultipartFile file) {
    try {
      if (file.isEmpty()) {
        throw new StorageException("Failed to store empty file.");
      }
      Path destinationFile = this.rootLocation.resolve(
          Paths.get(file.getOriginalFilename()))
          .normalize().toAbsolutePath();
      if (!destinationFile.getParent().equals(this.rootLocation.toAbsolutePath())) {
        // 저장 루트 밖으로 나가지 못하게 막는 보안 체크
        throw new StorageException(
            "Cannot store file outside current directory.");
      }
      try (InputStream inputStream = file.getInputStream()) {
        Files.copy(inputStream, destinationFile,
          StandardCopyOption.REPLACE_EXISTING);
      }
    }
    catch (IOException e) {
      throw new StorageException("Failed to store file.", e);
    }
  }

  @Override
  public Stream<Path> loadAll() {
    try {
      return Files.walk(this.rootLocation, 1)
        .filter(path -> !path.equals(this.rootLocation))
        .map(this.rootLocation::relativize);
    }
    catch (IOException e) {
      throw new StorageException("Failed to read stored files", e);
    }
  }

  @Override
  public Path load(String filename) {
    return rootLocation.resolve(filename);
  }

  @Override
  public Resource loadAsResource(String filename) {
    try {
      Path file = load(filename);
      Resource resource = new UrlResource(file.toUri());
      if (resource.exists() || resource.isReadable()) {
        return resource;
      }
      else {
        throw new StorageFileNotFoundException(
            "Could not read file: " + filename);
      }
    }
    catch (MalformedURLException e) {
      throw new StorageFileNotFoundException("Could not read file: " + filename, e);
    }
  }

  @Override
  public void deleteAll() {
    FileSystemUtils.deleteRecursively(rootLocation.toFile());
  }

  @Override
  public void init() {
    try {
      Files.createDirectories(rootLocation);
    }
    catch (IOException e) {
      throw new StorageException("Could not initialize storage", e);
    }
  }
}

이 밖에 다음과 같은 보조 클래스들이 함께 필요합니다.

  • StorageProperties: storage.location 프로퍼티로 저장 폴더 위치를 설정 (@ConfigurationProperties("storage"))
  • StorageException: 저장 관련 런타임 예외
  • StorageFileNotFoundException: 파일을 찾지 못했을 때의 예외

실무 팁: 실제 서비스에서는 파일을 애플리케이션 서버의 로컬 디스크에 직접 쌓기보다, 별도의 임시 저장소나 DB, 또는 오브젝트 스토리지(MongoDB GridFS 등)에 저장하는 편이 낫습니다.

3. FileUploadController

이제 이 서비스를 사용할 컨트롤러를 작성합니다.

package com.example.uploadingfiles;

import java.util.stream.Collectors;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.MvcUriComponentsBuilder;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;

import com.example.uploadingfiles.storage.StorageFileNotFoundException;
import com.example.uploadingfiles.storage.StorageService;

@Controller
public class FileUploadController {

  private final StorageService storageService;

  @Autowired
  public FileUploadController(StorageService storageService) {
    this.storageService = storageService;
  }

  @GetMapping("/")
  public String listUploadedFiles(Model model) {

    model.addAttribute("files", storageService.loadAll().map(
        path -> MvcUriComponentsBuilder.fromMethodName(FileUploadController.class,
            "serveFile", path.getFileName().toString()).build().toUri().toString())
        .collect(Collectors.toList()));

    return "uploadForm";
  }

  @GetMapping("/files/{filename:.+}")
  @ResponseBody
  public ResponseEntity<Resource> serveFile(@PathVariable String filename) {

    Resource file = storageService.loadAsResource(filename);

    if (file == null)
      return ResponseEntity.notFound().build();

    return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION,
        "attachment; filename=\"" + file.getFilename() + "\"").body(file);
  }

  @PostMapping("/")
  public String handleFileUpload(@RequestParam("file") MultipartFile file,
      RedirectAttributes redirectAttributes) {

    storageService.store(file);
    redirectAttributes.addFlashAttribute("message",
        "You successfully uploaded " + file.getOriginalFilename() + "!");

    return "redirect:/";
  }

  @ExceptionHandler(StorageFileNotFoundException.class)
  public ResponseEntity<?> handleStorageFileNotFound(StorageFileNotFoundException exc) {
    return ResponseEntity.notFound().build();
  }

}

각 매핑의 역할을 표로 정리하면 다음과 같습니다.

메서드경로역할
GET/업로드된 파일 목록을 조회해서 각 파일의 다운로드 링크와 함께 뷰에 전달
GET/files/{filename}파일을 Content-Disposition: attachment 헤더와 함께 응답 (다운로드)
POST/업로드된 MultipartFile을 받아 저장하고, 성공 메시지를 flash attribute로 담아 리다이렉트

파일을 찾지 못하면 @ExceptionHandlerStorageFileNotFoundException을 잡아 404 응답을 내려줍니다.

4. HTML 템플릿 - uploadForm.html

<html xmlns:th="https://www.thymeleaf.org">
<body>

	<div th:if="${message}">
		<h2 th:text="${message}"/>
	</div>

	<div>
		<form method="POST" enctype="multipart/form-data" action="/">
			<table>
				<tr><td>File to upload:</td><td><input type="file" name="file" /></td></tr>
				<tr><td></td><td><input type="submit" value="Upload" /></td></tr>
			</table>
		</form>
	</div>

	<div>
		<ul>
			<li th:each="file : ${files}">
				<a th:href="${file}" th:text="${file}" />
			</li>
		</ul>
	</div>

</body>
</html>

이 템플릿은 세 부분으로 구성됩니다.

  1. 업로드 성공 시 표시되는 flash 메시지
  2. enctype="multipart/form-data"로 설정된 업로드 폼 (이 속성을 빼먹으면 파일이 제대로 전송되지 않으니 주의)
  3. 업로드된 파일 목록과 각 파일로 이동하는 링크

5. 업로드 용량 제한 설정

5GB짜리 파일이 덜컥 업로드되는 상황을 막으려면 용량 제한을 걸어둬야 합니다. application.properties에 다음을 추가합니다.

spring.servlet.multipart.max-file-size=128KB
spring.servlet.multipart.max-request-size=128KB
  • max-file-size: 파일 하나의 최대 크기
  • max-request-size: multipart/form-data 요청 전체의 최대 크기

6. 시작 시 업로드 폴더 초기화

앱이 시작될 때마다 업로드 폴더를 깨끗하게 비우고 다시 만들고 싶다면, CommandLineRunner 빈을 등록합니다.

package com.example.uploadingfiles;

import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;

import com.example.uploadingfiles.storage.StorageProperties;
import com.example.uploadingfiles.storage.StorageService;

@SpringBootApplication
@EnableConfigurationProperties(StorageProperties.class)
public class UploadingFilesApplication {

  public static void main(String[] args) {
    SpringApplication.run(UploadingFilesApplication.class, args);
  }

  @Bean
  CommandLineRunner init(StorageService storageService) {
    return (args) -> {
      storageService.deleteAll();
      storageService.init();
    };
  }
}

7. 애플리케이션 실행

Gradle

./gradlew bootRun

Maven

./mvnw spring-boot:run

실행 후 http://localhost:8080/에 접속하면 업로드 폼이 보입니다. 작은 파일을 골라 업로드하면 성공 메시지와 함께 목록에 추가된 걸 확인할 수 있고, 설정한 용량을 초과하는 파일을 올리면 에러 페이지가 뜹니다.

8. 테스트 코드

서블릿 컨테이너를 직접 띄우지 않고도 MockMvcMockitoBean을 이용해 컨트롤러 동작을 검증할 수 있습니다.

package com.example.uploadingfiles;

import java.nio.file.Paths;
import java.util.stream.Stream;

import org.hamcrest.Matchers;
import org.junit.jupiter.api.Test;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;

import static org.mockito.BDDMockito.given;
import static org.mockito.BDDMockito.then;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import com.example.uploadingfiles.storage.StorageFileNotFoundException;
import com.example.uploadingfiles.storage.StorageService;

@SpringBootTest
@AutoConfigureMockMvc
public class FileUploadTests {

  @Autowired
  private MockMvc mvc;

  @MockitoBean
  private StorageService storageService;

  @Test
  public void shouldListAllFiles() throws Exception {
    given(this.storageService.loadAll())
        .willReturn(Stream.of(Paths.get("first.txt"), Paths.get("second.txt")));

    this.mvc.perform(get("/")).andExpect(status().isOk())
        .andExpect(model().attribute("files",
            Matchers.contains("http://localhost/files/first.txt",
                "http://localhost/files/second.txt")));
  }

  @Test
  public void shouldSaveUploadedFile() throws Exception {
    MockMultipartFile multipartFile = new MockMultipartFile("file", "test.txt",
        "text/plain", "Spring Framework".getBytes());
    this.mvc.perform(multipart("/").file(multipartFile))
        .andExpect(status().isFound())
        .andExpect(header().string("Location", "/"));

    then(this.storageService).should().store(multipartFile);
  }

  @SuppressWarnings("unchecked")
  @Test
  public void should404WhenMissingFile() throws Exception {
    given(this.storageService.loadAsResource("test.txt"))
        .willThrow(StorageFileNotFoundException.class);

    this.mvc.perform(get("/files/test.txt")).andExpect(status().isNotFound());
  }

}

각 테스트가 검증하는 내용은 다음과 같습니다.

  • shouldListAllFiles: 목록 조회 시 파일 URL이 올바르게 모델에 담기는지
  • shouldSaveUploadedFile: 업로드 요청이 들어오면 storageService.store()가 정확한 인자로 호출되는지
  • should404WhenMissingFile: 존재하지 않는 파일을 요청하면 404가 반환되는지

정리

이번 예제의 핵심 흐름을 정리하면 다음과 같습니다.

  • MultipartFile 타입 파라미터로 업로드된 파일을 받는다.
  • 저장/조회/삭제 로직은 컨트롤러가 아니라 별도의 StorageService 계층으로 분리한다.
  • 저장 시 경로 탈출(path traversal) 공격을 막기 위한 검증 로직을 반드시 넣는다.
  • spring.servlet.multipart.* 프로퍼티로 업로드 용량 제한을 건다.
  • MockMvc + MockitoBean으로 실제 서버 없이도 업로드/조회/예외 처리를 테스트한다.

파일 업로드는 프로필 사진, 첨부파일, 게시판 등 실무에서 정말 자주 쓰이는 기능이라, 이 기본 패턴에 스토리지를 S3나 별도 파일 서버로 바꾸는 식으로 확장해서 응용하면 좋을 것 같습니다.

0개의 댓글