[Spring MVC 2편] 11. 파일 업로드

HJ·2023년 1월 24일
1

Spring MVC 2편

목록 보기
11/13

김영한 님의 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 강의를 보고 작성한 내용입니다.
https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-mvc-2/dashboard


1. HTML 폼 전송 방식

1-1. application/x-www-form-urlencoded

  • form 태그에 enctype 속성이 없으면 웹 브라우저는 요청 메세지의 Content-Type에 application/x-www-form-urlencoded 추가

  • input 태그의 name 속성을 key로 해서 message body에 key=value 형식으로 담긴다

  • 각 input 태그( 전달되는 내용 )는 & 로 구분한다

  • 즉, Form에 입력한 내용을 message body에 담는다


1-2. multipart/form-data

  • 파일은 문자가 아닌 바이너리 데이터를 전송해야하고 파일과 문자가 함께 있는 경우 문자와 바이너리를 동시에 전송해야한다

  • 다른 종류의 여러 파일과 Form의 내용을 한 번에 전송할 수 있는 multipart/form-data를 사용한다

  • enctype 속성에 multipart/form-data를 지정해서 사용

  • 웹 브라우저가 생성한 HTTP 요청 메세지를 보면 각각의 전송 항목이 boundary로 구분

  • Content-Disposition 이라는 항목별 헤더가 추가

  • Form의 일반 데이터는 각 항목 별로( input 태그 별로 ) 문자가 전송

  • 파일의 경우 filename과 Content-Type이 추가되고 바이너리 데이터가 전송




2. 서블릿과 파일 업로드

2-1. 파일 업로드 기본

public class ServletUploadControllerV1 {

    @PostMapping("upload")
    public String saveFileV1(HttpServletRequest request) throws ServletException, IOException {
        Collection<Part> parts = request.getParts();
        log.info("parts={}", parts);

        return "upload-form";
    }
}
<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>
  • getParts() : multipart/form-data 전송 방식에서 나누어진 부분들을 받아서 확인할 수 있다

  • HTML을 보면 input 태그가 문자와 파일 두 개이기 때문에 parts를 로그로 찍어보면 아래처럼 두 부분으로 나뉘어서 출력

    • parts=[org.apache.catalina.core.ApplicationPart@4b4d23fd, org.apache.catalina.core.ApplicationPart@352057c]

    • 나뉘어서 데이터가 들어왔기 때문에 parts에서 원하는 데이터를 꺼낼 수 있다

  • 참고> HTTP 요청 메세지를 서버에서 로그로 남기고 싶은 경우 application.properties에 아래 코드 작성

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

2-2. 멀티파트 사용 옵션

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

    • 파일 하나의 최대 사이즈 제한 ( 기본 1MB )
  • spring.servlet.multipart.max-request-size=10MB

    • 멀티파트 요청 하나에 여러 파일 업로드가 가능한데 그 전체 합을 제한 ( 기본 10MB )
  • spring.servlet.multipart.enabled=false

    • 서블릿 컨테이너가 멀티파트 요청과 관련된 처리를 하지 않는다

    • request를 로그로 찍어보면 RequestFacade

    • getParameter()나 getParts()의 결과를 로그로 찍어보면 비어있다

  • spring.servlet.multipart.enabled=true

    • 멀티파트 요청과 관련된 처리를 한다

    • request를 로그로 찍어보면 StandardMultipartHttpServletRequest


2-3. 참고

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

  • MultipartResolver는 멀티파트 요청인 경우, 서블릿 컨테이너가 전달하는 일반적인 HttpServletRequest(RequestFacade)를 MultiPartHttpServletRequest로 변환해서 반환

    • MultiPartHttpServletRequestHttpServletRequest의 자식 인터페이스
  • 스프링이 제공하는 기본 MultipartResolverMultiPartHttpServletRequest를 구현한 StandardMultipartHttpServletRequest를 반환

  • 이제 컨트롤러에서 HttpServletRequest 대신 MultiPartHttpServletRequest를 주입받을 수 있는데 이것을 사용하면 멀티파트와 관련된 처리를 편리하게 할 수 있다

  • but> MultipartFile을 사용하는 것이 더 편하기 때문에 잘 사용하진 않는다




3. 서블릿 멀티파트 주요 메서드

