스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 - 섹션11. 파일 업로드
CS/김영한 스프링 강의

스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 - 섹션11. 파일 업로드

지금까지 보내던 HTTP의 enctype 방식은 x-www-form-urlencoded나 application/json 형식이었다. 즉 기껏해야 string이고 구분자도 그냥 편하게 되어있어서 잘 했다.

 

 

그런데 바이너리인 파일은 어떻게 보낼 것인가? 만약 이름, 나이, 이미지파일 같은 형식으로 string과 binary파일을 같이 보내야 한다면?? 이런것 때문에 enctype="multipart/form-data" 방식이 나왔다.

part라는게 서버 내에서도 중요한가 보다.

다른 string과 함께 이미지 파일 아무거나 binary 형태로 해서 multipart/form-data로 보낸걸 보면 실제 이론에서 배운것처럼 구분자가 있고 각자의 헤더와 내용이 들어있음을 알 수 있다. 컨트롤러에서 받는 request객체가 처음 보는 객체인데 resolver가 저걸 정해서 해준거다. 참고로 HttpServletRequest의 확장된 자식인 MultipartHttpServletRequest 인터페이스를 구현한 StandardMultipartHttpServletRequest가 있는데 이걸 받으면 .getFile()등 더 추가적인 기능이 가능하다. 하지만 나중에 배울 MultipartFile이 훨씬 편리해서 이것만 쓴다. 또 spring.servlet.multipart.enabled를 false로 설정하면 multipart를 처리안하고 그냥 받아서 parts가 비어있다.

 

 

multipart이기 때문에 part별로 마치 각각의 http처럼 header와 body를 구분할 수 있다. 어떤것이든 파일을 읽어들일 때 UTF_8등의 포맷을 잘 알려줘야 하고 저장할 수도 있다. 저장까지도 잘 된다.

근데 이걸 일일히 해야 할까? 즉 각자의 part 부분을 읽고 그 안에 binary파일이 있으면 stream으로 읽고 저장하는 코드를 직접 짜야 할까? 스프링에서 해당 기능을 당연히 제공한다.

 

MultipartFile이라고 하면 multipart/form-data 안에 있는 file을 argument resolver가 알아서 처리해준다. 물론 관련한 함수 기능들도 있다. 이제 이걸 업로드하고 다운로드도 하는 기능도 구현해보자.

 

사전 작업이 좀 많다...

 

uploadedFileName과 storeFileName을 따로 만드는 이유는 실제 서버에 저장할 때의 이름은 uuid같은 고유 이름으로 해야 이름 충돌이 안나기 때문이다.

 

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

    private String createStoreFileName(String originalFilename) {
        String ext = extractExt(originalFilename);

        // 서버에 저장하는 파일명
        String uuid = UUID.randomUUID().toString();
        return uuid + "." + ext;

    }

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

}

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

        // 데이터베이스에 저장
        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/{itemId}")
    public String items(@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 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 urlResource = new UrlResource("file:" + fileStore.getFullPath(storeFileName));

        log.info("uploadFileName={}", uploadFileName);

        String encodedUploadFileName = UriUtils.encode(uploadFileName, StandardCharsets.UTF_8);

        return ResponseEntity.ok()
                .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + encodedUploadFileName + "\"")
                .body(urlResource);
    }
}

 

너무 많은데 일단 item-form을 보자.

상품 이름이랑 파일 업로드가 된다. 이걸 제출을 누르면 해당 주소로 post를 보내서 컨트롤러의 saveItem이 작동한다. filestore에는 실제로 내 폴더에 저장하는 코드가 있으므로(transferTo) 내 폴더에 저장하는데 이름이 겹치면 안되면서 사용자가 업로드한 이름도 알고 있어야 하므로 이 목적을 위해 만든 Item으로 메모리에 저장한다. 물론 실제로는 db에 저장하고 파일은 s3같은곳에 저장하는게 일반적이다.

직접 만든 UploadedFile을 변수로 가지고 있는 Item을 ItemRepository에 Map<Long, Item> 형식으로 보관했으므로 메모리에 저장한 실제 객체 경로들을 불러오려면 저장한 long id를 key로 해서 가져올 수 있다. 그렇게 해서 가져와서 id로 보여주는게 컨트롤러 /items/{itemId} 부분임.

저 파란색의 첨부파일 링크는 클릭하면 저것이 다운받아지도록 했다. 이게 컨트롤러의 /images/{filename}, /attach/{itemId} 부분인데 저렇게만 사용하라는게 아니고 그냥 막 만들어서 사용하면 된다. 주목할건 /attach/{itemId} 함수 부분인데, 마지막에 헤더에  "Content-Disposition"를 붙이고 뒤에 attachment; filename= 해서 넘기는걸 알 수 있다. 이걸 줘서 클라이언트에게 해당 링크로 이동하면 다운받으라고 알려주는 것이다. 만약 없으면 해당 파일의 내용을 그냥 읽게 된다.