본문 바로가기
Spring MVC

[MVC] 파일 업로드

by 개발 Blog 2025. 12. 21.

공부 내용을 정리하고 앞으로의 학습에 이해를 돕기 위해 작성합니다.

 

서블릿과 파일 업로드1

서블릿을 통한 파일 업로드 방식을 살펴본다.

 

ServletUploadControllerV1

package hello.upload.controller;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.Part;
import java.io.IOException;
import java.util.Collection;

@Slf4j
@Controller
@RequestMapping("/servlet/v1")
public class ServletUploadControllerV1 {

    @GetMapping("/upload")
    public String newFile() {
        return "upload-form";
    }

    @PostMapping("/upload")
    public String saveFileV1(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);

        return "upload-form";
    }
}
  • request.getParts()는 multipart/form-data 방식으로 전송된 요청에서 각각의 부분을 Part 객체로 받을 수 있다.

upload-form.html

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="utf-8">
</head>
<body>
<div class="container">
    <div class="py-5 text-center">
        <h2>상품 등록 폼</h2>
    </div>
    <h4 class="mb-3">상품 입력</h4>
    <form th:action method="post" enctype="multipart/form-data">
        <ul>
            <li>상품명 <input type="text" name="itemName"></li>
            <li>파일 <input type="file" name="file"></li>
        </ul>
        <input type="submit"/>
    </form>
</div>
</body>
</html>

 

테스트 전 application.properties에 다음 옵션을 추가한다.

logging.level.org.apache.coyote.http11=trace

 

스프링 부트 3.2부터는 debug 대신 trace를 사용해야 HTTP 요청 로그가 출력된다.
이 옵션을 사용하면 실제 전송되는 multipart/form-data 요청 메시지를 확인할 수 있다.

 

실행 주소

http://localhost:8080/servlet/v1/upload

 

로그 예시

Content-Type: multipart/form-data; boundary=----xxxx
------xxxx
Content-Disposition: form-data; name="itemName"
Spring
------xxxx
Content-Disposition: form-data; name="file"; filename="test.data"
Content-Type: application/octet-stream
sdklajkljdf...

 

멀티파트 업로드 설정

 

업로드 사이즈 제한

spring.servlet.multipart.max-file-size=1MB
spring.servlet.multipart.max-request-size=10MB
  • max-file-size는 단일 파일의 최대 크기, 기본 1MB이다.
  • max-request-size는 한 요청 내 전체 파일 크기의 합으로 기본 10MB이다.
  • 제한을 초과하면 SizeLimitExceededException이 발생한다.

멀티파트 처리 비활성화

spring.servlet.multipart.enabled=false
  • 이 옵션을 끄면 서블릿 컨테이너는 multipart 요청을 일반 폼 요청처럼 처리한다.

이 경우 로그 결과는 다음과 같다.

request=org.apache.catalina.connector.RequestFacade@xxx
itemName=null
parts=[]
  • application/x-www-form-urlencoded 방식처럼 단순한 폼 요청으로 처리되어 멀티파트 정보가 비어 있게 된다.

멀티파트 처리 활성화

spring.servlet.multipart.enabled=true
  • 기본값은 true이며, 이 옵션을 켜면 스프링 부트가 서블릿 컨테이너에 멀티파트 처리를 위임한다.

결과 로그는 다음과 같다.

request=org.springframework.web.multipart.support.StandardMultipartHttpServletRequest
itemName=Spring
parts=[ApplicationPart1, ApplicationPart2]
  • itemName 값이 정상적으로 출력되고, request.getParts()에 업로드한 파일과 텍스트 입력이 포함되어 있다.

이때 HttpServletRequest가 RequestFacade에서 StandardMultipartHttpServletRequest로 바뀌는 것을 확인할 수 있다.
이는 스프링이 DispatcherServlet 내부에서 MultipartResolver를 실행했기 때문이다.


MultipartResolver는 멀티파트 요청을 감지하면 일반 HttpServletRequest를 MultipartHttpServletRequest로 변환한다.

 

MultipartHttpServletRequest는 HttpServletRequest를 확장한 인터페이스로 멀티파트 관련 기능을 추가로 제공한다.
스프링 부트의 기본 멀티파트 리졸버는 StandardMultipartHttpServletRequest를 반환한다.

 

