Post

[SpringBoot] 파일 업로드/다운로드

파일 업로드/다운로드

전송 방식

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

application/x-www-form-urlencoded


이 방식은 HTML 폼 데이터를 서버로 전송하는 가장 기본적인 방법이다.

Form 태그에 별도의 enctype 옵션이 없으면 웹 브라우저는 요청 HTTP메시지의 헤더에 다음 내용을 추가한다.

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

그리고 폼에 입력한 전송할 항목을 HTTP Body에 문자로 username=kim&age=20 와 같이 & 로 구분해서 전송한다.

하지만 파일을 전송할 때에는 위와 같이 문자가 아닌 바이너리 데이터를 전송해야 한다. 심지어 파일을 보낼 때 파일만 보내는 경우보다 섞어서 보내는 경우가 더 많다. 이럴 때 필요한 전송 방식이

multipart/form-data이다.

multipart/form-data


이 방식을 사용하려면 From태그에 별도의 enctype = “multipart/form-data”를 지정해야 한다. 이렇게 폼을 입력하면 그 결과로 생성된 HTTP 메시지에는 각각의 전송 항목이 구분이 되어있다.

Content-Disposition 이라는 항목별 헤더가 추가되어 있고 여기에 부가 정보가 있다. 폼의 일반 데이터는 각 항목별로 문자가 전송되고, 파일의 경우 파일이름과 Content-Type이 추가되고 바이너리 데이터가 전송된다.

서블릿 파일 업로드/저장


업로드

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
@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(MultipartHttpServletRequest 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-idsposition; 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";

    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<!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>

Desktop View

여기서 상품명은 itemName이라는 이름으로 올라가고 파일은 file이라는 이름으로 올라간다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class ServletUploadcontrollerV1 {

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

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

itemName은 알다시피 request.getParameter()로 받을 수 있고 파일은 다르게 받는다.

Part타입으로 받는 것을 알 수 있고 로그로 어떻게 넘어오는지 확인해보자.

Desktop View

request부터 보면 원래는 HttpServletRequest를 사용하는데 multipart의 전송방식으로 보내면 HttpServletRequest를 상속받은 StandardMultipartHtppServletRequest를 사용한다. 이것으로 바이너리 파일 등을 전송할 수 있다. 이 과정에서 멀티파트 리졸버가 멀티파트 요청일 때 서블릿 컨테이너가 전달하는 일반적인 servletrequest를 multipartservletrequest로 바꿔준다.

이후엔 그냥 MultipartFile이라는 것을 사용하는 것이 더 편하기 때문에 MultipartHttpServletRequest는 잘 사용하지 않는다.

그리고 itemName으로는 우리가 쓴 이름이 나오고 parts 부분에 전송한 파일들이 있다.

Desktop View

그리고 또다른 로그를 보면 Content-Disposition이라는 항목별 헤더가 보이고 문자는 그냥 문자를 출력해주고 파일은 그 파일의 확장자까지 알려준다.

저장

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
@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(MultipartHttpServletRequest 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-idsposition; 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";

    }
}

part 주요 메서드

  • part.getSubmittedFileName(): 클라이언트가 전달한 파일명
  • part.getInpuStream(): Part의 전송 데이터를 읽을 수 있다.
  • part.write(…): Part를 통해 전송된 데이터를 저장할 수 있다.

Desktop View

각각 part를 보면 문자는 submittedFilename은 없다. 파일이 없으니 당연하다. 파일부분에서는 헤더가 2개가 있다. 기본 헤더인 content-disposition과 content-type이 그것이다 2번째 헤더는 확장자를 나타낸다.

스프링 파일 업로드/저장


스프링 부터는 MultipartFile이라는 인터페이스로 간단하게 멀티파트 파일을 지원한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@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";
    }

}

Multipartfile 주요 메서드

file.getOriginalFilename(): 업로드 파일 명

file.transferTo(…): 파일저장

Desktop View

미리 지정해둔 path와 multipartfile의 객체에 대한 정보 로그도 잘 나온다.

파일 다운로드


jsp(view)

1
2
3
4
5
6
7
8
<c:forEach var="file" items="${article.fileInfos}">
	<li>${file.originalFile} 
		<a href="#" class="filedown" 
			sfolder="${file.saveFolder}" 
			sfile="${file.saveFile}" 
			ofile="${file.originalFile}">[다운로드]
		</a>
</c:forEach>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<form id="downform" action="${root}/board/download">
			<input type="hidden" name="sfolder">
			<input type="hidden" name="ofile">
			<input type="hidden" name="sfile">
	</form>

let files = document.querySelectorAll(".filedown");
    files.forEach(function(file) {
    	file.addEventListener("click", function() {
    		document.querySelector("[name='sfolder']").value = file.getAttribute("sfolder");
    		document.querySelector("[name='ofile']").value = file.getAttribute("ofile");
    		document.querySelector("[name='sfile']").value = file.getAttribute("sfile");
    		document.querySelector("#downform").submit();
    	});
    });

