요즘은 스프링에서 파일 업로드가 너무 당연하게 된다.
MultipartFile 한 줄이면 끝나니까, 딱히 어렵게 느껴지지도 않는다.
그런데 조금만 거슬러 올라가보면,
예전에는 파일 업로드가 꽤나 복잡한 작업이었다고 한다.
multipart/form-data 요청을 직접 파싱해야 했고,
파일 이름과 데이터 경계를 바운더리(------abc123)로 일일이 나눠야 했다.
업로드하려면 InputStream을 열고, 바이트 단위로 읽어서 디스크에 써야 했고,
중간에 에러라도 나면 로그를 뒤적이며 원인을 찾아야 했다.
지금처럼 file.transferTo() 한 줄로 끝나는 시대가 오기까지
스프링은 이 과정을 단계적으로 단순화시켜왔다.
서블릿의 Part API → MultipartFile 인터페이스 → 파일 저장과 다운로드 관리
이런 순서로 진화해온다.
오늘은 그 과정을 따라가 보려고 한다.
서블릿에서 직접 파일을 다루던 방식부터 시작해서,
스프링이 제공하는 편리한 기능들,
그리고 마지막엔 실무 예제까지 정리해볼 것이다.
1. 서블릿으로 직접 업로드
가장 기초적인 파일 업로드는 서블릿의 Part API를 이용한다.
요청을 받아서 request.getParts()로 데이터를 직접 꺼내는 방식이다.
@Slf4j
@Controller
@RequestMapping("/servlet/v1")
public class ServletUploadControllerV1 {
@GetMapping("/upload")
public String newFile() {
return "upload-form";
}
@PostMapping("/upload")
public String saveFile(HttpServletRequest request) throws ServletException, IOException {
log.info("request={}", request);
String itemName = request.getParameter("itemName");
log.info("itemName={}", itemName);
Collection<Part> parts = request.getParts();
log.info("parts={}", parts);
for (Part part : parts) {
log.info("==== PART ====");
log.info("name={}", part.getName());
log.info("submittedFileName={}", part.getSubmittedFileName());
log.info("size={}", part.getSize());
InputStream inputStream = part.getInputStream();
String body = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
log.info("body={}", body);
}
return "upload-form";
}
}
- request.getParts() → multipart 요청의 모든 파트를 가져온다.
- part.getSubmittedFileName() → 파일 이름을 가져온다.
- part.getInputStream() → 업로드된 파일 내용을 직접 읽는다.
즉, 파일을 읽는 데까지는 가능하지만 저장은 직접 해야 한다.
업로드 폴더 경로, 중복 파일명, 예외 처리까지 모두 개발자가 관리해야 한다.
작은 프로젝트라면 몰라도, 실무에서는 절대 이렇게 못 쓸 것이다.
2. Part API로 직접 파일 저장하기
그래서 다음 단계에서는 Part.write()를 이용해 직접 파일을 저장해본다.
@Slf4j
@Controller
@RequestMapping("/servlet/v2")
public class ServletUploadControllerV2 {
@Value("${file.dir}")
private String fileDir;
@GetMapping("/upload")
public String newFile() {
return "upload-form";
}
@PostMapping("/upload")
public String saveFile(HttpServletRequest request) throws ServletException, IOException {
String itemName = request.getParameter("itemName");
Collection<Part> parts = request.getParts();
for (Part part : parts) {
if (StringUtils.hasText(part.getSubmittedFileName())) {
String fullPath = fileDir + part.getSubmittedFileName();
log.info("파일 저장 fullPath={}", fullPath);
part.write(fullPath);
}
}
return "upload-form";
}
}
그리고 설정 파일(application.properties)에 저장 경로를 등록한다.
file.dir=C:/Users/****/***/
이렇게 동작한다
- 업로드 요청이 들어오면 서블릿이 Part 단위로 분리한다.
- 파일이 포함된 Part에는 getSubmittedFileName()이 존재한다.
- part.write(fullPath)로 실제 디스크에 저장된다.
하지만 여전히 불편하다
- 파일명 충돌 방지 안 됨
- 여러 파일 처리 불가능
- 컨트롤러가 너무 지저분함
즉, 파일 업로드는 되지만 관리의 지옥이 시작된다.
그래서 스프링은 MultipartFile을 도입했다.
3. 스프링이 제공하는 방식 – MultipartFile
스프링은 MultipartFile이라는 인터페이스를 제공해서
파일 업로드를 아주 쉽게 만들어준다.
@Slf4j
@Controller
@RequestMapping("/spring")
public class SpringUploadController {
@Value("${file.dir}")
private String fileDir;
@GetMapping("/upload")
public String newFile() {
return "upload-form";
}
@PostMapping("/upload")
public String saveFile(@RequestParam String itemName,
@RequestParam MultipartFile file) throws IOException {
log.info("itemName={}", itemName);
log.info("file={}", file);
if (!file.isEmpty()) {
String fullPath = fileDir + file.getOriginalFilename();
log.info("파일 저장 fullPath={}", fullPath);
file.transferTo(new File(fullPath));
}
return "upload-form";
}
}
이 코드의 핵심 포인트
- @RequestParam MultipartFile만 선언하면 스프링이 자동으로 multipart 요청을 파싱한다.
- file.getOriginalFilename()으로 파일명 바로 접근 가능.
- file.transferTo()로 저장까지 한 줄로 끝.
결과적으로
서블릿에서 직접 InputStream으로 읽던 시절과 달리,
이제는 파일 업로드가 단 한 줄로 가능해진다.
이해를 더 높이기 위해서 파일을 저장하고 이미지를 여러개 업로드 할 수 있는 실전 예제를 한번 알아보자.
4. 실전 예제 – 파일 업로드 + 다운로드
요구 사항은 다음과 같다.
1) 상품을 관리: 상품이름, 첨부파일 하나, 이미지 파일 여러개
2) 첨부파일을 다운로드 할 수 있다.
3) 업로드한 이미지를 웹 브라우저에서 확인할 수 있다.
구현하면 다음과 같다.
4-1) 도메인 구성
Item – 상품 도메인
@Data
public class Item {
private Long id;
private String itemName;
private UploadFile attachFile;
private List<UploadFile> imageFiles;
}
설명
- id: 상품 식별자
- itemName: 상품명
- attachFile: 첨부파일 1개 (예: 설명서 PDF)
- imageFiles: 이미지 여러 개 (예: 상품 사진)
상품과 파일의 관계를 단방향으로 설정했다.
하나의 상품이 여러 이미지 파일을 가질 수 있는 구조다.
ItemRepository – 상품 저장소
@Repository
public class ItemRepository {
private final Map<Long, Item> store = new HashMap<>();
private long sequence = 0L;
public Item save(Item item) {
item.setId(++sequence);
store.put(item.getId(), item);
return item;
}
public Item findById(Long id) {
return store.get(id);
}
}
설명
아직 DB 연동 전이라 간단하게 메모리 기반 저장소로 구성했다.
- 상품이 등록될 때 save()로 Map에 저장된다.
- 나중에 조회는 findById()로 바로 꺼내온다.
실무에서는 JPA나 MyBatis로 교체하면 된다.
UploadFile – 업로드된 파일 정보
@Data
public class UploadFile {
private String uploadFileName; // 고객이 올린 이름
private String storeFileName; // 서버 내부 저장용 이름
public UploadFile(String uploadFileName, String storeFileName) {
this.uploadFileName = uploadFileName;
this.storeFileName = storeFileName;
}
}
파일 업로드는 단순히 디스크에 저장한다고 끝이 아니다.
사용자가 업로드한 파일명으로 저장하면 충돌이 생긴다.
예를 들어 고객 A, B가 같은 이름의 파일 a.png를 올리면
서버에선 덮어쓰기 때문에 A의 파일이 사라진다.
그래서 실제 서버에는 이렇게 저장한다.
| 고객 파일명 | a.png |
| 서버 저장 파일명 | 51041c62-86e4-4274-801d-614a7d994edb.png |
즉, uploadFileName은 고객용 표시 이름이고
storeFileName은 서버 내부 식별용 이름이다.
4-2) 파일 저장소 – FileStore
FileStore
@Component
public class FileStore {
@Value("${file.dir}")
private String fileDir;
public String getFullPath(String filename) {
return fileDir + filename;
}
public List<UploadFile> storeFiles(List<MultipartFile> multipartFiles) throws IOException {
List<UploadFile> storeFileResult = new ArrayList<>();
for (MultipartFile multipartFile : multipartFiles) {
if (!multipartFile.isEmpty()) {
storeFileResult.add(storeFile(multipartFile));
}
}
return storeFileResult;
}
public UploadFile storeFile(MultipartFile multipartFile) throws IOException {
if (multipartFile.isEmpty()) {
return null;
}
String originalFilename = multipartFile.getOriginalFilename();
String storeFileName = createStoreFileName(originalFilename);
multipartFile.transferTo(new File(getFullPath(storeFileName)));
return new UploadFile(originalFilename, storeFileName);
}
private String createStoreFileName(String originalFilename) {
String ext = extractExt(originalFilename);
String uuid = UUID.randomUUID().toString();
return uuid + "." + ext;
}
private String extractExt(String originalFilename) {
int pos = originalFilename.lastIndexOf(".");
return originalFilename.substring(pos + 1);
}
}
동작 흐름
- 사용자가 업로드한 파일을 MultipartFile로 받는다.
- 파일이 비어 있지 않으면 내부적으로 저장 파일명을 만든다.
- UUID를 이용해 중복 방지 이름을 생성하고,
transferTo()로 지정된 폴더(file.dir)에 저장한다. - 저장 결과는 UploadFile(uploadName, storeName) 객체로 반환한다.
주요 메서드
| storeFile() | 단일 파일 저장 |
| storeFiles() | 여러 파일 저장 |
| createStoreFileName() | UUID 기반 파일명 생성 |
| extractExt() | 확장자 추출 (.jpg, .png 등) |
4-3) 폼 객체 – ItemForm
상품 등록 시 업로드된 파일 데이터를 함께 담는 객체다.
@Data
public class ItemForm {
private Long itemId;
private String itemName;
private List<MultipartFile> imageFiles;
private MultipartFile attachFile;
}
설명
- attachFile: 첨부파일 1개
- imageFiles: 이미지 여러 개 (다중 업로드용)
MultipartFile은 @ModelAttribute에서도 바로 쓸 수 있다.
따라서 별도의 파일 파싱 로직 없이 폼 데이터로 자동 바인딩된다.
4-4) 컨트롤러 – ItemController
이제 모든 구성요소를 연결하는 컨트롤러를 작성한다.
@Slf4j
@Controller
@RequiredArgsConstructor
public class ItemController {
private final ItemRepository itemRepository;
private final FileStore fileStore;
@GetMapping("/items/new")
public String newItem(@ModelAttribute ItemForm form) {
return "item-form";
}
@PostMapping("/items/new")
public String saveItem(@ModelAttribute ItemForm form,
RedirectAttributes redirectAttributes) throws IOException {
// 파일 저장
UploadFile attachFile = fileStore.storeFile(form.getAttachFile());
List<UploadFile> storeImageFiles = fileStore.storeFiles(form.getImageFiles());
// DB 저장
Item item = new Item();
item.setItemName(form.getItemName());
item.setAttachFile(attachFile);
item.setImageFiles(storeImageFiles);
itemRepository.save(item);
redirectAttributes.addAttribute("itemId", item.getId());
return "redirect:/items/{itemId}";
}
@GetMapping("/items/{id}")
public String items(@PathVariable Long id, Model model) {
Item item = itemRepository.findById(id);
model.addAttribute("item", item);
return "item-view";
}
@ResponseBody
@GetMapping("/images/{filename}")
public Resource downloadImage(@PathVariable String filename) throws MalformedURLException {
return new UrlResource("file:" + fileStore.getFullPath(filename));
}
@GetMapping("/attach/{itemId}")
public ResponseEntity<Resource> downloadAttach(@PathVariable Long itemId)
throws MalformedURLException {
Item item = itemRepository.findById(itemId);
String storeFileName = item.getAttachFile().getStoreFileName();
String uploadFileName = item.getAttachFile().getUploadFileName();
UrlResource resource = new UrlResource("file:" + fileStore.getFullPath(storeFileName));
String encodedUploadFileName = UriUtils.encode(uploadFileName, StandardCharsets.UTF_8);
String contentDisposition = "attachment; filename=\"" + encodedUploadFileName + "\"";
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition)
.body(resource);
}
}
코드 흐름 정리
- /items/new (GET)
→ 상품 등록 폼을 보여준다. - /items/new (POST)
→ 업로드된 파일들을 FileStore를 통해 서버에 저장하고
ItemRepository에 상품 데이터까지 함께 저장한다. - /items/{id}
→ 상품 상세 페이지로 이동한다.
첨부파일과 이미지 목록을 함께 조회한다. - /images/{filename}
→ 이미지 미리보기용.
@ResponseBody + UrlResource로 이미지 파일을 직접 반환한다. - /attach/{itemId}
→ 첨부파일 다운로드.
브라우저가 파일을 직접 다운로드하도록
Content-Disposition 헤더를 설정한다.
정리
| 업로드 | MultipartFile로 자동 처리, FileStore에서 저장 |
| 다중 업로드 | List<MultipartFile>로 여러 파일 업로드 |
| 다운로드 | UrlResource로 파일 리턴, Content-Disposition으로 다운로드 지정 |
| 경로 관리 | file.dir을 application.properties로 설정 |
| 파일명 충돌 방지 | UUID로 내부 파일명 생성 |
마무리하며:
처음엔 파일 업로드를 직접 구현하면서 서블릿의 복잡한 과정을 경험했다.
하지만 스프링은 이런 과정을 모두 추상화해, 훨씬 단순하게 처리할 수 있도록 만들어줬다.
이번 예제를 통해 실제 실무와 비슷한 구조로 업로드와 다운로드 흐름을 구현해보며,
스프링이 내부적으로 어떤 식으로 동작하는지 감을 익힐 수 있었다.
감사합니다.
'스프링' 카테고리의 다른 글
| [스프링] Swagger세팅과 사용 (0) | 2025.11.27 |
|---|---|
| [스프링] 타입 컨버터 (0) | 2025.10.09 |
| [스프링] API 예외 처리 (0) | 2025.10.05 |
| [스프링] 예외 처리와 오류 페이지 (0) | 2025.10.01 |
| [스프링] 로그인 처리 3 - 스프링 인터셉터(Interceptor) (0) | 2025.09.29 |
