백그라운드 환경에서 이미지 업로드하기

Meltsplit·2023년 12월 5일

Fitapat

목록 보기
4/7
post-thumbnail

상황

저번 포스트에선 이미지 업로드의 진행도를 Uploading Toast를 보여주어 유저의 이탈을 방지해보았지만, 유저가 앱을 이탈한다면 업로드 통신이 끊긴다는 문제가 여전히 남아있었습니다.

앱을 Xcode 빌드로 실기기에서 테스트 할 때에는 제가 앱을 나가더라도, HTTP 통신이 끊기지 않고 잘 진행되었지만, (URLSession의 configuration은 .default 였습니다 🤔)

Xcode가 아닌 실제 디바이스로 앱을 실행했을 때에는, 앱을 나가면 HTTP 통신이 끊겨 이미지 업로드에 실패하더라구요.

따라서 이번 포스트에선 유저가 앱을 이탈해도 이미지 업로드 통신이 원활히 진행되도록 background로 HTTP 통신하는 법을 다뤄보고자 합니다.

백그라운드 통신으로 이미지 업로드 해보자

1️⃣ URLSession 이란

URLSession은 네트워크 데이터 통신 작업들을 관리하는 객체입니다.
개발자들이 쉽게 HTTP 통신을 할 수 있도록 아래와 같은 기능들을 제공해줍니다.

  • URLRequest을 담으면 HTTP 통신을 진행할 수 있습니다.
  • body에 특정 데이터를 담으면 POST, PATCH 등 업로드 요청을 할 수 있습니다.
  • Configuration을 활용하여 어떤 환경으로 통신할 지 설정할 수 있습니다.
  • completion handler 혹은 async await을 통해 서버가 보낸 response를 쉽게 받을 수 있습니다.

URLSession의 생성자는 configuration를 인자로 받고 있습니다.
만약 URLSession 인스턴스를 만들지 않고, URLSession.shared 를 사용한다면 configuration 없이 통신하는 것이죠.

공식 문서: URLSession.shared
You can’t perform background downloads or uploads when your app isn’t running.

URLSession.shared는 간편하게 사용할 수 있다는 장점이 있지만, 서버로부터 데이터를 점진적으로 받을 수 없고, 백그라운드 환경에서도 돌아가지 않는 단점이 있습니다. 따라서 저는 싱글톤이 아닌 다른 URLSession 인스턴스를 사용해야 겠네요.

URLSessionConfiguration

Configuration은 총 세가지가 있습니다.

  • default: 기본적인 통신으로, delegate를 통해 점진적으로 데이터를 받을 수 있습니다.
  • ephemeral: default와 유사하지만, disk에 캐시나 쿠키를 저장하지 않습니다.
  • background: 앱이 실행중이지 않을 때에도 upload나 download가 가능합니다.

저는 백그라운드환경에서도 돌아가야 하기에 .background 를 사용하면 되겠네요.

URLSession 통신 메소드 종류

.background을 사용하려면 Session의 통신 메소드를 사용할 때에도 유의할 점이 있습니다.

URLSession은 통신 메소드로 dataTask(), uploadTask(), downloadTask()등 다양한 함수를 제공합니다. iOS15 버전부터는 각각의 함수의 async await 버전도 사용할 수 있습니다.

공식문서: URLSessionTask
data tasks are not supported in background sessions.
upload tasks are supported in background sessions.

단, 위와 같이 background 통신을 하기 위해선 upload task를 사용해야 한다고 명시되어있네요.

2️⃣ URLSessionConfiguration.background 사용하기

.background 에 대해 조금 더 알아봅시다.

공식문서: .background(withIdentifier:)

  • If an iOS app is terminated by the system and relaunched, the app can use the same identifier to create a new configuration object and session
  • A configuration object that causes the system to perform upload and download tasks in a separate process.
  • .background는 생성시에 identifier를 받습니다. 이는 통신이 중단되더라도, 앱을 재시작 했을 때 identifier를 통해 해당 작업에 대한 session을 가지고 올 수 있게 해줍니다. (identifier는 앱당 하나가 이상적이랍니다.)
  • 해당 background configuration은 시스템이 upload 통신을 분리된 프로세스에서 수행할 수 있도록 해줍니다. (여기서 분리된 프로세스에 주목해주세요. 추후 저의 발목을 잡습니다 하하!)

이제 위 내용들을 바탕으로 코드를 한번 구현해봅시다!

// PetService

final class DefaultPetService: Networking {

	private var urlSession: URLSession = URLSession(configuration: .background(withIdentifier: "com.meltsplit.fitapat.backgroundsession"),
                                                    delegate: nil,
                                                    delegateQueue: nil)
    

