Home 파일 업로드, 다운로드
Post
Cancel

파일 업로드, 다운로드

일반적으로 파일 업로드는 HTML Form을 통해 이루어진다.

폼을 전송하는 두 가지 방식이 있다.

  • application/x-www-form-urlencoded
    • 일반적으로 form을 전송하는 방식
  • 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 같이 &로 구분해 보낸다.

파일은 문자가 아닌 바이너리 데이터를 전송해야 한다. 문자를 전송하는 위와 같은 방식으로 파일을 전송하기는 어렵다. 그리고 또 한 가지 문제가 더 있다.

보통 폼을 전송할 때 파일만 전송하지 않는다.

1
2
3
- 이름
- 나이
- 첨부파일

위와 같이 이름과 나이는 문자로 전송하고, 첨부파일은 바이너리로 전송해야하는 상황이 흔하다. 문자와 바이너리를 동시에 전송해야 한다는 것이 문제인 것이다.

그래서 multipart/form-data 방식이 있다.

multipart/form-data

이 방식을 적용하려면 Form 태그에 별도의 속성을 지정해야 한다.

1
enctype="multipart/form-data

이 방식은 다른 종류의 여러 파일과 폼의 내용을 함께 전송할 수 있다.

HTTP 메시지의 내용을 보자. 각 부분이 ———XXX 로 각각의 전송항목이 구분되어있다.

각 구분 지점에는 Content-Disposition 이라는 항목별 헤더가 추가되어 있고, 여기에 더해 부가 정보가 있다.

위의 예시에서는 username, age, file1 이 분리되어 있고 input type = “text” 인 부분의 데이터는 각 항목별로 문자가 전송, 그리고 input type = “file” 의 파일의 경우 파일 이름과 Content-Type이 추가되고 바이너리 데이터가 전송되는 것을 볼 수 있다.

multipart/form-data는 복잡하고 이렇게 각각 구분지어져 있는데 이러한 HTTP 메시지를 서버에서 어떻게 다루는지 알아본다.

내용 정리 포스트

서블릿 파일업로드

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
@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);
		
		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);  
		}
		  
		return "upload-form";  
	}  
}

upload-form은 Form에서 itemName과 파일을 하나 전송한다.

위와 같이 로그가 남은 것을 볼 수 있는데,

parts는 ——XX로 구분되어진 각각의 한 부분을 의미한다.

첫 파트는 파일명의 itemName, 그리고 header 정보, submittedFilename은 사용자가 보낸 파일 명이기때문에 null, 등등의 정보를 볼 수 있다.

  • Part 주요 메서드
    • part.getSubmittedFileName() : 클라이언트가 전달한 파일 명
    • part.getInputStream() : Part의 전송 데이터를 읽을 수 있다.

멀티파트 사용 옵션

  • 업로드 사이즈 제한
1
2
spring.servlet.multipart.max-file-size=1MB
spring.servlet.multipart.max-request-size=10MB
  1. 큰 파일을 무제한으로 업로드하게 둘 수는 없으므로 업로드 사이즈를 제한한다.
  2. 멀티파트 요청 하나에 여러 파일을 업로드할 수 있는데 그 전체의 합을 제한한다.

그리고 멀티파트 관련 옵션 자체를 끌 수도 있다.

1
spring.servlet.multipart.enabled=false

서블릿 컨테이너는 멀티파트와 관련된 처리를 아예 하지 않는다. itemName도 안들어오고 파일도 들어오지 않는다. 즉 request.getParameter(“itemName”), request.getParts() 의 결과가 모두 비어있다.

true일때는 HttpServletRequest객체가 StandardMultipartHttpServletRequest, false일때는 HttpServletReqeust객체가 RequestFacade 이다.

참고

spring.servlet.multipart.enabled 옵션을 켜면 스프링의 DispatcherServlet에서 멀티파트 리졸버를 실행한다.

멀티파트 리졸버는 멀티파트 요청의 경우 서블릿 컨테이너가 전달하는 일반적인 HttpServletRequest를 MultipartHttpServletRequest로 변환해 반환한다. 멀티파트와 관련된 추가 기능을 제공한다.

스프링이 제공하는 기본 멀티파트 리졸버는 MultipartHttpServletRequest 인터페이스를 구현한 StandardMultipartHttpServletReqeust를 반환한다.

