Content-Type ‘application/octet-stream’ is not supported, Blob

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의 데이터를 파일로 다운로드 하도록 할 수 있다.

댓글

개발자  김철준

백엔드 개발자 김철준의 블로그입니다.

주요 프로젝트