RN + iOS + AWS 멀티파트 업로드2

김보람's avatar
Nov 04, 2024
RN + iOS + AWS 멀티파트 업로드2

[ 하고자 하는 작업 ] - 이전 포스팅 요약

대용량파일의 파트단위 업로드(aws multipart upload)
청크업로드단의 의존성을 줄이기 위해 업로드만을 네이티브로 짤것

[ 최종 작업될 순서 ]

  1. (RN)유저 비디오 탐색후 업로드할 비디오들의 경로를 담은 배열 겟

  2. (RN)1번에서 겟한 배열로 루프돌려 업로드작업 실행

    1. (RN) 요소의 청크 개수 확인

    2. (RN)[API 호출] a에서 겟한 청크 개수와 파일명을 바디에 담아 서버로 파트갯수만큼의 사인드 유알엘과 파트 병합요청때 쓰일 업로드아이디를 요청

    3. (Native) rn으로 부터 업로드할 파일의 경로와 파트만큼의 사인드 유알엘배열을 넘겨받아 업로드 진행

      1. 업로드될 파일유알엘을 생성하여 로드후 원하는 사이즈(rn의 청크사이즈와동일해야함)로 잘라 업로드를 진행하는데 이때 동기적으로 진행되어야한다.

      2. 파트순서대로 업로드하고 해당 파일 업로드시 리턴받은 ETag와 해당 인덱스를 배열로 저장한다. 이때 순서대로 저장해야 파일 병합요청시 편하다. 고대로 요청바디에 넣을예정 순서 안맞으면 병합요청 뱉어버림
        순서대로 푸쉬하기위해 동기적으로 진행되어야함

      3. 진행사항을 프로그래스로 그리기위해 파트별 업로드 될때마다 이를 rn으로 알리고 업로드가 완료되면 모두 완료되었다고 알린다.

    4. (RN) 파트단 업로드될때마다 이를 계산해 프로그래스를 그리고 완료시 완료뷰 띄워주기

[ RN - Upload Part Full Code ]

import * as RNFS from 'react-native-fs';
import {getChunkNum, getTesterId, getUserVideoPathArr} from '../utils/util';
import {GetChunkUploadURL, MultipartComplete} from '../apis/apis';
import useAxios from './useAxios';
import {NativeEventEmitter, NativeModules} from 'react-native';
import {useMutation} from 'react-query';
import {useEffect, useState} from 'react';

const useVideoUpload = () => {
  // 1. 유저 비디오 탐색후 경로담은 배열을 반환한다
  // 2. 비디오 배열로 루프 돌리기
  //// 2-1. 요소의 사이즈를 5메가로 나누어 몇개가 나오는지 확인
  //// 2-2. [api 통신]개수를 인자로 서버에 사인드유알엘 요청
  //// 2-3. 개수와 사인드유알엘 배열을 native로 넘기기
  //// 2-4. [네이티브에서 실행]네이티브에서 청크로 업로드할때마다 ETag 받아오기
  //// 2-5. [api 통신] 청크 업로드 끝나면 머지 요청

  const [executeAxios] = useAxios();

  const {Upload} = NativeModules;
  const uploadEvents = new NativeEventEmitter(Upload);

  const [uploadedPartNum, setUploadedPartNum] = useState<number>(0);

  useEffect(() => {
    const subscription_uploadProgress = uploadEvents.addListener(
      'UploadProgress',
      data => {
        console.log('Upload progress:', data);
        setUploadedPartNum(prev => prev + 1);
      },
    );

    const subscription_cancelUpload = uploadEvents.addListener(
      'UploadCancelled',
      data => {
        console.log('Upload Cancelled:', data);
      },
    );
    return () => {
      subscription_uploadProgress.remove();
      subscription_cancelUpload.remove();
    };
  }, []);

  /// [ 1 ]
  const getUserVideoPaths = async () => {
    const userVideoPaths = await getUserVideoPathArr();
    return userVideoPaths;
  };

  /// [ 2-1 ]
  const getPartNum = async (path: string) => {
    const partNum = getChunkNum(path);
    return partNum;
  };

  /// [ 2-2 ]
  const getSignedURL = useMutation(
    (data: {[key: string]: string | number}) =>
      executeAxios({
        requestAddressData: GetChunkUploadURL,
        payload: {
          partsNumber: data.chunkNum,
          fileName: data.fileName,
        },
      }),
    {
      onError: error => {
        console.log('getSignedURL Error : ', error);
      },
      onSuccess: res => {},
    },
  );

  /// [ 2-3 , 2-4]
  const nativeChunkUpload = async (uploadData: {
    fileName: string;
    filePath: string;
    urls: string[];
  }) => {
    try {
      // 청크 업로드 시작
      Upload.chunkUpload(uploadData);
    } catch (error) {
      console.log(error);
    }
  };

  /// [ 2-5 ]
  const chunkMerge = useMutation(
    (data: {
      uploadId: string;
      fileName: string;
      parts: [
        {
          [key: string]: string | number;
        },
      ];
    }) =>
      executeAxios({
        requestAddressData: MultipartComplete,
        payload: {
          uploadId: data.uploadId,
          fileName: data.fileName,
          parts: data.parts,
        },
      }),
    {
      onError: error => {
        console.log('getSignedURL Error : ', error);
      },
      onSuccess: res => {
        console.log('chunkMerge', res);
      },
    },
  );

  //play
  const videoUpload = async () => {
    const userVideoPaths = await getUserVideoPaths();
    for (let i = 0; i < userVideoPaths.length; i++) {
      const videoPath = userVideoPaths[i];
      let fileName = videoPath.split('/')[videoPath.split('/').length - 1];
      fileName = fileName.split('.')[0];
      const chunkNum = await getPartNum(videoPath);
      const {data} = (await getSignedURL.mutateAsync({chunkNum, fileName})) as {
        data: {
          presignedUrls: string[];
          isSuccess: boolean;
          uploadId: string;
        };
      };

      nativeChunkUpload({
        fileName,
        filePath: videoPath,
        urls: data.presignedUrls,
      });
      let parts;
      await new Promise(resolve => {
        const subscription = uploadEvents.addListener(
          'UploadComplete',
          event => {
            parts = event.results;
            subscription.remove();
            resolve(event);
          },
        );
      });

      await chunkMerge.mutateAsync({
        fileName,
        uploadId: data.uploadId,
        parts,
      });
    }
  };

  const uploadCancle = () => Upload.cancelUpload();

  return {videoUpload, uploadedPartNum, uploadCancle};
};

