스프링/4. 스프링 MVC-2

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

sdafdq 2023. 9. 25. 05:31

요구사항

상품관리

- 상품이름

- 첨부파일 하나

- 이미지 파일 여러개

 

첨부파일을 업로드 다운로드 할 수 있어야 한다.

 

업로드 한 이미지를 웹 브라우저에서 확인할 수 있다.

 

 

@Data
public class Item {
    private Long id;
    private String itemName;
    private UploadFile attachFile;
    private List<UploadFile> imageFiles;
}

먼저 Item을 구현했다.

 

그 다음 간이로 저장해보기 위해

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

그냥 메모리에 저장되는 Repository를 만들었다(서비스를 끄면 휘발된다.)

Repository가 Item 접근을 위한 Id를 부여한다.

 

 

@Data
public class UploadFile {
    private String uploadFileName;
    private String storeFileName;

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

UploadFile을 정의했다.

 

이거는 파일 이름만 저장시켜 놓는거다. 

보통 DB에는 이렇게 경로나 파일 이름정도만 저장시켜 놓고, 이미지 서버는 따로 두거나 한다.

파일 이름만 남겨두는 경우는, 또 그 위의 경로를 애플리케이션에다 static final 해서 저장시켜 놓기도 한다.

 

uploadFileName은 사용자가 업로드 했을 때 올렸던 파일의 이름을 넣을 것이고,

storeFileName은 서버에 저장 시킬 때의 파일 이름이다. 이건 나중에 겹치는 것을 방지하기 위해 UUID로 넣을 것이다.

 

 

@Component
public class FileStore {
    @Value("${file.dir}")
    String fileDir;

    public String getFullPath(String fileName){
        return fileDir + fileName;
    }

    public List<UploadFile> storeFiles(List<MultipartFile> multipartFiles) throws IOException {
        List<UploadFile> files = new ArrayList<>();
        for (MultipartFile multipartFile : multipartFiles) {
            if(!multipartFile.isEmpty()){
                files.add(storeFile(multipartFile));
            }
        }
        return files;
    }

    public UploadFile storeFile(MultipartFile multipartFile) throws IOException {
        if(multipartFile.isEmpty()){
            return null;
        }

        String originalFileName = multipartFile.getOriginalFilename();
        String serverFileName = createStoreFileName(originalFileName);

        multipartFile.transferTo(new File(getFullPath(serverFileName)));

        return new UploadFile(originalFileName, serverFileName);
    }

    private String createStoreFileName(String originalFileName){

        String extension = extracted(originalFileName);
        String serverFileName = UUID.randomUUID().toString() + "." + extension;
        return serverFileName;
    }

    private String extracted(String originalFilename){
        int pos = originalFilename.lastIndexOf(".");
        String extension = originalFilename.substring(pos + 1);
        return extension;
    }
}

파일을 관리하는 것이다. 여기서 저장시키고 서버에 저장 될 이름(UUID)도 준다.

@Value("file.dir")로

file.dir=G:/spring/files/

이걸 가져왔다.

 

getFullPath는 저 경로 + 파일이름이다. 

 

먼저 storeFile()부터 보면, 

Request로부터 올 수 있는 MultipartFile이 비어 있으면 그냥 null을 반환하고,

있으면 파일의 원래 이름을 originalFileName해서 넣고,

서버에 들어갈 파일 이름은 UUID로 생성을 하는데, 그 전에 원래 파일이름의 맨 끝의 "." 이 있는 것의 인덱스를 찾고,

substring()으로 그것의 다음것들을 모두 반환시켜 확장자를 얻는다.

그렇게 UUID + "." + 확장자

해서 서버에 저장 될 파일 이름이 완성된다. 

그거를 이제 메모리에 있던 것을 transferTo(new File(경로))

해서 경로로 옮겨준다.

 

그리고, UploadFile(원래이름, 서버로들어갈이름) 해서 반환해 주면 UploadFile이 된다.

 

storeFiles는 그걸 여러 번 해준거다.

 

 

 

@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 {
        Item item = new Item();
        item.setItemName(form.getItemName());
        item.setAttachFile(fileStore.storeFile(form.getAttachFile()));
        item.setImageFiles(fileStore.storeFiles(form.getImageFiles()));

        itemRepository.save(item);

        redirectAttributes.addAttribute("itemId", item.getId());
        return "redirect:/items/{itemId}";
    }

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

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


    @GetMapping("/attach/{itemId}")
    public ResponseEntity<Resource> downloadAttach(@PathVariable Long itemId) throws MalformedURLException {
        Item item = itemRepository.findById(itemId);
        String fileName = item.getAttachFile().getStoreFileName();
        String fullPath = fileStore.getFullPath(fileName);
        String userFileName = item.getAttachFile().getUploadFileName();

        UrlResource urlResource = new UrlResource("file:" + fullPath);

        String encodedUserFileName = UriUtils.encode(userFileName, StandardCharsets.UTF_8);
        String contentDisposition = "attachment; filename=\"" + encodedUserFileName + "\"";

        return ResponseEntity.ok()
                .header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition)
                .body(urlResource);
    }
}

아이템 컨트롤러이다.

Get메소드의 newItem() 컨트롤러는 그냥 form이 있는 템플릿을 주는거다.

 

