본문 바로가기
관리자

Programming-[Backend]/Spring

[스프링 웹MVC-2] 21. 업로드, 다운로드 기능 구현 예제

728x90
반응형

여러 개의 파일을 한 번에 업로드 하는 방법, 업로드 파일을 브라우저 상에서 확인하도록 하는 방법, 다운로드 하는 방법에 대해서 학습해본다. 기존 파일에 도메인 코드를 추가하여 진행한다. 각 메서드나 방법론 보다는 전체적인 파일 전송 방식에 대한 이해가 중요하다.

 

 


 

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

728x90
반응형