본문 바로가기

리액트 네이티브

[인스타그램 클론코딩] 사진 업로드 구현2 - AWS S3에 비동기 업로드 (Promise 이용)

여담

처음엔 백앤드로 이미지를 전송한 다음 백앤드에서 다시 AWS S3에 저장하는 것이 비효율적이라고 느껴졌지만, S3의 접속 경로가 그대로 노출될 수도 있기 때문에 인증 기능이 포함된 백앤드를 경유하는 것이 맞다고 생각합니다.

아니면 제가 사용한 인증 방법은 AWS Cognito로 부터 인증 토큰을 받아오는 방식이기 때문에, S3 버킷을 Cognito에서 인증된 사용자만 접근하도록 설정한다면, 로컬 디바이스에서도 충분히 업로드할 수도 있을 것 같습니다.

예전에 AWS LED Button 프로젝트를 진행할 때 비슷한 작업을 해봤었는데, 다시 한 번 살펴봐야 겠습니다.

참조: [AWS LED Button] 어플리케이션 (1) : 시스템 흐름

전 S3에 그냥 업로드 했습니다. 실제 서비스에서는 수정되어야 할 부분입니다.

 

그리고 모든 이미지를 동일한 폴더에 업로드했는데, 실제 서비스에서는 사용자별 폴더에 업로드하고, 이미지의 이름을 유니크하게 만들어줄 필요도 있다고 생각합니다. 그리고 리사이즈된 이미지까지 올려주는 것도 필요할 것 같습니다.

 

[인스타그램 클론코딩] 사진 업로드 구현1 - SelectablePhoto 포스트에서 선택된 이미지 객체 selectedPhotos라는 전역 변수에 모았습니다. 이제 해당 변수에 모인 이미지 객체를 업로드해 주어야 합니다.

SelectPhoto.js

사진 선택기에서 업로드 버튼을 누르면 다음 함수를 실행시켜서 업로드 화면(UploadPhoto.js)으로 넘어갑니다.

이때 selectedPhotos도 함께 넘겨줍니다.

const upload = () => {
    if (selectedPhotos.length > 0) {
        navigation.navigate("UploadPhoto", {photos: selectedPhotos});
    }
}

UploadPhoto.js

SelectPhoto.js에서 전송된 매개값은 UploadPhoto.js에서 첫번째 매개변수의 route 객체를 통해 얻을 수 있습니다.

export default({ route, navigation }) => {
    const { photos } = route.params;
}

이제 게시 버튼을 누르면 다음 함수를 통해 AWS S3에 업로드하고, 포스트를 생성할 수 있습니다.

const post = async() => {
    let photoUrls = [];
    setPostLoading(true);
    try {
        const promises = photos.map(async(photo) => {
            const file = {
                // `uri` can also be a file system path (i.e. file://)
                uri: photo.uri,
                name: photo.id + "_" + photo.filename,
                type: "image/png"
            }

            const response = await RNS3.put(file, options);

            if (response.status !== 201) {
                throw new Error("Failed to upload image to S3");
            }

            const {location} = response.body.postResponse;
            photoUrls = photoUrls.concat(location);
        });

        await Promise.all(promises);

        const {data: {
                upload
            }} = await uploadMutation({
            variables: {
                files: photoUrls,
                caption: captionInput.value,
                location: locationInput.value
            }
        });

        if (upload.id) {
            navigation.navigate("TabNavigation");
        }
    } catch (e) {
        console.log(e)
    }
}

하나씩 알아볼까요?

