프로그래밍/React

[React] 파일과 데이터를 모두 body에 넣어 API 요청하기 | form 태그 활용기 (3)

choar 2022. 9. 2. 17:59
반응형

[React] 파일과 데이터를 모두 body에 넣어 API 요청하기 | form 태그 활용기 (3)

 

React로 쇼핑몰 프로젝트를 짜다가 파일(이미지)과 데이터(문자열, 숫자)를 모두 body에 넣어 API를 요청해야 하는 경우가 생겼다.

상품 판매자가 상품을 등록할 때 위와 같은 기능이 필요했다.

상품 등록 페이지

React에서 바로 하려니 어려워서, (1) pure HTML로 먼저 짜보고 (2) pure HTML+JavaScript로 짜보고 (3) React에 적용하는 과정을 거쳤다.

HTML form 태그에 대해 잘 몰랐는데, pure HTML로 먼저 짜보면서 HTML form을 구성하는 방법에 대해서 공부할 수 있었다.

pure HTML, pure HTML+JavaScript로 짠 내용은 이전 포스팅을 참고하시면 된다.

 

ProductForm

다음 form 부분을 ProductForm.js라는 컴포넌트로 분리했다. (상품 등록, 수정 시 반복 사용하기 때문)

ProductForm.js 전체 코드는 GitHub에서 확인할 수 있다.

코드가 너무 길기 때문에, 부분부분 살펴보도록 하자.

 

상품명, 판매가, 기본 배송비, 재고의 경우 maxLength, onInput 속성만 다르고 동일한 로직으로 작동한다.

<label>상품명</label>
<input
  name="product_name"
  value={productInfo.product_name}
  onChange={onChangeProductInfo}
  maxLength={50}
/>
{productError.product_name && (
  <MessageError content={productError.product_name} />
)}

productInfo, setProductInfo는 상위 컴포넌트로부터 props로 받아오는데, 상품 등록 시에는 모든 input 칸의 내용이 비워져 있어야 하므로 다음과 같다.

// UploadProduct.js

const [productInfo, setProductInfo] = useState({
  product_name: '',
  image: '',
  price: '',
  shipping_method: 'DELIVERY',
  shipping_fee: '',
  stock: '',
  product_info: '',
});

onChangeProductInfo는 변경된 input value를 productInfo에 반영해주는 함수이다.

const onChangeProductInfo = (e) => {
  setProductInfo((info) => ({ ...info, [e.target.name]: e.target.value }));
};

판매가, 기본 배송비, 재고의 onInput 속성으로 들어간 onlyNumber라는 함수는 숫자만 입력되도록 강제하는 역할을 한다.

onChange가 아니라 onInput의 속성으로 넣은 이유는 다음에 자세히 포스팅해보도록 하겠다.

// src/utils/input.js
export const onlyNumber = (e) => {
  const regExp = /\D/g;
  e.target.value = e.target.value.replace(regExp, '');
};

 

이 form에서 핵심은 이미지를 같이 요청해야 한다는 것이다.

상품 등록 페이지에 들어가면 이미지를 클릭해 등록해달라는 썸네일이 뜨며,

이미지를 등록하면 썸네일이 해당 이미지로 변경된다.

<label>상품 이미지</label>
<img src={imageSrc} value={productInfo.image} onClick={onClickImage} />
<input
  type="file"
  accept="image/*"
  ref={uploadImageRef}
  onChange={onChangeImage}
/>
{productError.image && <MessageError content={productError.image} />}

imageSrc, setImageSrc는 상위 컴포넌트로부터 props로 받아온다.

// UploadProduct.js
import ImgUpload from '/public/assets/img-upload.png';

export const UploadProduct = () => {
  const [imageSrc, setImageSrc] = useState(ImgUpload);
  // ...
};

type="file"인 input 요소

type="file" 속성의 input 요소는 기본적으로 위와 같이 생겼다. (위: 파일을 선택하지 않았을 때, 아래: 파일을 선택했을 때)

썸네일을 보여주지 않기 때문에 img 요소를 추가하고, input 요소에 display: none; css 속성을 적용해 보이지 않도록 해준다.

