Spring

스프링 파일 업로드/다운로드

dev-rootable 2023. 8. 3. 13:14

📌 HTML 폼 전송 방식

 

🔎 application/x-www-form-urlencoded 방식

 

application/x-www-form-urlencoded 방식

 

HTML 폼 데이터를 서버에 전송하는 가장 기본적인 방법이다. Form 태그에 별도의 enctype 옵션이 없으면 웹 브라우저는 요청 HTTP 메시지의 헤더에
Content-Type으로 application/x-www-form-urlencoded를 추가한다.

 

폼에 입력한 전송할 항목을 HTTP body에 문자로 "username=kim&age=20"과 같이 &로 구분해서 전송한다.

 

✔ 문제점

 

파일은 문자가 아니라 Binary 데이터를 전송해야 하므로, 해당 방식으로는 전송이 어렵다. 또한, 보통 폼을 전송할 때 파일뿐만 아니라 다른 데이터도 함께 전달한다.
따라서, Binary 데이터와 문자 데이터를 동시에 전송해야 하는 문제도 발생한다.

 

🔎 multipart/form-data 방식

 

multipart/form-data 방식

 

다른 종류의 여러 파일과 폼의 내용을 함께 전송할 수 있어 앞서 언급된 문제점들을 해결할 수 있다. 이 방식은 각각의 항목을 구분해서 한 번에 전송하는 방식을 말한다.

 

✔ 특징

 

  • Form 태그에 별도의 enctype = "multipart/form-data"를 지정해야 한다.
  • boundary(랜덤값)로 각 Form 데이터를 구분한다.
  • 각 데이터마다 Content-Disposition이라는 항목별 헤더가 추가되어 있고 여기에 부가 정보가 있다.
  • application/x-www-form-urlencoded와 비교해서 매우 복잡하고 각각의 Part로 나누어져 있다.

 

📌 MultipartFile

 

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

 

@RequestParam 또는 @ModelAttribute를 통해 HTTP 요청에서 멀티파트(파일) 부분을 MultipartFile이라는 객체로 받을 수 있다. 이를 통해 파일에 대한 정보를 조회하거나 파일 객체를 생성하여 서버에 저장할 수 있다.

 

@Slf4j
@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 {

        log.info("request={}", request);
        log.info("itemName={}", itemName);
        log.info("multipartFile={}", file);

        if (!file.isEmpty()) {
            String fullPath = fileDir + file.getOriginalFilename();
            log.info("파일 저장 fullPath={}", fullPath);
            file.transferTo(new File(fullPath)); //파일 업로드
        }

        return "upload-form";

    }

}

 

@Value를 통해 application.properties에 저장된 값을 가져올 수 있다. fileDir은 서버(개발자 로컬 디렉터리)에 업로드 파일을 저장할 디렉터리다.

 

MultipartFile 객체로 받은 정보로 원본 파일 이름을 알 수 있고, 해당 정보를 통해 전체 경로 값을 얻을 수 있다. 이것으로 File 객체를 생성한 후 transferTo()를 하면
서버에 파일이 업로드된다.

 

📌 파일 업로드/다운로드 예제

 

🔎 요구사항

 

 

🔎 상품 도메인과 레포지토리

 

attachFile은 첨부 파일 필드로 단일 저장할 것이고, 다운로드가 가능해야 한다. imageFiles는 이미지 파일 필드로 복수 저장이 가능해야 한다. 그리고 두 필드 모두
업로드 기능이 있어야 한다.

 

@Data
public class Item {

    private Long id;
    private String itemName;
    private UploadFile attachFile; //첨부 파일 1개
    private List<UploadFile> imageFiles; //이미지 파일 여러 개

}

 

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

}

 

🔎 업로드용 파일 보관 - UploadFile

 

업로드할 파일을 보관하는 객체로 사용할 것이며, 업로드 파일의 양식이라고 보면 된다.

 

