Settings
- Xcode 15.3
- IOS Deplyment Target: 17.0
- multer: 1.4.4-lts.1
- axios: 1.6.8
- typescript: 5.4.5
- react-native: 0.72.5
- react-native-image-crop-picker: 0.40.3
- react-native-vision-camera: 3.6.4
서론
Node 서버에서 multer 로 이미지 업로드를 받아야 하는 상황.
여러 글들을 보면서 대충 다음과 같은 코드를 작성했다.
const client = Axios.create(/* axios options */);
const image = await ImageCropPicker.openPicker(/* crop options */);
const fd = new FormData();
fd.append('file', {
uri: image.path,
type: image.mime,
name: image.filename,
});
const response = await client.post('/image/upload', fd, {
headers: {
'Content-type': 'multipart/form-data',
},
});
여기까지는 simulator 로도, 실 기기로도 문제 없이 작동했다.
문제는
picker 를 통해 사진을 받아오는게 아니라,
camera 를 통해 사진을 찍고 업로드할 때 발생하였다.
const photo = await camera.current.takePhoto();
const image = await ImageCropPicker.openCropper({
...cropOptions,
path: photo.path,
});
const fd = new FormData();
fd.append('file', {
uri: image.path,
type: image.mime,
name: image.filename,
});
// ...
문제 1
server 에서 request body 가 undefined 로 나오고, 그로 인해 exception 이 발생
{"error": "Bad Request", "message": "File is required", "statusCode": 400}
문제 1 해결 시도...
더보기
fd.append('file', {
uri: Platform.OS === 'android' ? image.path : image.path.replace('file://', ''),
// ...
});
근데, 어떤 글은 android 일 경우에 제거하라고 하고, 어떤 글은 ios 일 경우에 제거하라 하고...
결국 내 상황에 알맞는 답변은 아니었는지 해결되진 않음.
더보기
const response = await client.post('/image/upload', fd, {
// ...
transformRequest: (data) => data,
});
axios 0.24.0 ~ 0.26.0 버전에서
자체적으로 form data 를 string 으로 변환하는 이슈가 있다고 함.
내 axios 버전이랑은 다르기도 해서... 안 될 줄은 예상하긴 했지만 역시는 역시 해결되지 않음.
(이것도 답변마다 다른데, 위 코드처럼 인자로 받은 data 를 다시 반환하는 방식과 FormData를 다시 반환하라는 답변도 있었다...만, 둘 다 내 상황을 해결해주진 못함)
더보기
fd.append('file', {
uri: `file://${image.path}`,
// ...
});
이것마저도...! 'file://' 를 붙이라는 답변과 'file:///' 를 붙이라는 답변이 있다...!!!!!!!!
역시나... 이 방법으로도 해결되지 않았다.
해결
fd.append('file', {
// ...
name: image.filename || 'upload-image.jpg',
});
사진을 찍어서 업로드를 하다보니 name 이 없어서 인식을 못하는 거였음.......................
문제된 페이지 예시
import { uploadMyImage } from '@apis';
import { BlockHeadSheet, BlockHeadTemplate, DecideButtonField, I18nText } from '@components';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { ImageSourcePropType, Pressable, Text, View } from 'react-native';
import ImageCropPicker, { type Options as CropOptions, type Image } from 'react-native-image-crop-picker';
import { Camera, useCameraDevice, useCameraPermission } from 'react-native-vision-camera';
import Guideline from './Guideline';
import { styles } from './styles';
type BlockHead = { /* Type Definition */ };
const cropOptions: CropOptions = {
mediaType: 'photo',
width: 640,
height: 480,
cropping: true,
compressImageQuality: 1,
};
const MyImageUploadComponent = () => {
const { hasPermission, requestPermission } = useCameraPermission();
const camera = useRef<Camera>(null);
const device = useCameraDevice('back');
useEffect(() => {
if (!hasPermission) {
requestPermission();
}
}, [hasPermission]);
const guidelineRef = useRef<Guideline>(null);
const [data, setData] = useState<BlockHead | null>(null);
const [previewImageSource, setPreviewImageSource] = useState<ImageSourcePropType>();
const submit = useCallback(async (image: Image) => {
try {
guidelineRef.current?.startAnimation();
setPreviewImageSource({ uri: image.path });
const response = await uploadMyImage({
uri: image.path,
type: image.mime,
name: image.filename || 'image.jpg',
});
setData(response.data);
guidelineRef.current?.stopAnimation();
} catch (error: any) {
setPreviewImageSource(undefined);
setData(null);
guidelineRef.current?.resetAnimation();
throw error;
}
}, []);
const openPicker = useCallback(async () => {
const image = await ImageCropPicker.openPicker(cropOptions);
if (image) {
await submit(image);
}
}, [submit]);
const capture = useCallback(async () => {
if (!camera.current) {
return;
}
const photo = await camera.current.takePhoto();
const image = await ImageCropPicker.openCropper({
...cropOptions,
path: photo.path,
writeTempFile: false,
});
if (image) {
await submit(image);
}
}, [submit]);
return (
<View>
<BlockHeadTemplate contentContainerStyle={styles.contentContainer}>
{device && !data && (
<Pressable onPress={capture} style={styles.layer}>
<Camera
device={device}
ref={camera}
style={styles.layer}
isActive
photo
enableZoomGesture
/>
</Pressable>
)}
<Guideline ref={guidelineRef} previewImage={previewImageSource!} />
<View style={styles.textBox}>
<I18nText style={styles.uploadText} text="blockhead.upload-text" onPress={openPicker} />
<Text>
<I18nText style={styles.uploadDescription} text="blockhead.upload-description" />
{'\n'}
<I18nText
I18nText={styles.uploadDetail}
text="blockhead.upload-image-size-limit"
options={{ replace: { size: 10 } }}
/>
</Text>
</View>
{data && <DecideButtonField declineText="re-upload" acceptText="next" acceptTo="Home" />}
</BlockHeadTemplate>
{data && <BlockHeadSheet data={data} />}
</View>
);
};
export default MyImageUploadComponent;
ETC