관리 메뉴

나구리의 개발공부기록

파일 업로드 - 소개 및 프로젝트 생성, 서블릿과 파일 업로드, 스프링과 파일 업로드, 예제로 구현하는 파일 업로드/다운로드 본문

인프런 - 스프링 완전정복 코스 로드맵/스프링 MVC 2편 - 백엔드 웹 개발 핵심 기술

파일 업로드 - 소개 및 프로젝트 생성, 서블릿과 파일 업로드, 스프링과 파일 업로드, 예제로 구현하는 파일 업로드/다운로드

소소한나구리 2024. 9. 10. 17:09

  출처 : 인프런 - 스프링 MVC 2편 - 백엔드 웹 개발 핵심 기술 (유료) / 김영한님  
  유료 강의이므로 정리에 초점을 두고 코드는 일부만 인용  

https://inf.run/Gmptq


1. 파일 업로드 소개 및 프로젝트 생성

1) 파일 업로드 소개

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

(1) HTML 폼 전송방식

  • application/x-www-form-urlencoded
  • multipart/form-data

(2) application/x-www-form-urlencoded

  • HTML 폼 데이터를 서버로 전송하는 가장 기본적인 방식이며 Form 태그에 별도의 enctype 옵션이 없으면 웹브라우저는 요청 HTTP 메시지의 헤더에 Content-Type: application/x-www-form-urlencoded 을 추가함
  • 그리고 폼에 입력한 전송항목을 HTTP Body에 문자로 username-kim&age=20과 같이 &으로 구분해서 전송함
  • 그런데 파일을 업로드 하려면 파일은 문자가 아닌 바이너리 데이터를 전송 해야하는데 문자를 전송하는 해당 방식으로는 처리가 어렵고, 보통 폼을 전송할 때는 파일만 전송하는것이 아니라 이름, 나이 등의 문자 정보와 첨부파일은 바이너리 데이터로 동시에 전송 해야함

application/x-www-form-urlencoded 방식

(3) multipart/form-data방식

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

PART :  폼데이터의  ------XXX 로 나뉘어진 부분을 각각 파트 부름,

multipart/form-data 방식, 웹브라우저가 생성한 요청메시지의 형식이 중요함

2) 프로젝트 생성

 