3-1. 데이터 확인

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

        // 데이터 읽기 ( body에 있는 데이터 읽기 )
        InputStream inputStream = part.getInputStream();
        String body = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);
        log.info("body={}", body);
    }
    return "upload-form";
}
// 결과
==== PART ====
name=itemName
header content-disposition: form-data; name="itemName"
submittedFileName=null
size=4
body=test
==== PART ====
name=file
header content-disposition: form-data; name="file"; filename="conversionService.JPG"
header content-type: image/jpeg
submittedFileName=conversionService.JPG
size=28661
body=���� JFIF  x x  ���Exif  MM *   ;    

  • getParts()를 통해 문자와 파일 전송 두 개로 나뉘어져 있음

  • parts.getName() : HTML의 input 태그의 name 속성에 지정한 이름을 반환

  • parts.getHeaderNames() : parts도 헤더와 바디로 구분, parts의 모든 헤더 이름을 반환

    • 문자라면 content-disposition 헤더가 존재

    • 파일이라면 content-disposition, content-type 헤더가 존재

  • part.getHeader(헤더이름) : parts의 헤더이름으로 헤더 가져오기

    • 문자라면 content-disposition에 name이 있음

    • 파일이라면 content-disposition에 name, filename이 있음

  • part.getSubmittedFileName() : content-disposition에 있는 filename을 반환 ( 클라이언트가 전달한 파일명 )

  • part.getInputStream() : parts의 body에 있는 데이터를 읽기

  • StreamUtils.copyToString() : 읽은 바이너리 데이터를 문자로 변환, 인코딩 방식 지정 필수


3-2. 파일 저장하기

public class ServletUploadControllerV2 {

    @Value("${file.dir}")
    private String fileDir;

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

        // 파일 저장하기
        if (StringUtils.hasText(part.getSubmittedFileName())) {
            String fullPath = fileDir + part.getSubmittedFileName();
            log.info("파일 저장 fullPath = {}", fullPath);
            part.write(fullPath);
        }
    }
    return "upload-form";
}
  • fild.dir=경로/ : application.propertoes에 파일 저장 경로 지정, 마지막에 / 붙여야함

  • @Value("${file.dir}") : application.properties에 있는 file.dir이라는 속성을 그대로 가져온다

  • StringUtils.hasText(part.getSubmittedFileName()) : 전송된 파일이 있는지 확인

  • part.write(경로) : part를 통해 전송된 데이터를 해당 경로에 저장




4. 스프링과 파일 업로드

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 {

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

        return "upload-form";
    }
}
  • 스프링은 MultipartFile 이라는 인터페이스로 멀티파트 파일을 편리하게 지원

  • HTML Form 의 name 에 맞춰 @RequestParam 을 적용하면 된다

  • file은 MultipartFile, 문자는 String 을 사용

  • input 태그의 name 속성과 파라미터 이름이 동일하면 @RequestParam 에 이름 생략 가능

  • @ModelAttribute 에서도 MultipartFile 사용 가능

  • file.getOriginalFilename() : 사용자가 업로드한 파일명

  • file.transferTo() : 파일 저장




5. 파일 업로드 및 다운로드 예제

5-1. 상황 가정

  • 상품 = 상품 이름, 첨부 파일 1개, 이미지 파일 여러 개

  • 첨부파일 업로드 및 다운로드 가능

  • 업로드한 이미지를 웹 브라우저에서 확인 가능


5-2. 클래스 설명

  • Item : id / 상품 이름 / UploadFile(첨부파일) / List<UploadFile>(이미지 파일)

  • UploadFile : 사용자가 업로드한 이름 / 서버에 저장되는 이름

  • ItemRepository : Item을 Map 객체에 저장 및 조회

  • ItemForm : id / 상품 이름 / MultipartFile(첨부파일) / List<MultipartFile>(이미지 파일)

  • FileStore

    • getFullPath() : 파일 이름을 받아서 전체 경로 + 파일명을 반환

    • storeFile() : MultipartFile 을 받아서 UploadFile 을 반환

      • MultipartFile 에서 사용자가 업로드한 파일명과 확장자를 추출

      • UUID 를 이용해 서버에 저장하는 파일명 생성하고, 추출한 확장자를 붙여서 저장

      • 서버에 저장한 이름과 사용자가 업로드한 이름을 가진 UploadFile 반환

    • storeFiles()

      • List<MultipartFile>을 받아 List<UploadFile>을 반환

      • storeFile() 을 이용

  • ItemController

    • @ModelAttribute ItemForm을 통해 데이터를 받는다

    • FileStore.storeFile() 을 통해 UploadFile 을 반환 ( 첨부파일 )

    • FileStore.storeFiles() 를 통해 List<UploadFile>을 반환

    • Item 객체를 만들어 ItemRepository에 저장


5-3. 파일명 분리

