본문 바로가기
Backend/Spring | SpringBoot

[SpringBoot] 파일 업로드

by 2245 2023. 10. 2.

목차

     

    HTML Form 전송 방식

    HTML에서 Form을 통한 데이터 전송방식에는 2가지가 있습니다.

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

     

    application/x-www-form-urlencoded

    일반적으로 데이터들을 문자타입으로 전송할 때 HTTP Body에 & 구분자를 사용하여 전송하는 가장 기본적인 방식입니다. 

    • Content-Type: application/x-www-form-urlencoded

     

     

    • 하지만, 파일은 문자가 아니라 바이너리 데이터를 전송해야 합니다.
    • 문자를 전송하는 방식으로 파일을 전송하지 못합니다.
    • 또한, 파일만 전송하는 것이 아니라 다음과 같이 다른 타입의 데이터들도 함께 전송합니다.
    - 이름
    - 나이
    - 첨부파일

     

    • 이름과 나이는 문자로 전송하고, 첨부파일은 바이너리로 전송해야 하므로 문자와 바이너리를 동시에 전송해야 합니다.
    • 이 때 HTTP는 multipart/form-data 라는 전송 방식을 사용합니다. (해당 방식을 사용하면 영상, 이미지, 파일, 음성 모두 전송 가능합니다.)

     

    multipart/form-data

    • 전송 시 Form 태그에 enctype 옵션이 포함되어 있어야 합니다. (enctype="multipart/form-data")
    • 해당 옵션을 지정하면 여러 파일과 폼의 내용을 함께 전송할 수 있습니다. (그래서 이름이 multipart)
    • HTTP 메시지 바디는 여러 개의 Part로 구성되어 있습니다.

     

    Part

    • 예제에선 username, age, file1 의 3개의 Part가 존재합니다.
    • 각각의 Part는 다시 Header와 Body로 나누어집니다.
    • Header에는 Content-Disposition이라는 항목별 헤더가 추가되어 있고 부가 정보가 담겨있습니다.
    • 파일의 경우, filename과 Content-Type 헤더가 추가됩니다. 
    • 일반 데이터의 Body에는 문자가 담겨 있고, 파일은 바이너리 데이터가 담겨 전송됩니다. 
    • multipart/form-data는 이렇게 각각의 항목을 구분해여 한번에 전송합니다.

     

    서블릿을 통한 파일 업로드

    먼저, 서블릿을 사용하여 파일 업로드를 구현해봅시다.

     

    파일명과 파일을 선택 후 제출

    • itemName: 짱구
    • file: 짱구운동회.jpg의 바이너리 파일
      • filename: 짱구운동회.jpg
      • content-type: image/jpg
      • 위의 두 개의 헤더는 파일을 전송하면 자동으로 부여되는 헤더입니다. 

    경로 설정 (application.properties)

    •  
    • 먼저 파일을 업로드하기 위해선 파일이 저장될 저장 공간의 경로가 필요합니다.
    • 해당 경로에 실제 폴더를 미리 생성해두고 경로를 입력해둡시다.
    file.dir=파일 업로드 경로 설정(예): /Users/kimyounghan/study/file/
    • 경로 마지막에 / (슬래시)가 반드시 포함되어야 합니다. 

    ServletUploadController

    @Slf4j
    @Controller
    @RequestMapping("/servlet/v2")
    public class ServletUploadControllerV2 {
    
        @Value("${file.dir}")
        private String fileDir;     //application.properties에 저장되어 있는 경로 가져옴
    
        /**
         * 등록 폼으로 이동
         */
        @GetMapping("/upload")
        public String newFile() {
            return "upload-form";
        }
    
        /**
         * 파일 업로드
         */
        @PostMapping("/upload")
        public String saveFileV1(HttpServletRequest request) throws ServletException, IOException {
            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());  //name=file
    
                //part도 header와 body로 구분된다.
                Collection<String> headerNames = part.getHeaderNames();
                for(String headerName : headerNames) {
                    log.info("header {}: {}", headerName, part.getHeader(headerName));  //header content-disposition: form-data; name="file"; filename="짱구운동회.jpg" header content-type: image/jpeg 
                }
    
                //편의 메서드
                log.info("submittedFileName={}", part.getSubmittedFileName());  //submittedFileName=짱구운동회.jpg
                log.info("size={}", part.getSize());    //size=2792
    
                //데이터 읽기
                InputStream inputStream = part.getInputStream();
                String body = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);
                log.info("body={}", body);  //body=wlkjek2ljlese...
    
                //파일에 저장하기
                if(StringUtils.hasText(part.getSubmittedFileName())) {
                    String fullPath = fileDir + part.getSubmittedFileName();
                    log.info("파일 저장 fullPath={}", fullPath);  //파일 저장 fullPath=C:/Joohui/fileupload/짱구운동회.jpg
                    part.write(fullPath);
                }
            }
    
            return "upload-form";
        }
    }

    part 주요 메서드

    서블릿이 제공하는 Part는 멀티파트 형식을 편리하게 읽을 수 있도록 다양한 메서드를 제공합니다.

    • part.getSubmittedFileName() :  클라이언트가 전송한 파일명 (filename 헤더)
    • part.getInputStream() : part의 전송 데이터
    • part.write(...) : part를 통해 경로에 파일 저장

     

    resources/templates/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>

    실행

    Part 출력 부분

    Content-Type: multipart/form-data; boundary=----xxxx
    
    ------xxxx
    Content-Disposition: form-data; name="itemName"
    
    Spring
    ------xxxx
    Content-Disposition: form-data; name="file"; filename="test.data"
    Content-Type: application/octet-stream
    
    sdklajkljdf...

     

    http://localhost:8080/servlet/v2/upload

    파일명과 파일을 선택 후 제출

    • itemName: 짱구
    • file: 짱구운동회.jpg

    결과 로그

    ==== PART ====
    name=itemName
    header content-disposition: form-data; name="itemName"
    submittedFileName=null
    size=6
    body=짱구
    ==== PART ====
    name=file
    header content-disposition: form-data; name="file"; filename="짱구운동회.jpg"
    header content-type: image/jpeg
    submittedFileName=짱구운동회.jpg
    size=2792
    body=wlkjek2ljlese...
    파일 저장 fullPath=C:/Joohui/fileupload/짱구운동회.jpg

    설정한 경로에 저장된 파일

     

    멀티파트 관련 옵션

    업로드 사이즈 제한

    • 큰 파일을 무제한 업로드하게 둘 수는 없으므로, 업로드 사이즈를 제한할 수 있습니다.
    • 사이즈를 넘으면 예외(SizeLimitExceededException)가 발생합니다.
    spring.servlet.multipart.max-file-size=1MB		//파일 하나의 최대 사이즈 (기본 1MB)
    spring.servlet.multipart.max-request-size=10MB		//전체 최대 사이즈 (기본 10MB)

     

    멀티파트 사용 유무

    끄기

    spring.servlet.multipart.enabled=false

     

    결과 로그

    request=org.apache.catalina.connector.RequestFacade@xxx
    itemName=null
    parts=[]
    • 전송을 클릭하면 웹 브라우저에서 파일을 전송하긴 하지만, 옵션을 끄면 서블릿 컨테이너가 처리를 하지 않습니다.
    • 따라서 로그를 보면 request.getParamter("itemName")이 null이고, request.getParts()가 비어있습니다.

     

    켜기

    spring.servlet.multipart.enabled=true	//기본 true

     

    결과 로그

    request=org.springframework.web.multipart.support.StandardMultipartHttpServletRequest
    itemName=Spring
    parts=[ApplicationPart1, ApplicationPart2]

     

    참고 RequestFacade → StandardMultipartHttpServletRequest
    옵션을 껐다 켰을 때의 결과 로그를 보면, request의 객체가 RequestFacade에서 StandardMultipartHttpServletRequest로 변경된 것을 확인할 수 있습니다. 

    이유는 옵션을 켜면, 스프링의 DispatcherServlet이 멀티파트 리졸버(MultipartResolver)를 실행합니다.
    멀티파트 리졸버는 서블릿 컨테이너가 전달하는 일반적인 HttpServletRequest를 MultipartHttpServletRequest로 변환합니다. MultipartHttpServletRequest는 HttpServletRequest의 자식 인터페이스이며, MultipartHttpServletRequest는 멀티파트 관련 추가 기능을 제공합니다. 
    스프링은 MultipartHttpServletRequest 인터페이스를 구현한 StandardMultipartHttpServletRequest를 전달합니다. 

    따라서, 옵션을 키면 HttpServletRequest 대신에 MultipartHttpServletRequest를 주입받을 수 있는데, 멀티파트와 관련된 여러 메서드를 제공하므로 매우 편리합니다.
    하지만, 서블릿에서 제공하는 MultipartHttpServletRequest 대신, 스프링이 제공하는 MultipartFile이 훨씬 편리하게 때문에 대부분 MultipartFile을 사용하여 개발합니다. 

     

     

    정리

    • 서블릿이 제공하는 Part는 HttpServletRequest를 사용해야 하고, 파일을 구분하기 위해서 여러 코드를 추가해야 합니다.
    • 스프링이 이 부분을 얼마나 편리하게 하는지 확인해봅시다.

     

     

    스프링을 통한 파일 업로드

    스프링은 @RequestParam MultipartFile을 사용하여 파일을 편리하게 조회할 수 있습니다.

     

    SpringUploadController

    @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에 맞추어 파라미터명을 작성해야 합니다.
    • @ModelAttribute에도 Multipart를 동일하게 사용할 수 있습니다.

     

    MultipartFile 주요 메서드

    • file.getOriginalFilename() : 클라이언트가 전송한 파일명
    • file.transferTo(...) : 파일 저장 

     

    실행

    http://localhost:8080/spring/upload

     

    실행 로그

    request=org.springframework.web.multipart.support.StandardMultipartHttpServletRequest@38703b07
    itemName=shinchan
    multipartFile=org.springframework.web.multipart.support.StandardMultipartHttpServletRequest$StandardMultipartFile@420a68e0
    파일 저장 fullPath=C:/Joohui/fileupload/짱구운동회.jpg

     

     

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

    본격적으로 요구사항에 따른 파일 업로드 및 다운로드를 구현해 봅시다.

    상품 등록

    요구사항

    • 상품 저장 및 조회
      • 상품 : 상품명, 첨부파일, 이미지 여러 장
    • 상품 조회 시 점부파일 다운로드 가능
    • 상품 조회 시 저장 시 업로드했던 이미지 여러 장 확인

     

    Item - 상품 도메인

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

     

    UploadFile - 업로드한 파일 데이터

    @Data
    public class UploadFile {
        private String uploadFileName;  //클라이언트가 전송한 파일명
        private String storeFileName;   //실제 저장된 파일명 (파일명 중복 제거)
    
        public UploadFile(String uploadFileName, String storeFileName) {
            this.uploadFileName = uploadFileName;       
            this.storeFileName = storeFileName;         
        }
    }

    파일명 분리 이유

    • 고객이 전송한 파일명은 다른 고객이 전송한 파일명과 겹칠 수 있습니다. (ex) test.jpg, test.jpg)
    • 따라서 서버 내부에서 파일을 구분하기 위해 겹치지 않는 고유한 이름을 사용하여 저장합니다.

     

    ItemRepository - 상품 레포지토리

    @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 - 파일 저장

    @Component
    public class FileStore {
    
        @Value("${file.dir}")
        private String fileDir;
    
        public String getFullPath(String filename) {
            return fileDir + filename;
        }
    
        /**
         * 파일 한개 저장
         */
        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);
        }
    
        /**
         * 파일 여러개 저장
         */
        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;
        }
    
        /**
         *  고유한 파일 이름 생성
         */
        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);     //확장자 반환
        }
    }

    ItemForm

    상품 저장 시 전달되는 데이터 저장

    @Data
    public class ItemForm {
        private Long itemId;
        private String itemName;
        private List<MultipartFile> imageFiles;     //이미지 여러 장 전송
        private MultipartFile attachFile;           //첨부파일 한 개 전송
    }

    ItemController

    package hello.upload.controller;
    
    import org.springframework.core.io.Resource;
    ...
    
    @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";
        }
    
        /**
         * <img> 태그로 이미지 조회
         * UrlResource로 경로에 저장된 이미지를 읽어서, @ResponseBody로 이미지 바이너리를 반환
         */
        @ResponseBody
        @GetMapping("/images/{filename}")
        public Resource downloadImage(@PathVariable String filename) throws MalformedURLException {
            return new UrlResource("file:" + fileStore.getFullPath(filename));
        }
    
        /**
         * 첨부파일 다운로드
         * 파일 다운로드 시, 권한 체크를 상황을 위해 이미지 id를 파라미터로 전달
         * 다운로드를 위해선 Content-Disposition 헤더를 추가해야 한다.
         * 다운로드 시 고객이 업로드한 파일명을 사용하기 위해 filename은 uploadFileName 사용
         */
        @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);
        }
    }

     

    참고 파일은 데이터베이스가 아닌 Storage에 저장
    코드를 보면, 데이터베이스에는 파일명만 저장이 되고, 실제 파일은 다른 곳에 저장이 됩니다. 
    AWS를 쓴다면 S3 같은 곳에 저장합니다.
    데이터 베이스에는 파일의 경로같은 정보만 저장하고, 파일 자체를 저장하진 않습니다. 
    경로도 절대경로보다는 상대경로를 사용합니다. 

     

    등록 폼 뷰 

    resources/templates/item-form.html

    • 다중 파일 업로드를 위해선 multiple="multiple" 옵션을 사용하면 됩니다. 
    • ItemForm의 List<MultipleFile>로 여러 파일이 저장됩니다. 
    <!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>

     

    조회 뷰

    resources/templates/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>

     

    실행

    http://localhost:8080/items/new

     

    상품 등록

     

    상품 조회

     

    해당 경로에 저장된 파일들

     

     

    다운로드

     

     

    다운로드된 모습

     

     


    출처

    https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-mvc-2/dashboard

     

    스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 - 인프런 | 강의

    웹 애플리케이션 개발에 필요한 모든 웹 기술을 기초부터 이해하고, 완성할 수 있습니다. MVC 2편에서는 MVC 1편의 핵심 원리와 구조 위에 실무 웹 개발에 필요한 모든 활용 기술들을 학습할 수 있

    www.inflearn.com