본문 바로가기
Java | Spring/SpringBoot

FetchAPI ↔ SpringBoot Multipart 비동기 요청중 발생한 에러

by 동기 2024. 2. 17.
반응형

HTML form태그의 action 요청으로 multipart/formdata 형식으로 다중파일과 본문 내용을 전달 했었는데,

JavaScript 의 fetch API FormData 를 활용한 비동기요청으로 변환하는 과정에서 발생한 에러 기록들을 남겨보았습니다.

Javascript

let postFileData = async (url, data = {}, csrf_header, csrf_token) => {
    const response = await fetch(url, {
        method: "POST", // *GET, POST, PUT, DELETE 등
        mode: "cors", // no-cors, *cors, same-origin
        cache: "no-cache", // *default, no-cache, reload, force-cache, only-if-cached
        credentials: "same-origin", // include, *same-origin, omit
        headers: {
            "Content-Type": "multipart/form-data",
            header: csrf_header,
            'X-CSRF-Token': csrf_token
        },
        redirect: "follow",
        referrerPolicy: "no-referrer",
        body: data
    });
    return response.json(); // JSON 응답을 네이티브 JavaScript 객체로 파싱
}

let content = form.querySelector(".question-input").value; // 요청 글
let files = document.querySelector("#files").files; // 요청 파일
const formData = new FormData();
for(let file of files) {
	formData.append("files", file);
}
formData.append("content",content);
postFileData(url,formData,csrf_header,csrf_token).then((response)=>{
console.log('response:',response)
})

Springboot Controller

@PreAuthorize("isAuthenticated()")
@PostMapping("/create")
@ResponseBody
public ResponseEntity<Object> questionCreate(
	@RequestBody(required = false) List<MultipartFile> files,
  @RequestBody @Valid QuestionForm questionForm,// @Valid 애노테이션을 통해 questionForm 의 @NotEmpty 등이 작동한다
	BindingResult bindingresult,// @Valid 애노테이션으로 인해 검증된 결과를 의미하는 객체
  Principal principal //현재 로그인한 사용자의 정보를 알려면 스프링 시큐리티가 제공하는 Principal 객체를 사용해야 한다.
){

//business logic 생략

return ResponseEntity.status(HttpStatus.OK).body(questionForm);
}

에러

the request was rejected because no multipart boundary was found

Fetch API의 Header부분 Content Type에 multipart/form 으로 직접 명시해서 요청을 보냈는데, 이렇게 하면 뒤에 boundary의 값이 없는 상태로 덮어씌워져 요청이 된다고 합니다.

Stackoverflow 답변을 보니 Content-Tpye에 multipart/form 으로 작성하지 말고 비워두고 진행하라고 합니다.

The problem is that you are setting the Content-Type by yourself, let it be blank. Google Chrome will do it for you. The multipart Content-Type needs to know the file boundary, and when you remove the Content-Type, Postman will do it automagically for you.

FetchAPI 의 헤더 Content-Type을 주석처리하고 요청하니 Boundary의 값이 붙어서 잘 요청 되고 있습니다.

정상적인 boundary 값이 함께 요청된다

하지만 다른 에러가 발생했습니다

the request doesn't contain a multipart/form-data or multipart/mixed stream, content type header is application/json

SpringBoot Controller

Controller 부분의 @RequestBody 를 @RequestPart 로 변경해 주었습니다.

@PreAuthorize("isAuthenticated()")
@PostMapping("/create")
@ResponseBody
public ResponseEntity<Object> questionCreate(
	@RequestPart(name = "files",required = false) List<MultipartFile> files,
  @RequestPart(name = "content") @Valid QuestionForm questionForm,// @Valid 애노테이션을 통해 questionForm 의 @NotEmpty 등이 작동한다
	BindingResult bindingresult,// @Valid 애노테이션으로 인해 검증된 결과를 의미하는 객체
  Principal principal //현재 로그인한 사용자의 정보를 알려면 스프링 시큐리티가 제공하는 Principal 객체를 사용해야 한다.
){

	return ResponseEntity.status(HttpStatus.OK).body(questionForm);
}

또 다른 에러 발생

Resolved [org.springframework.web.HttpMediaTypeNotSupportedException: Content-Type 'application/octet-stream' is not supported]

Client의 Payload 를 확인해 보니, 본문 내용의 content-type이 application/octet-stream 으로 전송되고 있다.