    func postPetImages(datasetID: String, 
                       files: [Data],
                       with manager: URLSessionTaskDelegate) async throws -> SimpleResponse {
        
        let body = makeMultipartFormImageBody(keyName: "files", images: files)
        
        let request = try makeHTTPRequest(method: .post,
                                          path: "/ai/images/\(datasetID)",
                                          headers: APIConstants.multipartHeaderWithBoundary, 
                                          body: nil)
        
   

        let (data, response) = try await urlSession.upload(for: request,
                                                           from: body,
                                                           delegate: manager)
    
        return try validataDataResponse(data, response: response, to: SimpleResponse.self)
    }
}
  • configuration을 background로 설정했습니다.
  • URLSessionTaskDelegate를 관찰할 객체를 메소드 주입으로 받아, delegate로 지정해주었습니다.

위 코드로 실행해보니 아래와 같은 오류메시지가 뜨며, 작업이 중단되었습니다.

⚠️ Background 세션은 콜백 로직을 지원하지 않는다.

*** Terminating app due to uncaught exception` 'NSGenericException', 
reason: 'Completion handler blocks are not supported in background sessions. 
Use a delegate instead.' ***

# background 세션은 completion handler 블럭을 지원하지 않는다.  
# 대신 delegate를 사용해라.

어라라.. 나는 completion handler를 사용한 적이 없는데,,,

결론부터 얘기하자면 background 세션이 콜백 로직을 지원하지 않기 때문에 발생하는 오류입니다.
async await도 결국엔 completion handler처럼 콜백하는 로직이니 지원하지 않는 것 같구요.

지원하지 않는 이유는 아마도 background configuration이 분리된 프로세스에서 작업이 진행되기 때문이 아닌가라고 추측됩니다. 자세한 내용은 아래에서 다루겠습니다.

코드를 아래와 같이 수정하고 다시 빌드해보았습니다.

func postPetImages(datasetID: String, files: [Data]) {
        
        let body = makeMultipartFormImageBody(keyName: "files", images: files)
        
        let request = try makeHTTPRequest(method: .post,
                                          path: "/ai/images/\(datasetID)",
                                          headers: APIConstants.multipartHeaderWithBoundary, 
                                          body: nil)
        
   

        let backgroundTask = urlSession.uploadTask(with: request, from: body)
        backgroundTask.resume()
    
        return
    }
}
  • 통신메소드가 아래와 같이 변경되었습니다.
    • Before: upload() async
    • After: uploadTask() -> URLSessionTask
  • URLSessionTaskDelegate 지정 코드가 삭제되었습니다 😢
  • 콜백 로직 대신task.resume() 하는 방식으로 수정하였습니다.

그리고 등장한 새로운 오류메시지...

⚠️ Background 세션은 Data를 통한 Upload Task를 지원하지 않는다.

*** Terminating app due to uncaught exception'NSGenericException', 
reason: 'Upload tasks from NSData are not supported in background sessions.' ***

# background 세션은 NSData를 통한 Upload Task는 지원하지 않는다.

어라라,, 그럼 어떻게 upload 하지..?

결론부터 얘기하자면 background session으로 업로드 방식은
body(Data)를 바로 uploadTask의 인자로 넣는 방식이 아니라,
캐시 디렉토리에 먼저 body값을 저장한 후 uploadTask의 인자로 해당 디렉토리의 경로(URL)를 알려주는 방식으로 진행됩니다.

최종 코드부터 보시죠!

func postPetImages(datasetID: String, files: [Data]) {
        
        let body = makeMultipartFormImageBody(keyName: "files", images: files)
        
        let request = try makeHTTPRequest(method: .post,
                                          path: "/ai/images/\(datasetID)",
                                          headers: APIConstants.multipartHeaderWithBoundary, 
                                          body: nil)
        
   
		let directoryURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!
        let fileURL = directoryURL.appendingPathComponent(APIConstants.boundary)
        let filePath = fileURL.path
        FileManager.default.createFile(atPath: filePath, contents: nil, attributes: nil)
        let file = FileHandle(forWritingAtPath: filePath)!
        file.write(body)
        file.closeFile()
        
        let backgroundTask = urlSession.uploadTask(with: request, fromFile: fileURL)
        backgroundTask.resume()
    
        return
    }
}
  • 디바이스 캐시 디렉토리에 특정 경로를 만들었습니다.
  • 해당 경로에 body 데이터를 저장했습니다.
  • 통신메소드가 아래와 같이 변경되었습니다.
    • Before: uploadTask(URLRequest, Data) -> URLSessionTask
    • After: uploadTask(URLRequest, URL) -> URLSessionTask
  • uploadTask 호출 시 캐시 디렉토리 URL을 인자로 넣어주었습니다.

그리고 빌드를 해보면!

와 기쁘다! 이제 유저가 앱을 나가도 걱정 없어요..!

근데 이전 포스트에서 열심히 만든 UploadingToast가 안보이죠?
위에서 작업 중에 URLSessionTaskDelegate 지정해주는 코드를 없어졌기 때문이에요.
다시 delegate를 지정해주려 이것저것 시도해보고 있다가 이런 오류메시지를 또 받게 됩니다 😡

⚠️ Background 세션은 Task Delegate를 지원하지 않는다.

*** Terminating app due to uncaught exception 'NSGenericException', 
reason: 'Task delegate is not supported on background session task' ***

# background 세션은 Task Delegate를 지원하지 않는다.

바로 URLSessionTaskDelegate를 지정해주지 못한다는 것이였어요.
이 말은 UploadingToast를 사용하지 못한다는 것과 마찬가지 였어요.
이 문제는 다음 포스트에서 해결해보자구요..


백그라운드 업로드는 성공적으로 마쳤지만 몇가지 의문과 문제점이 남아있어요.

  • 왜 background 세션은 콜백 로직을 지원하지 않을까?
  • 왜 background 세션 중 upload는 fileURL만을 지원할까?
  • 왜 background 세션은 Task Delegate를 지원하지 않을까?

이제 이 의문들을 하나씩 파헤쳐보고자 합니다.

백그라운드 통신의 프로세스 과정을 유추해보자

위에서 던졌던 3가지 질문은 한 가지의 이유로 귀결될 것 같아요.

공식문서: .background(withIdentifier:)
background configuration은 시스템이 upload 통신을 분리된 프로세스에서 수행할 수 있도록 해줍니다.

바로 분리된 프로세스이기 때문인데요, 그 전에 멀티쓰레딩, 멀티프로세스에서의 메모리 구조 먼저 알아봅시다!

기본 메모리 구조, 멀티 쓰레딩 메모리 구조


좌측은 기본 메모리구조이고, 우측은 멀티쓰레딩 메모리 구조입니다.
기본 메모리 구조는 Code Data Heap Stack으로 나뉩니다.

  • Stack: 지역변수나 함수 호출 스택이 저장되는 곳
  • Heap: reference Type이 저장되는 곳

멀티쓰레딩 환경에서 쓰레드는Code Data Heap 영역은 공유하지만, 쓰레드별로 독립적인 스택을 갖게 됩니다.

Swift에서는 GCD를 통해 멀티 쓰레딩을 관리할 수 있고, 최근에는 Concurrecny을 통해서도 멀티 쓰레드를 관리할 수 있습니다.

멀티 프로세싱 메모리 구조


멀티 프로세싱 환경에서는 두개의 프로세스를 가질 수 있습니다. 단 프로세스는 서로 독립된 자원 (Heap, Stack...)을 갖게 됩니다.

iOS Application Security
Interprocess communication (IPC) on iOS is, depending on your perspective, refreshingly simple or horribly limiting.

만약 프로세스간 통신을 하고 싶다면 IPC를 사용하여 통신해야하지만, iOS에서는 보안 상의 이유로 IPC를 사용이 제한적입니다.

이제 3가지의 의문을 해결해봅시다!

  • 왜 background 세션은 콜백 로직을 지원하지 않을까?
  • 왜 background 세션 중 upload는 fileURL만을 지원할까?
  • 왜 background 세션은 Task Delegate를 지원하지 않을까?

(여기서부턴 개인적인 의견이니 참고해주세요.)

  • A Process: fitapat 앱이 구동되는 프로세스
  • B Process: 백그라운드 세션이 구동될 프로세스

background 세션은 앱이 실행 중이지 않더라도 수행되어야 하는 작업이기에, iOS 운영체제가 앱(fitapat)과 독립된 B Process를 background세션에게 할당해줍니다.

B ProcessA Process로부터 독립되었기에 앱의 생명주기와 관계없이 작업을 수행할 수 있지만,
이를 반대로 생각한다면 A Process와 독립되었기에 앱에게 관여할 수가 없다는 것 입니다.

기존엔 A Process멀티쓰레딩 환경 속에서 작업을 처리했지만,
background 세션을 사용하므로써 A ProcessB Process를 사용하는 멀티프로세싱 환경이 되어버린 것이죠.

🤔 의문 1) 왜 background 세션은 콜백 로직을 지원하지 않을까?

단일 프로세스 멀티 쓰레딩에서의 콜백 로직

기존 콜백 로직은 단일 Process멀티쓰레딩 환경에서 아래와 구동되고 있었습니다.

urlSession.uploadTask(with: request, fromFile: fileURL) 
{ data, response, error in
    // response 처리
}

이때 completionHandler는 탈출 클로저이고, 탈출 클로저는 reference Type이기 때문에, completionHandlerheap영역에 저장됩니다.

이후 HTTP 통신은 비동기로 처리 되기에 Thread2에서 실행됩니다.
시간이 흘러 서버에게 응답이 도착했을 시, heap에 저장되어 있던 completionHandler이 실행되면서 콜백되는 로직이었죠.

만약 background 세션에서 사용한다면 어떻게 될까요?

멀티 프로세싱에서의 콜백 로직이 불가능한 이유

background configuration은 시스템에게 분리된 Process에서 HTTP 통신 작업을 하게 해달라고 요청합니다.
그러면 system에서 분리된 Process를 할당하고 해당 프로세스에서 작업을 실행합니다.

이후 시간이 흘러 response작업이 왔다고 해보죠.

이때 문제가 발생합니다.
프로세스는 각자 독립된 Code, Data, Heap 영역을 가지고 있기에
B Process에서 A ProcesscompletionHandler를 실행할 수 없는 것 입니다.

따라서 background 세션은 콜백로직을 사용할 수 없었던 것이었죠.

🤔의문 2) 왜 background 세션 중 upload는 fileURL만을 지원할까?

A Process에서 작업을 요청하고 앱을 종료한 상황입니다.
하지만 B Process는 계속 작업을 진행하고 싶죠.
또한 background 세션은 유저가 앱을 다시 들어왔을 때에도 작업을 재시도 할 수 있는 기능을 제공해주죠.
따라서, Upload할 데이터를 A Process로부터 가져오긴 무리가 있어 보입니다.

따라서 위와 같이 업로드할 데이터를 시스템에서 저장하고,
B Process는 시스템에서 데이터를 가져오며 작업을 수행하기 위해 fileURL만을 지원하는 것 같습니다.

🤔의문 3) 왜 background 세션은 Task Delegate를 지원하지 않을까?

사진은 생략하겠습니다 :)

TaskDelegate를 지정한다는 것은 HTTP 통신의 진행도를 실시간으로 관찰할 객체를 지정하는 것과 같은 의미 입니다.
HTTP 통신은 B Process에서 하지만, 실시간으로 관찰할 UploadingToast(UIView)는 A Process의 heap 영역에 할당된 객체입니다.

이 역시 의문 1) 과 마찬가지로 프로세스간 Heap영역을 공유할 수 없기에 TaskDelegate를 지원하지 않는 것입니다.

단, iOS에서는 통신이 끝났을 때 아래 함수를 사용해서 알려주는 것 같습니다.
<urlSessionDidFinishEvents(forBackgroundURLSession:)>
IPC 통신을 통해 프로세스간 통신을 했을 것이라 추측됩니다.

결론

이번 포스트에선 background 세션을 통해 이미지를 백그라운드 환경에서 업로드 되도록 구현해보았습니다.

하지만, background 세션을 사용하면 콜백 불가,taskDelegate 지정 불가 등 개발에 제약되는 부분이 많았습니다.
URLSessionTaskDelegate를 지정하지 못하므로써 UploadingToast가 동작할 수 없다는 큰 단점도 겪었구요.

.default를 사용하면

  • 장점: UploadingToast를 사용할 수 있다.
  • 단점: 앱을 나가는 순간 요청이 종료된다

.background를 사용하면

  • 장점: 앱을 나가도 업로드를 계속 할 수 있다.
  • 단점: UploadingToast를 사용할 수 없다.

URLSession의 Configuration을 어떤 것을 선택하느냐에 장단점이 달라지는 trade off 관계인 것 같더라구요.

둘 중 어떤 것과 타협을 해야할까 라는 고민이 많이 들었는데요...

다음 포스트에서는 fitapat iOS팀이 두마리의 토끼를 다 잡은 법에 대해 소개해드리고자 합니다!
그럼 안녕~


나만의 커스텀 반려동물 굿즈를 구매하고 싶으시다면,
지금 바로 AppStore에서 만나보세요!

참고

상어: 스택 & 힙

asong: 클로저, 탈출 클로저

개발자 소들이: 프로세스 & 쓰레드

김동현: iOS IPC

Medium: background upload task

SwiftLee: URLSession with background

profile
iOS Developer

0개의 댓글