logo
|
Blog

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

    RN에서 네이티브단으로 멀트파트 업로드 구현하기
    김보람's avatar
    김보람
    Oct 14, 2024
    RN + iOS + AWS 멀티파트 업로드1
    Contents
    왜!! 네이티브로 구현하려 하는가[ 앞단 입장에서의 멀티파트 프로세스 ]

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

    난 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
    Contents
    왜!! 네이티브로 구현하려 하는가[ 앞단 입장에서의 멀티파트 프로세스 ]

    김보람 | 930802qhfka@gmail.com

    RSS·Powered by Inblog