export default useVideoUpload;

TypeScript

Copy


위는 회사의 두개의 프로젝트에서 같은 로직을 사용하기에 훅으로 만들었다 이대로 갖다 붙히려고 허허
여기서는 videoUpload 함수만 보고 각 네이티브 로직으로 바로 넘어가도록 한다

[ RN에서의 dispatcher ]

🌟와🔽 요 주석 확인

const videoUpload = async () => {

    // 1.🌟 userVideoPaths는 업로드할 비디오 경로를 담은 어레이를 반환한다.
    const userVideoPaths = await getUserVideoPaths();

    // 2.🌟 userVideoPaths의 요소의 갯수 만큼 반복을 실행한다.
    for (let i = 0; i < userVideoPaths.length; i++) {
      
      // 🔽 현재 비디오 경로
      const videoPath = userVideoPaths[i]; 

      // 🔽 현재 비디오 파일명
      let fileName = videoPath.split('/')[videoPath.split('/').length - 1]; 
      fileName = fileName.split('.')[0];

      // 🔽 파트 수(청크로 나누어지는 수)
      const chunkNum = await getPartNum(videoPath);

      // 2-1.🌟 presignedUrls와 uploadId를 반환하는 api 요청.
      const {data} = (await getSignedURL.mutateAsync({chunkNum, fileName})) as {
        data: {
          presignedUrls: string[];
          isSuccess: boolean;
          uploadId: string;
        };
      };

      // 2-2.🌟 part 업로드
      // 🔽 네이티브 모듈, 아래()안의 경우에 RN으로 알려줘야하며, 
      // 🔽 (파트 업로드시, 업로드 중단완료시, 업로드 실패시, 파일업로드완료시)
      nativeChunkUpload({
        fileName,
        filePath: videoPath,
        urls: data.presignedUrls,
      });

      // 🔽 한 파일이 모두 업로드 완료될 시 실행되는 이벤트를 기다리고
      // 🔽 파트 업로드시 저장된 partIdx에 따른 etag를 리턴받아 parts에 할당
      let parts;
      await new Promise(resolve => {
        const subscription = uploadEvents.addListener(
          'UploadComplete',
          event => {
            parts = event.results;
            subscription.remove();
            resolve(event);
          },
        );
      });
      // 2-3.🌟 업로드된 part들의 병합을 요청하는 api 통신.
      await chunkMerge.mutateAsync({
        fileName,
        uploadId: data.uploadId,
        parts,
      });
    }
  };

TypeScript

Copy


[ Swift 로직 ]

import Foundation
import React

@objc(Upload) 
class Upload: RCTEventEmitter { 

