여러 개의 파일을 한 번에 업로드 하는 방법, 업로드 파일을 브라우저 상에서 확인하도록 하는 방법, 다운로드 하는 방법에 대해서 학습해본다. 기존 파일에 도메인 코드를 추가하여 진행한다. 각 메서드나 방법론 보다는 전체적인 파일 전송 방식에 대한 이해가 중요하다.
1. 도메인 생성
Item 클래스의 필드값은 상품의 이름, 다운로드할 수 있는 상품 이미지, 조회했을 때 표시되는 이미지 리스트를 필드값으로 한다. 그리고 업로드된 파일의 이름을 저장할 수 있도록 UploadFile 클래스를 만든다.
java/hello/upload/domain/Item.java
1
2
3
4
5
6
7
8
9
|
@Data
public class Item {
private Long id;
private String itemName;
private UploadFile attachFile;
private List<UploadFile> imageFiles;
}
|
cs |
java/hello/upload/domain/UploadFile.java
1
2
3
4
5
6
7
8
9
10
11
12
13
|
@Data
public class UploadFile {
//같은 파일명으로 업로드 되어 덮어쓰기 되는 것을 방지하기 위해 구분
private String uploadFileName; //고객이 업로드한 파일명
private String storeFileName; //서버 내부에서 관리하는 파일명
public UploadFile(String uploadFileName, String storeFileName) {
this.uploadFileName = uploadFileName;
this.storeFileName = storeFileName;
}
}
|
cs |
java/hello/upload/domain/ItemRepository.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
@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);
}
}
|
cs |
2. 파일 저장 클래스
파일을 저장할 수 있는 FileStore 클래스르 만든다. 앞서 배운 MultiparFile 객체의 transferTo 메서드로 파일을 저장한다.
클라이언트가 업로드한 파일명과 uuid 및 파일 확장자를 기반으로 하는 파일 이름을 UploadFile 객체로 저장하는 것에 주목하자. 보통 이러한 방식으로 이미지를 저장한다. 맨 마지막 extractExt의 방식으로 파일의 확장자를 추출해내는 방식도 하나의 배울점이라 할 수 있다.
java/hello/upload/file/FileStore.java
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
|
@Component
public class FileStore {
@Value("${file.dir}")
private String fileDir;
public String getFullPath(String filename) {
return fileDir + filename;
}
public UploadFile storeFile(MultipartFile multipartFile) throws IOException {
if (multipartFile.isEmpty()) {
return null;
}
String originalFilename = multipartFile.getOriginalFilename();
//image.png -> uuid... .png, 확장자를 갖도록 하여 이미지 파일이 구분되도록 한다.
String storeFileName = createStoreFileName(originalFilename);
multipartFile.transferTo(new File(getFullPath(storeFileName)));
return new UploadFile(originalFilename, storeFileName);
}
public List<UploadFile> storeFiles(List<MultipartFile> multipartFiles) throws IOException {
ArrayList<UploadFile> storeFileResult = new ArrayList<>();
for (MultipartFile multipartFile : multipartFiles) {
if (!multipartFile.isEmpty()) {
storeFileResult.add(storeFile(multipartFile));
}
}
return storeFileResult;
}
private String createStoreFileName(String originalFilename) {
String uuid = UUID.randomUUID().toString();
String ext = extractExt(originalFilename);
return uuid + "." + ext;
}
private String extractExt(String originalFilename) {
int index = originalFilename.lastIndexOf(".");
return originalFilename.substring(index + 1);
}
}
|
cs |
3. 컨트롤러 : 입력 폼
이제 이전에 배웠던 컨트롤러 부분을 다뤄본다. 우선 @ModelAttribute 방식으로 정보를 전달하는 ItemForm 객체를 만들고, 정보를 입력할 수 있는 입력 폼을 작성한다.
아래 코드 중, item-form.html 의 input 태그 속성 중 multiple="multiple" 속성에 유의하자. 이 부분은 파일 업로드 시에 여러 파일을 선택하여 입력할 수 있도록 해준다.
java/hello/upload/controller/ItemForm.java
1
2
3
4
5
6
7
8
9
|
@Data
public class ItemForm {
private Long id;
private String itemName;
private MultipartFile attachFile;
private List<MultipartFile> imageFiles;
}
|
cs |
java/hello/upload/controller/ItemController.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
@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";
}
... 중략
}
|
cs |
resources/templates/item-form.html
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
|
<!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>
|
cs |
입력 폼 화면
4. 컨트롤러 : 저장 및 출력
업로드한 파일을 "제출" 했을 때, 실제로 저장이 일어나고 그 내용들을 출력하는 컨트롤러와 화면을 만들어본다.
순서대로 진행된다. 생략된 컨트롤러 코드에서 정의된 fileStore를 이용하여 클라이언트에서 받아오는 @ModelAttribute의 form 데이터 값을 MultipartFile로 실제 서버 내에 저장한다. 그리고 여기서는 메모리지만, 데이터 베이스 상에 해당 파일의 이름을 저장한다. 이미지 파일을 저장할 때, 실제 파일은 서버에 저장하지만 데이터베이스에는 파일명 등 텍스트 파일만 저장한다는 개념을 이해해야 한다.
redirect를 통해 다음 @GetMapping으로 전달된 itemId값은, repository에서 해당 Item의 객체 정보를 찾는데 사용된다. 실제 "item-view"에서는 아래와 같이, Item 객체내의 원래 파일명과 uuid를 통해 변환된 저장 파일명을 이용하여 URL을 생성한다.(다음 섹션에서 더 자세히 이해가 가능하다.)
java/hello/upload/controller/ItemController.java의 일부
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
|
...생략
@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";
}
|
cs |
첨부 파일과 그 밑의 th:each 반복문을 보자. 여기서 최초에 의도했던대로 "/attach/상품 아이디" URL로 링크를 걸어놨다. 다음 섹션에서 이 링크로 이동하면 다운로드가 되도록 할 것이다. 그리고 반복문은 업로드된 다중 파일들이 "/images/저장된 파일명" URL로 접속 시 해당 파일을 바로 보여줄 수 있도록 작성되었다.
resources/templates/item-view.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
<!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>
|
cs |
입력 화면
조회 화면
첨부 파일 클릭 시 URL
이미지 파일의 URL
5. 컨트롤러 : 다운로드 및 조회
이제 작성할 부분이 명확하다. 첨부파일 클릭 시, 파일이 다운로드 되도록 한다. 그리고 각 이미지들의 src가, 실제 파일을 표현해줄 수 있으면 된다.
그러나 이 부분이 핵심이고, 중요한 부분이다!
"/images/{filename}" 으로 요청 시, 파일을 UrlResource 객체 형태로 반환해준다. UrlResource는 반환 타입인 Resource 인터페이스를 상속받은 클래스로, file: 뒤에 파일의 경로명을 붙여주면, 실제 경로에 저장된 파일을 찾아온다. 그리고 @ResponseBody를 통해 응답의 body에 이 객체를 실어보냄으로써, 실제 페이지에 저장했던 이미지들이 보여지게 된다.
"/attach/{itemId}"로 요청 시, ResponseEntity<Resource>로 응답값을 설정해주었다. 이것은 마지막 26번줄 이후 코드에서, 헤더에 Content-disposition 정보를 넣어주기 위함이다. 주석문에 써놓은 것처럼 이렇게 하지 않으면 브라우저가 body에 적힌 값을 그대로 읽어들여서 표시해버리고, 다운로드가 실행되지 않는다. 따라서, 23~24번 줄과 같이 contentDisposition 값을 설정하여 header에 넣어준다. UriUtils.encode, HttpHeaders.CONTENT_DISPOSITION 부분을 추가한 것을 기억하자.
java/hello/upload/controller/ItemController.java
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
|
... 생략
@ResponseBody
@GetMapping("/images/{filename}")
public Resource downloadImage(@PathVariable String filename) throws MalformedURLException {
//file:/Users/.../uuid 로 작성된 파일명
//UrlResource 가 실제 서버의 파일 경로에 있는 파일을 찾아온다.
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 urlResource = 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에 Content_disposition을 넣어주지 않으면, 브라우저가 단순히 body 값을 읽어들여서 그대로 브라우저에서 보여주게 된다.
.header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition)
.body(urlResource);
}
|
cs |
결과화면
업로드한 이미지가 잘 조회되고, "고양이.jpg" 링크를 클릭하면 다운로드 창이 열리는 것을 확인할 수 있다.
참조
1. 인프런_스프링 MVC 2편 - 백엔드 웹개발 핵심 기술_김영한 님 강의
https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-mvc-2
'Programming-[Backend] > Spring' 카테고리의 다른 글
[TIL][링크] 트랜잭션의 전파, Spring @Transactional 중첩 (0) | 2021.11.22 |
---|---|
[TIL][Thymeleaf] String null 처리 : #string.defaultString (0) | 2021.10.13 |
[스프링 웹MVC-2] 20. 파일 업로드 (0) | 2021.09.21 |
[스프링 웹MVC-2] 19. 타입 컨버터 - Formatter (0) | 2021.09.19 |
[스프링 웹MVC-2] 18. 타입 컨버터 - ConversionService (2) | 2021.09.15 |