onClickImage 함수는 이미지를 클릭했을 때 input 태그를 누른 것처럼 작동하게 만들어주는 함수이다. useRef를 활용했다.

onChangeImage 함수는 이미지가 바뀌었을 때 작동하는 함수로, FileReader 객체를 활용해 productInfo의 image 값을 업로드한 파일로 변경해준다.

const uploadImageRef = useRef();

const onClickImage = (e) => {
  e.preventDefault();
  uploadImageRef.current.click();
};

const onChangeImage = (e) => {
  const reader = new FileReader();
  reader.onload = () => {
    if (reader.readyState === 2) {
      setImageSrc(reader.result);
    }
  };
  reader.readAsDataURL(e.target.files[0]);
  setProductInfo({ ...productInfo, image: e.target.files[0] });
};

 

'저장하기'를 눌렀을 때 작동하는 onClickSave 함수는 다음과 같다.

const onClickSave = async () => {
  // 상품 등록을 시도하고 결과를 받아온다.
  const result = await tryUpload(productInfo);
  
  // 기존에 있던 에러 메시지를 모두 제거해준다.
  for (const key of Object.keys(productError)) {
    setProductError((error) => ({ ...error, [key]: '' }));
  }

  // 상품 등록에 성공한 경우
  if (result.is_succeeded) {
    // 상품 등록 성공 모달을 띄운다.
    setIsModalVisible(true);
  }
  // 상품 등록에 실패한 경우
  else {
    // 각 input 요소 밑에 에러 메시지를 설정한다.
    setProductError(result);
    // 화면의 좌측 하단에 알림창을 띄운다.
    openNotification({
      type: 'error',
      message: '상품 등록에 실패했습니다.',
      description: '각 입력창 하단 에러 메시지를 참고하세요.',
      placement: 'bottomLeft',
    });
  }
};

openNotification은 antd의 기능이다.

최대한 view와 business 로직을 분리하기 위해 tryUpload는 다른 파일에 작성했다.

onClickSave의 핵심인 tryUpload 함수를 살펴보도록 하자.

// sellerRequest.js

export const tryUpload = async ({
  product_name,
  image,
  price,
  shipping_method,
  shipping_fee,
  stock,
  product_info,
}) => {
  // FormData 객체를 사용해 body에 포함해야 하는 값을 입력해준다. (key, value)
  const formData = new FormData();
  formData.append('product_name', product_name);
  formData.append('image', image); // 이미지 파일도 다른 값들과 동일하게 append 해주면 된다.
  formData.append('price', price);
  formData.append('shipping_method', shipping_method);
  formData.append('shipping_fee', shipping_fee);
  formData.append('stock', stock);
  formData.append('product_info', product_info);

  // result를 빈 객체로 선언해준다.
  let result = {};
  
  // formData를 body에 담아 상품 등록을 요청한 후
  await uploadProduct(formData)
    .then((data) => {
      // 상품 등록에 성공했을 때
      if (data.product_id) {
      	// result 객체에 is_succeeded: true 프로퍼티를 추가해준다.
        result.is_succeeded = true;
      }
      // 상품 등록에 실패했을 때
      else {
        // 에러 메시지를 등록해준다.
        for (const [key, value] of Object.entries(data)) {
          result[key] = value.join(' ');
        }
        // result 객체에 is_succeeded: false 프로퍼티를 추가해준다.
        result.is_succeeded = false;
      }
    })
    .catch((err) => console.log(err));

  // result 객체를 반환한다.
  return result;
};

export const uploadProduct = (formData) => {
  return fetch(`${API_URL}/products/`, {
    method: 'POST',
    headers: {
      Authorization: `JWT ${localStorage.getItem('token')}`,
    },
    body: formData,
  }).then((res) => res.json());
};

 

상품 등록에 대한 API 명세는 다음과 같았다.

필요하신 분들은 참고하시기 바란다.

⚠️ Req, Res에 적혀 있는 "products_info"는 "product_info"이다. 오타를 내신 듯하다.

 

API 명세


References

https://developer.mozilla.org/ko/docs/Web/API/Fetch_API/Using_Fetch#%ED%8C%8C%EC%9D%BC_%EC%97%85%EB%A1%9C%EB%93%9C

반응형