본문 바로가기
Spring/스프링 MVC 활용

#11 파일 업로드

by 히포파타마스 2021. 8. 3.

파일 업로드

 

HTML Form 으로 파일을 서버에 전송하기 위해서는 문자가 아니라 바이너리 데이터를 전송해야 한다.

게다가 보통 파일을 업로드할 때는 파일만 전송하는 것이 아니라 다른 추가적인 정보도 전송해야 한다.

 

이 문제를 해결하기 위해 HTTP는 mutipart/form-data라는 전송 방식을 제공한다.

 

[mutipart/form-data]

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

 

각 항목은 Content-Disposition이라는 항목별 헤더로 구분되어있다.

 

 

 

 

1. mutipart 설정

스프링에서 multipart 데이터를 사용할 때 application.properties에서 여러 설정을 적용할 수 있다.

 

[업로드 사이즈 제한]

spring.servlet.multipart.max-file-size=1MB 
spring.servlet.multipart.max-request-size=10MB

multipart에 큰 파일을 무제한 업로드하지 못하게 업로드 사이즈를 제한할 수 있다.

사이즈를 넘으면 예외(SizeLimitExceededException)가 발생한다.

 

● max-file-size : 파일 하나의 최대 사이즈, 기본 1MB

● max-request-size : 멀티파트 요처 하나에 업로드할 수 있는 파일들의 용량 총합, 기본 10MB

 

[multipart 기능 끄기]

spring.servlet.multipart.enabled=false

멀티파트 기능을 사용하지 않게 설정할 수 있다.

mutipart 기능을 끄고 mutipart를 사용하면 데이터가 넘어오지 않는다.

 

[파일 경로 설정]

file.dir=파일 업로드 경로 설정 (예):/Users/kimyounghan/study/file/

서블릿이나 스프링을 사용해서 파일을 저장할 경로를 미리 메시지 형식으로 만들어 놓을 수 있다.

※ 마지막에 /(슬래시)가 포함되어야 한다.

 

 

 

 

2. 스프링과 파일 업로드 - MultipartFile

서블릿에서 Part로 멀티 파트 데이터를 받아서 각 데이터를 처리하고 파일을 저장할 수 도 있다.

하지만 서블릿으로 이런 기능들을 구현하려면 HttpServletRequest를 사용해야 하고, 멀티 파트 데이터를 받아서 각 데이터를 나누고 하나하나 따로 처리를 해주어야 한다.

 

스프링은 MultipartFile 이라는 인터페이스로 멀티파트 파일을 매우 편리하게 지원한다.

 

[파일 저장 - MultipartFile]

@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 {
        
		if (!file.isEmpty()) {
			String fullPath = fileDir + file.getOriginalFilename();
			file.transferTo(new File(fullPath));
		}

		return "upload-form"; 
	}
}

itemName과 파일을 받고, 파일을 지정된 경로에 저장하는 컨트롤러이다.

서블릿은 HttpServletRequest를 사용해야 하는 것과 달리 업로드하는 HTML Form의 데이터에 맞춰 @RequestParam을 적용해서 데이터를 받을 수 있다.

물론 @ModelAttribute에서도 MultipartFile을 동일하게 사용할 수 있다.

 

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

● file.transferTo(...) : 파일 저장

 

 

 

 

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

실제 파일이나 이미지를 업로드, 다운로드할 때 몇 가지 고려할 점이 있는데 구체적인 예제로 확인해본다.

 

다음과 같은 요구사항을 만족시키는 웹 서비스를 만든다.

 

● 상품을 관리

◎ 상품 이름

◎ 첨부파일 하나

◎ 이미지 파일 여러 개

 

● 첨부파일을 업로드 다운로드할 수 있다.

 

● 업로드한 이미지를 웹 브라우저에서 확인할 수 있다.

 

[상품 도메인]

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

UploadFile에는 클라이언트가 업로드할 때 사용한 파일 이름인 uploadFileName과 서버에서 저장할 때 사용할 파일 이름인 storeFileName이 필드로 있다.

 