Post메소드의 saveItem()은 위와 같은 form이지만, 클라이언트가 위에서 받은 form에 정보를 입력하고 submit을 눌러 전송했기에 그 form의 메소드를 post로 설정했기 때문에 Post 요청으로 온다. 

 

그럼 그냥 그 정보를 토대로 아이템을 생성하고(아이템이름, 파일, 사진들), 

Repository에 저장하면 id는 알아서 부여 해 준다. 

그 다음 아이템 저장소에 중복 저장되는것을 막기 위해 PRG 패턴을 적용했다.

참고로 RedirectAttributes는 Redirect를 도와주는 객체인데, return과 같이 redirect 경로를 지정하는데 도움을 줄 수도 있다.

굳이 redirect:/items/ + getId() 해도 되지만 저렇게 한 이유는,

만약 저게 한글이나 그런거면 인코딩문제가 있을수도 있기 때문에,

저 RedirectAttributes는 그걸 알아서 해결 해 준다.

 

 

@ModelAttribute ItemForm form

이거는,

@Data
public class ItemForm {
    private Long itemId;
    private String itemName;
    private List<MultipartFile> imageFiles;
    private MultipartFile attachFile;
}

이렇게 Form 전용 클래스를 따로 만든 것이다.

이게 html의 name

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

덕분에 알아서 잘 맞춰서 들어가 진다.

 

input 파일 태그에서 multiple="multiple" 이렇게 하면 여러 개 받을 수 있다. 이메일과 파일만 적용되는 속성이다.

 

 

viewItem()은 그냥 id 받고 그거 찾아서 모델에 넣어서 뷰에 전해준 다음에 보여주는 것이다.

 

downloadImage() 저게 뭐냐면, 먼저 템플릿 먼저 보자면

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

이미지의 src를 /image/서버이름

으로 해놨다.

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

UrlResource(경로+파일이름) 하면 그걸 실제로 찾아와서 Stream형태로 반환이 된다고 한다.

@ResponseBody니 응답 메시지 바디에 넣는 것이다.

"file:"이렇게 하면 로컬파일을 뒤지는 것인데, 저기에 서버url을 넣어서 쓰면 될 것 같다.

 

여튼, 태그의 src는 위와 같은 Stream 형태로 반환 받을 수 있다. img태그는 그것을 이미지 형태로 표현할 것이다.

 

 

 

다음은 파일 다운로드이다.

@GetMapping("/attach/{itemId}")
public ResponseEntity<Resource> downloadAttach(@PathVariable Long itemId) throws MalformedURLException {
    Item item = itemRepository.findById(itemId);
    String fileName = item.getAttachFile().getStoreFileName();
    String fullPath = fileStore.getFullPath(fileName);
    String userFileName = item.getAttachFile().getUploadFileName();

    UrlResource urlResource = new UrlResource("file:" + fullPath);

    String encodedUserFileName = UriUtils.encode(userFileName, StandardCharsets.UTF_8);
    String contentDisposition = "attachment; filename=\"" + encodedUserFileName + "\"";

    return ResponseEntity.ok()
            .header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition)
            .body(urlResource);
}

먼저 Id를 받아와 item을 찾고 거기서 파일이름을 찾아오고(업로드할 때 파일이름을 가져오는 것은 그 파일이름으로 그냥 보여주기 위해.)

 

마찬가지로 그 파일을 Stream 형태로 가져온다.

그리고, 파일 이름이 한글이나 그런 거 일수도 있어서, UriUtils 중에 encode() 기능을 이용해서 인코더를 UTF-8로 설정해 준 다음 넣어줬다.

 

그 다음 ResponseEntity를 이용해 바디에 넣어주는 것인데, 

<a th:if="${item.attachFile}" th:href="|/attach/${item.id}|" th:text="${item.getAttachFile().getUploadFileName()}" /><br/>

이렇게 링크를 클릭하면 다운로드 하게끔 하고 싶은데,

 

그냥 클릭하면 그냥 브라우저 자체에서 ResponseBody를 읽는 것이다. 우리는 다운로드 받게끔 하고 싶다.

그럴 때 필요한 게 헤더의 Content-disposition (컨텐츠의 성향)을 attachment(첨부, 첨부파일)로 해 줘야 한다.

그리고 거기에 추가로 filename="파일이름" 해 주면 저장할 때 그 파일 이름으로 미리 써 넣어준다.

filename 해 놓는 게 좋은 게, 따로 안 써넣으면 .htm등 이런 파일로 자동으로 써 넣어진다. 업로드 될 때 파일 이름이 확장자도 포함하고 있는 이름이라, 헤더의 Content-disposition 속성에 추가해 주는 게 좋다.

반환 타입은 ResponseEntity<Resouce(UrlResouce의 조상 인터페이스)>이고, 

바디에 그 UrlResouce를 넣어 반환했다.

 

그러면,

<a th:if="${item.attachFile}" th:href="|/attach/${item.id}|" th:text="${item.getAttachFile().getUploadFileName()}" /><br/>

저 경로 /attach/id와 맞춰 놓았으므로, 

클릭하면 downloadAttach()가 호출되고,

거기에 링크로 Stream타입의 Resouce가 들어가고, attachment해놨으므로 첨부 형태이므로 다운로드 되는 형태이다.