제육's 휘발성 코딩
article thumbnail
반응형

파일 업로드

HTML 폼 전송 방식에는 application/x-www-form-urlencoded, multipart/form-data 두 가지가 있다.

파일 업로드를 이해하려면 이 두 가지 방식의 차이에 대해 이해해야 한다.

image

  • HTML 폼데이터를 서버로 전송하는 가장 기본적인 방법으로, 별도의 enctype이 없으면 웹 브라우저는 요청 HTTP 메시지의 헤더에 Content-Type: application/x-www-form-urlencoded 를 추가한다.

여기서 단점은 보내야할 데이터가 여러 개인 경우, 문자와 바이너리를 동시에 전송해야하는 상황이 자주 있다는 것이다.

이 문제를 해결하기 위해 HTTP는 multipart/form-data 라는 전송 방식을 제공한다.

image

  • multipart/form-data 방식은 여러 파일을 함께 전송할 수 있다. HTTP 메시지를 보면 Content-Disposition이라는 항목별 헤더가 추가되어 있고 여기에 부가 정보가 있다.

 

프로젝트 생성

image

  • 다음과 같이 spring.io 에서 프로젝트를 생성하자.

Index.html

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>Title</title>
  </head>
  <body> <ul>
    <li>상품 관리 <ul>
      <li><a href="/servlet/v1/upload">서블릿 파일 업로드1</a></li> <li><a href="/servlet/v2/upload">서블릿 파일 업로드2</a></li> <li><a href="/spring/upload">스프링 파일 업로드</a></li> <li><a href="/items/new">상품 - 파일, 이미지 업로드</a></li>
      </ul> </li>
    </ul>
  </body>
</html>

 

서블릿과 파일 업로드 1

ServletUploadControllerV1

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";
    }
}
  • servlet에서 part 라는 기능을 제공하는 데 multipart의 part의 기능이다. 즉. 각각 나누어진 부분을 확인할 수 있다.

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>
  • enctypemultipart/form-data로 설정

application.properties

logging.level.org.apache.coyote.http11=debug
  • 이 옵션을 사용하면 HTTP 요청 메시지를 확인할 수 있다.

결과 로그

Host: localhost:8080
Connection: keep-alive
Content-Length: 61949
Cache-Control: max-age=0
sec-ch-ua: " Not A;Brand";v="99", "Chromium";v="101", "Google Chrome";v="101"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "macOS"
Upgrade-Insecure-Requests: 1
Origin: http://localhost:8080
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryLbjA8IrB4gTtnRoP
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.4951.64 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Referer: http://localhost:8080/servlet/v1/upload
Accept-Encoding: gzip, deflate, br
Accept-Language: ko,en-US;q=0.9,en;q=0.8,ko-KR;q=0.7

------WebKitFormBoundaryLbjA8IrB4gTtnRoP
Content-Disposition: form-data; name="itemName"

asd
------WebKitFormBoundaryLbjA8IrB4gTtnRoP
Content-Disposition: form-data; name="file"; filename="배경.png"
Content-Type: image/png

‰PNG

IHDRJ
 Üÿ.ïnDy¿ê¬äúçü]PÎq¦@Îχø8x_*+€¨Ô[M.*ñlˆõd0@ˆW)q¶
ñ±@UwPŠH‘¬²GMør¬0€ØUÀ‰‚Øâ0I^L´ZŸ™%ãBw:E\ÀM‚Øâ…Byh¢Úf£lb‚ÚZŸ%ã°Õús<Y¿_¥¯ŠÜd¶šÿHÈUócô"QR*Ĉ­Å)1Ó!v‘ç&F©mF‰8162E‚2~kˆ„’ð`?V˜%KPۗäËòÅ6ŠÄÜ5ÞW JŠPÕ;ÅçõÇsÁ.%ìä¡|tô@.aH¨*wì¹P’œ¨æù NP­Å)Ò¼8µ=n)ÌWê-!ö&ª×â)psªøñ,iA\’*N¼(‡§Š_¢„&PÀ‘    &‚ níj肿T3a€d ³Z3°"µF¯‰ü‘È×÷Ï
A!ÔÔª®Î «¶°E.x
------ xx
  • multipart/form-data 방식으로 전송된 결과를 로그로 확인할 수 있다.

멀티파트 사용 옵션

# 파일 하나의 최대 사이즈 기본 1MB
spring.servlet.multipart.max-file-size=1MB

