순간을 성실히, 화려함보단 꾸준함을

[Javascript, Spring]fetch API 로 데이터 서버로 전송하기 본문

나의 개발 메모장

[Javascript, Spring]fetch API 로 데이터 서버로 전송하기

폭발토끼 2023. 2. 21. 23:30

안녕하세요. 폭발토끼입니다.

오늘은 제가 사이드 프로젝트를 진행하면서 겪었던 까다로움(?)을 어떻게 코드를 작성하였는지 더불어 어떤 점을 공부하였는지 여러분들께 공유를 하고 싶어 글을 적기 시작했습니다.

아마 이 글이 글또 2번째 글이 되기도 하겠네요 ㅎㅎ

 

먼저 fetch API 란 무엇일까요???

 

많은 분들이 jsp 를 사용하신 경험이 있으셨으면 ajax 라는 비동기 http 통신 기술을 사용해본적이 있으셨을 겁니다. 그러나 javascript 도 점점 발전하게 되었고 너무 오래된 기술이 되어버린 ajax는 가독성도 좋지 못하고 태그들이 추가되면 파일 사이즈가 커진다는 단점 때문데 최근에는 프로미스 기반의 fetch API 혹은 axios 를 많이 사용하는 추세입니다.
(https://developer.mozilla.org/ko/docs/Web/API/Fetch_API/Using_Fetch)

 

그럼 fetch API 를 사용해서 데이터를 서버로 전송해보겠습니다.

먼저 간단한 html, controller 를 작성하겠습니다.

<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head>
</head>
<body>
<div class="container h-100">
    <div class="row align-items-center h-100">
        <div class="jumbotron align-self-center">
            <h1 class="text-center"><b>Eun <span class="highlight">stargram</span>!</b></h1>
            <label for="name">이름</label>
            <input type="text" id="name" th:value="Karina"/>

            <label for="age">나이</label>
            <input type="text" id="age" th:value="22"/>

            <div>
                <button type="submit" id="submit">전송</button>
            </div>
        </div>
    </div>
</div>
<script src="/js/index.js"></script>
</body>
</html>
package jipdol2.fetchapitest.controller;

import jipdol2.fetchapitest.dto.SaveDTO;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.ResponseBody;

@Slf4j
@Controller
public class IndexController {

    @GetMapping("/")
    public String index(){
        return "index";
    }

    @ResponseBody
    @PostMapping("/save")
    public SaveDTO save(@RequestBody SaveDTO saveDTO){
        log.info("name={},age={}",saveDTO.getName(),saveDTO.getAge());
        return saveDTO;
    }

}

간단하게 설명하자면 이름과 나이를 받는 html 코드가 있고 전송 버튼을 누르면 /save의 url 을 받는 컨트롤러가 존재합니다.

const addIndexEvent = () => {
    const buttonEvent = document.getElementById("submit");
    buttonEvent.addEventListener("click",submitServer)
};

const submitServer = async (event) =>{
    event.preventDefault();
    const COMMON_URL = 'http://localhost:8080';

    const param = {
        'name' : document.getElementById('name').value,
        'age' : document.getElementById('age').value
    };

    const option = {
        method : 'POST',
        headers:{
            'Content-Type' : 'application/json'
        },
        body: JSON.stringify(param)
    };

    const res = await fetch(`${COMMON_URL}/save`, {
        ...option
    });
    console.log(res.json());
};

addIndexEvent();

fetch API를 사용하는 js 소스입니다.

잘들어왔죠?! 기본적인 문자열 데이터는 성공!!했습니다 ㅎㅎ

근데 어느날 사진을 같이 전송해야 되는 일이 발생했다고 해봅시다.

<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head>
</head>
<body>
<div class="container h-100">
    <div class="row align-items-center h-100">
        <div class="jumbotron align-self-center">
            <h1 class="text-center"><b>Eun <span class="highlight">stargram</span>!</b></h1>
            <label for="name">이름</label>
            <input type="text" id="name" th:value="Karina"/>

            <label for="age">나이</label>
            <input type="text" id="age" th:value="22"/>

            <input type="file" id="file" />

            <div>
                <button type="submit" id="submit">전송</button>
            </div>
        </div>
    </div>
</div>
<script src="/js/index.js"></script>
</body>
</html>

file 을 첨부하는 input 태그를 추가 한뒤

const addIndexEvent = () => {
    const buttonEvent = document.getElementById("submit");
    buttonEvent.addEventListener("click",submitServer)
};

const submitServer = async (event) =>{
    event.preventDefault();
    const COMMON_URL = 'http://localhost:8080';

    const param = {
        'name' : document.getElementById('name').value,
        'age' : document.getElementById('age').value,
        'image' : document.getElementById("file").files[0]
    };

    const option = {
        method : 'POST',
        headers:{
            'Content-Type' : 'application/json'
        },
        body: JSON.stringify(param)
    };

    const res = await fetch(`${COMMON_URL}/save`, {
        ...option
    });
    console.log(res.json());
};

addIndexEvent();

이미지 파일을 js 에서 전송하려는 데이터에 추가해주었습니다.
DTO 파일에도 MultipartFile 을 추가 해주죠 ㅎㅎ

package jipdol2.fetchapitest.dto;

import lombok.Getter;
import org.springframework.web.multipart.MultipartFile;

@Getter
public class SaveDTO {

    private String name;

    private String age;

    private MultipartFile image;
}

그리고 전송을 누르면 어떻게 될까요?

Exception 이 발생했네요....왜 그런걸까요????

바로 MultipartFile 형태는 클라이언트에서 요청보낸 Json 타입을 역직렬화(deserialize) 할 수 없기 때문입니다. 파일형태의 요청은 json 이 아닌 multipar/form-data 형식으로 보내야 합니다.

그럼 js 파일도 FormData 형식으로 전송해야 되고 Controller 에서도 데이터를 받는 형식을 수정해야 합니다.

const addIndexEvent = () => {
    const buttonEvent = document.getElementById("submit");
    buttonEvent.addEventListener("click",submitServer)
};

const submitServer = async (event) =>{
    event.preventDefault();
    const COMMON_URL = 'http://localhost:8080';

/*    const param = {
        'name' : document.getElementById('name').value,
        'age' : document.getElementById('age').value,
        'image' : document.getElementById("file").files[0]
    };*/
    const formData = new FormData();
    formData.append('name',document.getElementById('name').value);
    formData.append('age',document.getElementById('age').value);
    formData.append('image',document.getElementById('file').files[0]);

    const option = {
        method : 'POST',
        headers:{
            // 'Content-Type' : 'multipart/form-data'
            // 'Content-Type' : 'application/json'
        },
        body: formData
    };

    const res = await fetch(`${COMMON_URL}/save`, {
        ...option
    });
    console.log(res.json());
};

addIndexEvent();
package jipdol2.fetchapitest.controller;

import jipdol2.fetchapitest.dto.SaveDTO;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.multipart.MultipartFile;

@Slf4j
@Controller
public class IndexController {

    @GetMapping("/")
    public String index(){
        return "index";
    }

    @ResponseBody
    @PostMapping("/save")
    public SaveDTO save(
            @RequestParam String name,
            @RequestParam String age,
            @RequestParam MultipartFile image
    ){
//        log.info("name={},age={},image={}",saveDTO.getName(),saveDTO.getAge(),saveDTO.getImage());
        log.info("name={},age={},image={}",name,age,image);
        return new SaveDTO();
    }
}

번거롭지만 천천히 보면 FormData 형식으로 전송해야 하기 때문에 객체를 생성해 주고 데이터 하나하나 append 해주었습니다.
그리고 기존 'application/json' 으로 Content-Type 을 정리해주었던 부분을 주석처리 해주었습니다.

컨트롤러에서는 @RequestBody 어노테이션으로 더 이상 받을 수 없으니 쿼리스트링 형식을 받을 수 있는 @RequestParam 어노테이션을 사용하여 객체들을 받아주었습니다.

문제없이 서버에서 받아왔습니다.

그런데 전송하려는 데이터의 수가 만약 30개정도 된다고 가정해보면 일일히 @RequestParam 을 사용해서 정의해주기 너무 번거롭죠 ㅠㅠ
그래서 @ModelAttribute 를 사용해서 받을 수 있습니다.ㅎㅎ

우리는 이제 파일까지 fetch API 를 사용하여 전송하는 방법을 터득하게 되었습니다 ㅎㅎㅎ

그런데!!!이상한점이 있지 않나요?????
Json 형태로 데이터를 보낼때는 'application/json' 이라고 Content-Type 을 정의해 주었는데 왜 'multipart/form-data' 로 보낼때는 Content-Type 을 정의해주지 않았음에도 전송이 되는 걸까요??

한번 Content-Type을 정의해 볼까요?

const addIndexEvent = () => {
    const buttonEvent = document.getElementById("submit");
    buttonEvent.addEventListener("click",submitServer)
};

const submitServer = async (event) =>{
    event.preventDefault();
    const COMMON_URL = 'http://localhost:8080';

/*    const param = {
        'name' : document.getElementById('name').value,
        'age' : document.getElementById('age').value,
        'image' : document.getElementById("file").files[0]
    };*/
    const formData = new FormData();
    formData.append('name',document.getElementById('name').value);
    formData.append('age',document.getElementById('age').value);
    formData.append('image',document.getElementById('file').files[0]);

    const option = {
        method : 'POST',
        headers:{
            'Content-Type' : 'multipart/form-data'
            // 'Content-Type' : 'application/json'
        },
        body: formData
    };

    const res = await fetch(`${COMMON_URL}/save`, {
        ...option
    });
    console.log(res.json());
};

addIndexEvent();

결과는?!

Exception이 발생하네요?! 이게 어떻게 된건가요?????
정답은 formData 에 파일을 포함하여 서버로 전송하게 될때는 Content-Type 을 정의하면 안됩니다(매우중요!!!!)

왜 그럴까요???
한번 성공했을 경우와 그렇지 않은 경우의 Request 를 확인해 볼까요?

  • Content-Type 을 정의하지 않았을때

  • Content-Type 을 multipart/form-data 로 정의했을때

차이점이 보이시죠? Content-Type 을 설정하지 않았을 경우에는 자동으로 boundary 가 붙여지게 됩니다.
Content-Type 을 설정하면 Override 되어서 boundary 가 사라지게 됩니다.

 

만약 boundary 를 Content-Type 에 각 데이터마다 정의해 줄 수 있다면 직접 정의해 주어도 되지만 굳이 그렇게 할 필요없이 Content-Type 을 정의하지 않아도 브라우저가 임의의 boundary 를 정의해주는 것 같습니다.

 

boundary 란?

  • (chatGPT 답변)The boundary is a unique string of characters that separates each part of the request. It is specified in the Content-Type header of the request and is prefixed with two hyphens (--).

간략하게 설명하면 클라이언트에서 요청보낸 데이터들을 구분지어주는 고유한 식별자의 역할을 수행한다고 합니다.
(https://stackoverflow.com/questions/3508338/what-is-the-boundary-in-multipart-form-data)

어떤가요??도움이 많이 되셨으면 좋겠네요 ㅎㅎ

잘못된 내용이 기재되어 있거나 궁금한 사항 있으시면 언제라도 댓글로 남겨주세요!!