Java Category/Spring

[Spring MVC] 파일 업로드

ReBugs 2024. 3. 25.

이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다.


파일 업로드

일반적으로 사용하는 HTML Form을 통한 파일 업로드를 이해하려면 먼저 폼을 전송하는 다음 두 가지 방식의 차이를 이해해야 한다.

HTML 폼 전송 방식

 

  • application/x-www-form-urlencoded
  • multipart/form-data

 

application/x-www-form-urlencoded 방식

application/x-www-form-urlencoded 방식은 HTML 폼 데이터를 서버로 전송하는 가장 기본적인 방법이다.

Form 태그에 별도의 enctype 옵션이 없으면 웹 브라우저는 요청 HTTP 메시지의 헤더에 다음 내용을 추가한다.

 

→Content-Type: application/x-www-form-urlencoded

 

그리고 폼에 입력한 전송할 항목을 HTTP Body에 문자로 username=kim&age=20 와 같이 & 로 구분해서 전송한다.

파일을 업로드 하려면 파일은 문자가 아니라 바이너리 데이터를 전송해야 한다. 문자를 전송하는 이 방식으로 파일을 전
송하기는 어렵다.

 

그리고 또 한가지 문제가 더 있는데, 보통 폼을 전송할 때 파일만 전송하는 것이 아니라는 점이다.

이름과 나이 등도 전송해야 하고, 첨부파일도 함께 전송해야 한다. 문제는 이름과 나이는 문자로 전송하고, 첨부 파일은 바이너리로 전송해야 한다는 점이다.

여기에서 문제가 발생한다. 문자와 바이너리를 동시에 전송해야 하는 상황 이다.
이 문제를 해결하기 위해 HTTP는 multipart/form-data 라는 전송 방식을 제공한다.

 

multipart/form-data 방식

이 방식을 사용하려면 Form 태그에 별도의 enctype="multipart/form-data" 를 지정해야 한다.

multipart/form-data 방식은 다른 종류의 여러 파일과 폼의 내용 함께 전송할 수 있다.

폼의 입력 결과로 생성된 HTTP 메시지를 보면 각각의 전송 항목이 구분이 되어있다.

Content-Disposition 이라 는 항목별 헤더가 추가되어 있고 여기에 부가 정보가 있다.

 

예제에서는 username, age, file1 이 각각 분리되어 있고, 폼의 일반 데이터는 각 항목별로 문자가 전송되고, 파일의 경우 파일 이름과 Content-Type이 추가되고 바이너리 데이터가 전송된다.

 

multipart/form-data 는 이렇게 각각의 항목을 구분해서, 한번에 전송하는 것이다.

 

Part

multipart/form-data 는 application/x-www-form-urlencoded 와 비교해서 매우 복잡하고 각각의 부
분( Part )로 나누어져 있다.

 

 

서블릿과 파일 업로드

컨트롤러

@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 전송 방식에서 각각 나누어진 부분을 받아서 확인할 수 있다.

 

뷰(타임리프)

<!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> <!-- /container -->
    </body>
</html>

 

 

 

HTTP 요청 메시지 확인

application.properties
→logging.level.org.apache.coyote.http11=trace

 

멀티파트 사용 옵션

업로드 사이즈 제한

application.properties

 

  • spring.servlet.multipart.max-file-size=1MB
  • spring.servlet.multipart.max-request-size=10MB

 

큰 파일을 무제한 업로드하게 둘 수는 없으므로 업로드 사이즈를 제한할 수 있다. 사이즈를 넘으면 예외(SizeLimitExceededException )가 발생한다.

 

  • max-file-size : 파일 하나의 최대 사이즈, 기본 1MB
  • max-request-size : 멀티파트 요청 하나에 여러 파일을 업로드 할 수 있는데, 그 전체 합(기본 10MB)

 

멀티파트 옵션

application.properties

spring.servlet.multipart.enabled=true

 

spring.servlet.multipart.enabled 옵션을 켜면 스프링의 DispatcherServlet 에서 멀티 파트 리졸버(MultipartResolver)를 실행한다.
멀티파트 리졸버는 멀티파트 요청인 경우 서블릿 컨테이너가 전달하는 일반적인 HttpServletRequest를 MultipartHttpServletRequest 로 변환해서 반환한다.