이제 컨트롤러에서 HttpServletRequest 대신 MultipartHttpServletRequest를 주입받을 수도 있지만,
실무에서는 이후 설명할 MultipartFile 방식을 사용하는 것이 더 편리하므로 거의 사용하지 않는다.
자세한 내용은 MultipartResolver를 참고한다.

 

서블릿과 파일 업로드2

서블릿이 제공하는 Part 객체를 통해 실제 파일을 서버에 업로드하는 과정을 살펴본다.

파일 업로드를 위해 먼저 파일이 저장될 실제 경로를 지정해야 한다.

 

이 경로에 폴더를 미리 만들어 두고, application.properties에 등록한다.

 

application.properties

file.dir=/Users/eunchan/study/file/

주의

  1. 반드시 실제로 존재하는 폴더 경로여야 한다.
  2. 마지막에 슬래시(/)를 포함해야 한다.

ServletUploadControllerV2

package hello.upload.controller;

import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Controller;
import org.springframework.util.StreamUtils;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.Part;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.Collection;

@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 saveFileV1(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());
            Collection<String> headerNames = part.getHeaderNames();
            for (String headerName : headerNames) {
                log.info("header {}: {}", headerName, part.getHeader(headerName));
            }

            // 편의 메서드
            log.info("submittedFileName={}", part.getSubmittedFileName());
            log.info("size={}", part.getSize());

            // 데이터 읽기
            InputStream inputStream = part.getInputStream();
            String body = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);
            log.info("body={}", body);

            // 파일 저장
            if (StringUtils.hasText(part.getSubmittedFileName())) {
                String fullPath = fileDir + part.getSubmittedFileName();
                log.info("파일 저장 fullPath={}", fullPath);
                part.write(fullPath);
            }
        }
        return "upload-form";
    }
}

설명

  • @Value("${file.dir}") : application.properties에 설정된 file.dir 값을 주입한다.
  • 멀티파트 전송은 데이터를 여러 Part로 나누어 전송하며, request.getParts()로 접근할 수 있다.
  • Part는 멀티파트 데이터를 읽고 저장할 수 있는 다양한 메서드를 제공한다.

Part 주요 메서드

  • part.getSubmittedFileName() : 업로드된 파일 이름을 반환한다.
  • part.getInputStream() : 업로드된 파일 데이터를 읽을 수 있다.
  • part.write(...) : 파일을 지정 경로에 저장한다.

실행
http://localhost:8080/servlet/v2/upload

 

전송 데이터

  • itemName: 상품A
  • file: 스크린샷.png

결과 로그

==== PART ====
name=itemName
header content-disposition: form-data; name="itemName"
submittedFileName=null
size=7
body=상품A
==== PART ====
name=file
header content-disposition: form-data; name="file"; filename="스크린샷.png"
header content-type: image/png
submittedFileName=스크린샷.png
size=112384
body=qwlkjek2ljlese...
파일 저장 fullPath=/Users/kimyounghan/study/file/스크린샷.png

파일 저장 경로에 실제 이미지가 저장된 것을 확인할 수 있다. 저장이 되지 않았다면 file.dir 설정 경로를 다시 확인한다.

참고

  • 큰 용량의 파일을 테스트할 경우 다음 설정을 끄면 로그가 과도하게 출력되지 않는다.
logging.level.org.apache.coyote.http11=trace
  • 또한 파일의 바이너리 데이터를 출력하는 log.info("body={}", body); 부분은 주석 처리하는 것이 좋다.

정리

서블릿의 Part는 멀티파트 처리를 직접 다룰 수 있는 유용한 기능을 제공하지만,
HttpServletRequest를 직접 사용해야 하고 파일 파트만 구분하려면 코드가 복잡해진다.
다음 단계에서는 스프링이 제공하는 MultipartFile을 사용해 더 간단하게 파일 업로드를 처리한다.

 

스프링과 파일 업로드

스프링은 MultipartFile 인터페이스를 통해 멀티파트 파일 업로드를 매우 간단하게 처리할 수 있다.

 

SpringUploadController

package hello.upload.controller;

import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.multipart.MultipartFile;
import jakarta.servlet.http.HttpServletRequest;
import java.io.File;
import java.io.IOException;

@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,
                           HttpServletRequest request) throws IOException {
        log.info("request={}", request);
        log.info("itemName={}", itemName);
        log.info("multipartFile={}", file);

        if (!file.isEmpty()) {
            String fullPath = fileDir + file.getOriginalFilename();
            log.info("파일 저장 fullPath={}", fullPath);
            file.transferTo(new File(fullPath));
        }
        return "upload-form";
    }
}

