Spring

[인프런/스프링 MVC 2편] 11. 파일 업로드, 다운로드

주니어주니 2023. 6. 2. 01:07

 

서블릿 파일 업로드 -> 스프링 파일 업로드

 

 

1. 파일 업로드 

 

 

HTML 폼 전송 방식 2가지 

  • application/x-www-form-urlencoded (문자만 전송)
  • multipart/form-data (문자 + 바이너리 데이터 함께 전송)

 

 

1) application/x-www-form-urlencoded 방식

 

 

  • 폼 데이터를 서버로 전송하는 가장 기본적인 방법 (문자만 전송할 때)
  • Form 태그에 별도의 enctype 옵션 X 
    -> 웹 브라우저는 요청 HTTP 메시지의 헤더에 기본적으로 
        Content-Type : application/x-www-form-urlencoded 추가
    -> 폼에 입력한 전송할 데이터를 HTTP Body에 문자로 username=kim&age=20 같이 &로 구분해서 전송 

 

 

 

2) multipart/form-data 방식 

 

 

  • Form 태그에 별도의 enctype = "multipart/form-data" 지정 (다른 종류의 여러 파일과 폼의 내용 함께 전송 = multipart)
  • 웹 브라우저가 생성한 요청 HTTP 메시지
    • Content-Type : multipart/form-data
    • 각각의 전송 항목 구분 (boundary)
    • Content-Disposition (항목별 헤더) : username / age / file1 (부가정보) 로 분리
    • 일반 데이터 -> 각 항목별로 문자 전송
      파일 데이터 -> 파일 이름(filename), Content-Type(자동) 추가 -> 바이너리 데이터 전송
    • 각각의 항목을 구분해서(Part) 한번에 전송

 

 


 

2. 서블릿 파일 업로드 

 

2-1. 서블릿 파일 업로드 알아보기 

 

1) 서블릿 파일 업로드 실행

 

 

(1) 컨트롤러 - ServletUploadControllerV1

  • 서블릿 request에서 파일 정보 확인 
  • request.getParts() : multipart/form-data 전송 방식에서 각각 분리된 항목을 받아서 확인
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 javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.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";
    }
}

 

 

(2) 뷰 - 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> <!-- /container -->
</body>
</html>

 

 

 

(3) HTTP 요청 메시지 확인하는 로그 설정 - application.properties 

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

 

 

(4) 실행

 

HTTP 요청 메시지 로그

  • Content-Type : multipart/form-data
  • boundary로 구분된 part 확인 

 

 

컨트롤러에서 찍은 로그

  • request 객체 정보
  • itemName 파라미터 정보
  • parts 구분된 두 파트 

 

 

 

 

2) 멀티파트 사용 옵션 

 

(1) 업로드 사이즈 제한

  • 사이즈를 넘으면 SizeLimitExceededException 예외 발생
  • max-file-size : 파일 하나의 최대 사이즈, 기본 1MB
  • max-request-size : 멀티파트 요청 하나에 업로드 할 수 있는 여러 파일의 전체 사이즈, 기본 10MB
spring.servlet.multipart.max-file-size=1MB
spring.servlet.multipart.max-request-size=10MB

 

 

(2) 멀티파트 처리 여부

  • 옵션 끄기 (false)
    • 멀티파트 관련 처리 X
    • request = RequestFacade - HttpServletRequest의 기본객체
      itemName = null
      parts = [ ]

 

request=org.apache.catalina.connector.RequestFacade@xxx
itemName=null
parts=[]

 

 

  • 옵션 켜기 (true) - 기본값
    • 멀티파트 처리 O
    • request = RequestFacade -> StandardMultipartHttpServletRequest 

 

 

💡 참고

spring.servlet.multipart.enabled = true -> 스프링의 DispatcherServlet에서 멀티파트 리졸버(MultipartResolver) 실행

멀티파트 리졸버
: HttpServletRequest -> MultipartHttpServletRequest 객체로 변환해서 반환 (HttpServletRequest 의 자식 인터페이스)
-> 멀티파트 관련 추가 기능 제공

근데 스프링의 MultipartFile을 주로 사용해서 MultipartHttpServletRequest 는 잘 사용 X

 

 

 