    // 📌 React Native로 전송할 수 있는 이벤트 등록
    override func supportedEvents() -> [String]! {
        return ["UploadProgress", "UploadComplete"] 
    }

    // 😼 청크 업로드 디스패처함수
    @objc func chunkUpload(_ uploadData: NSDictionary) {

        // 😼-1 인자로 filePath와 urls가 잘 들어왔는지 확인
        guard let filePath = uploadData["filePath"] as? String,
              let urls = uploadData["urls"] as? [String] else {

            // 💔에러처리 filePath와 urls가 없다면 작업할수없으므로 에러 반환 

            return
        }


        // 😼-2 URL 객체 생성
        let fileURL = URL(fileURLWithPath: filePath)

        do {
            // 🔽 파일 로드
            let fileData = try Data(contentsOf: fileURL)

            // 🔽 반복할 파트 수(인자로 받아온 urls length)
            let totalChunks = urls.count 

            // 🔽 비동기 작업을 위해 DispatchGroup 객체 생성
            let dispatchGroup = DispatchGroup() 

            // 🔽 파트로 자를 사이즈
            let chunkSize = 5 * 1024 * 1024 

            // 🔽 current part number와 etag 담을 배열
            var uploadResults = [[String: String]]()

            // 🔽 RN으로부터 받아온 파트개수만큼 반복
            for i in 0..<totalChunks {

                // 🔽 비동기 작업 시작
                dispatchGroup.enter() 

                // 🔽 chunkSize로 자를 시작점
                let start = i * chunkSize

                // 🔽 chunkSize로 잘라질 끝점
                let end = min(start + chunkSize, fileData.count) 

                // 🔽 잘린 파일 데이터 
                let chunkData = fileData[start..<end] 

                // 🔽 RN에서 받아온 파트업로드 유알엘
                let uploadURL = urls[i] 

                // 🔽 파트업로드 실행
                uploadChunk(data: chunkData, to: uploadURL) { success,                 
                    eTag in if success, let eTag = eTag {

                      // 🔽 성공하고 eTag가 존재한다면 uploadResults에 할당
                      uploadResults.append([
                          "partNumber": "\(i + 1)","eTag": eTag ])

                      // 🔽💥프로그래스 그릴수있도록 파트업로드 완료당 RN보고
                        self.sendEvent(withName: "UploadProgress",
                            body: ["partNumber": "\(i)"])
                    } else {
                        // 💔에러처리 업로드 실패 시 실패 메시지 전송
                    }

                    // 🔽 해당 작업이 완료
                    dispatchGroup.leave() 
                }
              
              // 🔽 해당 작업을 기다림
              dispatchGroup.wait()
            }


            // 🔽 모든 업로드가 완료된 후 
            dispatchGroup.notify(queue: .main) {

                // 🔽 파일업로드 완료 후 RN에 알려주기
                self.sendEvent(withName: "UploadComplete", 
                    body: ["results": uploadResults])
            }

        } catch {
            // 🔽 파일 로드 오류 처리
        }
    }



    // 😼 청크 업로드 함수
    private func uploadChunk(data: Data, to url: String, completion: @escaping (Bool, String?) -> Void) {

        // 🔽 url이 유효하지 않으면 실패처리
        guard let uploadURL = URL(string: url) else {
            completion(false, nil) 
            return
        }

        // 🔽 업로드 요청을 위해 요청 객체 생성
        var request = URLRequest(url: uploadURL) 
        request.httpMethod = "PUT" 
        request.setValue("application/octet-stream", forHTTPHeaderField: "Content-Type")
      

        // 🔽 업로드 요청
        let session = URLSession.shared
        let uploadTask = session.uploadTask(with: request, from: data)       
                { responseData, response, error in
                    if let error = error {
                    // 💔에러처리 파트 업로드 실패 시 실패 메시지 전송
                    return
                }

            guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
                // 💔에러처리 파트 업로드 실패 시 실패 메시지 전송
                completion(false, nil) // 비정상 응답 처리
                return
            }


            // 🔽 ETag 추출
            if let etag = httpResponse.allHeaderFields["Etag"] as? String {
                // 💛 업로드 성공 && etag가 존재한다면 ETag 반환
                completion(true, etag) 
            } else {
                // 💔 업로드 성공 && etag가 존재하지 않음
                // 💔 실패처리
                completion(false, nil)
            }
        }

        // 😼 uploadTask 실행
        uploadTask.resume() 
    }

    @objc override static func requiresMainQueueSetup() -> Bool {
        return false
    }
}

위 함수는 주석으로 그 역할을 기재했으나 GPT의 도움을 많이 받았으므로 한블럭씩 문법과 함께 공부해보자

Share article

b0-0d