설명

스프링에서는 MultipartFile을 사용하면 HttpServletRequest나 Part를 직접 다룰 필요 없이 파일 업로드를 단순화할 수 있다.
@RequestParam MultipartFile file 은 HTML form의 name 속성과 매핑되어 자동으로 파일을 받아준다.
@ModelAttribute 로 바인딩할 때도 동일하게 MultipartFile을 사용할 수 있다.

 

MultipartFile 주요 메서드

  • getOriginalFilename() : 클라이언트가 업로드한 파일명
  • transferTo(...) : 지정한 경로로 파일 저장

실행
http://localhost:8080/spring/upload

 

결과 로그

request=org.springframework.web.multipart.support.StandardMultipartHttpServletRequest@5c022dc6
itemName=상품A
multipartFile=org.springframework.web.multipart.support.StandardMultipartHttpServletRequest$StandardMultipartFile@274ba730
파일 저장 fullPath=/Users/kimyounghan/study/file/스크린샷.png

 

정리

서블릿의 Part API보다 훨씬 간결하게 파일 업로드를 구현할 수 있다.
MultipartFile을 사용하면 요청 파싱, 인코딩, 파일 저장 로직 등을 스프링이 대신 처리해주며,
개발자는 필요한 비즈니스 로직에만 집중할 수 있다.

 

예제로 구현하는 파일 업로드, 다운로드

상품 관리 화면에서 파일 업로드와 다운로드를 구현한다. 첨부파일 1개와 이미지 파일 여러 개를 저장하고, 첨부파일은 다운로드, 이미지는 브라우저에서 바로 확인 가능하게 만든다.

 

요구사항 정리

  • 상품을 관리한다
    • 상품 이름을 가진다
    • 첨부파일 1개를 가진다
    • 이미지 파일 여러 개를 가진다
  • 첨부파일을 업로드하고 다운로드할 수 있어야 한다
  • 업로드한 이미지를 웹 브라우저에서 볼 수 있어야 한다

Item 상품 도메인

상품은 상품명과 함께 첨부파일 1개, 이미지 파일 리스트를 가진다.

package hello.upload.domain;

import lombok.Data;
import java.util.List;

@Data
public class Item {
    private Long id;
    private String itemName;
    private UploadFile attachFile;
    private List<UploadFile> imageFiles;
}

 

ItemRepository 상품 리포지토리

예제에서는 DB 대신 메모리 Map으로 저장한다. save 시 시퀀스를 증가시켜 id를 만들고 저장한다. findById로 조회한다.

package hello.upload.domain;

import org.springframework.stereotype.Repository;
import java.util.HashMap;
import java.util.Map;

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

 

UploadFile 업로드 파일 정보

업로드 파일명과 서버 저장 파일명을 분리해서 관리한다. uploadFileName은 고객이 업로드한 원본 파일명이고, storeFileName은 서버 내부에서 저장에 사용하는 파일명이다. 원본 파일명으로 서버에 저장하면 같은 이름 업로드 시 파일이 덮어써지는 문제가 생기기 때문에 서버에서는 유일한 파일명으로 저장해야 한다.

package hello.upload.domain;

import lombok.Data;

@Data
public class UploadFile {
    private String uploadFileName;
    private String storeFileName;

    public UploadFile(String uploadFileName, String storeFileName) {
        this.uploadFileName = uploadFileName;
        this.storeFileName = storeFileName;
    }
}

 

FileStore 파일 저장 처리

MultipartFile을 실제 디스크에 저장하고 UploadFile을 만들어 반환한다. 서버 저장 파일명은 UUID로 생성해 충돌을 방지하고, 확장자는 원본 파일명에서 추출해서 서버 파일명에도 붙인다. 다중 업로드는 리스트를 반복하면서 비어있지 않은 파일만 저장한다.

package hello.upload.file;

import hello.upload.domain.UploadFile;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;

import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;

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

 

ItemForm 상품 저장 폼

폼 전송 데이터를 한 번에 받기 위한 객체이다. 첨부파일 1개는 MultipartFile로 받는다. 이미지 다중 업로드는 List<MultipartFile>로 받는다. 멀티파트는 ModelAttribute 바인딩으로도 받을 수 있다.

package hello.upload.controller;