컨트롤러에서 HttpServletRequest대신 MultipartHttpServletRequest를 주입받을 수 있는데, 이것을 사용하면 멀티파트와 관련된 여러가지를 편리하게 처리할 수 있다.

그러나 아래에서 설명할 MultipartFile 이라는 것을 사용하는 방법이 더 편하기 때문에 잘 사용하지 않는다.

구현

서블릿이 제공하는 ‘Part’에 대해 알아보고 실제 파일을 서버에 업로드한다.

우선 파일을 업로드하려면 실제 파일이 저장되는 경로가 필요하다.

아래 경로에 실제 폴더를 만들어두고 그 경로를 입력해본다.

1
2
3
4
5
//application.properties

file.dir=/Users/upload/test/file/

//마지막에 슬래시(/) 가 있는 것에 주의하자.
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
@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 saveFileV2(HttpServletRequest request) throws ServletException, IOException { 

		Collection<Part> parts = request.getParts();  
		log.info("parts={}", parts);  
		  
		for (Part part : parts) {  
			//파일에 저장하기  
			if (StringUtils.hasText(part.getSubmittedFileName())) {  
				String fullPath = fileDir + part.getSubmittedFileName();  
				log.info("파일 저장 fullPath={}", fullPath);  
				part.write(fullPath);  
			}  
		}  
		return "upload-form";  
	}  
}

@Value를 이용해 application.properties에 있는 file.dir을 가져와 변수에 저장하고, 그것을 이용해 Part에 getSubmittedFileName이 있다면 그 이름으로 파일을 저장하도록 한다.

  • part.write() : Part를 통해 전송된 데이터를 저장한다.

서블릿이 제공하는 Part는 편리하지만, HttpServletRequest를 사용해야 하고, 추가로 파일 부분만 구분하려면 여러가지 코드를 넣어야 하는 것을 볼 수 있다.

스프링 파일 업로드

스프링은 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
@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) throws IOException {  
		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 String itemName : itemName은 @RequestParam으로.
  • @RequestParam MultipartFile file : 업로드하는 HTML Form의 name에 맞추어 @RequestParam을 적용하면 된다.
    • @ModelAttribute에서도 MultipartFile을 동일하게 사용할 수 있다.
  • MultipartFile 주요 메서드
    • file.getOriginalFilename(): 업로드 파일 명
    • file.transferTo(): 파일 저장

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

실제 파일이나 이미지를 업로드, 다운로드 할 때는 몇 가지 고려할 점이 있다. 이 부분을 알아보기 위해 예제로 구현해본다.

  • 요구사항
    • 상품을 관리
      • 상품 이름
      • 첨부파일 하나
      • 이미지 파일 여러개
    • 첨부파일을 업로드, 다운로드 할 수 있다.
    • 업로드한 이미지를 웹 브라우저에서 확인할 수 있다.

도메인

1
2
3
4
5
6
7
8
@Data  
public class Item {  
  
	private Long id;  
	private String itemName;  
	private UploadFile attachFile;  
	private List<UploadFile> imageFiles;  
}
1
2
3
4
5
6
7
8
9
10
11
@Data  
public class UploadFile {  
  
	private String uploadFileName; //고객이 업로드한 파일명
	private String storeFileName; //서버 내부에서 관리하는 파일명
	
	public UploadFile(String uploadFileName, String storeFileName) {  
		this.uploadFileName = uploadFileName;  
		this.storeFileName = storeFileName;  
	}
}

고객이 업로드한 파일명으로 서버 내부에 파일을 저장하면 서로 다른 고객이 같은 파일이름을 업로드한다면 충돌이 날 수 있기 때문에 내부에서 별도로 관리하는 별도의 파일 명을 가지고 있는 것이다.

상품 리포지토리

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@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

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
@Component  
public class FileStore {  
  
	@Value("${file.dir}")  
	private String fileDir;  
	
	private String originalFilename;  
	  
	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();  
		String storeFileName = createStoreFileName(originalFilename);  
		multipartFile.transferTo(new File(getFullPath(storeFileName)));  
		return new UploadFile(originalFilename, storeFileName);  
	}  
	  
	private String createStoreFileName(String originalFilename) {  
		String ext = extractExt(originalFilename);  
		//파일명이 그냥 uuid면 어떤 파일인지 구분이 안가므로 original에서 확장자만 뗴온다.  
	  
		//서버에 저장하는 파일명  
		String uuid = UUID.randomUUID().toString();  
		return uuid + "." + ext;  
	}  
	  
		private String extractExt(String originalFilename) {  
		int pos = originalFilename.lastIndexOf(".");  
		return originalFilename.substring(pos + 1);  
	}  
}

