Spring MVC v2 - 파일 업로드

Kwon Yongho·2023년 5월 9일
0

Spring-MVC-v2

목록 보기
13/13
post-thumbnail

파일 업로드

  1. 파일 업로드 소개
  2. 서블릿과 파일 업로드1
  3. 서블릿과 파일 업로드2
  4. 스프링과 파일 업로드
  5. 예제로 구현하는 파일 업로드, 다운로드

1. 파일 업로드 소개

일반적으로 사용하는 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 메시지의 헤더에 다음 내용을 추가한다.
  • 폼에 입력한 전송할 항목을 HTTP Body에 문자로 username=kim&age=20와 같이 &로 구분해서 전송한다.

하지만 문제는 파일 업로드 시 파일은 바이너리 데이터로 전송하고 문자도 전송 한다. 문자와 바이너리를 동시에 전송해야 되는 상황이라는 것이다.

multipart/form-data 방식

  • multipart/form-data 방식은 다른 종류의 여러 파일과 폼의 내용 함께 전송할 수 있다. (그래서 이름이 multipart이다.)
  • 이 방식을 사용하려면 Form 태그에 별도의 enctype="multipart/form-data"를 지정해야 한다.

2. 서블릿과 파일 업로드1

fileupload 프로젝트를 새로 생성해서 진행하였다.

서블릿을 통한 파일 업로드를 코드와 함께 알아보겠습니다.

ServletUploadControllerV1

package com.example.fileupload.controller;

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.Part;
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 java.io.*;
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 전송 방식에서 각각 나누어진 부분을 받아서 확인할 수
    있다.

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>


멀티파트 사용 옵션

업로드 사이즈 제한

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

spring.servlet.multipart.enabled 끄기

spring.servlet.multipart.enabled=false
  • spring.servlet.multipart.enabled 옵션을 끄면 서블릿 컨테이너는 멀티파트와 관련된 처리를 하지
    않는다.
  • 기본은 true이다.

3. 서블릿과 파일 업로드2

서블릿이 제공하는 Part 에 대해 알아보고 실제 파일도 서버에 업로드 해보자.

파일 업로드할 폴더 경로 지정

/C:file.dir=/Users/KYH/Desktop/fileuploadEx/

ServletUploadControllerV2

package com.example.fileupload.controller;

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.Part;
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 java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.Collection;

@Slf4j
@Controller
@RequestMapping("/servlet/v2")

public class ServletUploadControllerV2 {

    // application.properties에서 설정한 file.dir의 값을 주입한다.
    @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);

        // multipart 읽어보기
        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

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

멀티파트 형식은 전송 데이터를 하나하나 각각 부분(Part)으로 나누어 전송한다. parts에는 이렇게 나누어진 데이터가 각각 담긴다.

Part 주요 메서드

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

  • 큰 용량의 파일을 업로드를 테스트 할 때는 로그가 너무 많이 남아서 다음 옵션을 끄는 것이 좋다.
  • logging.level.org.apache.coyote.http11=debug

4. 스프링과 파일 업로드

스프링은 MultipartFile이라는 인터페이스로 멀티파트 파일을 매우 편리하게 지원한다.

SpringUploadController

package com.example.fileupload.controller;

import jakarta.servlet.http.HttpServletRequest;
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 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);
        
        // MultipartFile 짱
        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(...): 파일 저장

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

요구사항

  • 상품을 관리
    • 상품 이름
    • 첨부파일 하나
  • 이미지 파일 여러개
  • 첨부파일을 업로드 다운로드 할 수 있다.
  • 업로드한 이미지를 웹 브라우저에서 확인할 수 있다.

Item - 상품 도메인

package com.example.fileupload.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 - 상품 리포지토리

package com.example.fileupload.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 - 업로드 파일 정보 보관

package com.example.fileupload.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 - 파일 저장과 관련된 업무 처리

package com.example.fileupload.file;

import com.example.fileupload.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);

    }

    // storeFilename 만들기
    private String createStoreFilename(String originalFilename) {
        // .png 따오기
        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);
    }

}

멀티파트 파일을 서버에 저장하는 역할을 담당한다.

  • createStoreFileName(): 서버 내부에서 관리하는 파일명은 유일한 이름을 생성하는 UUID를 사용해서 충돌하지 않도록 한다.
  • extractExt(): 확장자를 별도로 추출해서 서버 내부에서 관리하는 파일명에도 붙여준다. 예를 들어서 고객이 a.png 라는 이름으로 업로드 하면 51041c62-86e4-4274-801d-614a7d994edb.png와 같이 저장한다.

ItemForm - 상품 저장용 폼

package com.example.fileupload.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;
}
  • List<MultipartFile> imageFiles: 이미지를 다중 업로드 하기 위해 MultipartFile를 사용

ItemController

package com.example.fileupload.controller;

import com.example.fileupload.domain.Item;
import com.example.fileupload.domain.ItemRepository;
import com.example.fileupload.domain.UploadFile;
import com.example.fileupload.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.*;
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());

        // 보통 파일은 store, AWS에 저장
        // 데이터베이스에는 보통 경로를 저장

        // 데이터베이스에 저장
        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 {
        // "file:/C:/Users/KYH/Desktop/fileuploadEx/파일명.png"
        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));
        log.info("uploadFileName={}", uploadFileName);

        // 한글이 깨질 수 있으므로 인코딩
        String encodedUploadFileName = UriUtils.encode(uploadFileName, StandardCharsets.UTF_8);
        String contentDisposition = "attachment; filename=\"" + encodedUploadFileName + "\"";

        // header를 넣어야 되는 이유? -> CONTENT_DISPOSITION이 있어야 다운로드를 함
        return ResponseEntity.ok()
                .header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition)
                .body(resource);
    }
}
  • @GetMapping("/items/new") : 등록 폼을 보여준다.
  • @PostMapping("/items/new") : 폼의 데이터를 저장하고 보여주는 화면으로 리다이렉트 한다.
  • @GetMapping("/items/{id}") : 상품을 보여준다.
  • @GetMapping("/images/{filename}") : <img>태그로 이미지를 조회할 때 사용한다. UrlResource로 이미지 파일을 읽어서 @ResponseBody로 이미지 바이너리를 반환한다.
  • @GetMapping("/attach/{itemId}") : 파일 다운로드시에는 고객이 업로드한 파일 이름으로 다운로드 하는게 좋다. 이때는 Content-Disposition해더에 attachment; filename="업로드 파일명" 값을 주면 된다.

상품 등록

파일 저장 확인

상품 조회

파일 다운로드 확인

참고
김영한: 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술(인프런)
Github - https://github.com/b2b2004/Spring_MVC

0개의 댓글