[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로 짠 내용은 이전 포스팅을 참고하시면 된다.
다음 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 요소는 기본적으로 위와 같이 생겼다. (위: 파일을 선택하지 않았을 때, 아래: 파일을 선택했을 때)
썸네일을 보여주지 않기 때문에 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"이다. 오타를 내신 듯하다.
References
'프로그래밍 > React' 카테고리의 다른 글
Vite 프로젝트를 GitHub Pages에 배포했을 때 빈 화면이 뜨는 에러 (0) | 2022.10.25 |
---|---|
[React, TS, MUI] 여러 컴포넌트에 일괄적으로 커스텀 스타일 적용하기 (0) | 2022.09.28 |
[React] react-query 적용하기 | useQuery, refetch (0) | 2022.06.22 |
[React] useEffect 무한 루프 해결하기, cleanup function (0) | 2022.06.19 |
[React] Side effect(사이드 이펙트)란? | 부수 효과, useEffect (0) | 2022.06.03 |