RestApi로 파일 전송하기에서는 BeanValidation을 사용할 수 없다. 라고 이야기 했던 적이 있다. RequestBody 애노테이션은 @ModelAttribute처럼 MultipartFile까지 묶여 오지 않기 때문인데, 대체할 방법으로 RequestPart를 찾았다.
RequestPart
public ResponseEntity<JsonResponse> addBook(@RequestParam(required = false) String bookName,
@RequestParam(required = false) String isbn,
@RequestParam(required = false) MultipartFile coverImage) throws IOException {
...
에서,
public ResponseEntity<JsonResponse> addBook(@RequestPart("bookData") @Validated NewBookForm form,
BindingResult bindingResult,
@RequestPart(name = "coverImage", required = false) MultipartFile coverImage) {
으로 간소화 시킬 수 있다. MultiPart 타입으로 전송시에 데이터를 이런 식으로 Json화 시킬 수 있는 데이터는 묶을 수 있는 것이다. 이 방식의 장점은, BeanValidation이 가능하다는 점이다.
public class NewBookForm {
@NotBlank
private String bookName;
@NotBlank
private String isbn;
private MultipartFile coverImage;
}
단, MultipartFile같은 경우에는 따로 받아 와야 하지만, 이것만 해도 매우 좋다고 생각한다.
public ResponseEntity<JsonResponse> addBook(@RequestPart("bookData") @Validated NewBookForm form,
BindingResult bindingResult,
@RequestPart(name = "coverImage", required = false) MultipartFile coverImage) {
form.setCoverImage(coverImage);
log.debug("bindingResult.target={}", bindingResult.getTarget());
log.debug("bindingResult.objectName={}", bindingResult.getObjectName());
log.debug("form={}", form);
log.debug("form.bookName={}", form.getBookName());
log.debug("form.isbn={}", form.getIsbn());
if (form.getCoverImage() == null || form.getCoverImage().isEmpty()) { // 여기서 체크한다.
bindingResult.rejectValue("coverImage", "NotBlank", null);
}
if (bindingResult.hasErrors()) {
log.debug("errors={}", bindingResult);
return errorResponseUtils.handleValidationErrors(bindingResult);
}
bookService.addBook(form);
return ResponseEntity.ok().body(JsonResponse.builder()
.message("정상 등록되었습니다.")
.build());
}
클라이언트
클라이언트에서는 이런 식으로 bookData로 묶어주고, 미리 JSON.stringfy를 해주면 된다. 여기에 사용된 fetchRequest함수는 이 글을 참고하자.
const addBook = async errorContainer => {
const bookInfo = {
bookName : document.getElementById("addBookName").value,
isbn : document.getElementById("addBookIsbn").value,
}
const coverImage = document.getElementById("addBookCoverImage").files[0]; // 파일 선택
const body = new FormData();
body.append("bookData", JSON.stringify(bookInfo));
body.append("coverImage", coverImage);
try {
const data = await fetchRequest(`/books/add`, "POST", body);
if (data.redirected) {
console.log("페이지가 리다이렉트되었습니다.");
return;
}
alert("결과 : " + data.message);
location.reload();
} catch (e) {
handleError(e, errorContainer);
}
};
Content-Type ‘application/octet-stream’ is not supported
오늘의 주제, 이런 에러가 스프링 콘솔에서 발생했다. 무슨 일일까? 헤더는 아래처럼 되어 있어서, 전혀 application/octet-stream 이라는 헤더는 존재하지도 않았다.
POST /books/add HTTP/1.1
Accept: */*
Accept-Encoding: gzip, deflate, br, zstd
Accept-Language: ko,en;q=0.9,en-US;q=0.8
Cache-Control: no-cache
Connection: keep-alive
Content-Length: 107403
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryyGoGs8vw1aR1FnMf
Cookie: JSESSIONID=0BF7C7C564011184A52B4E776840863C
Host: localhost:8080
Origin: http://localhost:8080
Pragma: no-cache
Referer: http://localhost:8080/admin/book
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: same-origin
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36
sec-ch-ua: "Google Chrome";v="131", "Chromium";v="131", "Not_A Brand";v="24"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "macOS"
Blob 객체로 묶어서 보내자
Blob 객체로 묶어서 보내니, 문제가 해결되었다.
const addBook = async errorContainer => {
const bookInfo = {
bookName : document.getElementById("addBookName").value,
isbn : document.getElementById("addBookIsbn").value,
}
const coverImage = document.getElementById("addBookCoverImage").files[0]; // 파일 선택
const body = new FormData();
body.append("bookData", new Blob(
[JSON.stringify(bookInfo)], {type : "application/json"}
));
body.append("coverImage", coverImage);
try {
const data = await fetchRequest(`/books/add`, "POST", body);
if (data.redirected) {
console.log("페이지가 리다이렉트되었습니다.");
return;
}
alert("결과 : " + data.message);
location.reload();
} catch (e) {
handleError(e, errorContainer);
}
};
Blob 객체
new Blob()는 JavaScript에서 제공하는 Web API로, 주어진 데이터를 “블롭(blob)” 객체, 즉 파일과 유사한 불변의 원시 데이터 객체로 만들어주는 역할을 한다. Binary Large Object의 줄임말로, 다양한 형태의 원시 데이터를 불변 형태로 저장하는 역할을 해준다.
이 상황에서는, bookData의 타입을 지정해주는 역할을 해주었다. 단순히 JSON.stringfy로는 타입을 지정해줄 수 없었다. 그래서 위와 같은 오류가 났던 것이다.
아래 코드는 bookInfo 객체를 JSON 문자열로 변환한 다음, 그 문자열을 포함하는 Blob 객체를 생성한다. BloB 객체에는 MIME 타입이 application/json으로 지정되어 있어서 서버에서 이 데이터 파트를 JSON으로 올바르게 인식할 수 있다.
클라이언트에서 파일과 일반 데이터를 전송할 때, JSON 데이터를 올바른 타입으로 보내고자 할 때 매우 유용하다. 파일 업로드, 다운로드, 미리보기, 데이터 스트림 처리 등 여러 상황에서 유용하게 사용 가능한 객체라고 한다.
new Blob([ JSON.stringify(bookInfo) ], { type : "application/json" })
Blob 활용 예
이미지의 src를 가지고 미리보기 이미지를 생성할 수도 있다. URL.createObjectURL() 메서드를 사용하는 것인다. Blob 객체로부터 임시 URL을 생성하고, 이를 이미지의 src 속성에 할당하면 사용자가 업로드한 이미지의 미리보기를 쉽게 구현할 수 있다.
const objectUrl = URL.createObjectURL(imageBlob);
imageElement.src = objectUrl;
또, Blob 데이터를 기반으로 파일 다운로드 링크를 생성할 수도 있다. 사용자가 링크를 클릭하면 Blob의 데이터를 파일로 다운로드 하도록 할 수 있다.