Framework/React & RN

[Expo 44] 최신 Expo 환경에서 복수 이미지 업로드하는 방법

Joonfluence 2022. 4. 2.

서론

대상독자

React Native Expo 개발 시, 이미지 업로드를 구현하고 싶은 개발자

학습목표

최신 Expo 환경(Expo 44)에서 복수 이미지 업로드를 구현하는 방법에 대해서 알아보겠습니다. expo-image-picker에선 하나의 이미지만 선택할 수 있으므로, 한 번에 여러 이미지들을 업로드 할 수 있도록 프로그램을 작성하겠습니다.

본론

먼저 필요한 패키지들을 설치합니다.

 

라이브러리 설치하기

expo install expo-image-picker expo-media-library

expo-image-picker와 expo-media-library 라이브러리를 설치해줍니다. expo 모듈 같은 경우에는 expo 버젼에 따라 패키지 사용방법이 상이하므로, expo cli로 설치해주는 것이 중요합니다. 그럼 자동으로 expo에서 앱의 expo 버젼에 맞게 패키지를 설치해줍니다.

app.json 설정하기

카메라, 사진첩, 마이크 총 3개에 접근 가능하도록 설정해줄 수 있는데, 이번 시간엔 사진첩에만 접근하도록 하겠습니다.

{
  "expo": {
    "plugins": [
      [
        "expo-image-picker",
        {
          "photosPermission": "사진첩 접근을 허용하시겠습니까?"
        }
      ]
    ]
  }
}

 

복수의 이미지 업로드하기

이제 본격적으로 코드를 작성하겠습니다. 과정은 다음과 같습니다.

1) 이미지를 불러올 스크린으로 이동한다.
2) 사진첩 권한을 요청 받고 해당 스크린에서 이미지를 화면에 불러온다.
3) 불러온 이미지를 실제 화면에 뿌려준다.
4) 불러온 이미지들 중 업로드할 이미지를 선택한다.
5) 선택한 이미지를 서버에 전송한다.

먼저, 이미지 업로드 버튼을 누르면 이미지 업로드를 위한 스크린으로 이동할 수 있도록 처리하겠습니다.

import {Button, Text, View} from "react-native";

const Component: FC<Props> = ({ navigation }) => {
     return (
        <View>
          <Button onPress={() => navigation.navigate(CREATE_SCREEN_NAME.UPLOAD)}>
              이미지 업로드 화면으로 이동
          </Button>
        </View>
      );
};

다음으로 이동된 화면에서 필요한 작업들을 처리해줍니다. 가장 먼저, 사진첩 권한을 요청합니다.

import * as ImagePicker from "expo-image-picker";

  const getPermissionsAsync = async () => {
    // 카메라 앨범 접근 권한 요청
    const { status: cameraRollPermission } =
      await ImagePicker.requestMediaLibraryPermissionsAsync();
  };

다음은 사진첩에 있는 사진들을 볼러오고 화면에 뿌려주도록 하겠습니다. getAssetsAsync 메서드를 실행하여, 사진첩에 있는 사진들을 PagedInfo<Asset> 타입 형태로 불러오겠습니다.

import * as MediaLibrary from "expo-media-library";

const [hasNextPage, setHasNextPage] = useState<boolean>(true);

    const getPhotos = () => {
      const params: getPhotoParams = {
        first: loadCount,
        mediaType,
        sortBy: [MediaLibrary.SortBy.creationTime],
      };

      if (after) {
        params.after = after;
      }
      if (!hasNextPage) return;
      MediaLibrary.getAssetsAsync(params).then(processPhotos);
  };

  useEffect(() => {
    async function InitAsync() {
      getPermissionsAsync();
      getPhotos();
    }
    InitAsync();
  }, []);

이제 불러온 이미지들을 useState 훅 안의 로컬 스토어에 photos라는 변수명으로 저장하겠습니다. 앞서 받아온 정보를 화면에 뿌려주기 위해선, Asset의 localUri 정보가 필요합니다. 이를 알기 위해선, MediaLibary의 getAssetInfoAsync 함수를 실행해줘야 합니다. 따라서 불러온 Assets를 반복문을 돌려, 하나씩 getAssetInfoAsync 함수를 통해 반환된 이미지 localUri를 받아와 저장합니다.

  const [photos, setPhotos] = useState<MediaLibrary.Asset[]>([]);
  const [after, setAfter] = useState<string | null>(null);
  const [hasNextPage, setHasNextPage] = useState<boolean>(true);

  const processPhotos = async (
    data: MediaLibrary.PagedInfo<MediaLibrary.Asset>
  ) => {
    if (data.totalCount) {
      if (after === data.endCursor) return;
      const assets = data.assets;
      const newAssets: MediaLibrary.Asset[] = [];
      for (const asset of assets) {
        const { localUri } = await MediaLibrary.getAssetInfoAsync(asset);
        if (localUri) {
          newAssets.push({
            ...asset,
            uri: localUri,
          });
        }
      }
      setPhotos([...photos, ...newAssets]);
      setAfter(data.endCursor);
      setHasNextPage(data.hasNextPage);
    }
  };

이제, useState 훅에 저장된 이미지 정보들을 FlatList 컴포넌트의 데이터로 넣어줍니다. 참고로 실제 화면에 보여줄 아이템은 FlatList의 renderItem 안에서 뿌려줘야 합니다. 아래와 renderImageTile 안에서 말이죠. 해당 함수의 첫번째 파라미터인 item은 FlatList에 등록된 data이고 곧 MediaLibrary의 Asset 정보입니다. 해당 데이터를 뿌려주기 위해, ImageBackground 컴포넌트 안에 source 속성으로 asset의 uri 정보를 넣어줍니다. 그럼 성공적으로 화면에 띄워진 것을 알 수 있습니다.