업로드 파일에서 고려해야 할 점은 파일명의 분리다. 사용자가 업로드하는 파일명을 그대로 서버에서 사용할 수 없다. 그 이유는 파일명의 중복을 초래하기 때문이다.
따라서, 클라이언트가 인지한 파일명과 서버에서 인지하는 파일명을 분리할 목적으로 별도 클래스가 필요한 것이다.

 

@Data
public class UploadFile {

    private String uploadFileName; //업로드 파일명(클라이언트)
    private String storeFileName; //저장 파일명(서버)

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

}

 

🔎 파일 업로드 - FileStore

 

스프링에서 Bean으로 등록 후 컨테이너에서 관리하도록 @Component를 선언한다. FileStore는 컨트롤러에서 쓰기 위해 존재하는 것이다. 따라서, 다른 클래스에서
사용되려면 의존성 주입이 필요하므로 Bean으로 등록한 것이다.

 

  • 업로드 파일 ➡ 단일 저장 ➡ 반환 값 UploadFile
  • 이미지 파일 ➡ 복수 저장 ➡ 반환 값 List<UploadFile>

 

✔ 업로드 경로 얻기

 

서버에 업로드될 디렉터리를 알아야 하고, 이 정보를 통해 파일명을 포함한 전체 경로 정보를 알아야 한다.

 

@Component
public class FileStore {

    @Value("${file.dir}")
    private String fileDir; //디렉터리 정보

    public String getFullPath(String filename) {
        return fileDir + filename; //파일명 포함 전체 경로
    }
    
    ...

 

✔ 단일 저장

 

단일 저장의 반환 값은 UploadFile이다. UploadFile은 서버에 저장할 파일명이 필요하다. 따라서, MultipartFile이라는 객체로 HTTP 요청의 멀티파트 정보를
가져온 후 확장자를 추출하는 기능UUID를 통해 랜덤 한 파일명을 생성하는 기능이 필요하다. 최종적으로 UploadFile의 생성자에 필요한 두 매개변수가 준비되면 서버에 저장하고 객체를 반환한다.

 

    //파일 저장 기능
    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);
    }

    //서버 파일 저장 형식대로 파일명 생성 (uuid.확장자)
    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);
    }

 

✔ 복수 파일 저장

 

이미지 파일 여러 개를 저장하기 위해 복수 파일 저장 기능이 필요하다. 앞에서 파일 저장 기능을 이미 구현했으므로, 간단히 처리할 수 있다. 루프 안에서 MultipartFile 정보가 있으면 업로드를 대기하는 파일이 있는 것으로 보고, 그때마다 단일 저장 기능을 호출하면 된다. 호출 후 반환된 UploadFile은 List에 담아 반환한다.

 

    //파일 여러 개 저장
    public List<UploadFile> storeFiles(List<MultipartFile> multipartFiles) throws IOException {
        List<UploadFile> storeFileResult = new ArrayList<>();
        for (MultipartFile multipartFile : multipartFiles) {
            if (!multipartFile.isEmpty()) {
                storeFileResult.add(storeFile(multipartFile)); //저장된 업로드 파일을 List에 추가
            }
        }
        return storeFileResult;
    }

 

🔎 전체 코드

 

@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()) {
                storeFileResult.add(storeFile(multipartFile)); //저장된 업로드 파일을 List에 추가
            }
        }
        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);
    }

    //서버 파일 저장 형식대로 파일명 생성 (uuid.확장자)
    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);
    }

}

 

🔎 DTO 객체 - ItemForm

 

HTTP 요청에서 전달된 파일 정보는 MultipartFile 객체 타입이다. FileStore에서 멀티파트 정보를 사용한 것도 MultipartFile 덕분이다. 하지만 MultipartFile 정보는 다양한 정보가 혼합되어 있고, 바이너리 파일 정보도 들어 있다. 이것을 DB에 그대로 저장하는 것은 매우 비효율적이고 보안 성능도 떨어진다.

 

✔ DTO의 역할

 

