회원가입, 게시판, 프로필 사진 등 웹 서비스를 만들다 보면 언젠가는 반드시 마주치게 되는 기능이 있습니다. 바로 파일 업로드입니다. 이번 글에서는 Spring Boot로 서버에 파일을 업로드하고, 업로드된 파일 목록을 조회·다운로드하는 기능을 만드는 과정을 정리해봤습니다.
Spring Initializr에서 프로젝트를 생성하면서 다음 의존성을 추가합니다.
@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);
}
}
파일을 어디에, 어떻게 저장할지는 컨트롤러가 아니라 별도의 서비스 계층으로 분리하는 게 좋습니다.
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 등)에 저장하는 편이 낫습니다.
이제 이 서비스를 사용할 컨트롤러를 작성합니다.
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로 담아 리다이렉트 |
파일을 찾지 못하면 @ExceptionHandler가 StorageFileNotFoundException을 잡아 404 응답을 내려줍니다.
<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>
이 템플릿은 세 부분으로 구성됩니다.
enctype="multipart/form-data"로 설정된 업로드 폼 (이 속성을 빼먹으면 파일이 제대로 전송되지 않으니 주의)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 요청 전체의 최대 크기앱이 시작될 때마다 업로드 폴더를 깨끗하게 비우고 다시 만들고 싶다면, 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();
};
}
}
Gradle
./gradlew bootRun
Maven
./mvnw spring-boot:run
실행 후 http://localhost:8080/에 접속하면 업로드 폼이 보입니다. 작은 파일을 골라 업로드하면 성공 메시지와 함께 목록에 추가된 걸 확인할 수 있고, 설정한 용량을 초과하는 파일을 올리면 에러 페이지가 뜹니다.
서블릿 컨테이너를 직접 띄우지 않고도 MockMvc와 MockitoBean을 이용해 컨트롤러 동작을 검증할 수 있습니다.
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 계층으로 분리한다.spring.servlet.multipart.* 프로퍼티로 업로드 용량 제한을 건다.MockMvc + MockitoBean으로 실제 서버 없이도 업로드/조회/예외 처리를 테스트한다.파일 업로드는 프로필 사진, 첨부파일, 게시판 등 실무에서 정말 자주 쓰이는 기능이라, 이 기본 패턴에 스토리지를 S3나 별도 파일 서버로 바꾸는 식으로 확장해서 응용하면 좋을 것 같습니다.