파일 업로드, 다운로드 구현

바그다드·2023년 6월 8일
0

파일 업로드

목록 보기
2/2

지난 포스팅까지 서블릿과 스프링을 활용한 업로드를 구현해 보았다. 그런데 결국 업로드 기능도 다운로드 기능까지 구현이 되어야 의미가 있으므로 파일 업로드부터 다운로드까지 기능을 구현해보자.

1. Item 도메인 생성

@Data
public class Item {

    private Long id;
    private String itemName;
    private UploadFile attachFile;
    private List<UploadFile> imageFiles;
}
  • 이미지의 경우 여러개의 파일을 업로드하는 경우가 많으므로 List로 설정해주자

2. UploadFile 도메인 생성

@Data
public class UploadFile {

    private String uploadFileName;
    private String storeFileName;

    public UploadFile(String uploadFileName, String storeFileName) {
        this.uploadFileName = uploadFileName;
        this.storeFileName = storeFileName;
    }
}
  • 파일 명의 경우 같은 여러 사용자가 파일을 업로드하는만큼 파일명이 겹칠 수 있으므로 서버에서 구분하는 이름과 사용자가 올린 파일 이름을 따로 저장해주자.

3. ItemRepository 생성

  • 사용자가 전송한 Item객체를 저장하기 위한 Repository를 생성해주자
@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);
    }
}

4. 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 : 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);
    }


}
  • storeFile : 파일을 업로드하고, UploadFile객체를 반환하는 역할을 한다.
    - UploadFile객체는 원래 파일 이름, 서버에서 사용하는 파일 이름을 저장하고 있다.
  • createStoreFileName : 파일 이름의 경우 이름이 겹칠 수도 있는데, 이럴경우 파일을 덮어쓰거나, 충돌이 일어날 수 있기 때문에 UUID를 이용하여 서버에서 이용할 파일 이름을 생성한다.
  • extractExt : 파일의 확장자를 추출하는 메서드
  • getFullPath : createStoreFileName으로 생성된 파일 이름과 파일 저장경로를 더해 파일의 fullpath를 생성한다.
  • storeFiles : 여러개의 파일을 저장하는 메서드
    이미지 같은 경우 한번에 여러개를 업로드하는 경우가 많기 때문에 List에 UploadFile객체를 담아 반환한다.
    for문을 이용하여 각 MultipartFile객체에 접근하고, MultipartFile가 비어있지 않으면 해당 파일을 storeFile()을 이용해 업로드 한다.
    - storeFile()은 UploadFile을 반환하므로 객체를 List에 담아준다.

5. ItemForm 생성

  • 폼으로부터 컨트롤러로 데이터를 받아오기 위해 ItemForm을 생성해주자
@Data
public class ItemForm {

    private Long itemId;
    private String itemName;
    private List<MultipartFile> imageFiles;
    private MultipartFile attachFile;
}
  • Item도메인과 달리 MultipartFile 타입임에 주목하자.

6. 컨트롤러 생성

  • 뷰로 연결하기 위해 컨트롤러를 생성해주자
@Slf4j
@Controller
@RequestMapping("/spring")
public class SpringUploadController {

    @Value("${file.dir}")
    private String fileDir;

    @GetMapping("/upload")
    public String newFile() {
        return "upload-form";
    }
}

7. item-form.html 생성

<!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>
  • form태그의 속성으로 enctype="multipart/form-data"을 줬다.
  • input type="file"로 파일 필드를 생성하는데,
    multiple="multiple"속성을 부여하면 여러개의 파일을 선택할 수 있다.
  • 이제 이 폼을 이용하여 item을 저장하는 컨트롤러 메서드를 생성해주자

8. 컨트롤러 수정

  • 폼에서 받아온 데이터를 이용해 파일 업로드와 item객체를 저장하는 메서드를 만들어주자.
    @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}";
    }
  • fileStore의 storeFile()과 storeFiles()는 파일을 업로드하고, 각각 UploadFile과 List을 리턴한다.
    이 두 객체와 form으로 넘어온 데이터를 이용해 item데이터를 db에 저장해준다.

    파일이 정상적으로 업로드된 것을 확인할 수 있다.
    이제 이 item생성의 결과 화면을 보여주기 위한 컨트롤러를 생성해주자

9. 컨트롤러 수정

    @GetMapping("/items/{id}")
    public String items(@PathVariable Long id, Model model) {
        Item item = itemRepository.findById(id);
        model.addAttribute("item", item);
        return "item-view";
    }