# 하나의 여러 파일을 업로드할 수 있는데 그 전체 합, 기본 10MB
spring.servlet.multipart.max-request-size=10MB
  • 업로드 사이즈를 제한하는 설정을 추가할 수 있다. 사이즈가 넘으면 예외 발생
spring.servlet.multipart.enabled=true
  • 기본이 true로 설정되어 있으며 multipart를 처리하기 위해 사용한다. **해당 옵션이 켜져있으면 멀티파트 리졸버를 실행한다. 멀티파트 요청인 경우 HttpServletRequestMultipartHttpServletRequest로 변환해서 반환한다.
  • *MultipartFile 객체를 사용하는 것이 더 편리하기 때문에 참고만 해두자. *

 

서블릿과 파일 업로드 2

서블릿이 제공하는 Part에 대해 알아보고 실제 파일도 서버에 업로드 해보자. 파일 업로드를 위해선 실제 파일이 저장되는 경로가 필요하다.

application.properties

file.dir=/Users/jaewookjung/uploadTest/
  • 반드시 해당 경로가 실제로 존재해야 한다. 마지막에 /가 포함되어야 한다.

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

      // 데이터 읽기
      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";
  }
}
  • @Valueapplication.properties에 지정한 값을 가져오는 스프링에서 제공하는 어노테이션이다.
  • 멀티파트 형식은 전송 데이터를 하나하나 Part로 나누어 전송한다. parts에는 나누어진 데이터가 각각 담긴다/
    • part.getSubmittedFileName() : 클라이언트가 전달한 파일명
    • part.getInputStream() : Part의 전송 데이터 읽기
    • part.write() : Part를 통해 전송된 데이터 저장

큰 용량의 파일을 업로드하면 바이너리 데이터가 많아지므로 로그 옵션을 끄는 것이 좋다.

서블릿이 제공하는 Part는 편하긴 하지만, HttpServletRequest를 사용해야 하고, 추가로 파일 부분만 구분하려면 여러 코드를 넣어야한다. 스프링에서 제공하는 MultipartFile에 대해 이어서 알아보자.

 

스프링 파일 업로드

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

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 에서도 동일하게 사용 가능ㅎ다ㅏ.
  • file.getOriginalFilename() : 업로드 파일명
  • file.transferTo() : 파일 저장

 

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

image

요구사항

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

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;
  private List<UploadFile> imageFiles;
}

UploadFile - 업로드 파일 정보 보관

package hello.upload.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;
  }
}
  • 파일명이 중복되는 경우 덮어씌워지는 것을 방지하기 위해 storeFileName 별도 생성 (일종의 UUID)
  • uploadFileName 은 고객에게 보여줄 파일명

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;

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

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

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;

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

  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);
  }
}
  • createStoreFileName() : 서버 내부에서 관리하는 파일명을 UUID를 사용해서 충돌하지 않도록 처리
  • extractExt() : 확장자를 파싱한 후 해당 확장자를 UUID에 붙여서 저장

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 List<MultipartFile> imageFiles;
    private MultipartFile attachFile;
}
  • 이미지를 다중 업로드 하기 위해 List<MultipartFile> 로 생성

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.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.multipart.MultipartFile;
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());

    // 데이터베이스에 저장
    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:/Users/../UUID.png"
    log.info(fileStore.getFullPath(filename));
    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 +"\"";
    return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition).body(resource);
  }
}
  • @GetMapping("/images/{filename}") : img 태그로 이미지를 조회할 때 사용하며, UrlResource로 이미지 파일을 읽어서 @ResponseBody로 이미지 바이너리를 반환한다.
  • @GetMapping("/attach/{itemId}") : 파일을 다운로드할 때 고객이 업로드한 파일 명으로 다운로드할 수 있도록 한다. 추가적으로 이미지 id 값을 요청받았는데 권한 체크 같은 상황에 적용시킬 수 있다. HttpHeaders.CONTENT_DISPOSITION을 헤더에 attach; filename="파일명"을 담아줘야 파일 다운로드가 가능하다.

item-form - 등록 폼 뷰

<!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 을 부여하면 파일을 여러 개 업로드할 수 있다.

item-new - 조회 뷰

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

본 포스팅은 인프런 - 김영한님의 '스프링 MVC 2편 - 백엔드 웹 개발 활용 기술'을 참고하였습니다.

반응형
profile

제육's 휘발성 코딩

@sasca37

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요! 맞구독은 언제나 환영입니다^^