MultipartHttpServletRequest 는 HttpServletRequest 의 자식 인터페이스이고, 멀티파트와 관련된 추가 기능을 제공한다.
스프링이 제공하는 기본 멀티파트 리졸버는 MultipartHttpServletRequest 인터페이스를 구현한StandardMultipartHttpServletRequest 를 반환한다.

그러면 컨트롤러에서 HttpServletRequest 대신에 MultipartHttpServletRequest 를 주입받을 수 있는데, 이것을 사용하면 멀티파트와 관련된 여러가지 처리를 편리하게 할 수 있다.

 

그런데 MultipartFile 이라는 것을 사용하는 것이 더 편하기 때문에 MultipartHttpServletRequest 를 잘 사용하지는 않는다.

 

파일 업로드 경로 설정

application.properties

file.dir=파일이 저장될 경로/
  • 해당 경로에 실제 폴더가 있어야 함
  • application.properties 에서 설정할 때 마지막에 / (슬래시)가 있어야 한다.

 

실제 파일 서버에 업로드

컨트롤러

@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));
            }
            //편의 메서드
            //content-disposition; filename
            log.info("submittedFileName={}", part.getSubmittedFileName());
            log.info("size={}", part.getSize()); //part body size

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

 

@Value("${file.dir}")

  • import org.springframework.beans.factory.annotation.Value;
  • application.properties 에서 설정한 file.dir 의 값을 주입한다.

 

멀티파트 형식은 전송 데이터를 하나하나 각각 부분( Part )으로 나누어 전송한다.

parts 에는 이렇게 나누어진 데이터가 각각 담긴다.

서블릿이 제공하는 Part 는 멀티파트 형식을 편리하게 읽을 수 있는 다양한 메서드를 제공한다.

 

Part 주요 메서드

  • part.getSubmittedFileName() : 클라이언트가 전달한 파일명
  • part.getInputStream(): Part의 전송 데이터를 읽을 수 있다.
  • part.write(...): Part를 통해 전송된 데이터를 저장할 수 있다.

 

파일 저장 경로에 가보면 실제 파일이 저장된 것을 확인할 수 있다.

 

스프링과 파일 업로드

스프링은 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, 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";
    }
}

@RequestParam MultipartFile file

  • 업로드하는 HTML Form의 name에 맞추어 @RequestParam 을 적용하면 된다.
  • @ModelAttribute 에서도 MultipartFile 을 동일하게 사용할 수 있다.

 

MultipartFile 주요 메서드

  • file.getOriginalFilename() : 업로드 파일 명
  • file.transferTo(...) : 파일 저장

 

 

파일 업로드, 다운로드 예제

아이템 객체

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

 

리포지토리

@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 - 업로드 파일 정보 보관

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

    public UploadFile(String uploadFileName, String storeFileName) {
        this.uploadFileName = uploadFileName;
        this.storeFileName = storeFileName;
    }
}
  • uploadFileName : 고객이 업로드한 파일명
  • storeFileName : 서버 내부에서 관리하는 파일명

 

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); //UUID 로 변환된 파일 이름
        multipartFile.transferTo(new File(getFullPath(storeFileName))); //파일을 서버의 지정된 경로에 저장
        return new UploadFile(originalFilename, storeFileName);
    }

    //파일 이름이 충돌하지 않도록 UUID로 지정하는 메서드
    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.transferTo() : 업로드된 파일을 서버의 지정된 위치로 저장하는 데 사용

 

 

상품 저장용 폼

@Data
public class ItemForm {
    private Long itemId;
    private String itemName;
    private List<MultipartFile> imageFiles;
    private MultipartFile attachFile;
}
  • List<MultipartFile> imageFiles : 이미지를 다중 업로드 하기 위해 MultipartFile 를 사용
  • MultipartFile attachFile : 멀티파트는 @ModelAttribute 에서 사용할 수 있음

 

컨트롤러

@Slf4j
@Controller
@RequiredArgsConstructor
public class ItemController {
    private final ItemRepository itemRepository;
    private final FileStore fileStore;

    //@GetMapping("/items/new") : 등록 폼을 보여준다.
    @GetMapping("/items/new")
    public String newItem(@ModelAttribute ItemForm form) {
        return "item-form";
    }

