[스프링 MVC 2편] 파일 업로드
포스트
취소

[스프링 MVC 2편] 파일 업로드

파일 업로드 소개

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

application/x-www-form-urlencoded

  • HTML 폼 데이터를 서버로 전송하는 가장 기본적인 방법이다.
  • Form 태그에 별도의 enctype 옵션이 없으면 웹 브라우저는 요청 HTTP 메시지의 헤더에 다음 내용을 추가한다.
    • Content-Type: application/x-www-form-urlencoded
  • 그리고 폼에 입력한 전송할 항목을 HTTP Body에 문자로 username=kim&age=20와 같이 &로 구분해서 전송한다.
  • 다만 문제점은 파일은 문자가 아니라 바이너리 데이터를 전송해야 한다.
    • 바이너리 데이터만 전송한다면 모를까 실무에서는 문자와 바이너리를 동시에 전송해야 하는 상황이 대부분이다.
    • 이 문제를 해결하기 위해 HTTP는 multipart/form-data라는 전송 방식을 제공한다.

multipart/form-data

  • multipart/form-data 전송 방식은 application/x-www-form-urlencoded 전송 방식과 메시지를 생성하는 방법부터 다르다.
    • application/x-www-form-urlencoded
      • HTTP Body에 문자로 username=kim&age=20와 같이 &로 구분해서 전송한다.
    • multipart/form-data
      • HTTP Body에 각각의 전송 항목이 구분되어 있다.
      • application/x-www-form-urlencoded에서는 username=kim&age=20와 같이 전송 항목들이 연결되어 있다.
      • multipart/form-data에서는 usernameage가 구분되어 있다.
      • Content-Disposition이라는 항목별 헤더가 추가되어 있고 여기에 부가 정보가 있다.
      • 파일의 경우 intro.png처럼 파일 이름과 image/png처럼 Content-Type이 추가되고 바이너리 데이터가 전송된다.
      • 구분이 되어있다고 따로따로 보내는 게 아니라 구분이 되어 있는 상태로 한 번에 전송한다.

프로젝트 생성

  • 스프링 이니셜라이저를 통해 프로젝트를 생성하자.
    • 프로젝트 선택
      • Project
        • Gradle - Groovy Project
      • Language
        • Java
      • Spring Boot
        • 3.x.x
    • Project Metadata
      • Group
        • hello
      • Artifact
        • typeconverter
      • Name
        • typeconverter
      • Package name
        • hello.typeconverter
      • Packaging
        • Jar (주의!)
      • Java
        • 17 또는 21
    • Dependencies
      • Spring Web
      • Thymeleaf
      • Lombok

서블릿과 파일 업로드1

HTML 파일 만들기

  • 우선은 파일을 업로드하기 위해 HTML을 생성하자.
<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>

컨트롤러 만들기

  • 파일을 업로드하기 위해 간단한 컨트롤러를 만들자.
@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";
}

테스트

  • application.propertieslogging.level.org.apache.coyote.http11=trace를 추가해주자.
    • 스프링 부트 3.2 이전이면 trace 대신에 debug를 사용해도 된다.
  • 상품명으로 “테스트”를 입력하고, 적당한 파일과 함께 제출 버튼을 눌러보자.
    • 그럼 아래와 같은 결과가 나온다.
request=org.springframework.web.multipart.support.StandardMultipartHttpServletRequest@a467fff
itemName=테스트
parts=[org.apache.catalina.core.ApplicationPart@48180c7, org.apache.catalina.core.ApplicationPart@be148f]
  • 콘솔을 확인해보면 multipart/form-data 방식으로 전송된 것을 확인할 수 있다.

멀티파트 사용 옵션

  • 업로드 사이즈 제한
    • spring.servlet.multipart.max-file-size=1MB
      • 파일 하나의 최대 사이즈
      • 기본값 : 1MB
    • spring.servlet.multipart.max-request-size=10MB
      • 멀티파트 요청 하나에 업로드한 여러 파일들 크기의 전체 합이다.
      • 기본값 : 10MB
    • 큰 파일을 무제한 업로드하게 둘 수는 없으므로 업로드 사이즈를 제한할 수 있다.
      • 사이즈를 넘으면 예외( SizeLimitExceededException )가 발생한다.
  • 멀티파트 금지
    • spring.servlet.multipart.enabled=false
      • multipart/form-data 방식 자체를 금지시키는 방법이다.
      • 그래서 해당 옵션을 false로 바꾸면 itemName도 null로 넘어오는 것을 볼 수 있다.
      • 기본값 : true

서블릿과 파일 업로드2

  • 이번에는 파일을 실제로 업로드해보자.

경로 만들기

  • application.properties에 실제 파일을 업로드할 경로를 정의해두자.
  • 해당 경로에 실제 폴더를 미리 만들어둬야 한다.
    • 안 그러면 파일 업로드할 때 예외가 발생한다.
  • application.properties에서 설정할 때 마지막에 / (슬래시)가 포함된 것을 꼭 확인하자.
  • 윈도우에서는 경로를 못 찾을 수도 있다.
    • IOS에서는 잘 기억이 안 나는데 Linux의 경우에는 /files라고 하면 절대 경로로 찾으려고 한다.
    • 그런데 윈도우에서는 스프링 부트의 내장 톰캣때문에 그런지는 모르곘지만 톰캣 경로도 같이 들어가버린다.
      • 예시 : C:\Users\leews\AppData\Local\Temp\tomcat.8080.11212911520451062349\work\Tomcat\localhost\ROOT\file\test.txt
    • 그래서 윈도우에서는 file.dir=C:/files/처럼 명시해줘야 한다.

경로 가져오기

  • 방금 application.properties에서 설정한 경로 변수의 이름으로 경로를 불러오자.
