왜!! 네이티브로 구현하려 하는가
난 RN을 메인으로 하는 앞단 개발자다.
우리회사는 30분이상 최대 3시간까지의 영상을 서버로부터 사인드 유알엘을 받아 업로드 하는 기능이있다.
현재 rn-fetch-blob이라는 라이브러리를 통해 이를 구현하고 있지만 2시간 이상의 영상또는 특정 디바이스에서 업로드중 앱이 충돌하여 터지고 있다.
충돌하는 이유는 'rn-fetch-blob'을 사용하여 파일을 업로드할때 파일 데이터가 메모리(RAM)에 로드되는데 이때 대용량 파일의 경우 전체 파일을 한번에 메모리에 로드하도록 시도할 경우 운영체제는 과도한 메모리를 사용하는 앱을 종료해 버리기 때문이다.
iOS부터간다 할수있다!!! 겁먹지마 보람아!!!!!!!!!!!!!!!!!!
[ 멀티파트 업로드 란 ]
간단히 말하면 청크단위로 파일을 잘라 해당 파일마다 사인드유알엘이 제공되어 대용량 파일도 안정적으로 업로드할 수 있도록 업로드 중간에 끊긴다해도 해당 부분부터 업로드할 수 있도록 만들어진 AWS의 업로드 서비스이다.
[ 앞단 입장에서의 멀티파트 프로세스 ]
파일 경로에 실제 파일의 존재여부를 확인
존재한다면 해당 파일의 사이즈를 청크단위(5메가)로 나누어 몇개로 나누어지는지 확인
나눠지는 개수를 인자로 넣어 서버에 업로드를 시작할테니 나에게 이 개수만큼의 url을 제공해달라는 요청을한다
제공된 url의 개수만큼 청크 업로드를한다
이때 청크 업로드가 끝난 후 병합요청을 하기위해서 ETag를 청크의 순서와 함께 매칭하여 가지고 있어야한다청크 업로드시 기억하고 있던 ETag를 인자로 넣어 서버에 모든 청크파일의 업로드가 끝났으니 하나로 병합해달라는 요청을한다
5번까지 한 파일의 업로드가 끝난다 만약 파일이 2개 이상일 경우 그냥 요 프로레스를 몇번 더 돌리면된다.
사실 나는 파일 경로만 RN에서 Naitive단으로 넘겨 서버요청부터 업로드까지 다 네이티브로 작업하려고 생각하고 로직을 작성중에 있었다 생각해 보니 현재 내 해결점은 대용량 업로드중 앱터짐으로 업로드를 네이티브로 진행한다 로 다른 부분까지 네이티브로 진행한다면 둘을 통합해서 개발하는 RN개발자의 메리트가 떨어진다고 생각했다 문제가 되는 부분만 네이티브로 진행하여 해결하면 되는것이다 즉 아래와 같이 진행할 것이다
RN에서 파일의 청크개수를 파악후 서버로 사인드유알엘을 요청한다
RN에서 파일의 경로, 청크개수, 청크 업로드 사인드유알엘 배열을 각 플랫폼별 네이티브단으로 전달한다
네이티브단에서 파일의 경로에 파일을 체크하고 청크로 잘라 배열에 담긴 URL로 업로드한후 ETag와 청크 순서를 기록한 변수를 RN으로 넘겨준다
각 플랫폼의 네이티브단에서 전달받은 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요청까지를 네이티브에서 구현한 코드이지만 이렇게 까지 다 네이티브에서 처리할 필요가 없다고 판단 필요한 부분만 다시 작업예정이다!! 다음 글에서 보쟈구