[React-Native] 이미지 업로드 (with image-crop-picker)

DaYoung·2023년 3월 17일

React-Native

목록 보기
8/35

task flow

  1. image-crop-picker로 얻은 이미지 객체를 FormData에 담기
    (사진을 찍거나 갤러리에서 사진을 선택하면 프로미스 콜백의 인자로 image라는 객체를 던저줌)
  2. formData 객체를 서버로 전송
  3. form에 있는 데이터들과 images에 담겨있는 이미지들을 서버에 전송해준다.

문의사항, 게시판 등 이미지를 사용하는 곳이 많아 별도의 component로 빼서 사용하였다.

Ask.tsx

const [images, setImages] = useState<Array<ImageModel.IImages>>([]);
<ImageForm
    images={images}
    onUploaded={(image) => {
        setImages([...images, image])
    }}
    onDeleted={(image) => {
        setImages(produce(images, draft => {
            remove(draft, (i) => image.id === i.id);
        }))
    }}
/>

ImageForm.tsx
interface IProps {
    images: Array<ImageModel.IImages>;
    onUploaded: (image: ImageModel.IImages) => void;
    onDeleted: (image: ImageModel.IImages) => void;
    style?: StyleProp<ViewStyle>;
}
<View style={style}>
    <FlatList
        data={images.concat(getDummyImages())}
        renderItem={renderItem}
        keyExtractor={(item) => String(item.id)}
        horizontal={true}
        scrollEnabled={false}
    />
    {isLoading &&
        <Container>
            <FullScreenLoading />
        </Container>}
</View >

현재 진행하는 프로젝트에서는 최대 3개까지만 이미지 첨부가 가능하지만, 나중에는 여러개의 이미지를 첨부할 수도 있어 FlatList를 사용하였다.

  • FlatList
    data : 만들고자 하는 리스트의 source를 담는 props
    renderItem : data로 받은 각각의 item들을 render 시켜주는 콜백함수
    keyExtractor : item에 고유의 키를 주는 것

const getDummyImages = (): Array<ImageModel.IImages> => {
    if (images.length < 3) {
        return map(range(0, 1), () => {
            return {
                'key': '',
                'id': uniqueId(),
                'uuid': '',
                'createdAt': '',
                'updatedAt': '',
                'title': '',
                'description': '',
                'fileName': '',
                'fileType': '',
                'serviceUrl': '',
            }
        })
    } else if (images.length === 3) {
        return map(range(0), () => {
            return {
                'key': '',
                'id': uniqueId(),
                'uuid': '',
                'createdAt': '',
                'updatedAt': '',
                'title': '',
                'description': '',
                'fileName': '',
                'fileType': '',
                'serviceUrl': '',
            }
        })
    }
    return images;
}

props로 받은 빈 배열의 images에 getDummyImages라는 문자열을 합쳐 하나의 배열로 만들어 주었다. (선택한 이미지들이 images라는 빈 객체에 하나씩 추가되기 위해서)

만약, images.length가 3보다 작다면 이미지 첨부할때마다 하나씩 빈 이미지가 뜨고

images.length가 3이라면 더이상 빈 이미지가 뜨지 않도록 하였다.

  • lodash(map, range)

lodash는 Javascript의 인기있는 라이브러리중 하나인데
arrray, data 등 데이터의 필수적인 구조를 쉽게 다를 수 있게끔 하는데 사용된다. JavaScript의 코드를 줄여주고 빠른 작업에 도움이 된다고 한다.

_.range(start, end, step);

인자를 주면 그 범위만큼 순차 배열을 생성해주며
오름차순이나 내림차순 숫자로 이뤄진 배열을 만들 때 쓴다.

_.map(Collection, fieldName)

순회하여 얻은 값들을 모아서 배열로 반환시켜준다.
객체의 특정 key의 value들을 모아 배열로 만들 때 유용하다.