(1) 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> ```

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

1) 코드 작성 및 실행

(1) ServletUploadControolerV1

  • .getParts()로 multipart/form-data 전송 방식에서 각각 나누어진 파트들을 받아서 확인할 수 있음
package hello.upload.controller;

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

 

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

 

(3) application.properties

  • logging.level.org.apache.coyote.http11=trace : HTTP 메시지를 로그로 남겨서 볼 수 있음
  • 스프링 3.2부터는 trace로 설정해야 로그로 볼수 있고 그 이전 버전은 debug로도 가능

(4) 실행 결과

  • 코드를 작성하고 실행 후 입력 창에 값과 파일을 넣고 제출을 해보면 아래의 로그를 확인할 수 있음 (한글은 깨지는 이슈가 있음)
  • 이렇게 Part 별로 구분되어 있고, log.info의 parts를 보면 2개가 담겨있는 것을 확인할 수 있음 -> 꺼내서 자유롭게 쓸 수 있다는 뜻
  • PNG 밑에 로그가 엄청 깨져서 출력될텐데 PNG 파일을 바이너리 데이터로 전송했다보니 깨져서 보이는 것이므로 정상임

출력된 로그들

2) 멀티파트 사용 옵션 - application.properties에 적용

(1) 업로드 사이즈 제한

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

 

(2) spring.servlet.mutipart.enabled = false

  • 기본값은 true, 서블릿 컨테이너에게 멀티파트 데이터를 처리하라고 설정
  • 해당 옵션을 끄고 실행해 보면 서블릿 컨테이너는 멀티파트와 관련된 처리를 하지 않아서 로그에  request.getParameter(), request.getParts()의 결과가 비어있음
  • false일 겨우 HttpServletRequest 객체가 RequestFacade로 처리되고 true일 경우 StandardMultipartHttpServletRequest로 처리됨

** 참고

  • 해당 옵션이 true일 경우 스프링의 DispatcherServlet에서 멀티파트 리볼버(MultipartResovler)를 실행함
  • 멀티파트 리졸버는 서블릿 컨테이너가 멀티파트 요청인 경우 MultipartHttpServletRequest으로 변환해서 반환하는데, MultipartHttpServletRequest는 HttpServletRequest의 자식 인터페이스이고 멀티파트와 관련된 추가 기능을 제공함
  • 스프링이 제공하는 기본 리졸버는 MultipartHttpServletRequest 인터페이스를 구현한 StandardMultipartHttpServletRequest를 반환하고, 컨트롤러에서 MultipartHttpServletRequest를 주입받을 수 있어 멀티파트와 관련된 여러가지 처리를 편리하게 할 수 있음
  • 그러나 MultipartFile을 사용하는 것이 더 편리하기 때문에 MultipartHttpServletRequest를 잘 사용하지 않아서 더 자세한 내용은 MultipartResovler를 검색 해볼 것

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

  • Part와 실제 파일을 서버에 업로드 해보기
  • allication.properties에 실제 파일이 저장될 경로를 지정 후 학습
file.dir=/Users/jinagyeomi/Downloads/file/

1) 코드 작성

(1) ServletUploadControllerV2

  • @Value(): application.properties에서 설정한 file.dir의 값을 주입
  • part.getSubmittedFileName(): 클라이언트가 전달한 파일명
  • part.getInputStream(); Part의 전송 데이터를 읽을 수 있음
  • part.write(...): Part를 통해 전송된 데이터를 저장
package hello.upload.controller;

@Slf4j
@Controller
@RequestMapping("/servlet/v2")
public class ServletUploadControllerV2 {

    @Value("${file.dir}")   // application.properties 속성을 그대로 가져올 수 있음
    private String fileDir; // fileDir 변수에 /Users/jinagyeomi/Downloads/file/ 값이 들어가짐

    @GetMapping("/upload")
    public String newFile() {
        return "upload-form";
    }

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

        // 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());    // 파트의 body size 정보

            // 데이터 읽기
            // part.getInputStream() : 파트의 전송 데이터를 읽을 수 있음
            InputStream inputStream = part.getInputStream();

            /**
             * 바이너리데이터 <-> 문자 변환시에는 항상 캐릭터 셋을 지정해줘야함
             * StreamUtils : 스프링에서 제공하는 스트림을 편리하게 다룰 수 있는 클래스
             * 성능 최적화 및 예외처리가 포함되어있어 사용자가 직접 스트림 처리를 구현하는 것보다 효율 적임
             * .copyToString : 문자열로 변환
             */

            String body = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);
            log.info("body={}", body);

            // 파일에 저장하기
            // StringUtils.hasText() : 문자열이 null, 빈 문자열, 혹은 공백만으로 이루어지지 않았는지를 확인하는 유틸리티 메서드
            if (StringUtils.hasText(part.getSubmittedFileName())) {
                String fullPath = fileDir + part.getSubmittedFileName();
                log.info("fullPath={}", fullPath);
                part.write(fullPath);   // 파트를 통해 전송된 데이터를 저장할 수 있음
            }
        }
        return "upload-form";
    }
}

 

(2) 실행

  • 실행 후 로그에 찍힌 경로에 가보면 실제로 파일이 저장되어있음 (파일이 저장되어있지 않다면 저장 경로를 다시 확인해볼 것)
  • 큰 용량의 파일을 업로드 테스트 하면 버벅일 수 있으므로 적당한 용량으로 테스트하고, 그래도 버벅인다면 로그 출력하는 옵션을 모두 끄거나, 파트의 body의 내용을 출력하는 로그를 끄면 조금 나아질 수 있음

실행 후 로그와 파일이 저장된 결과

  • 서블릿이 제공하는 Part는 편하기는 하지만 HttpServletRequest를 사용해야하고 추가로 파일 부분만 구분하려면 여러가지 코드를 넣어야하는데, 스프링 이러한 부분은 편리하게 처리하는 기능들을 게종함

4. 스프링과 파일 업로드

1) 코드 작성 및 실행

  • 코드 작성후 실행해보면 서블릿으로 실행했던 것처럼 똑같이 실행되지만 Controller 코드가 상당히 간편해 진 것을 확인할 수 있음
  • file.getOriginalFileename() : 업로드 파일 명
  • file.transferTo(...) : 파일 저장
package hello.upload.controller;

@Slf4j
@Controller
@RequestMapping("/spring")
public class SpringUploadController {

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

    @GetMapping("/upload")
    public String newFile() {
        return "upload-form";
    }

    @PostMapping("/upload") // HttpServletRequest 는 없어도 되지만 로그를 찍기위해 파라미터에 추가함
    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";
    }
}

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

1) 요구사항 정의

(1) 상품을 관리

  • 상품 이름
  • 첨부파일 하나
  • 이미지 파일 여러개

(2) 첨부파일을 업로드 다운로드 할 수 있음

(3) 업로드한 이미지를 웹 브라우저에서 확인 할 수 있음

2) 코드 구현

(1) Item - 상품 도메인

package hello.upload.domain;

@Data
public class Item {

    private Long id;
    private String itemName;
    private UploadFile attachFile;
    private List<UploadFile> imageFiles;    // 이미지는 여러개를 업로드가 가능하게
}

 

(2) UploadFile - 업로드 파일 정보 보관

  • 서버에서는 저장할 파일명이 겹치지 않도록 내부에서 관리하는 별도의 파일명이 중요함
  • 서로 다른 고객이 같은 파일 이름을 업로드 하는 경우 기존 파일 이름과 충돌이 날 수 있기 때문에 고객이 업로드한 파일명, 서버 내부에서 관리하는 파일명 이렇게 따로 관리해야함
package hello.upload.domain;

@Data
@AllArgsConstructor
public class UploadFile {
    /**
     * 업로드 파일명과 저장 파일명을 다르게 하는 이유는, 여러 사람이 동일한 파일명으로 업로드 할 시
     * 덮어쓰기가 되는 것을 방지하기위해 저장한 파일명은 UUID 등으로 안겹치게 만들어서 저장하도록 하기위해서 따로 관리함
     */
    private String uploadFileName;  // 업로드한 파일명
    private String storeFileName;   // 저장한 파일명

}

 

(3) ItemRepository

package hello.upload.domain;

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

 

(4) FileStore - 파일 저장과 관련된 업무 처리

  • createStoreFileName() : 파일명을 UUID로 변환하고 추출한 확장자와 합쳐서 서버에 저장할 파일명으로 변환하는 메서드 생성
  • extractExt() : 업로드한 파일명에서 확장자만 분리해서 반환하는 메서드 생성
package hello.upload.file;

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

        // 업로드한 파일명을 확장자를 유지한채 파일명은 UUID로 바꿔서 storeFileName 으로 반환
        String storeFileName = createStoreFileName(originalFilename);
        multipartFile.transferTo(new File(getFullPath(storeFileName)));
        return new UploadFile(originalFilename, storeFileName);
    }

    // 파일명을 UUID로 변환하고, 추출한 확장자와 합쳐서 저장할 파일명으로 반환하는 메서드
    private String createStoreFileName(String originalFilename) {
        String uuid = UUID.randomUUID().toString();  // 업로드 파일명 -> UUID 변환
        String ext = extractExt(originalFilename);   // 확장자 추출
        return uuid + "." +ext;      // UUID.확장자 로 파일명이 완성됨
    }

    // 파일명에서 확장자를 추출하는 메서드
    private String extractExt(String originalFilename) {
        int pos = originalFilename.lastIndexOf(".");      // 뒤에서부터 "."을 찾고 가장 먼저 찾은 위치의 인덱스 값을 반환
        return originalFilename.substring(pos + 1); // 확장자만 추출
    }
}

 

(5) ItemForm - 상품 저장용 폼

  • DTO 역할(데이터를 전달해주는 역할)해주는 Form
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 MultipartFile attachFile;
    private List<MultipartFile> imageFiles;

}

 

(6) ItemController

  • @GetMapping("/items/new") : 등록 폼을 보여줌
  • @PostMapping("/items/new") : 폼의 데이터를 저장하고 보여주는 화면으로 리다이렉트
  • @GetMapping("/items/{id}") : 상품을 보여줌
  • @GetMapping("/images/{filename}") : <img> 태그로 이미지를 조회할 때 사용, UrlResource로 이미지 파일을 읽어서 @ResponseBody로 이미지 바이너리를 반환함
  • @GetMapping("/attach/{itemId}") : 파일을 다운로드 할 때 실행, 파일 다운로드 시 권한 체크같은 복잡한 상황까지 가정한다고 생각했을 때 itemId를 요청하도록 하면 아이템에 접근할 수 있는 권한이 있는 대상자만 파일을 다운로드 할 수 있게 되고, 고객이 업로드한 파일 이름으로 다운로드 할 수 있도록 해줌
  • 상세한 로직들은 코드의 주석을 확인
package hello.upload.controller;

@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 {

        /**
         * 보통 파일은 별도의 스토리지에(AWS-s3와같은 스토리지) 저장하고 데이터베이스에는 파일이 저장된 경로만 저장함
         * 데이터베이스에는 파일 자체를 저장하지 않음
         * 경로도 경로의 FullPath 를 저장하는 경우보다는 기본 경로만 맞춰놓고 그 이후의 상대경로를 데이터베이스에 저장함
         */

        // 파일 저장
        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()에 id를 itemId로 넘김
        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(...): 스프링의 Resource 구현체중 하나로 URL을 통해 리소스를 나타내는 객체
         * "file:파일이 저장된 전체 경로"로 해당 위치를 가리키는 UrlResource 객체를 생성 후 반환
         * 해당 방법은 보안에 취약하므로 여러 체크 로직을 넣어주는 것이 좋음
         */
        // 한글, 특수문자 깨짐을 방지하기 위해 UriUtils.encode()로 UTF-8로 변환
        return new UrlResource("file:" + fileStore.getFullPath(filename));
    }

    // 텍스트 파일 다운로드 로직
    @GetMapping("/attach/{itemId}")
    public ResponseEntity<Resource> downloadAttach(@PathVariable Long itemId) throws MalformedURLException {
        // item에 접근하기 위한 여러 권한 검증 로직이 있다고 가정했을 때
        // item에 접근할 수 있는 사용자만 해당 파일을 다운받을 수 있도록 itemId로 조회
        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);

        // 한글, 특수문자 깨짐을 방지하기 위해 UriUtils.encode()로 UTF-8로 변환
        String encodeUploadFileName = UriUtils.encode(uploadFileName, StandardCharsets.UTF_8);

        // .header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition)
        // 리턴시 위의 내용을 넣지 않고 그냥 .body()로만 보내면 다운로드가 아니라 그냥 웹 브라우저에서 파일의 내용을 보여줌
        String contentDisposition = "attachment; filename=\"" + encodeUploadFileName + "\"";
        return ResponseEntity.ok()
                .header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition).body(resource);
    }
}

 

(7) 조회 뷰, item-view.html

  • 첨부 파일은 링크로 걸어두고 이미지는 <img> 대그를 반복해서 출력
<!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>

 

(8) 등록 폼 뷰, item-form.html

  • 다중 파일 업로드가 필요할 경우 multiple="multiple" 옵션을 주면 됨
<!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>

</body>
</html>

 

(8) 실행

  • 실행해보면 파일 업로드 및 다운로드, 다중 이미지 업로드 및 출력, 저장 스토리지에 파일들 저장 등이 정상적으로 동작하는걸 확인할 수 있음