지난 포스팅까지 서블릿과 스프링을 활용한 업로드를 구현해 보았다. 그런데 결국 업로드 기능도 다운로드 기능까지 구현이 되어야 의미가 있으므로 파일 업로드부터 다운로드까지 기능을 구현해보자.
@Data
public class Item {
private Long id;
private String itemName;
private UploadFile attachFile;
private List<UploadFile> imageFiles;
}
@Data
public class UploadFile {
private String uploadFileName;
private String storeFileName;
public UploadFile(String uploadFileName, String storeFileName) {
this.uploadFileName = uploadFileName;
this.storeFileName = 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);
}
}
@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 : multipartFiles) {
if (!multipartFile.isEmpty()) {
UploadFile uploadFile = storeFile(multipartFile);
storeFileResult.add(uploadFile);
}
}
return storeFileResult;
}
// 파일 하나 업로드
public UploadFile storeFile(MultipartFile multipartFile) throws IOException {
if (multipartFile.isEmpty()) {
return null;
}
String originalFilename = multipartFile.getOriginalFilename();
// image.png
//서버에 저장하는 파일명
String storeFileName = createStoreFileName(originalFilename);
multipartFile.transferTo(new File(getFullPath(storeFileName)));
return new UploadFile(originalFilename, storeFileName);
}
// 파일 이름을 생성하는 메서드
private String createStoreFileName(String originalFilename) {
String uuid = UUID.randomUUID().toString();
String ext = extractExt(originalFilename);
return uuid + "." + ext;
}
// 확장자를 추출하는 메서드
private String extractExt(String originalFilename) {
int pos = originalFilename.lastIndexOf(".");
return originalFilename.substring(pos + 1);
}
}
@Data
public class ItemForm {
private Long itemId;
private String itemName;
private List<MultipartFile> imageFiles;
private MultipartFile attachFile;
}
@Slf4j
@Controller
@RequestMapping("/spring")
public class SpringUploadController {
@Value("${file.dir}")
private String fileDir;
@GetMapping("/upload")
public String newFile() {
return "upload-form";
}
}
<!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>
@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 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";
}
<!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>
@ResponseBody
@GetMapping("/images/{filename}")
public Resource downloadImage(@PathVariable String filename) throws MalformedURLException {
// "file:D:/SpringStudy/.../uploadfiles/파일이름.확장자"
return new UrlResource("file:" + fileStore.getFullPath(filename));
}
나는 이부분에서 이해가 잘 안됐는데, 내가 아는 일반적인 흐름은 컨트롤러에서 뷰에 필요한 데이터를 모델에 담고, 뷰에서는 모델에 담긴 데이터를 가져와서 뿌려주는 것만 생각을 했다.
그래서 모델에 담겨있지 않은 이미지 파일을 어떻게 뷰에서 다시 요청을 하는거지? 한번에 요청을 여러개 생성하는 건가? 하는 생각이 들어 잘 이해가 되지 않았다.
그런데 결국 뷰에서 image의 src속성에 들어가는 것은 이미지 파일이 존재하는 리소스 경로이기 때문에 해당 URL로 리소스를 요청하는 방식이었다.
예를들어 위와 같은 경우에는 th:src="|/images/${imageFile.getStoreFileName()}|"이 값이
src="/images/1c150fc3-1725-44ee-9e0a-e1fd3dd09ce4.jpg" 이런식으로 치환되기 때문에 이 url로 해당 리소스를 요청을 하고,
해당 url과 @GetMapping으로 매칭되는 downloadImage()가 연결이 된다.
이후 downloadImage()에서 파라미터로 넘어온 파일 이름을 이용해 실제 파일 리소스가 들어있는 경로로 연결하는 UrlResource를 반환해준다.
@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);
}
이것으로 파일 업로드/다운로드에 대해 알아보았다. 여태 한 것들중에 제일 복잡하게 느껴져서 정리하는데도 제일 오랜 시간이 걸린거 같다ㅜㅜ
출처 : 김영한 스프링MVC2편