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

RN에서 네이티브단으로 멀트파트 업로드 구현하기
김보람's avatar
Oct 14, 2024
RN + iOS + AWS 멀티파트 업로드1

왜!! 네이티브로 구현하려 하는가

난 RN을 메인으로 하는 앞단 개발자다.
우리회사는 30분이상 최대 3시간까지의 영상을 서버로부터 사인드 유알엘을 받아 업로드 하는 기능이있다.
현재 rn-fetch-blob이라는 라이브러리를 통해 이를 구현하고 있지만 2시간 이상의 영상또는 특정 디바이스에서 업로드중 앱이 충돌하여 터지고 있다.
충돌하는 이유는 'rn-fetch-blob'을 사용하여 파일을 업로드할때 파일 데이터가 메모리(RAM)에 로드되는데 이때 대용량 파일의 경우 전체 파일을 한번에 메모리에 로드하도록 시도할 경우 운영체제는 과도한 메모리를 사용하는 앱을 종료해 버리기 때문이다.

iOS부터간다 할수있다!!! 겁먹지마 보람아!!!!!!!!!!!!!!!!!!

[ 멀티파트 업로드 란 ]

간단히 말하면 청크단위로 파일을 잘라 해당 파일마다 사인드유알엘이 제공되어 대용량 파일도 안정적으로 업로드할 수 있도록 업로드 중간에 끊긴다해도 해당 부분부터 업로드할 수 있도록 만들어진 AWS의 업로드 서비스이다.

[ 앞단 입장에서의 멀티파트 프로세스 ]

  1. 파일 경로에 실제 파일의 존재여부를 확인

  2. 존재한다면 해당 파일의 사이즈를 청크단위(5메가)로 나누어 몇개로 나누어지는지 확인

  3. 나눠지는 개수를 인자로 넣어 서버에 업로드를 시작할테니 나에게 이 개수만큼의 url을 제공해달라는 요청을한다

  4. 제공된 url의 개수만큼 청크 업로드를한다
    이때 청크 업로드가 끝난 후 병합요청을 하기위해서 ETag를 청크의 순서와 함께 매칭하여 가지고 있어야한다

  5. 청크 업로드시 기억하고 있던 ETag를 인자로 넣어 서버에 모든 청크파일의 업로드가 끝났으니 하나로 병합해달라는 요청을한다

5번까지 한 파일의 업로드가 끝난다 만약 파일이 2개 이상일 경우 그냥 요 프로레스를 몇번 더 돌리면된다.

사실 나는 파일 경로만 RN에서 Naitive단으로 넘겨 서버요청부터 업로드까지 다 네이티브로 작업하려고 생각하고 로직을 작성중에 있었다 생각해 보니 현재 내 해결점은 대용량 업로드중 앱터짐으로 업로드를 네이티브로 진행한다 로 다른 부분까지 네이티브로 진행한다면 둘을 통합해서 개발하는 RN개발자의 메리트가 떨어진다고 생각했다 문제가 되는 부분만 네이티브로 진행하여 해결하면 되는것이다 즉 아래와 같이 진행할 것이다

  1. RN에서 파일의 청크개수를 파악후 서버로 사인드유알엘을 요청한다

  2. RN에서 파일의 경로, 청크개수, 청크 업로드 사인드유알엘 배열을 각 플랫폼별 네이티브단으로 전달한다

  3. 네이티브단에서 파일의 경로에 파일을 체크하고 청크로 잘라 배열에 담긴 URL로 업로드한후 ETag와 청크 순서를 기록한 변수를 RN으로 넘겨준다

  4. 각 플랫폼의 네이티브단에서 전달받은 ETag와 청크 순서를 가지는 변수를 인자로 서버에 병합을 요청한다

