[FE] FormData를 서버로 보내주고 싶은데 어떻게 하죠?

보투게더·2023년 10월 4일
1
post-custom-banner

이전 이야기 요약..

  1. 사용자가 글을 작성/수정할 때 이미지를 5개까지 등록할 수 있다.
  2. 사용자에게 <input type="file" /> 으로 사진을 받는다.
  3. 이를 서버에 넘겨주려면 기존에 사용하던 방식인 'Content-Type': 'application/json'을 사용할 수 없다.
  4. 때문에 FormData를 넘겨주는 방식을 사용했다.
  5. FormData 형식은 아래와 같다
formData (
	request: {
		title: string;
		content: string;
		options: [...];
			:
	},
	contentImages: file;
	optionImages: file[];
)
  1. 만약 사용자가 이미지 업로드를 하지 않아 파일이 없다면 new File(['없는사진'], '없는사진.jpg'으로 의미없는 파일을 만들어 넣어줬다. (넣지 않으면 undefined이 가는데, 이를 받지 못한다는 백엔드 요청으로 이렇게 처리됨)
  2. 이렇게 사진이 포함된 게시글이 등록되면 해당 게시글 정보를 사용자에게 보여줄 때 imageUrl에 사진의 주소가 기입되어 있다. 다만, http--하는 도메인 주소값이 없어 프론트엔드측에서 도메인을 붙여서 이미지를 보여줘야 했다. 게시글을 수정을 하는 경우에는 이 과정에서 붙인 도메인을 떼어서 서버로 보내줘야 하는 번거로움도 있었다.

그래서 말이죠. 리팩토링을 했습니다.

리팩토링 대상은 두 개였다.

첫번째, formData의 형식을 바꾸어 new File(['없는사진'], '없는사진.jpg'로 넣지 않기

작성/수정 통신이 되지 않아 일단 데이터를 주고받는데 의의를 둔 명세를 수정했다.

// 수정 전
formData (
	request: {
		title: string;
		content: string;
		options: optionInfo[...];
			:
	},
	contentImages: file;
	optionImages: file[];
)

// 수정 후
formData(
  categoryIds : number[],
  title : string,
  content : string,
  imageUrl : "" | "imageUrl", //이 url은 이미지 미리보기를 위한 url
  imageFile : file, //사용자가 등록한 이미지 파일
  postOptions : [
      {
        content : string,
        imageUrl : "" | "imageUrl",
        imageFile : file,
      },
      ...
    ],
  deadline : "yyyy-mm-dd hh:mm"
)

수정 전 데이터는 글의 정보와 본문의 이미지 파일, 선택지의 이미지 파일로 크게 구분했다. 이보다는 연관성 높은 정보끼리 같은 곳에, 같은 뎁스에 있어야 한다고 생각을 해 아래와 같이 명세를 수정했다.

(참고) 폼데이터에 필드 추가하는 방법

폼데이터 객체는 또 객체와는 작성법이 달라서 안에 객체 안 객체, 객체 안 배열을 넣을 수 있나 싶었는데 찾아보니 방법이 있었다.

//기본적으로 프론트에서 이렇게 넣어 서버에 보내면
formData.append(필드명: string, file | string)

//서버에서는 아래처럼 객체로 보입니다.
{
    필드명: file | string
}


//이렇게 프론트에서 동일한 필드명을 넣어 보내면
formData.append(A, "Apple")
formData.append(A, "Banana")

//서버에서는 아래처럼 배열로 보입니다.
{
    A: ["Apple", "Banana"]
}


//이렇게 프론트에서 "동일한 필드명[index].필드명"을 넣어 보내면
formData.append(A[0].a, "Apple")
formData.append(A[0].b, "Banana")

//서버에서는 아래처럼 객체가 들어있는 배열로 보입니다.
{
    A: [{
        a: "Apple", 
        b: "Banana"
    }]
}

이에 따라 우리 코드를 아래와 같이 수정하였다.

const handlePostFormSubmit = (e: React.FormEvent<HTMLFormElement>) => {
      e.preventDefault();
      const formData = new FormData();
  
      // type="file"인 input을 배열형태로 가지고 오기
	  const imageFileInputs = e.target.querySelectorAll<HTMLInputElement>('input[type="file"]');
      const fileInputList = [...imageFileInputs];
  
  	  // 글의 카테고리/제목/내용/본문 imageUrl/마감일시 정보 넣기
      (선택한 카테고리가 담긴 배열).forEach(categoryId =>
        formData.append('categoryIds', categoryId.id.toString())
      );
      formData.append('title', writingTitle);
      formData.append('content', writingContent);
      formData.append('imageUrl', contentImage);
	  formData.append('deadline', addTimeToDate(time, baseTime));
  
      // 선택지 id/내용/imageUrl이 포함된 객체의 배열 넣기
      writingOptionList.forEach((option, index) => {
        option.isServerId && formData.append(`postOptions[${index}].id`, option.id.toString());
        formData.append(`postOptions[${index}].content`, deleteOverlappingNewLine(option.content));
        formData.append(`postOptions[${index}].imageUrl`, option.imageUrl);
      });
  
      // 본문/선택지 이미지 파일들 넣기
      fileInputList.forEach((item: HTMLInputElement, index: number) => {
        if (!item.files) return;

        // type="file"인 input을 배열형태로 모두 가지고 왔기 때문에 index로 본문인지 선택지인지 판별함
        // 우리는 본문에 사진 1장, 각 선택지마다 사진 1장을 넣을 수 있음
        // 때문에 "index === 0"는 본문에 들어가는 이미지 파일, 그 이후의 것은 선택지에 들어가는 이미지 파일.
        if (index === 0) {
          item.files[0] && formData.append('imageFile', item.files[0]);
        } else {
          item.files[0] && formData.append(`postOptions[${index - 1}].imageFile`, item.files[0]);
        }
      });

  	  // 글 작성/수정을 요청하는 api와 연결된 리액트쿼리 함수
      mutate(formData);
    }

이제 보내게 되면

개발자도구 네트워크 탭 > preview 에서 위와 같이 확인할 수 있다.

카테고리 id,제목, 내용, 본문 imageUrl,
선택지 정보(내용/imageUrl), 마감시간,
본문 image file, 선택지 image file.

선택지 이미지는 두 번째 선택지에만 넣었다.

두번째, imageUrl은 서버에서 완전한 상태로 주기

이 부분은 서버에서 완전한 상태로 전달하게 되면서 프론트엔드에선 기존에 도메인을 붙였다가 삭제하던 부분을 수정하면 끝났다. 훨씬 덜 복잡하고, 덜 번잡해졌다.


이렇게 작성과 수정을 위한 폼 컴포넌트 리팩토링은 끝났다.
혹시 FormData를 사용하는 사람이 있다면 이 글을 통해 손쉽게 원하는 형태로 서버와 통신했으면 좋겠다.


이미지 관련 로직 요약

사용자가 이미지를 업로드하면 다음과 같이 처리된다.

  1. 사용자가 이미지를 업로드한다.
  2. 일련의 과정을 거쳐 파일에서 이미지 미리보기 url을 추출한다.
  3. 이미지 미리보기 url을 이미지 관련 훅에 저장한다.
  4. 이미지 관련 훅에 url이 ""가 아니라면 이미지 추가 버튼을 없애고 이미지 태그를 렌더링한다.

이 상태에서 이미지를 삭제하면 다음과 같이 처리된다.

  1. 이미지 관련 훅에서 url을 ""으로 처리한다.
  2. ref로 연결해놓은 input을 ref.current.value = '' 처리 한다.
    • 이렇게 하지 않으면 동일한 파일을 올렸을때 이벤트가 발생하지 않음
    • 파일 업로드 이벤트가 changeEvent로 연결되어 있기 때문

때문에 게시글 작성의 경우라면 imageUrl이 있다면 file도 있고, imageUrl이 없다면 file도 없다.
하지만 게시글 수정의 경우에는 사용자가 기존에 작성한 글에 사진이 있다면 props로 전달되는 postData에 imageUrl은 있지만 file은 없게 된다.

때문에 수정의 경우 5가지 경우의 수가 생긴다.

  1. 기존에 사진을 등록하고 이를 수정하지 않음 : url (O) / file (X)
  2. 기존에 사진을 등록하고 이를 삭제함 : url (X) / file (X)
  3. 기존에 사진을 등록하고 이를 삭제하고 새로 등록함 : url (O) / file (O)
  4. 기존에 사진을 등록하지 않고 게시글을 수정하며 새로 등록함 : url (O) / file (O)
  5. 기존에 사진을 등록하지 않고 게시글을 수정할때도 등록하지 않음 : url (X) / file (X)

경우의 수가 늘어나면서 이 로직을 백엔드와 완전히 이해하고 api를 맞추어 나가는 것이 어려웠다. 이해하고 나면 별 것 아닌데 말이다.


개발을 하다보면 늘 그런 것 같다.
할때는 모르겠고, 무슨 소린가 싶은데 이해하고 익숙해지면 별거 아니게 느껴진다.
백엔드와 이해를 동기화하고 동시에 작업을 진행, 머지하며 더 좋은 코드로 수정할 수 있어서 만족스럽다.

profile
Fun from Choice! 오늘도 즐거운 한 표
post-custom-banner

1개의 댓글

comment-user-thumbnail
2023년 10월 7일

FormData에 대해 정리한 글을 보고 복습할 수 있어서 너무 좋았어요 - 우스 -

답글 달기