본문 바로가기
Spring|Spring-boot

파일 업로드 및 다운로드

by oncerun 2022. 1. 26.
반응형

서블릿부터 스프링이 제공해주는 다양한 방식으로 파일 업로드 및 다운로드를 정리하려고 한다.

 

현재 클라이언트 요청에 따른 ReportingServer 모듈을 활용해 Report를 보여줌과 동시에 해당 report를 한글, 엑셀, PDF 형태로 서버로 다운로드 요청을 보내고,  응답 값으로 서버에 저장된 파일명을 리턴하여 대기후, 다운로드 요청 시 사용자 요청에 따른 형식의 보고서를 내려주는 걸 고민하고 있다. 

 

음.. 리포트 테이블 설계해야 되고, 애플리케이션에서 리포트 서버로 3번 요청을 해야 하는데, 비동기로 처리해야 할 것 같고, 동시성 문제 생각해야 되고, 모바일 기기에 응답 형식 정의해야 하고, 객체 설계해야 하고.. 확장 가능성 생각하고 중간중간 인터페이스도 생각해보고... 고민이 많은데  우선 파일 업로드 및 다운로드를 정리하다 보면 생각 날 것 같기도 하다.

 

 

사전 지식

  • multipart/form-data, application/x-www-form-urlencoded를 통한 Form 전송 시 브라우저 추가하는 HTTP Header와 HTTP Body의 이해

 

서블릿 파일 업로드

 

- 서블릿으로 테스트 환경을 구성하지는 않지만 서블릿만 사용하던 시절 파일 업로드를 어떻게 구성했는지 복습해보자.

 

1. 타임리프로 간단한 폼 생성

<form th:action method="post" enctype="multipart/form-data">
    <ul>
        <li>사용자 정의 이름 <input type="text" name="userName"></li>
        <li>파일 <input type="file" name="file" ></li>
    </ul>
    <input type="submit"/>
</form>

2. 요청을 주고받을 컨트롤러 생성

//view 페이지
@GetMapping("/servlet/file-upload")
public String fileView(){
    return "uploadTest-form";
}

//실제 파일요청 받는 부분
@PostMapping("servlet/file-upload")
public String saveFileTest(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
    Collection<Part> parts = request.getParts();
    parts.stream()
            .forEach( part -> {
                log.info("part name = {}",part.getName());

                Collection<String> headerNames = part.getHeaderNames();
                for (String headerName : headerNames) {
                    log.info(" part headerName : {},  headerValue : {}", headerName, part.getHeader(headerName));
                }
                log.info("submittedFileName : {}", part.getSubmittedFileName());
                log.info("file size : {}", part.getSize());

                InputStream inputStream = null;
                try {
                    inputStream = part.getInputStream();
                    String data = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);
                    log.info("data = {}", data);
                } catch (IOException e) {
                    e.printStackTrace();
                }

                if(StringUtils.hasText(part.getSubmittedFileName())){
                    String fullPath = fileDirectory + part.getSubmittedFileName();
                    log.info("폴더에 업로드된 파일 저장, 저장 경로 {}", fullPath);
                    try {
                        part.write(fullPath);
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                }
            });
    return "uploadTest-form";
}

 

HTTP 요청 로그

------WebKitFormBoundaryF8m1ICzWCqoBTyLu
Content-Disposition: form-data; name="userName"

스프링부트
------WebKitFormBoundaryF8m1ICzWCqoBTyLu
Content-Disposition: form-data; name="file"; filename="Spring boot.png"
Content-Type: image/png

‰PNG


 

폼에서 전송한 멀티 파트 부분의 데이터가 포함되어 서버에서 처리할 수 있다. 만약 서블릿 컨테이너가 멀티파트 요청을 처리하지 않는다면 spring.servlet.multipart.enabled 옵션을 확인해야 한다! 

HttpServletRequest객체를 log로 찍어보았을 때 RequestFacde...로 시작하면 옵션이 false로 되어 있으므로 true로 변경해주자.

 

 

- Multipartresolver

 

스프링의 DispatcherServlet에서는 spring.servlet.multipart.enabled옵션이 켜져 있는 경우 MultipartResolver를 실행한다. 

이는 서블릿 컨테이너가 전달하는 HttpServletRequest의 자식 인터페이스인 MultipartHttpServletRequest를 반환하며, 멀티파트와 관련된 추가 기능을 제공한다. 

 

스프링은 MultipartHttpServletRequest의 기본 구현체로 StandardMultipartHttpServletRequest를 반환하여 HttpServletRequest 대신 StandardMultipartHttpServletRequest를 주입받을 수 있다. 

 

public interface MultipartHttpServletRequest extends HttpServletRequest, MultipartRequest {

   /**
    * Return this request's method as a convenient HttpMethod instance.
    */
   @Nullable
   HttpMethod getRequestMethod();

   /**
    * Return this request's headers as a convenient HttpHeaders instance.
    */
   HttpHeaders getRequestHeaders();

   /**
    * Return the headers for the specified part of the multipart request.
    * <p>If the underlying implementation supports access to part headers,
    * then all headers are returned. Otherwise, e.g. for a file upload, the
    * returned headers may expose a 'Content-Type' if available.
    */
   @Nullable
   HttpHeaders getMultipartHeaders(String paramOrFileName);

}

 

 

서블릿 파일 업로드 정리

 

서블릿이 제공하는 Part 객체는 편하긴 하지만, HttpServletRequest를 주입받아야 하고, 추가로 파일 부분만 필터링하기 위해서 추가적인 코드 작성이 필요하다. 

 

 

스프링 파일 업로드

 

사실 Part를 사용하는 곳은 레거시 프로젝트 정도지 스프링에서는 MultipartFile이라는 인터페이스로 쉽게 멀티파트 데이터를 관리할 수 있다.

 

