Spring 파일 업로드

강정우·2024년 1월 8일
0

Spring-boot

목록 보기
51/73

HTML 폼 전송 방식

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

Content-Type: application/x-www-form-urlencoded
Content-Type: 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 와 같이 & 로 구분해서 전송한다.

하지만 파일을 업로드 하려면 파일은 문자가 아니라 바이너리 데이터를 전송해야 한다. 문자를 전송하는 이 방식으로 파일을 전송하기는 어렵다. 그리고 또 한가지 문제가 더 있는데, 보통 폼을 전송할 때 파일만 전송하는 것이 아니라는 점이다.

- 이름
- 나이
- 첨부파일 

여기에서 이름과 나이도 전송해야 하고, 첨부파일도 함께 전송해야 한다. 문제는 이름과 나이는 문자로 전송하고, 첨부 파일은 바이너리로 전송해야 한다는 점이다. 여기에서 문제가 발생한다. 문자와 바이너리를 동시에 전송해야 하는 상황이다

multipart/form-data 방식

Part 개념 도입

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

이 방식을 사용하려면 Form 태그에 별도의 enctype="multipart/form-data" 를 지정해야 한다.
multipart/form-data 방식은 다른 종류의 여러 파일과 폼의 내용 함께 전송할 수 있다.
폼의 입력 결과로 생성된 HTTP 메시지를 보면 각각의 전송 "항목"이 구분이 되어있다.
Content-Disposition 이라는 항목별 헤더가 추가되어 있고 여기에 부가 정보가 있다.
위 HTTP message에서는 username , age , file1 이 각각 분리되어있고, 폼의 일반 데이터는 각 항목별로 문자가 전송되고, 파일의 경우 파일 이름과 Content-Type이 추가되고 바이너리 데이터가 전송된다.

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

log 확인

req하는 컨트롤러와 html 파일을 간단하게 만들고 application.properties에 logging.level.org.apache.coyote.http11=debug이 옵션을 추가하고 WebKitFormBoundary를 검색해보면 로그를 확인해 볼 수 있다.

  • 참고로 원래 HttpServletRequest에서 구현체는 RequestFacade 였다. 그런데 multipart/form-data로 보낼 때는 구현체가 StandardMultipartHttpServletRequest인 것을 확인해 볼 수 있다.

업로드 사이즈 제한

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

큰 파일을 무제한 업로드하게 둘 수는 없으므로 업로드 사이즈를 제한할 수 있다.
사이즈를 넘으면 예외( SizeLimitExceededException )가 발생한다.
max-file-size : 파일 하나의 최대 사이즈, 기본 1MB
max-request-size : 멀티파트 요청 하나에 여러 파일을 업로드 할 수 있는데, 그 전체 합이다. default 10MB

  • 참고
    spring.servlet.multipart.enabled 옵션을 켜면 스프링의 DispatcherServlet 에서 멀티파트 리졸버( MultipartResolver )를 실행한다.
    멀티파트 리졸버는 멀티파트 요청인 경우 서블릿 컨테이너가 전달하는 일반적인 HttpServletRequest 를 MultipartHttpServletRequest 로 변환해서 반환한다.
    MultipartHttpServletRequest 는 HttpServletRequest 의 자식 인터페이스이고, 멀티파트와 관련된 추가 기능을 제공한다. 스프링이 제공하는 기본 멀티파트 리졸버는 MultipartHttpServletRequest 인터페이스를 구현한 StandardMultipartHttpServletRequest 를 반환한다.
    이제 컨트롤러에서 HttpServletRequest 대신에 MultipartHttpServletRequest 를 주입받을 수 있는데, 이것을 사용하면 멀티파트와 관련된 여러가지 처리를 편리하게 할 수 있다. 그런데 MultipartFile 이라는 것을 사용하는 것이 더 편하기 때문에 MultipartHttpServletRequest 를 잘 사용하지는 않는다.

업로드 파일 저장

1. 저장될 경로를 저장한다.

경로에 저장할 폴더가 없다면 만들어준 후 application.properties에 등록해두자.

file.dir=경로.../

그리고 이때 주의할 점은 끝에 반드시 / 가 들어가줘야한다. 안 그러면 접두어로 인식해버린다.