import lombok.Data;
import org.springframework.web.multipart.MultipartFile;

import java.util.List;

@Data
public class ItemForm {
    private Long itemId;
    private String itemName;
    private List<MultipartFile> imageFiles;
    private MultipartFile attachFile;
}

 

ItemController 전체 흐름

등록 폼 조회, 저장 처리, 상품 조회, 이미지 조회, 첨부 다운로드를 제공한다. 저장 시에는 FileStore를 통해 첨부파일 1개와 이미지 여러 개를 각각 저장하고, 반환된 UploadFile 메타데이터를 Item에 담아 저장소에 저장한 뒤 상세 조회 화면으로 리다이렉트한다. 이미지는 img 태그에서 접근할 수 있도록 파일 리소스를 그대로 반환하는 엔드포인트를 만들고, 첨부 다운로드는 Content-Disposition 헤더를 attachment로 내려서 다운로드로 동작하게 만든다. 이때 다운로드 파일명은 storeFileName이 아니라 uploadFileName을 쓰는 편이 사용자 입장에서 자연스럽고, 한글 파일명은 인코딩 처리로 깨짐을 방지한다.

package hello.upload.controller;

import hello.upload.domain.Item;
import hello.upload.domain.ItemRepository;
import hello.upload.domain.UploadFile;
import hello.upload.file.FileStore;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
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.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import org.springframework.web.util.UriUtils;

import java.io.IOException;
import java.net.MalformedURLException;
import java.nio.charset.StandardCharsets;
import java.util.List;

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

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

 

등록 폼 뷰 item-form.html

resources/templates/item-form.html에서 form 전송을 multipart/form-data로 처리해야 파일이 서버로 전송된다. 첨부파일은 단일 input file로 받고, 이미지 파일은 multiple="multiple" 옵션을 주면 한 번에 여러 파일을 선택해 전송할 수 있다. 이때 input name이 imageFiles라서 ItemForm의 List<MultipartFile> imageFiles로 바인딩된다.

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="utf-8">
</head>
<body>
<div class="container">
    <div class="py-5 text-center">
        <h2>상품 등록</h2>
    </div>

    <form th:action method="post" enctype="multipart/form-data">
        <ul>
            <li>상품명 <input type="text" name="itemName"></li>
            <li>첨부파일 <input type="file" name="attachFile"></li>
            <li>이미지 파일들 <input type="file" multiple="multiple" name="imageFiles"></li>
        </ul>
        <input type="submit"/>
    </form>
</div> <!-- /container -->
</body>
</html>

 

조회 뷰 item-view.html

resources/templates/item-view.html에서 첨부파일은 다운로드 링크로 제공하고 /attach/{itemId}를 호출하도록 th:href를 구성한다. 링크 텍스트로 파일명이 보이게 하려면 a 태그를 self-closing으로 쓰지 말고 닫는 태그를 사용해야 한다. 이미지는 imageFiles를 반복하면서 img 태그 src에 /images/{storeFileName}을 넣으면 브라우저가 이미지를 직접 요청해 화면에 렌더링한다.

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="utf-8">
</head>
<body>
<div class="container">
    <div class="py-5 text-center">
        <h2>상품 조회</h2>
    </div>

    상품명: <span th:text="${item.itemName}">상품명</span><br/>

    첨부파일:
    <a th:if="${item.attachFile}"
       th:href="|/attach/${item.id}|">
        <span th:text="${item.attachFile.uploadFileName}">파일명</span>
    </a>
    <br/>

    <img th:each="imageFile : ${item.imageFiles}"
         th:src="|/images/${imageFile.storeFileName}|"
         width="300" height="300"/>
</div> <!-- /container -->
</body>
</html>

 

실행

http://localhost:8080/items/new 에서 상품명, 첨부파일 1개, 이미지 여러 개를 업로드한다. 저장 후 조회 화면에서 첨부파일은 파일명

이 보이는 다운로드 링크로 내려받을 수 있고, 이미지 파일은 화면에서 바로 렌더링 된다.

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

[MVC] 스프링 타입 컨버터(2)  (0) 2025.11.26
[MVC] 스프링 타입 컨버터(1)  (0) 2025.10.26
[MVC] API 예외 처리(2)  (1) 2025.09.22
[MVC] API 예외 처리(1)  (0) 2025.09.10
[MVC] 예외 처리와 오류 페이지  (0) 2025.09.06