지금까지 보내던 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= 해서 넘기는걸 알 수 있다. 이걸 줘서 클라이언트에게 해당 링크로 이동하면 다운받으라고 알려주는 것이다. 만약 없으면 해당 파일의 내용을 그냥 읽게 된다.


'CS > 김영한 스프링 강의' 카테고리의 다른 글
스프링 DB 1편 - 데이터 접근 핵심 원리 - 섹션2. 커넥션풀과 데이터소스 이해 (0) | 2023.08.12 |
---|---|
스프링 DB 1편 - 데이터 접근 핵심 원리 - 섹션1. JDBC 이해 (0) | 2023.08.12 |
스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 - 섹션10. 스프링 타입 컨버터 (0) | 2023.08.09 |
스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 - 섹션9. API 예외 처리 (0) | 2023.08.06 |
스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 - 섹션8. 예외 처리와 오류 페이지 (0) | 2023.08.05 |