@PostMapping("/spring/file-upload")
public String saveFileTest(@RequestParam String userName,
                           @RequestParam MultipartFile file) throws ServletException, IOException {

    log.info("userName = {}", userName);
    log.info("file = {}", file);

    return "uploadTest-form";
}

 

@RequestParam을 사용해 업로드 요청 Form의 name을 맞추어 사용하면 깔끔하게 해당 파일을 받을 수 있을 뿐만 아니라 파일 업로드도 transferTo()로 간단히 저장할 수 있다.

@PostMapping("/spring/file-upload")
public String saveFileTest(@RequestParam String userName,
                           @RequestParam MultipartFile file) throws ServletException, IOException {

    String originalFilename = file.getOriginalFilename();
    log.info("업로드 파일명 : {}", originalFilename);

    if(!file.isEmpty()){
        String fullPath = fileDirectory + file.getOriginalFilename();
        file.transferTo(new File(fullPath));
    }

    return "uploadTest-form";
}
------WebKitFormBoundaryaAEAcUT0fN9Ev86B
Content-Disposition: form-data; name="userName"

test1
------WebKitFormBoundaryaAEAcUT0fN9Ev86B
Content-Disposition: form-data; name="file"; filename="ionic.png"
Content-Type: image/png

‰PNG

 

 

파일 다운로드

 

1. 하이브리드 앱 혹은 HTML에서 <img> 태그를  통해 서버에 이미지를 요청하는 경우 간단하게 이미지의 바이너리를 반환해줄 수 있다.

 

@GetMapping("/images/view")
public String imageView(Model model){
    model.addAttribute("imageFile", "cloud.png");
    return "image-view";
}

@ResponseBody
@GetMapping("/images/{filename}")
public Resource downloadImage(@PathVariable String fileName) throws MalformedURLException {
    //@PathVariable filename을 통해 서버에 저장된 파일 경로와 파일이름을 가져오는 로직
    return new UrlResource("file:" + fileDirectory + filename);
}

 

image-view 페이지에서는 <img> 태그의 src속성을 통해 서버에게 이미지를 GET 요청한다. 

<img th:src="|/images/${imageFile}|" width="300" height="300"/>

 

서버에서는 요청을 받으면 @PathVariable String fileName을 통해 서버에서 해당 이미지가 저장된 path와 원본 이름을 생성하는 로직을 만들어 Resouce의 구현체인 UrlResource로 리소를 읽어 @ResponseBody로 바이너리 형태로 반환하면 이미지가 다운로드된다.

 

해당 부분에서 고려해야 할 점은 해당 URL을 호출할 때 권한과 인가가 적절히 섞여 있어야 한다는 점이다. 

쉽게 말하면 누군가 내 서버에 무분별하게 이미지를 요청할 수 도있다.

 * Tip 

org.springframework.core.io.Resource

스프링의 Resource 객체는 java.net.URL을 추상화한 인터페이스이며 리소스를 읽어오는 기능을 담당하며 스프링 컨테이너가 생성될 때 관련 설정 정보 파일을 가져올 때도 사용된다. 

 

구현체

  • UrlResource : URL을 기준으로 리소스를 읽을 수 있다.  지원하는 프로토콜은 http, https, ftp, file, jar이다.
  • ClassPathResource : 지원하는 접두어가 classpath: 일 때, 클래스 패스를 기준으로 리소스를 읽어 들인다.
  • ServletContextResource : 웹 애플리케이션 루트에서 상대 경로로 리소스를 찾는다.
  • FileSystemResource : 파일 시스템을 기준으로 읽는다.

 

2. 파일을 HTTP 응답 값으로 내려주기

 

@GetMapping("/file/{fileNo}")
public ResponseEntity<Resource> downloadAttach(@PathVariable Long fileNo) throws MalformedURLException {
    
    String storeFileName = database.getStoreFileName();
    String uploadFileName = database.getUploadFileName();
	
    UrlResource resource = new UrlResource("file:" + "서버에 저장된 파일의 위치");

    String encodedUploadFileName = UriUtils.encode(uploadFileName, StandardCharsets.UTF_8);
    String contentDisposition = "attachment; filename=\"" + encodedUploadFileName + "\"";

    return ResponseEntity.ok()
            .header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition)
            .body(resource);
}

 

Resource의 구현체를 통해 서버에서 파일을 읽어오는데, ResponseEntity <Resource>를 통해 별도의 응답 값을 지정하거나 추가할 수도 있다.

 

이때 반드시 추가해줄 header가 존재하는데 "attachment; filename="upload FileName"이다. 

한글로 저장된 업로드 파일명은 내려줄 때 인코딩이 맞지 않으면 깨지는 현상이 자주 발견된다. 

Spring에서는 URI 인코딩을 위한 UriUtils패키지를 제공하기 때문에 URL인코딩을 하여 Content-Disposition 헤더 값으로 넣어주면 된다.

 

 

* Accept-Ranges 헤더는 다운로드 파일이 중간에 끊긴 경우 클라이언트의 요청으로 끊긴 이후 데이터부터 다시 다운로드하기 위한 협상 헤더 중 하나이다. 

Range 헤더를 통해 bytes=60~65바이트 협상 후, 서버에서 60~65에 해당하는 응답을 제공했다.

반응형

'Spring|Spring-boot' 카테고리의 다른 글

스프링 DB (2)  (0) 2022.08.15
스프링 DB (1)  (0) 2022.08.15
Spring-boot ExceptionResolver  (0) 2022.01.15
Spring-boot Error Page  (0) 2022.01.13
Spring Formatter  (0) 2022.01.10

댓글