Javascript

JS의 formData에 본문내용을 그대로 담지 말고 blob 객체의 타입을 application/json으로 만들어서 formdata에 append 해 주었습니다.

let postFileData = async (url, data = {}, csrf_header, csrf_token) => {
    const response = await fetch(url, {
        method: "POST", // *GET, POST, PUT, DELETE 등
        mode: "cors", // no-cors, *cors, same-origin
        cache: "no-cache", // *default, no-cache, reload, force-cache, only-if-cached
        credentials: "same-origin", // include, *same-origin, omit
        headers: {
            "Content-Type": "multipart/form-data",
            header: csrf_header,
            'X-CSRF-Token': csrf_token
        },
        redirect: "follow",
        referrerPolicy: "no-referrer",
        body: data
    });
    return response.json(); // JSON 응답을 네이티브 JavaScript 객체로 파싱
}

let content = form.querySelector(".question-input").value; // 요청 글
let files = document.querySelector("#files").files; // 요청 파일
const formData = new FormData();
for(let file of files) {
	formData.append("files", file);
}
formData.append("content",new Blob([JSON.stringify(content)],{type: "application/json"}),
postFileData(url,formData,csrf_header,csrf_token).then((response)=>{
console.log('response:',response)
})

또 다른 에러 발생!!

Resolved [org.springframework.http.converter.HttpMessageNotReadableException: JSON parse error: Cannot construct instance of `com.weight.gym_dude.question.QuestionForm` (although at least one Creator exists): no String-argument constructor/factory method to deserialize from String value ('안녕하세요 제가 쓰는 글 입니다')]

본문 내용을 deserialize 할 이름이 없다는 겁니다!!

JS 에서 Blob 객체를 만들때 {key:value} 형태로 만들지 않았었네요.

아래와 같이 변경해 주었습니다. 더 이상 에러를 발생하지 않고 잘 진행되었습니다.

//new Blob([JSON.stringify(content)]
new Blob([JSON.stringify({"content":content})]

최종 코드

Javascript

let postFileData = async (url, data = {}, csrf_header, csrf_token) => {
    const response = await fetch(url, {
        method: "POST", // *GET, POST, PUT, DELETE 등
        mode: "cors", // no-cors, *cors, same-origin
        cache: "no-cache", // *default, no-cache, reload, force-cache, only-if-cached
        credentials: "same-origin", // include, *same-origin, omit
        headers: {
            "Content-Type": "multipart/form-data",
            header: csrf_header,
            'X-CSRF-Token': csrf_token
        },
        redirect: "follow",
        referrerPolicy: "no-referrer",
        body: data
    });
    return response.json(); // JSON 응답을 네이티브 JavaScript 객체로 파싱
}

let content = form.querySelector(".question-input").value; // 요청 글
let files = document.querySelector("#files").files; // 요청 파일
const formData = new FormData();
for(let file of files) {
	formData.append("files", file);
}
formData.append("content",new Blob([JSON.stringify(content)],{type: "application/json"}),
postFileData(url,formData,csrf_header,csrf_token).then((response)=>{
console.log('response:',response)
})

SpringBoot Controller

@PreAuthorize("isAuthenticated()")
@PostMapping("/create")
@ResponseBody
public ResponseEntity<Object> questionCreate(
	@RequestPart(name = "files",required = false) List<MultipartFile> files,
  @RequestPart(name = "content") @Valid QuestionForm questionForm,// @Valid 애노테이션을 통해 questionForm 의 @NotEmpty 등이 작동한다
	BindingResult bindingresult,// @Valid 애노테이션으로 인해 검증된 결과를 의미하는 객체
  Principal principal //현재 로그인한 사용자의 정보를 알려면 스프링 시큐리티가 제공하는 Principal 객체를 사용해야 한다.
){

	return ResponseEntity.status(HttpStatus.OK).body(questionForm);
}

참고 자료

https://stackoverflow.com/questions/36005436/the-request-was-rejected-because-no-multipart-boundary-was-found-in-springboot

https://muffinman.io/blog/uploading-files-using-fetch-multipart-form-data/

https://developer.mozilla.org/ko/docs/Web/API/Fetch_API/Using_Fetch

https://somuchthings.tistory.com/160

https://velog.io/@hhhminme/Axios에서-Post-시-Contenttypeapplicationoctet-streamnotsupported-핸들링415-에러

반응형

댓글