위 처럼 진행할 예정이고 아래는 스위프트로 작성한 로직이다 다음 글에서 필요한 부분만 뜯어서 다시 공부하자!! 아래 로직으로 잘작동하는것까지 확인했음!!!!

  //1.비디오 경로에 비디오가 실존하는지 체크 후 청크 개수 리턴
    func videoPathCheckEndGetChunkNum(urlString: String) async throws -> Int {
        print(urlString)
        
        guard let fileUrl = URL(string: urlString) else {
                throw NSError(domain: "경로가 유효하지 않음", code: 400, userInfo: nil)
            }
        
        //비디오 경로 확인
        guard FileManager.default.fileExists(atPath: fileUrl.path) else {
            print(fileUrl.path)
            throw NSError(domain: "파일이 존재 하지 않음", code: 404, userInfo: nil)
        }
         
        
        //청크개수 확인
        do{
            let fileAttributes = try FileManager.default.attributesOfItem(atPath: fileUrl.path)
            if let fileSize = fileAttributes[.size] as? Int64 {
                //파일사이즈가 있을때
                let chunks = Double(fileSize) / Double(chunkSize)
                return Int(ceil(chunks))
            }else {
                throw NSError(domain: "파일 사이즈를 가져올 수 없음", code: 500, userInfo: nil)
            }
        }catch{
            throw NSError(domain: "파일사이즈 가져오기 실패", code: 500, userInfo: [NSLocalizedDescriptionKey: error.localizedDescription])
               
        }
        
    };
    
    //2.서버로 부터 파트유알엘 받아오기
    func getUploadPartUrl(chunkNum: Int, fileName:String) async throws -> (presignedUrls: [String], uploadId: String) {
        var chunkUploadRequestData: (presignedUrls: [String], uploadId: String) = (presignedUrls: [], uploadId: "")
        uploadInfo.fileName = fileName
        
       
        var request = URLRequest(url: URL(string: "")!)
        request.httpMethod = "POST"
        
    
        let parameters: [String: Any] = [
            "fileName": fileName,
            "partsNumber": chunkNum
        ]
        
    
        request.httpBody = try? JSONSerialization.data(withJSONObject: parameters)
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        
    
        let (data, response) = try await URLSession.shared.data(for: request)
        
    
        guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 201 else {
            throw NSError(domain: "Invalid response", code: 0, userInfo: nil)
        }
        
   
        do {
            if let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] {
                if let presignedUrls = json["presignedUrls"] as? [String] {
                    chunkUploadRequestData.presignedUrls = presignedUrls
                }
                if let uploadId = json["uploadId"] as? String {
                    chunkUploadRequestData.uploadId = uploadId
                    uploadInfo.uploadId=uploadId
                }
            }
        } catch {
            throw NSError(domain: "Error parsing JSON", code: 0, userInfo: nil)
        }
        
        return chunkUploadRequestData
    }
    
    //3. 청크 업로드
    func divideAndUploadVideo(chunkUploadRequestData: (presignedUrls: [String], uploadId: String), completion: @escaping (Result<Void, Error>) -> Void) {
        if let validDummyVideoUrl = dummyVideoUrl {
            do {
                let videoURL = URL(fileURLWithPath: validDummyVideoUrl)
                let fileData = try Data(contentsOf: videoURL)  // 비디오 파일의 데이터를 읽음
                let totalSize = fileData.count  // 파일의 전체 크기
                var offset = 0  // 파일을 나누기 시작할 위치
                var partIndex = 0  // 조각 인덱스

                // 조각 URL 배열의 크기와 나눠야 할 조각 수를 비교
                guard chunkUploadRequestData.presignedUrls.count >= (totalSize / chunkSize + 1) else {
                    completion(.failure(NSError(domain: "Invalid URL count", code: 1, userInfo: [NSLocalizedDescriptionKey: "Not enough URLs for the number of chunks"])))
                    return
                }

                let dispatchGroup = DispatchGroup() // 비동기 업로드 대기 그룹

 
                while offset < totalSize {
                    let chunkLength = min(chunkSize, totalSize - offset)
                    let chunkData = fileData.subdata(in: offset..<(offset + chunkLength))  // 현재 조각의 데이터
                    
                    guard partIndex < chunkUploadRequestData.presignedUrls.count else {
                        completion(.failure(NSError(domain: "URL Count Mismatch", code: 2, userInfo: [NSLocalizedDescriptionKey: "Not enough URLs to upload chunks."])))
                        return
                    }

                    let partUploadURL = chunkUploadRequestData.presignedUrls[partIndex]
                    
                   
                    let currentPartIndex = partIndex
                    dispatchGroup.enter()  // 작업 시작

                    print("Uploading part \(currentPartIndex + 1) to \(partUploadURL)")

                    if let partURL = URL(string: partUploadURL) {
                        var request = URLRequest(url: partURL)
                        request.httpMethod = "PUT"
                        request.setValue("application/octet-stream", forHTTPHeaderField: "Content-Type")

                        let task = URLSession.shared.uploadTask(with: request, from: chunkData) { data, response, error in
                            if let error = error {
                                dispatchGroup.leave()
                                completion(.failure(error))
                                return
                            }

                            // 서버 응답 확인 (200번대 응답 확인)
                            if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 {

                                if let etag = httpResponse.allHeaderFields["Etag"] as? String {
                                    self.uploadInfo.parts.append(Part(eTag: etag, partNumber: (currentPartIndex + 1)))
                                        print("Etag:", etag)
                                   
                                    } else {
                                        print("Etag not found in response headers")
                                    }
                            } else {
                               
                                let error = NSError(domain: "UploadError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to upload chunk \(currentPartIndex + 1)"])
                                dispatchGroup.leave()
                                completion(.failure(error))
                                return
                            }

                            dispatchGroup.leave()  // 작업 완료 (성공)
                        }
                        task.resume()  // 업로드 시작
                    }

                 
                    offset += chunkLength
                    partIndex += 1

                 
                    dispatchGroup.wait()  // 이전 작업이 끝날 때까지 대기
                }

                // 모든 작업이 완료되면 notify 호출
                dispatchGroup.notify(queue: .main) {
                    completion(.success(()))
                }

            } catch {
                // 파일 읽기 중 에러 발생 시 처리
                completion(.failure(error))
            }
        }
    }
    
    // 영상 병합 요청 /api/v1/demo/multipart/complete
    func multipartComplete(uploadInfo: UploadInfo) {
        guard let url = URL(string: "") else {
            print("Invalid URL")
            return
        }
        
        var request = URLRequest(url: url)
        request.httpMethod = "POST"
        
        do {
           //json으로 이쁘게 만들기
            let encoder = JSONEncoder()
            encoder.outputFormatting = .prettyPrinted
            
            let jsonData = try encoder.encode(uploadInfo)
            
            
            if let jsonString = String(data: jsonData, encoding: .utf8) {
                print("Serialized JSON: \(jsonString)")
            }
            
            
            request.httpBody = jsonData
            
        } catch {
            print("Error serializing JSON: \(error.localizedDescription)")
            return
        }
        
        
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        
        let task = URLSession.shared.dataTask(with: request) { data, response, error in
            if let error = error {
                print("Error completing upload: \(error)")
                return
            }
            
            guard let httpResponse = response as? HTTPURLResponse else {
                print("Invalid response")
                return
            }
            
            
            if httpResponse.statusCode == 201 {
                print("Upload complete successfully.") //업로드 성공
            } else {
                print("Upload failed with status code: \(httpResponse.statusCode)")
            }
            
            if let data = data, let responseBody = String(data: data, encoding: .utf8) {
                print("Response body: \(responseBody)") // 바디확인위해 출력
            }
        }
        
        task.resume()
    }

위는 파일존재 여부부터 API요청까지를 네이티브에서 구현한 코드이지만 이렇게 까지 다 네이티브에서 처리할 필요가 없다고 판단 필요한 부분만 다시 작업예정이다!! 다음 글에서 보쟈구

Share article

b0-0d