10. item-view.html 생성

<!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>

  • 업로드는 정상적으로 처리되었지만 이미지 파일도 제대로 되지 않고 첨부파일도 다운로드할 수 없는 상황이다.
  • 브라우저 개발자 창을 띄워보면 404에러가 뜨고 첨부파일의 경우 200이 뜨지만 막상 해당 url에 접근해보면 마찬가지로 404가 뜬다.
    먼저 이미지 파일을 띄워줄 컨트롤러를 생성해주자.

11. 이미지 리소스 컨트롤러 생성

  • 이미지 파일을 연결해줄 컨트롤러를 생성해주자
    @ResponseBody
    @GetMapping("/images/{filename}")
    public Resource downloadImage(@PathVariable String filename) throws MalformedURLException {
        // "file:D:/SpringStudy/.../uploadfiles/파일이름.확장자"
        return new UrlResource("file:" + fileStore.getFullPath(filename));
    }
  • 이 메서드의 리턴 타입을 보면 Resource를 반환하고 있는데, Resource는 파일 시스템이나, 클래스 경로, 원격 위치 등 다양한 리소스를 표헌하거나 접근하는 메서드를 제공한다.
    - 예를 들어, UrlResource 클래스는 URL을 기반으로한 리소스에 대한 접근을 처리하고, ClassPathResource 클래스는 클래스 경로를 기반으로한 리소스에 대한 접근을 처리합니다.
  • UrlResource : 스프링에서 제공하는 클래스로 파일이나 원격 리소스에 접근할 수 있게 도와준다.
  • 해당 메서드는 이미지 파일 경로만 연결시켜주는 역할을 하기 때문에 @ResponseBody를 이용하여 api메서드로 선언해주자.

    나는 이부분에서 이해가 잘 안됐는데, 내가 아는 일반적인 흐름은 컨트롤러에서 뷰에 필요한 데이터를 모델에 담고, 뷰에서는 모델에 담긴 데이터를 가져와서 뿌려주는 것만 생각을 했다.
    그래서 모델에 담겨있지 않은 이미지 파일을 어떻게 뷰에서 다시 요청을 하는거지? 한번에 요청을 여러개 생성하는 건가? 하는 생각이 들어 잘 이해가 되지 않았다.
    그런데 결국 뷰에서 image의 src속성에 들어가는 것은 이미지 파일이 존재하는 리소스 경로이기 때문에 해당 URL로 리소스를 요청하는 방식이었다.
    예를들어 위와 같은 경우에는 th:src="|/images/${imageFile.getStoreFileName()}|"이 값이
    src="/images/1c150fc3-1725-44ee-9e0a-e1fd3dd09ce4.jpg" 이런식으로 치환되기 때문에 이 url로 해당 리소스를 요청을 하고,
    해당 url과 @GetMapping으로 매칭되는 downloadImage()가 연결이 된다.
    이후 downloadImage()에서 파라미터로 넘어온 파일 이름을 이용해 실제 파일 리소스가 들어있는 경로로 연결하는 UrlResource를 반환해준다.

  • 이제 첨부파일을 다운로드 받을 수 있는 메서드를 생성해주자

12. 첨부파일 다운로드 컨트롤러 생성

  @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);
  }
  • ResponseEntity의 헤더에 Content_Disposition을 넣지 않으면 첨부파일을 다운로드 받는 것이 아니라 브라우저에서 해당 파일을 오픈하는 기능만 하기 때문에 꼭 헤더를 넣어주자.
  • content-disposition에서 attachment를 부여할 경우 해당 파일이 첨부파일로 처리되어야 함을 나타내고, 두번째 파라미터인 filename은 다운로드할 파일의 이름을 나타낸다.
  • 파일 이름이 한글인 경우 깨질 수 있으니 utf-8 형식으로 인코딩을 해주자.
  • UrlUtils : url에 존재하는 특수문자나 한글 같은 것들을 인코딩하고 디코딩하는 여러가지 방식을 지원한다.
  • 헤더를 넣지 않았을 경우
  • 헤더를 넣을 경우

    정상적으로 첨부파일이 다운로드된 것을 확인할 수 있다.

이것으로 파일 업로드/다운로드에 대해 알아보았다. 여태 한 것들중에 제일 복잡하게 느껴져서 정리하는데도 제일 오랜 시간이 걸린거 같다ㅜㅜ

출처 : 김영한 스프링MVC2편

profile
꾸준히 하자!

0개의 댓글