2. 경로값 import

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

3. 저장 로직 작성

@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();  // Exception 추가
    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

        //데이터 읽기
        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 주요 메서드

  1. part.getSubmittedFileName() : 클라이언트가 전달한 파일명 원래는 우리가 넘어오는 값 에 대하여 일일이 parsing을 해야하지만 위 메서드로 간단하게 값을 읽어올 수 있다.

  2. part.getInputStream() : Part의 전송 데이터를 읽을 수 있다.

  3. part.write(...) : Part를 통해 전송된 데이터를 저장할 수 있다

  • 참고
    큰 용량의 파일을 업로드를 테스트 할 때는 로그가 너무 많이 남아서 다음 옵션을 끄는 것이 좋다.
    logging.level.org.apache.coyote.http11=debug
    다음 부분도 파일의 바이너리 데이터를 모두 출력하므로 끄는 것이 좋다.
    log.info("body={}", body);
    서블릿이 제공하는 Part 는 편하기는 하지만, HttpServletRequest 를 사용해야 하고, 추가로 파일 부분만 구분하려면 여러가지 코드를 넣어야 한다. 이번에는 스프링이 이 부분을 얼마나 편리하게 제공하는지 확인해보자.

Spring으로 파일 업로드

public class SpringUploadControllerV2 {

    @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 을 동일하게 사용할 수 있다.

그리고 이제는 @RequestParam MultipartFile file을 딱 보고 argument resolver가 뭔가를 해주는게 있구나 하고 생각하면 된다.

MultipartFile 주요 메서드

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

적용해보기

1. 요구사항

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

2. 도메인 정보

UploadFile.java

@Data
public class UploadFile {
	private String uploadFileName;
	private String storeFileName;
    
	public UploadFile(String uploadFileName, String storeFileName) {
		this.uploadFileName = uploadFileName;
		this.storeFileName = storeFileName;
	}
}

uploadFileName : 고객이 업로드한 파일명
storeFileName : 서버 내부에서 관리하는 파일명

  • 고객이 업로드한 파일명으로 서버 내부에 파일을 저장하면 안된다. 왜냐하면 서로 다른 고객이 같은 파일이름을 업로드하는 경우 기존 파일 이름과 충돌이 날 수 있다. 서버에서는 저장할 파일명이 겹치지 않도록 내부에서 관리하는 별도의 파일명( UUID )이 필요하다.

Item.java

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

ItemRepository.java

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

3. 컴포넌트 만들기

@Component
public class FileStore {
    @Value("${file.dir}")
    private String fileDir;

    public String getFullPath(String filename) {
        return fileDir + filename;
    }
    
    // 파일 저장하는 함수를 iter를 돌려서 파일 여러개를 저장하기
    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;
    }

    // 파일을 1개를 저장하는 함수
    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);
    }
}

사실 extractExt 함수는 안 만들어도 되는데 서버에서 uuid만 떡하니 있는 경우 해당 파일이 이미지인지 텍스트인지 영상인지 전혀 알 수 없기 때문에 이를 알고자 따로 뒤에 확장자를 붙여주는 것이다.

4. Form용 dto 만들기

ItemForm.java

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

5. Form html 만들기

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>

6. 컨트롤러 작성

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}";
    }
    
    // <img> 태그로 이미지를 조회할 때 사용한다. UrlResource 로 이미지 파일을 읽어서 @ResponseBody 로 이미지 바이너리를 반환한다
    @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:/user/../uuid.ext
        return new UrlResource("file:" + fileStore.getFullPath(filename));
    }

	//  파일 다운로드시에는 고객이 업로드한 파일 이름으로 다운로드 하는게 좋다. 이때는 Content-Disposition 해더에 attachment; filename="업로드 파일명" 값을 주면 된다.
    @GetMapping("/attach/{itemId}")
    public ResponseEntity<Resource> downloadAttach(@PathVariable Long itemId) throws MalformedURLException {
        Item item = itemRepository.findById(itemId);g
        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()
                .header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition)
                .body(resource);
    }
}

7. 아이템을 보여줄 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>
profile
智(지)! 德(덕)! 體(체)!

0개의 댓글