2-2. 서블릿으로 실제 파일을 서버에 업로드 

 

1) 파일 저장 경로 - application.properties 

  • 주의: 마지막 슬래시(/) 포함 ! 
file.dir=/C:/study/file/

 

 

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 javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.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 saveFileV2(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());
            // parts의 헤더
            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());

            // parts 바디의 데이터 읽기
            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";
    }
}

 

(1) 파일 저장 경로 주입

@Value("${file.dir}")
private String fileDir;
  • application.properties에서 설정한 file.dir의 값 주입 

 

 

(2) Part 

 

HTTP 요청 메시지 로그 Part 부분

// Part 1 
------WebKitFormBoundaryYpfJu8ZzzymRMXhv
// part의 Header
Content-Disposition: form-data; name="itemName"

// part의 Body
itemC


// Part 2
------WebKitFormBoundaryYpfJu8ZzzymRMXhv
// part의 Header
Content-Disposition: form-data; name="file"; filename="나미리.jpg"
Content-Type: image/jpeg

// part의 Body (파일 데이터)
ÿØÿà JFIF ,,  ÿÛ C 

 

@PostMapping("/upload")
public String saveFileV2(HttpServletRequest request) throws ServletException, IOException {

    Collection<Part> parts = request.getParts();
    log.info("parts={}", parts);

    for (Part part : parts) {
        log.info("==== PART ====");
        log.info("name={}", part.getName());
        // parts의 헤더
        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());

        // parts 바디의 데이터 읽기
        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);
        }
    }

 

멀티파트 형식은 전송 데이터를 각 항목(Part)으로 나누어 전송

parts에는 이렇게 나누어진 Part 데이터가 각각 담김

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

 

  • Part 주요 메소드 
    • part.getSubmittedFileName() : 클라이언트가 전달한 파일명
    • part.getInputStream() : Part의 전송 데이터를 읽을 수 있음 (문자 <-> 바이너리 데이터 : Charsets 지정 필수)
    • part.write() : Part를 통해 전송된 데이터를 저장할 수 있음 

 

 

 

결과 로그

 

파일 저장 경로에 실제 파일 저장

 

 

 

---> 서블릿 파일 업로드 Part 기능 -> 쫌 복잡함

 

 


 

3. 스프링 파일 업로드 - MultipartFile

 

 

1) 컨트롤러 -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 javax.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";
    }
}

 

  • @RequestParam MultipartFile file
    업로드하는 HTML Form의 name에 맞춰 @RequestParam 적용
    +) @ModelAttribute 에서도 MultipartFile을 동일하게 사용 가능
  • MultipartFile 주요 메소드 
    • file.getOriginalFilename() : 업로드 파일 명
    • file.transferTo( ... ) : 파일 저장
      (파일명을 받아서 경로 생성 -> 경로에다가 파일 객체를 만들어서 저장)

 

 

2) 실행 

 

 

실행 로그

 

 

파일 경로에 실제 파일 저장

 

 

 


 

4. 파일 업로드, 다운로드 구현

 

 

1) 요구사항 

  • 상품명
  • 첨부파일 1개 -> 업로드, 다운로드
  • 이미지 파일 여러개 -> 웹 브라우저에서 확인

 

 

* 패키지 구조

 

 

 

2) Item - 상품 도메인

package hello.upload.domain;

import lombok.Data;

import java.util.List;

@Data
public class Item {

    private Long id;
    private String itemName;
    private UploadFile attachFile;		// 첨부파일 1개
    private List<UploadFile> imageFiles;	// 이미지파일 여러개
}

 

 

3) ItemRepository - 상품 저장소

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;     // Item Id 생성

    public Item save(Item item) {
        item.setId(++sequence);
        store.put(item.getId(), item);
        return item;
    }

    public Item findById(Long id) {
        return store.get(id);
    }

}

 

 

4) UploadFile - 업로드 파일 정보 보관

package hello.upload.domain;

import lombok.AllArgsConstructor;
import lombok.Data;

@Data
@AllArgsConstructor
public class UploadFile {