const renderItem = ({ item, index }: { item: ImageModel.IImages, index: number }) => {
    //이미지 첨부 했을 때
    const ActiveImage = (
        <View key={index} style={{ marginRight: 10 }}>
            <Image
                source={{ uri: item.serviceUrl }}
                style={{ width: 74, height: 74, borderRadius: 5.7 }}
            />
            {selectedImage &&
                <GrayFullfilClose
                    style={{ position: 'absolute', top: 6, right: 6 }}
                    width={14}
                    height={14}
                    onPress={() => {
                        Alert.alert(
                            '해당 사진을 삭제하시겠습니까?',
                            '',
                            [
                                {
                                    text: '취소',
                                    style: 'cancel',
                                },
                                {
                                    text: '삭제',
                                    onPress: async () => {
                                        onDeleted(item)
                                    }
                                }
                            ]
                        );
                    }}
                />
            }
        </View>
    )

    //이미지 첨부 안했을 떄
    const EmptyImage = (
        <ImageContainer
            onPress={() => {
                if (Platform.OS === 'ios') {
                    request(PERMISSIONS.IOS.PHOTO_LIBRARY).then((result) => {
                        switch (result) {
                            case RESULTS.GRANTED: //권한부여
                                ImageCropPicker.openPicker({
                                    mediaType: 'photo',
                                }).then(image => {
                                    onUpload(image)
                                }).catch((e) => { console.log(e) });
                                break;
                            default:
                                Alert.alert(
                                    '사진첨부를 원하시면 ‘설정’을 눌러 ‘사진’ 접근을 허용해주세요.',
                                    '',
                                    [
                                        {
                                            text: '취소',
                                            style: 'cancel',
                                        },
                                        {
                                            text: '설정',
                                            onPress: () => {
                                                openSettings();
                                            }
                                        }
                                    ]
                                )
                        }
                    })
                } else if (Platform.OS === 'android') {
                    request(PERMISSIONS.ANDROID.CAMERA).then((result) => {
                        switch (result) {
                            case RESULTS.GRANTED:
                                ImageCropPicker.openPicker({
                                    mediaType: 'photo',
                                    cropping: true
                                }).then(image => {
                                    onUpload(image)
                                }).catch((e) => { console.log("e", e) });
                                break;
                            default:
                                Alert.alert(
                                    '사진첨부를 원하시면 ‘설정’을 눌러 ‘사진’ 접근을 허용해주세요.',
                                    '',
                                    [
                                        {
                                            text: '취소',
                                            style: 'cancel',
                                        },
                                        {
                                            text: '설정',
                                            onPress: () => {
                                                openSettings();
                                            }
                                        }
                                    ]
                                );
                        }
                    });
                }
            }}
        >
            <ImageContainerDetailStyle />
            <ImageContainerDetailStyle style={{ transform: [{ rotate: '90deg' }], bottom: 1 }} />
        </ImageContainer>
    )
    return item.serviceUrl ? ActiveImage : EmptyImage
}

개발자가 사용자의 위치, 카메라, 사진, 파일 등의 데이터에 접근하기 위해서는 해당 접근에 대한 사용자 권한 승인이 필수적이다. 권한 요청 및 확인을 할 수 있도록 하는 react-native-permissions라는 라이브러리를 사용하여 권한 승인을 할 수 있도록 구현하였다.

다음으로는 서버에 이미지 파일을 보내는 작업이 필요하다.

FormData 객체는 폼의 각 필드와 값을 나타내는 키/값 쌍들의 집합을 쉽게 구성할 수 있는 방법을 제공한다.
이를 이용하면 데이터를 multipart/form-data 형식으로 전송할 수 있다.

반드시 uri, name, type을 같이 전송해줘야하는데
type은 실험해보니 'multipart/form-data'도 가능하고 'image/jpeg'도 가능하다.
세가지 속성 중 하나라도 없으면 네트워크 에러가 난다.

ImageForm.tsx

//사진 업로드
const onUpload = async (image) => {
    setLoading(true);

    const formData = new FormData()
    formData.append('file', {
        uri: image.path,
        name: `${uniqueId('image')}.jpg`,
        type: image.mime
    })

    const { status, data } = await UploadFileService.File.upload(formData);

    if (status === 201) {
        onUploaded(data)
    } else {
        Toast.show({
            text1: data?.message,
            type: ConfigType.Toast.ErrorToast,
        });
    }
    setLoading(false)
}


UploadFileService.ts

export namespace UploadFileService {
export const File = {
    upload: async (files: FormData) => {
        try {
            const config: AxiosRequestConfig = {
                headers: { "Content-Type": "multipart/form-data" },
            };

            const { data, status } = await AxiosContext.post(
                "/api/upload/inquiry",
                files,
                config,
            );
            return { data: data, status: status };
        } catch (error) {
            return { error: error };
        }
    },
  };
}