@Value("${file.dir}")
private String fileDir;

파일 저장하기

  • 이번에는 실제로 파일을 저장하는 기능을 만들어보자.
@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()); //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";
}
  • 실제로 실행해보면 파일이 잘 올라가는 것을 확인할 수 있다.
    • 다만 코드가 매우 길고 번거롭다.
    • 그래서 스프링에서는 MultipartFile이라는 인터페이스로 멀티파트 파일을 매우 편리하게 지원해준다.

스프링과 파일 업로드

  • 이번에는 스프링이 제공하는 MultipartFile 인터페이스를 통해 파일 업로드를 구현해보자.
@PostMapping("/upload")
@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";
}
  • 서블릿으로 업로드했을 때와 비교하면 코드 길이는 뒤로 미뤄도 사용 방식이 매우 직관적으로 바뀌었다.
  • 사용하는 메소드명들이 눈에 띄게 직관적이다.
    1. 파일이 비어있는가?
    2. 파일의 원본명을 달라.
    3. 파일을 변환해달라.
  • 다만 실제 폴더가 없으면 예외가 발생하는 건 서블릿과 동일하니 그건 참고하자.

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

  • 실제 파일이나 이미지를 업로드, 다운로드 할 때는 실제 서비스 특성에 따라 몇 가지 고려할 점이 있다.
  • 예시
    • 첨부파일은 1개만 등록 가능하다.
    • 이미지 파일은 여러 개 등록 가능하다.
    • 첨부파일을 업로드 및 다운로드할 수 있다.
    • 업로드한 이미지를 웹 브라우저에서 확인할 수 있다.
  • 위 사항들을 고려해서 기능을 만들어보자.

도메인 만들기

  • 파일 도메인
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;
  }
}
  • 상품 도메인
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;
}

폼 객체 만들기

  • 상품명, 1개의 첨부파일, 여러 개의 이미지 파일을 저장할 수 있는 폼 객체를 만들자.
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;
}

파일 관리 서비스 만들기

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;

    /**
     * 서버에 저장할 전체 파일 경로 구하기
     * @param filename 서버에 저장할 파일명
     * @return
     */
    public String getFullPath(String filename) {
        return fileDir + filename;
    }

    /**
     * 복수 파일 저장하기
     * @param multipartFiles 파일 목록
     * @return
     * @throws IOException
     */
    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;
    }

    /**
     * 단일 파일 저장하기
     * @param multipartFile 단일 파일
     * @return
     * @throws IOException
     */
    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);
    }

    /**
     * 서버 내부에서 관리하는 파일명은 유일한 이름을 생성하는 UUID를 사용해서 충돌하지 않도록 한다.
     * - 예를 들어서 고객이 a.png 라는 이름으로 업로드 하면 51041c62-86e4-4274-801d-614a7d994edb.png 와 같이 저장한다.
     * @param originalFilename
     * @return
     */
    private String createStoreFileName(String originalFilename) {
        String ext = extractExt(originalFilename);
        String uuid = UUID.randomUUID().toString();
        return uuid + "." + ext;
    }

    /**
     * 확장자를 별도로 추출해서 반환한다.
     * @param originalFilename 원본 파일명
     * @return
     */
    private String extractExt(String originalFilename) {
        int pos = originalFilename.lastIndexOf(".");
        return originalFilename.substring(pos + 1);
    }
}

상품 관리 서비스 만들기

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

업로드 컨트롤러 만들기

@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 {
    //UrlResource로 이미지 파일을 읽어서 @ResponseBody로 이미지 바이너리를 반환한다.
    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로 이미지 파일을 읽는다.
    UrlResource resource = new UrlResource("file:" + fileStore.getFullPath(storeFileName));
    log.info("uploadFileName={}", uploadFileName);

    String encodedUploadFileName = UriUtils.encode(uploadFileName, StandardCharsets.UTF_8); //UTF-8로 원본 파일명을 인코딩한다.
    String contentDisposition = "attachment; filename=\"" + encodedUploadFileName + "\""; //다운받을 때의 파일명을 지정한다.

    //요청 성공 메시지와 함께 HTTP 응답 메시지를 반환한다.
    //HTTP 응답 헤더에는 파일 정보가 포함되어 있다.
    //HTTP 응답 바디에는 파일 바이너리 데이터가 포함되어 있다.
    return ResponseEntity.ok()
            .header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition)
            .body(resource);
}

Base64 인코딩

  • Base64 인코딩 방식을 통해서 바이너리 데이터를 문자열로 바꾸는 방법이 있긴하다.
    • 다만 Base64는 바이너리 데이터를 아스키 코드 일부와 일대일로 매칭되는 문자열로 단순 치환하는 인코딩 방식이다.
    • 그냥 어거지로 Base64를 사용하여 바이너리 데이터를 문자열로 만들어주는 뜻이다.
  • Base64로 인코딩하면 UTF-8과 호환 가능한 문자열을 얻을 수 있다.
    • 이미지 파일을 Base64로 인코딩하여 문자열로 바꿀 수 있다는 뜻이다.
    • 그러면 json 형식으로 다른 문자열 데이터와 같이 보낼 수 있다는 장점이 있긴하다.
    • 또한 문자열이기 때문에 전송이 안정적이며 데이터 호환성이 좋다는 장점도 있습니다.
  • 다만 데이터를 실시간으로 주고 받는 환경에서는 보통은 Base64 인코딩 방식을 사용하지는 않는다.
    • 왜냐하면 Base64로 인코딩된 데이터는 원래의 바이너리 데이터보다 용량이 더 커지기 때문이다.
    • 그래서 실시간으로 대용량 데이터를 전송 및 저장할 때는 적합하지 않다.

출처

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.