[상품 리포지토리]

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

Item을 저장하고 id로 store에서 Item을 찾는다.

 

[UploadFile]

@Data
public class UploadFile {

	private String uploadFileName; 
	private String storeFileName;

	public UploadFile(String uploadFileName, String storeFileName) {
		this.uploadFileName = uploadFileName;
		this.storeFileName = storeFileName; 
	}
}

업로드된 파일의 정보를 보관한다.

고객이 업로드한 파일명으로 서버 내부에 파일을 저장하면 중복이 발생할 수 있기 때문에 각 파일명을 분리해야 한다.

 

● uploadFileName : 고객이 업로드한 파일명

● storeFileName : 서버 내부에서 관리하는 파일명

 

[FileStore]

@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 : imageFiles) {
			if (!imageFile.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);
		String uuid = UUID.randomUUID().toString();
		return uuid + "." + ext; 
	}
    
	private String extractExt(String originalFilename) {
		int pos = originalFilename.lastIndexOf(".");
		return originalFilename.substring(pos + 1);
	} 
}

파일 저장과 관련된 업무를 처리한다.

 

● createStoreFileName(String originalFilename)

◎업로드된 파일 이름으로 서버에서 관리할 파일 이름을 생성한다.

◎ extractExt() : 확장자를 추출한다.

 

● storeFile(MultipartFile mutipartFile)

◎ 고객이 업로드한 파일명과 서버에서 관리할 파일명을 만들어 UploadFile을 반환한다.

◎ 지정된 경로로 서버에서 관리하는 파일명으로 파일을 저장한다.

 

● storeFiles(List<MultipartFile> mutipartFiles)

◎ 복수의 파일들에 storeFile() 메서드를 적용시키고 storeFileResult에 담아 반환한다.

 

[ItemForm]

@Data
public class ItemForm {
	private Long itemId;
	private String itemName;
	private MultipartFile attachFile;
	private List<MultipartFile> imageFiles;
}

상품 저장용 폼을 만든다.

 

[ItemController]

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

 

● @GetMapping("/item/new") : 등록 폼을 보여준다.

 

● @PostMapping("/itmes/new") : 폼의 데이터를 저장하고 보여주는 화면으로 리다이렉트 한다.

 

● @GetMapping("/item/{id}") : 상품을 보여준다.

 

● @GetMapping("/images/{filename}") : <img> 태그로 이미지를 조회할 때 사용한다.

◎ UrlResource로 이미지 파일을 읽어서 @ResponseBody로 이미지 바이너리를 반환한다.

 

● @GetMapping("/attach/{itemId}") : 파일을 다운로드할 때 실행한다.

◎ 파일 다운로드 시에는 고객이 업로드한 파일 이름으로 다운로드하는 게 좋다. 이때는 Content-Disposition 헤더에 attachment; filename= "업로드 파일명" 값을 주면 된다.

※ 파일 인코딩 시, 인코딩 파일명을 사용해야 한다.

◎ 헤더에 CONTENT_DISPOSITION을 넣어주지 않으면 파일 다운로드가 되지 않고 웹페이지에 해당 파일이 열어진다.

◎ 파일을 인코딩하지 않으면 환경에 따라 파일이 깨질 수 있다.

 

[등록 폼 뷰]

<!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="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>
		
        상품명: <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>태그를 반복해서 출력한다.

 

위의 예제를 실행하면 하나의 첨부파일을 업로드, 다운로드할 수 있고 여러 이미지 파일을 한 번에 업로드할 수 있다.

'Spring > 스프링 MVC 활용' 카테고리의 다른 글

Spring ArgumentResolver  (0) 2022.08.19
#10 스프링 타입 컨버터  (0) 2021.08.03
#9 API 예외 처리  (0) 2021.08.03
#8 예외 처리와 오류 페이지  (0) 2021.08.02
#7 로그인 처리 - 필터, 인터셉터  (0) 2021.08.01

댓글