    private String uploadFileName;  // 고객이 업로드한 파일명
    private String storeFileName;   // 서버에서 관리되는 파일명

}
  • 주의) 고객이 업로드한 파일명서버에서 관리되는 파일명이 달라야 함 !
    -> 서로 다른 고객이 같은 파일명으로 업로드하면 충돌날 수 있음

 

 

5) FileStore - 서버에 파일 저장하는 로직

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;

    // 파일 저장 경로(fullPath) 생성
    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();  // image.png
        String storeFileName = createStoreFileName(originalFilename);   // dfw-dwf-dwf.png
        multipartFile.transferTo(new File(getFullPath(storeFileName))); // 서버에 저장된 파일명으로 실제 파일 생성
        return new UploadFile(originalFilename, storeFileName);
    }

    // 서버에 저장되는 파일명 생성 메소드
    private String createStoreFileName(String originalFilename) {
        // 확장자 추출
        String ext = extractExt(originalFilename);
        // UUID로 서버에 저장되는 파일명 생성 + 확장자
        String uuid = UUID.randomUUID().toString();
        // 서버에 저장되는 파일명 반환 (dfw-dwf-dwf.png)
        return uuid + "." + ext;
    }

    // 확장자 추출 메소드
    private String extractExt(String originalFilename) {
        // "."의 마지막 인덱스 위치
        int pos = originalFilename.lastIndexOf(".");
        // 그 "."의 다음 글자부터 출력 (=확장자)
        return originalFilename.substring(pos + 1);

    }
}

 

 

6) ItemForm - 상품 저장용 폼

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 MultipartFile attachFile;
    private List<MultipartFile> imageFiles; // 이미지 다중 업로드
}

 

📌 Item vs ItemForm 

Item : DB에 저장할 때 파일 경로, 이름 등만 저장하기 위한 객체 
ItemForm : MultipartFile같은 파일 객체를 저장해서 서버 <-> 뷰로 왔다갔다하기 위한 객체 

 

 

7) ItemController

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.apache.tomcat.util.buf.UriUtil;
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.*;
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 itemForm) {
        return "item-form";
    }

    // 파일 업로드
    @PostMapping("/items/new")
    public String saveItem(@ModelAttribute ItemForm form, RedirectAttributes redirectAttributes) throws IOException {

        // 파일 경로에 파일 1개 저장
        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 {
        // UrlResource를 이용해서 파일에 직접 접근해서 바이트 정보(이미지 바이너리)로 반환
        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 uploadFileName = item.getAttachFile().getUploadFileName();   // 다운로드 받을 때 고객에 업로드한 파일명으로 보이기 위함
        String storeFileName = item.getAttachFile().getStoreFileName();

        // UrlResource를 이용해서 파일에 직접 접근해서 바이트 정보(이미지 바이너리)로 반환
        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()
                .header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition)    // 헤더를 안넣으면 링크로 이동해버림 (다운로드 X)
                .body(resource);
    }

}

 

  • 이미지 조회
    • @ResponseBody + byte[] 또는 Resource 반환 
      -> UrlResource를 사용해서 파일 경로에 직접 접근해서 이미지 파일을 읽어서 바이트 정보(이미지 바이너리) 반환
      -> <img> 에서는 이 바이트 정보를 읽어서 이미지로 반환 
  • 첨부파일 다운로드 
    • 파일 다운로드 시 권한 체크 -> 요청 파라미터에 넣어줌 (ex. 로그인한 회원 아이디)
    • 상품 아이디로 상품 조회하기 위해 itemId를 요청 파라미터에 넣어줌
    • Content-Disposition 헤더attachment; filename="업로드 파일명"
    • ResponseEntity.ok() .header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition) .body(resource);

 

 

8) 등록 폼 뷰 - item-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>

  <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 에서 다중 이미지 파일을 받음

 

 

9) 상품 조회 뷰 - item-view.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>

  상품명: <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>
  • 첨부파일 : 링크 -> 클릭 시 다운로드 - 업로드 파일명
  • 다중 이미지파일 : each로 <img> 태그 반복 출력 - 서버 저장 파일명

 

 

10) 실행

 

상품 등록 폼

 

상품 조회

 

서버에 저장된 파일 (업로드)