@Component

멀티파트 파일을 서버에 저장하는 역할을 담당한다.

  • createStoreFileName() : 서버 내부에서 관리하는 파일명은 유일한 이름을 생성하는 UUID를 사용해 충돌하지 않도록 한다.
  • extractExt() : 확장자를 별도로 추출해 서버 내부에 관리하는 파일명에도 붙임으로써 어떤 파일인지는 알도록 저장한다.

상품 저장용 폼

1
2
3
4
5
6
7
8
@Data  
public class ItemForm {  
  
	private Long itemId;  
	private String itemName;  
	private MultipartFile attachFile;  
	private List<MultipartFile> imageFiles;  
}
  • imageFiles : 이미지를 다중 업로드 하기 위해 MultipartFile를 사용
  • attachFile : 첨부파일 하나. 멀티파트는 @ModelAttribute에서 사용할 수 있다.

컨트롤러

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
55
56
57
58
59
60
61
62
@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 {  
		MultipartFile attachFile = form.getAttachFile();  
		UploadFile uploadFile = fileStore.storeFile(attachFile);  
		  
		List<MultipartFile> imageFiles = form.getImageFiles();  
		List<UploadFile> storeImageFiles = fileStore.storeFiles(imageFiles);  
		  
		//데이터베이스에 저장  
		Item item = new Item();  
		item.setItemName(form.getItemName());  
		item.setAttachFile(uploadFile);  
		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 {  
		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 resource = new UrlResource("file:" + fileStore.getFullPath(storeFileName));  
		  
		String encodedUploadFileName = UriUtils.encode(uploadFileName, StandardCharsets.UTF_8);  
		String contentDisposition = "attachment; filename=\"" + encodedUploadFileName + "\"";  
		  
		return ResponseEntity.ok()  
			.header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition)  
			.body(resource);  
	}  
}

//데이터베이스 저장 부분.

파일은 보통 데이터베이스에 저장하는 것이 아니다. 보통 스토리지에 저장하고, AWS를 쓴다면 S3 같은 곳에 저장한다.

실제로 DB에 저장하는 부분은 그 파일이 저장된 경로같은 것을 저장한다. 경로도 FullPath를 저장하지 않는다.

  • @GetMapping(“/items/new”) : 등록 폼을 보여준다.
  • @PostMapping(“/items/new”): 폼의 데이터를 저장하고 보여주는 화면으로 리다이렉트 한다.
  • @GetMapping(“/items/id”): 상품을 보여준다.
  • @GetMapping(“/images/filename”): img 태그로 이미지를 조회할 때 사용한다. UrlResource로 이미지 파일을 읽어 @ResponseBody로 이미지 바이너리를 반환한다.
    • UrlResource가 실제 파일 경로에 접근해 이 파일을 Stream으로 반환하게 된다.
  • @GetMapping(“/attach/itemId”): 파일을 다운로드할 때 실행한다. 예제를 더 단순화 할 수 있지만, 파일 다운로드 시 권한 체크같은 복잡한 상황을 가정하고 이미지 ID를 요청하도록 했다. 파일 다운로드 시에는 고객이 업로드한 파일이름으로 다운로드 하는게 좋다.
    • ResponseEntity에 현재는 body와 header를 다 담아 보냈다. 만약 header를 담지 않는다면?
      • 목적은 href 클릭 시 다운로드가 되는 것이 목적인데 헤더를 담지 않으면 해당 링크로 이동 시 다운로드가 아닌 해당 파일의 내용이 출력된다. (txt면 text, png면 바이너리 데이터)
      • 다운로드를 위해 위와 같은 헤더를 넣어주면 다운로드가 된다.

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

multiple 옵션으로 여러 개의 파일을 받을 수 있다.

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

첨부파일은 링크로 걸어두고, 이미지는 img 태그를 반복해서 출력한다.

th:href, th:src 모두 컨트롤러와 매칭하면 된다.

This post is licensed under CC BY 4.0 by the author.