images와 data console을 찍으면 아래와 같이 나온다.


마지막으로 form에 있는 데이터들과 images에 담겨있는 이미지들을 서버에 전송해주면 된다.

Ask.tsx

<Container>
    <HeaderNavigation
        hasBackButton={true}
        title="문의하기"
        onPressBackButton={() => { navigation.goBack() }} />
    <LineBorderBottom />
    <View style={{ marginHorizontal: 16, flex: 1, justifyContent: 'space-between' }}>
        <FormProvider {...form}>
            <View>
                <View style={{ flexDirection: 'row', }}>
                    <Input
                        name="contact"
                        rules={{ required: true }}
                        keyboardTypeOption={'default'}
                        placeholder={'답변을 받으실 메일 주소나 전화번호를 입력해 주세요'}
                        onChangeText={(e) => { setContact(e) }}
                        style={{ marginTop: 20, width: '100%' }} />
                    <TouchableOpacity
                        onPress={() => { setValue('contact', '') }}
                        style={{ justifyContent: 'center', paddingRight: 16 }}>
                        {value &&
                            <Title
                                font={FontStyle.regular.font12}
                                text="수정하기"
                                color={Color.Gray80}
                                style={{ position: "absolute", top: 32, right: 26 }} />
                        }
                    </TouchableOpacity>
                </View>
                <Title
                    font={FontStyle.bold.font14}
                    color={Color.Gray80}
                    text={"문의 내용"}
                    style={{ marginBottom: 20, marginTop: 35 }}
                />
                <Input
                    name="title"
                    keyboardTypeOption={'default'}
                    placeholder={'제목을 입력해 주세요 (최대 80자)'} />
                <Textarea
                    name="content"
                    rules={{ required: true }}
                    placeholder={'문의 내용을 입력해 주세요 (최대 300자)'}
                    height={190}
                />
                <Title font={FontStyle.bold.font14} text="이미지 첨부" color={Color.Gray80} style={{ marginTop: 35, marginBottom: 20 }} />
                <ImageForm
                    images={images}
                    onUploaded={(image) => {
                        setImages([...images, image])
                    }}
                    onDeleted={(image) => {
                        setImages(produce(images, draft => {
                            remove(draft, (i) => image.id === i.id);
                        }))
                    }}
                />
                <Title font={FontStyle.regular.font12} color={Color.Gray60}
                    text={"이미지는 JPEG, JPG, PNG만 가능하며\n최대 3개까지 첨부 가능합니다."} style={{ marginTop: 10 }} />
            </View>
            <DefaultButton
                text="문의하기"
                onPress={onSubmit}
                disabled={!isValid}
            />
        </FormProvider>
    </View>
</Container>

const onSubmit = async () => {
    setLoading(true);

    const model = {
        ...getValues(),
        fileUuids: map(images, p => p.uuid)
    } as UserModel.IInquiryModel;

    const { status } = await AccountService.Inquiry.create(model);

    if (status === 201) {
        navigation.goBack();
    } else {
        Alert.alert('입력된 정보를 다시한번 확인해주세요');
    }
    setLoading(false)
}


AccountService.ts

export const Inquiry = {
    create: async (model: UserModel.IInquiryModel) => {
        try {
            const { data, status } = await AxiosContext.post(`/api/user/inquiry`, model)
            return { data: data, status: status }
        } catch (error) {
            return { data: null, error: error }
        }
    },
} 

<참고>
1)
https://www.youtube.com/watch?v=FVN3InBGvHA

2)
https://velog.io/@everydamnday/%EB%A9%80%ED%8B%B0%ED%8C%8C%ED%8A%B8-%ED%8F%BC%EB%8D%B0%EC%9D%B4%ED%84%B0-%EC%97%85%EB%A1%9C%EB%93%9C-in-React-nativewith-image-crop-picker-AWS-S3

profile
안녕하세요. 프론트앤드 개발자 홍다영입니다.

1개의 댓글

comment-user-thumbnail
2024년 4월 27일

저도 다중 이미지 업로드 기능을 구현하고 있어서 전체 코드를 확인하고 싶은데, repo 공유 가능하신가요??

답글 달기