ItemForm의 역할은 DTO와 같은 데이터 전달이다. 직접 도메인으로 전달하지 못하는 것은 결국 필요한 정보를 추출하여 객체를 만들고자 하는 것이고, 이는 캡슐화 효과도 있다. 그리고 해당 예제는 업로드/다운로드로 끝이지만 일반적으로 뷰와 연결된 컨트롤러는 요구사항의 변화가 잦다. 이것은 도메인에도 영향을 줄 여지가 많아짐을
의미하므로, DTO를 통해 도메인을 보호할 수도 있다.

 

DTO는 필요한 정보를 추출하여 담기 위한 모델이고, 이를 토대로 DB에 저장할 도메인 객체를 생성한다.

 

@Data
public class ItemForm {

    private Long itemId;
    private String itemName;
    private List<MultipartFile> imageFiles;
    private MultipartFile attachFile;

}

 

🔎 뷰

 

✔ item-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>

 

item-form 화면

 

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

 

첨부 파일의 링크 정보를 보면 "/attach/${item.id}"라고 되어 있다. 따라서, 이를 처리하는 컨트롤러가 추가로 필요하다.
또한, "/images/${서버 저장 파일명}"을 처리하고 이미지를 보여 주는 컨트롤러도 필요하다.

 

th:text에 getUploadFileName()을 가져온 것은 클라이언트에게 서버 저장 파일명이 아닌 자신이 저장했던 파일명을 보여주기 위함이다.

 

item-view 화면

 

🔎 ItemController

 

✔ 폼 데이터 저장

 

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

        //DB에 저장
        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}";
    }

 

DTO 부분에서 언급한 것처럼 폼에서 입력한 값을 ItemForm으로 받는다. 또한, 파일과 관련된 정보는 MultipartFile 타입이다. ItemForm으로 받은 데이터는 FileStore를 통해 Item 도메인에 저장하고 싶은 UploadFile 객체로 변환된다. 최종적으로 Item 도메인을 생성하여 DB에 저장 후 리다이렉트한다.

 

✔ 업로드 및 이미지 출력

 

업로드한 이미지 파일들을 받아서 item-view에서 보여줘야 한다. 이것은 UrlResource라는 객체로 간단하게 구현할 수 있다.

 

UrlResource : File이 있으면 해당 경로의 이미지 파일을 읽음

 

이미지 파일은 Binary 데이터이므로, HTTP 컨버터가 동작하는 @ResponseBody나 ResponseEntity를 사용해야 한다.

 

    @ResponseBody
    @GetMapping("/images/{filename}")
    public Resource downloadImage(@PathVariable String filename) throws MalformedURLException {
        return new UrlResource("file:" + fileStore.getFullPath(filename));
    }

 

item-view 화면의 업로드 파일 목록(저장 파일명)

 

✔ 첨부 파일 업로드 & 다운로드

 

첨부 파일 다운로드도 UrlResource를 이용하면 된다. 다만, 브라우저가 첨부 파일임을 인식하도록 해야 한다. 이를 위해 Comtent-Disposition 헤더에
"attachment; filename="첨부 파일명""
값을 넣는 것이 규약이다.

 

마찬가지로 파일은 Binary 데이터이므로, HTTP 컨버터가 동작하는 @ResponseBody나 ResponseEntity를 사용해야 한다. 그런데 @ResponseBody는
헤더에 접근하는 기능이 없다. 따라서, ResponseEntity를 사용한다.

 

UriUtils를 사용한 것은 한글 이름의 첨부 파일도 깨지지 않도록 하기 위함이다.

 

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

    }

 

한글 파일명 첨부 파일(업로드 결과)

 

파일 다운로드 결과

 

✔ 전체 코드

 

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

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

        //DB에 저장
        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));

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

    }
}

 

Reference:

https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-mvc-2/dashboard

 

스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 - 인프런 | 강의

웹 애플리케이션 개발에 필요한 모든 웹 기술을 기초부터 이해하고, 완성할 수 있습니다. MVC 2편에서는 MVC 1편의 핵심 원리와 구조 위에 실무 웹 개발에 필요한 모든 활용 기술들을 학습할 수 있

www.inflearn.com