SelectPhoto.js로 부터 전달받은 이미지 객체의 주소는 디바이스의 메모리에 저장되어 있고, 디바이스에서만 접근할 수 있는 주소(file://)입니다.

이미지 객체들을 AWS S3에 업로드 하고 얻은 각각의 웹 경로를 photoUrls에 넣어줄 것입니다.

let photoUrls = [];

 

이제 각각의 이미지 객체에 대하여 AWS S3에 업로드하는 promises 함수 배열을 만들어 Promise.all 메소드로 실행시킬 것입니다.

promises 배열에 입력된 함수가 모두 완료되어야 다음 코드가 실행됩니다. 

// 아직 실행 x
const promises = photos.map(async(photo) => {
    // 각 이미지(photo)를 AWS S3에 업로드
});

// 여기서 실행
await Promise.all(promises);

 

AWS S3에 업로드할 때는 react-native-s3-upload 모듈을 사용합니다.

공식 예제에는 then 메소드를 사용하지만, RNS3.put 메소드는 Promise와 비슷한 XMLHttpRequest 객체를 반환하기 때문에 async-await로 대체할 수 있습니다.

await RNS3.put 메소드에 의해 반환된 response 객체에서 S3에 이미지가 저장된 주소(location)를 얻을 수 있습니다.

그리고 S3에 파일을 업로드하기 위해서는 S3 업로드 관련 권한이 부여된 IAM 사용자의 ACCESS KEY와 SECRET KEY가 필요합니다. 필요한 권한은 react-native-s3-upload npm 링크에 나와 있습니다.

참조: 서버리스 프레임워크 초기화 및 AWS에 연결 > 2. IAM 사용자 생성~

const options = {
    keyPrefix: "uploads/",
    bucket: "joondong-prismagram",
    region: "ap-northeast-2",
    accessKey: Constants.manifest.extra.AWS_S3_ACCESS_KEY,
    secretKey: Constants.manifest.extra.AWS_S3_SECRET_KEY,
    successActionStatus: 201
}

const promises = photos.map(async (photo) => {
    const file = {
        // `uri` can also be a file system path (i.e. file://)
        uri: photo.uri,
        name: photo.id + "_" + photo.filename,
        type: "image/png"
    }

    const response = await RNS3.put(file, options);

    if (response.status !== 201) {
        throw new Error("Failed to upload image to S3");
    }

    const {location} = response.body.postResponse;
    photoUrls = photoUrls.concat(location);
});

await Promise.all(promises);

왜 Promise를 사용했나요?

다음과 같이 배열의 forEach 메소드를 사용해도 되지않을까요?

const post = async() => {
    let photoUrls = [];
    setPostLoading(true);
    try {
        photos.forEach(async (photo, index) => {
            const file = {
                // `uri` can also be a file system path (i.e. file://)
                uri: photo.uri,
                name: photo.id + "_" + photo.filename,
                type: "image/png"
            };

            const response = await RNS3.put(file, options);

            if (response.status !== 201) {
                throw new Error("Failed to upload image to S3");
            }

            const {location} = response.body.postResponse;
            photoUrls = photoUrls.concat(location);

            // 마지막 요소인 경우
            if(index === photos.length - 1) {
                const {data: {
                    upload
                }} = await uploadMutation({
                    variables: {
                        files: photoUrls,
                        caption: captionInput.value,
                        location: locationInput.value
                    }
                });

                if (upload.id) {
                    navigation.navigate("TabNavigation");
                }
            }
        })
    } catch (e) {
        console.log(e)
    }
}

부끄럽지만 처음에 사용했던 코드입니다.

forEach 메소드의 async 콜백 함수 안에서 await를 만나면, 바로 다음 요소에 대한 콜백 함수를 실행시킵니다.

이 경우 마지막 요소의 업로드가 먼저 완료된다면? 아직 photoUrls에 추가되지 않은 이미지는 데이터베이스에 File 레코드로 만들어지지 않습니다.

그래서 배열의 map 메소드를 사용하여 콜백 함수의 배열로 만든 다음, Promise.all 메소드 안에서 실행시켰습니다.

콜백 함수의 배열에 있는 모든 함수가 완료되어야 Promise.all 메소드 다음 코드를 실행할 것입니다.

 

AWS S3에 업로드가 완료되면 각 이미지의 웹 경로를 가지고 있는 photoUrls과 함께 백앤드(GraphQL) 서버에 upload API를 요청합니다.

const {data: {
        upload
    }} = await uploadMutation({
    variables: {
        files: photoUrls,
        caption: captionInput.value,
        location: locationInput.value
    }
});

참조: [인스타그램 클론코딩] upload

 

업로드가 완료되면 이전 네비게이션 스크린으로 돌아갑니다. (현재 PhotoNavigation 스크린)

navigation 객체의 navigate 메소드는 특정 스크린으로 이동할 때, 해당 스크린이 이미 밑에 있다면, 스크린 객체를 새로 만들지 않고, 그 위에 있는 스크린을 모두 삭제합니다.

// 만약 업로드 과정에서 오류가 발생하면 upload 객체는 undefined일 것이기 때문.
if (upload.id) { 
    navigation.navigate("TabNavigation");
}

인스타그램 클론코딩 네비게이션 구조에 관해서는 나중에 추가로 포스팅하겠습니다.