다운로드 버튼을 누르면 각 파일의 정보가 hidden form으로 들어가고 전송된다.

sfolder는 저장된 경로를 뜻하고 ofile은 원래 파일이름 sfile은 고유화로 저장될 파일의 이름이다.

자바(controller)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@GetMapping(value = "/download")
	public ModelAndView downloadFile(@RequestParam("sfolder") String sfolder, 
																	@RequestParam("ofile") String ofile,
																	@RequestParam("sfile") String sfile,
																	HttpSession session) {
		MemberDto memberDto = (MemberDto) session.getAttribute("userinfo");
		if (memberDto != null) {
			Map<String, Object> fileInfo = new HashMap<String, Object>();
			fileInfo.put("sfolder", sfolder);
			fileInfo.put("ofile", ofile);
			fileInfo.put("sfile", sfile);

			return new ModelAndView("fileDownLoadView", "downloadFile", fileInfo);
		} else {
			return new ModelAndView("redirect:/");
		}
	}

여기에선 들어온 정보를 하나의 Map객체로 다 받는다. 그리고 그 객체를 downloadFile이라는 이름으로 fileDownLoadView.java로 향한다.

자바(model)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
public class FileDownLoadView extends AbstractView {

	public FileDownLoadView() {
		setContentType("apllication/download; charset=UTF-8");
	}
	
	@Override
	protected void renderMergedOutputModel(Map<String, Object> model, 
HttpServletRequest request, HttpServletResponse response) throws Exception {
		ServletContext ctx = getServletContext();
		String realPath = ctx.getRealPath("/upload");
		
		// 전송받은 모델(파일 정보)   
		Map<String, Object> fileInfo = (Map<String, Object>) model.get("downloadFile"); 
        String saveFolder = (String) fileInfo.get("sfolder");	// 파일 경로
        String originalFile = (String) fileInfo.get("ofile");	// 원본 파일명(화면에 표시될 파일 이름)
        String saveFile = (String) fileInfo.get("sfile");    	// 암호화된 파일명(실제 저장된 파일 이름)
        File file = new File(realPath + File.separator  + saveFolder, saveFile);
		
        response.setContentType(getContentType());
        response.setContentLength((int) file.length());
        
        String header = request.getHeader("User-Agent");
        boolean isIE = header.indexOf("MSIE") > -1 || header.indexOf("Trident") > -1;
        String fileName = null;
        // IE는 다르게 처리
        if (isIE) {
        	fileName = URLEncoder.encode(originalFile, "UTF-8").replaceAll("\\+", "%20");
        } else {
            fileName = new String(originalFile.getBytes("UTF-8"), "ISO-8859-1");
        }
        response.setHeader("Content-Disposition", "attachment; filename=\"" + fileName + "\";");
        response.setHeader("Content-Transfer-Encoding", "binary");
        OutputStream out = response.getOutputStream();
        FileInputStream fis = null;
        try {
            fis = new FileInputStream(file);
            FileCopyUtils.copy(fis, out);
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            if(fis != null) {
                try { 
                    fis.close(); 
                }catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
        out.flush();
    }
}
  • 먼저 realPath를 얻기 위해 ServeltContext객체를 가져온다.
  • downloadFile의 이름으로 보내온 Map객체를 받는다.
  • sfolder, ofile, sfile을 문자열로 받아 놓는다.
  • 그리고 File객체를 만든다( 여기서 File.separator는 운영체제에 맞는 구분자를 맞춰주기 위함)
  • response.setContentType()은 파일의 확장자별로 다른데 getContentType()으로 각각의 파일 확장자에 맞는 contentType으로 설정
  • setContentLength는 파일의 용량을 설정하는데 우리가 갖고 있는 file의 길이를 알려주면 된다.
  • request.getHeader(”User-Agent”)는 요청을 보낸 사용자에 기반한 브라우저와 운영체제 정보가 들어있다.
  • isIE라는 boolean 변수를 만든다. 이것은 인터넷익스플로러는 따로 처리해야하기 때문에 위에서 가져온 header에서 IE를 분리 해낸다.
  • 인코딩을 해야하는데 IE는 URLEncoder.encode()로 utf-8로 인코딩을 한다.
  • 나머지 브라우저는 new String의 생성자 선에서 처리가 가능하다.
  • response.setHeader에서 Content-Disposition헤더에 attachment를 주면 다운로드를 받을 때 다운로드 박스가 뜬다. inline옵션을 주면 웹에서 바로 파일을 열어버린다.
  • 또다른 header인 Content-Transfer-encoding은 전송 인코딩 설정이다. 파일이므로 binary를 해준다.
  • FileCopyUtils.copy()에선 인자를 2개 받는데 순서대로 from, to의 개념이라고 보면 된다.
  • from에는 다운로드 받을 파일을 stream으로 감싼다.
  • to에는 response에 있는 OutputStream을 가져온다.
This post is licensed under CC BY 4.0 by the author.

Comments powered by Disqus.