    //@PostMapping("/items/new") : 폼의 데이터를 저장하고 보여주는 화면으로 리다이렉트 한다.
    @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}") : 상품을 보여준다.
    @GetMapping("/items/{id}")
    public String items(@PathVariable Long id, Model model) {
        Item item = itemRepository.findById(id);
        model.addAttribute("item", item);
        return "item-view";
    }

    //@GetMapping("/images/{filename}") : <img> 태그로 이미지를 조회할 때 사용한다.
    @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); //아이템의 ID를 통해서 해당 객체를 가져옴
        String storeFileName = item.getAttachFile().getStoreFileName();
        String uploadFileName = item.getAttachFile().getUploadFileName();

        UrlResource resource = new UrlResource("file:" + fileStore.getFullPath(storeFileName)); //요청받은 파일 이름을 기반으로 실제 파일의 경로를 구성하여 객체로 저장
        log.info("uploadFileName={}", uploadFileName);

        String encodedUploadFileName = UriUtils.encode(uploadFileName, StandardCharsets.UTF_8); //파일 이름의 인코딩 설정

        String contentDisposition = "attachment; filename=\"" + encodedUploadFileName + "\""; //파일 다운로드를 위한 헤더 설정
        return ResponseEntity.ok() //상태코드 200
                .header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition) //(헤더 이름, 헤더 값) -> (응답 본문을 어떻게 나타낼지, 파일을 다운로드 하라고 지시)
                .body(resource); //이 객체를 응답 본문으로 설정함으로써, 클라이언트는 이 데이터를 파일로 다운로드할 수 있다.
    }
}

 

자세한 설명

@ResponseBody
@GetMapping("/images/{filename}")
public Resource downloadImage(@PathVariable String filename) throws MalformedURLException {
    return new UrlResource("file:" + fileStore.getFullPath(filename)); //요청받은 파일 이름을 기반으로 실제 파일의 경로를 구성 -> 해당 파일의 내용을 클라이언트에게 전송
}
  • @ResponseBody 어노테이션을 사용한 downloadImage 메소드는 클라이언트가 요청한 이미지 파일을 반환하는 역할을 한다. 
  • @GetMapping("/images/{filename}")은 클라이언트가 /images/{filename} 형태의 URL로 GET 요청을 보냈을 때 이 메소드가 처리하도록 지정한다. 여기서 {filename}은 클라이언트가 요청한 파일의 이름을 의미하는 경로 변수이다.
  • 메소드의 인자로 @PathVariable String filename을 사용하여 URL에서 {filename}에 해당하는 부분을 문자열로 받는다. 이 문자열은 클라이언트가 요청한 파일의 이름이다.
  • 메소드 내부에서는 new UrlResource("file:" + fileStore.getFullPath(filename))를 통해 요청받은 파일 이름을 기반으로 실제 파일의 경로를 구성한다. fileStore.getFullPath(filename)는 파일 저장소 내에서 해당 파일의 전체 경로를 반환한다. 이 경로를 사용하여 UrlResource 객체를 생성하고, 이 객체는 요청한 파일의 내용을 나타낸다.
  • UrlResource 객체는 Resource 인터페이스를 구현하며, 이를 반환함으로써 스프링 MVC는 자동으로 해당 파일의 내용을 클라이언트에게 전송한다. 

 

 

  @GetMapping("/attach/{itemId}")