@Data
public class UploadFile {
    private String uploadFileName;  // 고객이 업로드한 파일명
    private String storeFileName;   // 서버 내부에서 관리하는 파일명
}
  • 위처럼 사용자가 업로드 하는 파일명과 실제로 서버에 저장되는 파일명을 다르게 해야한다

  • 여러 명의 사용자가 동일한 이름의 파일을 업로드하면 파일이 덮어씌워질 수 있기 때문

  • 이를 방지하기 위해 서버에 저장되는 파일명은 UUID 같은 것을 통해 겹치지 않도록 설정해야한다

  • 경로를 저장할 때 전체 경로를 저장하지는 않고, 공통부분을 제외한 일부 상대경로만 저장


5-4. 여러 개 선택 가능

<input type="file" multiple="multiple" name="imageFiles" >
  • input 태그에 multiple 속성을 "mlultiple"로 하면 여러 개의 파일을 업로드 할 수 있다

  • Java 에서 List<MultipartFile>로 받을 수 있다


5-5. DB에 저장

public class ItemController {

    @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}";
}
  • DB에 저장할 때 실제로 파일 자체를 저장하지 않고( MultipartFile ) 파일이 저장된 경로 같은 것만 저장( UploadFile )

  • 파일들은 실제 스토리지 같은 곳에 저장

  • 그래서 form에서 받은 ItemForm의 첨부파일과, 이미지 파일을 FileStore의 메서드를 통해 스토리지에 저장하고 파일 이름을 가진 UploadFile 객체를 만들어 반환

  • 변환한 것을 가지고 Item 객체를 만들어 ItemRepository를 통해 저장 ( DB 저장 )


5-6. 업로드한 상품 조회

<!-- Thymeleaf -->
상품명: <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"/>
  • 첨부파일은 저장한 파일 이름이 아니라 고객이 업로드한 이름이 보여지도록

    • item.getAttachFile() ➜ 반환형이 UploadFile

    • item.getAttachFile().getUploadFileName() ➜ UploadFile.getUploadFileName()

  • /attach/${item.id} : 첨부파일의 다운로드 링크, 이 URL을 처리하는 메서드나 컨트롤러가 필요

  • 이미지는 여러 개가 올 수 있기 때문에 th:each로 작성

  • /images/${imageFile.getStoreFileName()} : 이미지를 보여주기 위한 경로, 이 URL을 처리하는 메서드나 컨트롤러 필요


5-7. 이미지 출력

public class ItemController {

    @ResponseBody
    @GetMapping("/images/{filename}")
    public Resource downloadImage(@PathVariable String filename) throws MalformedURLException {
        return new UrlResource("file:" + fileStore.getFullPath(filename));
    }
}
  • getFullPath() 를 통해 파일이 저장된 위치 + 파일명의 형태로 만든다

  • UrlResource를 통해 경로에 있는 파일에 접근해서 파일을 스트림으로 반환

  • 즉, UrlResource로 이미지 파일을 읽어서 @ResponseBody 로 이미지 바이너리를 반환

  • but> 보안에는 취약하기 때문에 체크로직 같은 것을 넣으면 좋다


5-8. 첨부파일 다운로드

public class ItemController {

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

        log.info("uploadFileName={}", uploadFileName);

        String encodeUploadFileName = UriUtils.encode(uploadFileName, StandardCharsets.UTF_8);
        String contentDisposition = "attachment; filename=\"" + encodeUploadFileName + "\"";

        return ResponseEntity.ok()
                .header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition)
                .body(resource);
    }
}
  • DB에 저장되는 id가 아니라 itemID로 한 이유

    • 특정 상품에 접근할 수 있는지 확인을 위한 정보 및 로직이 있다고 가정했을 때

    • item에 접근할 수 있는 사용자만 첨부파일을 다운로드 할 수 있는 기능을 넣으려면 itemId로 하는게 좋다

  • 파일을 다운로드 받기 위해 서버에 저장된 이름이 필요
  • 다운로드 받을 때 사용자가 업로드한 파일명이 나와야하기 때문에 업로드 이름 필요

    • Content-Disposition 헤더에 attachment; filename="업로드 파일명" 형태로 작성
  • UriUtils.encode() : 다운로드 받을 때 한글로 된 파일명이 깨질 수도 있기 때문에 추가
  • ResponseEntity를 반환할 때 다운로드를 받으려면 Content-Disposition 헤더가 필요하기 때문에 추가

    • 헤더가 없는 경우 첨부파일이나 이미지가 열려서 내용이 보여지기만 한다

    • 헤더를 추가하면 웹 브라우저가 첨부파일이라고 인식해서 파일을 다운로드 받는다

profile
공부한 내용을 정리해서 기록하고 다시 보기 위한 공간

0개의 댓글