const renderImageTile = ({ item, index }): {
    item: MediaLibrary.Asset;
    index: number;
  } => {
    return (
      <TouchableHighlight
        underlayColor="transparent"
        onPress={() => selectImage(index)}
      >
        <View>
          <View>
            <ImageBackground source={{ uri: item.uri }}>
              {selected &&
                renderSelectedComponent &&
                renderSelectedComponent(selectedItemNumber)}
            </ImageBackground>
          </View>
        </View>
      </TouchableHighlight>
    );
}

  return (
    <View>
      <FlatList
        data={photos}
        renderItem={renderImageTile}
      />
    </View>
  );

다음 단계는 화면에 뿌려진 이미지 중, 업로드할 이미지를 선택하는 단계입니다. 앞서, renderImageTile의 두번째 파라미터인 index 정보를 활용할 예정입니다. 클릭했을 때, index 정보를 selected라는 배열에 추가해줍니다. 이를 위해, selectImage라는 함수를 추가해줍니다. 이 때, 이미 클릭된 상태에선 배열에 존재해선 안되므로 삭제해줍니다. 이를 위해, indexOf 메서드로 해당 배열이 존재하는지 판별하고 이미 존재하면 splice 메서드로 해당 데이터를 삭제해줍니다. 또 props로 전달 받을 max보다 선택된 배열의 크기가 넘어설 땐, 선택되서는 안되므로 함수를 종료해줍니다.

const [selected, setSelected] = useState<number[]>([]);

  const selectImage = (index: number) => {
    let newSelected = Array.from(selected);

    if (newSelected.indexOf(index) === -1) {
      newSelected.push(index);
    } else {
      const deleteIndex = newSelected.indexOf(index);
      newSelected.splice(deleteIndex, 1);
    }
    if (newSelected.length > max) return;
    if (!newSelected) newSelected = [];
    setSelected(newSelected);
  };

 

컴포넌트에 합치기

사용자로부터 전달 받은 callback 함수를 활용하여, 실제 데이터를 서버로 전송하겠습니다. 이를 위해, onChange란 함수를 추가하고 선택된 이미지가 달라질 때마다 실행해줍니다. 그리고 해당 함수의 두번째 파라미터에 실제 업로드를 위한 callback 함수를 처리하기 위한 prepareCallback 함수를 등록해줍니다. 이 함수는 callback 요청이 성공적일 때, Promise 요청을 전부 처리한 결과를 반환합니다.

const ImageBrowser: FC<Props> = ({
  max,
  onChange,
  callbackHandler,
}) => {
...
useEffect(() => {
    onChange(selected.length, () => prepareCallback());
  }, [selected]);

  const prepareCallback = () => {
      const selectedPhotos = selected.map((i) => photos[i]);
      callbackHandler(Promise.all(selectedPhotos));
    }
  };
...
   return (
    <View>
      <FlatList data={photos} key={numColumns} renderItem={renderImageTile} />
    </View>
  );
}

 

 

실제 서버로 요청 보내기

이제 마지막 단계입니다. 앞서 작성한 컴포넌트를 활용하여, 실제 서버로 네트워크 요청을 보내겠습니다. 상위 컴포넌트에 차례로 onChane 함수, callbackHandler를 등록해줍니다. 해당 API 경로로 이미지 정보를 formData 형식으로 보내주면 끝입니다! 참고로 formData의 경우, 웹과는 다르게 리액트 네이티브에선 any 타입으로 지정되어 있습니다. 필수로 uri과 name만 추가해주시면 됩니다.

  const _getHeaderLoader = () => {
    return <ActivityIndicator size="small" color={"#0580FF"} />;
  };

  const ImagesCallback = (callback: Promise<Asset[]>) => {
    navigation.setOptions({
      headerRight: () => _getHeaderLoader(),
    });

    callback
      .then(async (photos: Asset[]) => {
        for (let photo of photos) {
          let localUri = photo.uri;
          let filename = photo.filename;
          let match = localUri.match(/&ext=(\w+)$/);
          let type = match ? `image/${match[1]}` : `image`;

          const formdata: FormData = new FormData();
          const imageObj: any = {
            name: filename,
            uri: localUri,
          };

          formdata.append("images", imageObj);

          await fetch("/API경로", {
            headers: {
                "Content-type": "multipart/form-data; charset=UTF-8",
            },
            method: "POST",
            body: formData,
          });

          navigation.navigate(CREATE_SCREEN_NAME.POST);
        }
      })
      .catch((e: any) => console.log(e));
  };

  const _renderDoneButton = (count: number, onSubmit: any) => {
    if (!count) return null;
    return (
      <TouchableWithoutFeedback onPress={onSubmit}>
        <>
          <Text>{count}장</Text>
          <Text onPress={onSubmit}>선택완료</Text>
        </>
      </TouchableWithoutFeedback>
    );
  };

  const updateHandler = (count: number, onSubmit: () => void) => {
    navigation.setOptions({
      headerRight: () => _renderDoneButton(count, onSubmit),
    });
  };

return (
  <View>
    <ImageBrowser
      max={3}
      onChange={updateHandler}
      callbackHandler={ImagesCallback}
    />
  </View>
);

 

마무리

이것으로 Expo에서 복수 이미지 처리 방법에 관해 알아보았습니다. 읽어주셔서 감사합니다!

 

참조한 코드

expo-image-picker-multiple

반응형

댓글