public ResponseEntity<Resource> downloadAttach(@PathVariable Long itemId) throws MalformedURLException {
    Item item = itemRepository.findById(itemId); //아이템의 ID를 통해서 해당 객체를 가져옴
    String storeFileName = item.getAttachFile().getStoreFileName();
    String uploadFileName = item.getAttachFile().getUploadFileName();

    UrlResource resource = new UrlResource("file:" + fileStore.getFullPath(storeFileName)); //요청받은 파일 이름을 기반으로 실제 파일의 경로를 구성하여 객체로 저장
    log.info("uploadFileName={}", uploadFileName);

    String encodedUploadFileName = UriUtils.encode(uploadFileName, StandardCharsets.UTF_8); //파일 이름의 인코딩 설정

    String contentDisposition = "attachment; filename=\"" + encodedUploadFileName + "\""; //파일 다운로드를 위한 헤더 설정
    return ResponseEntity.ok() //상태코드 200
            .header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition) //(헤더 이름, 헤더 값) -> (응답 본문을 어떻게 나타낼지, 파일을 다운로드 하라고 지시)
            .body(resource); //이 객체를 응답 본문으로 설정함으로써, 클라이언트는 이 데이터를 파일로 다운로드할 수 있다.
}
  • @GetMapping("/attach/{itemId}") 어노테이션을 사용한 downloadAttach 메소드는 특정 아이템의 첨부 파일을 다운로드하는 기능을 수행한다. 클라이언트가 /attach/{itemId} 형태의 URL로 GET 요청을 보내면, 이 메소드가 해당 요청을 처리한다. 여기서 {itemId}는 다운로드하려는 첨부 파일이 속한 아이템의 식별자를 나타낸다.
  • 메소드는 먼저 itemRepository.findById(itemId)를 호출하여 요청된 itemId에 해당하는 아이템을 데이터베이스에서 찾는다. 찾은 아이템에서 첨부 파일의 저장된 파일 이름(storeFileName)과 업로드됐을 때의 원본 파일 이름(uploadFileName)을 얻는다.
  • UrlResource 객체는 "file:" + fileStore.getFullPath(storeFileName)를 사용하여 생성된다. 이는 첨부 파일이 서버상에서 실제로 저장된 전체 경로를 나타낸다. 생성된 UrlResource 객체는 요청된 첨부 파일의 실제 데이터를 나타내며, 이 데이터는 응답 본문으로 클라이언트에게 전송된다.
  • String encodedUploadFileName = UriUtils.encode(uploadFileName, StandardCharsets.UTF_8);는 업로드된 파일 이름을 URL 인코딩한다. 이는 파일 이름에 특수 문자나 공백 등이 포함되어 있을 경우, HTTP 헤더에 안전하게 포함시키기 위한 처리이다.
  • String contentDisposition = "attachment; filename=\"" + encodedUploadFileName + "\"";는 HTTP 응답의 Content-Disposition 헤더를 설정하는 코드이다. 이 헤더는 브라우저에게 해당 응답이 파일 다운로드임을 알리고, 다운로드될 파일의 이름을 지정한다.
  • 마지막으로, ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition).body(resource);는 HTTP 상태 코드 200(OK)과 함께 설정된 Content-Disposition 헤더 및 파일 데이터를 포함하는 ResponseEntity를 반환한다. 이를 통해 클라이언트는 설정된 파일 이름으로 첨부 파일을 다운로드할 수 있다.

 

ResponseEntity.ok()

  • ResponseEntity 클래스는 스프링 MVC에서 HTTP 요청의 응답을 나타낸다. 이 클래스는 응답 본문, 상태 코드, 헤더 등을 포함할 수 있다.
  • ResponseEntity.ok() 메소드는 상태 코드로 200 OK를 가지는 ResponseEntity 객체를 생성한다. 이는 요청이 성공적으로 처리되었음을 나타낸다.

 

.header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition)

  • .header(String name, String value) 메소드는 HTTP 응답 헤더를 추가한다. 첫 번째 매개변수는 헤더의 이름이고, 두 번째 매개변수는 헤더의 값이다.
  • HttpHeaders.CONTENT_DISPOSITION는 Content-Disposition 헤더의 이름을 나타내는 상수이다. Content-Disposition 헤더는 응답의 본문이 웹 브라우저에 의해 어떻게 처리될지를 나타낸다.
  • contentDisposition 변수는 "attachment; filename=\"" + encodedUploadFileName + "\"" 형태의 값을 가진다. 이는 브라우저에게 응답 본문을 파일로 다운로드하라고 지시하며, filename 매개변수는 다운로드될 파일의 이름을 지정한다.

 

.body(resource)

  • .body(T body) 메소드는 HTTP 응답의 본문을 설정한다. 여기서 body 매개변수는 응답 본문에 포함될 데이터이다.
  • resource는 UrlResource 객체로, 요청된 파일의 실제 데이터를 나타낸다. 이 객체를 응답 본문으로 설정함으로써, 클라이언트는 이 데이터를 파일로 다운로드할 수 있다.

 

등록 폼 뷰

<!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>

다중 파일 업로드를 하려면 multiple="multiple" 옵션을 주면 된다.

ItemForm 의 다음 코드에서 여러 이미지 파일을 받을 수 있다.

→private 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>
        상품명: <span th:text="${item.itemName}">상품명</span><br/>
        첨부파일: <a th:if="${item.attachFile}" th:href="|/attach/${item.id}|" th:text="${item.getAttachFile().getUploadFileName()}"/><br/>
        <img th:each="imageFile : ${item.imageFiles}" th:src="|/images/${imageFile.getStoreFileName()}|" width="300" height="300"/>
    </div> <!-- /container -->
    </body>
</html>

첨부 파일은 링크로 걸어두고, 이미지는 <img> 태그를 반복해서 출력한다.

댓글