앱 생명주기를 관찰하여 URLSession Task를 background 세션으로 전환하기

Meltsplit·2023년 12월 9일

Fitapat

목록 보기
5/7

상황

저저번 포스트에선 HTTP 통신 진행도를 알 수 있는 UploadingToast를 구현하였고,
저번 포스트에선 URLSessionConfiguration.background을 통해 이미지를 백그라운드 환경에서도 업로드 할 수 있게 구현하였어요.

(좌측은 UploadingToast, 우측은 background 세션 구현 영상)

다만, .background는 URLSessionTaskDelegate를 지원하지 않아 두 기능을 모두 챙길 수 없는 상황이 발생했어요.

.default를 사용하면

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

.background를 사용하면

  • 장점: 유저가 앱을 나가도 이미지 업로드가 끊기지 않는다.
  • 단점: UploadingToast를 사용할 수 없다.

fitapat iOS팀은 베이스오일과 텅스텐을 고민하는 이성민 선배처럼 깊은 고민에 빠졌습니다.

유저를 지키기 위해선 부드러워야 해.
UploadingToast를 봐, 부드럽게 진행도를 보여주잖아

아니!!!! 더 강해야 유저를 끝까지 지킬 수 있어!
background 통신 그 강력함이 기술이야!

엔진오일의 마모를 줄이기 위해 둘 중 하나도 포기할 수 없는 이성민 선배님을 보니 저희의 상황과 정말 유사하다고 느껴지네요.

둘 다... 가질 순 없는거야..?

이성민 선배님이 무언가 깨달은 것 처럼, 저희도 둘 다 가질 수 있는 아이디어가 필요했습니다.

구현 아이디어

  1. 유저가 이미지 업로드를 시도할 시 .default세션으로 통신하여 UploadingToast를 보여준다.
  2. 앱의 생명주기를 관찰하여, 이미지 업로드 중에 유저가 앱을 나간다면, task를 .background 세션으로 전환한다.

위와 같이 앱의 생명주기에 따라 HTTP 통신 환경을 전환한다면 둘 다 챙길 수 있을 것 같습니다. 해당 아이디어를 구현하기 위해선 아래와 같은 로직을 수행해야 했어요.

  • UploadTask를 전환해주는 객체(Manager)가 필요하다.
  • Manager는 앱의 생명주기를 관찰해야한다.
  • Manager는 HTTP 통신의 진행도를 관찰해야한다.
  • Manager는 업로드한 task 정보를 알고 있어야 한다.
  • 업로딩 중에 유저가 앱을 나간다면, 해당 task를 background로 실행해야 한다.

조금 더 디테일 한 부분까지 말해보자면

  • 업로드는 완료되었으나 response가 오지 않은 상황에선 background세션으로 전환하면 안된다.
  • 유저가 앱을 강제종료한다면, 업로드가 중단됐다는 푸시알림을 발송한다.
  • 업로딩 진행도를 담당하므로 UploadingToast에게 진행도를 알려주는 역할도 Manager가 하면 좋을 것 같다.


이번 포스트에선 위 아이디어를 수행해줄 ImageUploadManager라는 객체를 구현해보도록 하겠습니다.

ImageUploadManager를 설계해보자

1️⃣ UIApplication.State 란

앱이 현재 어떤 상태인지 알 수 있는 enum입니다.

UIApplication.State
Scene-base 앱에서는 활성화된 UIScene의 activationState에 의해 결정된다.

UIApplication.State는 UIScene.ActivationState에 의해 결정되지만, fitapat은 UIScene을 하나만 사용하니 두 State가 같다고 생각해도 무방할 것 같네요.

UIApplication.State

  • active: 유저가 앱을 사용하고 있다는 상태
  • inactive: Switcher 모드와 같이 유저의 event를 받을 수 없는 상태
  • background: 유저가 앱을 나간 상태

저희는 background 상태에 들어갈 시 백그라운드로 전환하는 로직을 구현해야 합니다. 유저가 background로 진입한 것을 알기위해선 총 3가지 방식이 있는데요

이 중 저희는 3번째 방식인 NotificationCenter 관찰하는 방법을 사용하고자 합니다. 자세한 내용은 아래에서 다루겠습니다.

이제 ImageUploadManager를 설계해볼까요?

2️⃣ ImageUploadManager 설계하기


앞서 말했던 구현 아이디어를 도식화 해보면 위와 같습니다.
ImageUploadManager는 많은 객체들과 협력해야 할 것 같군요.

3️⃣ ImageUploadManager 코드 구현하기

ImageUploadManager의 pseudo코드는 아래와 같습니다.

enum UploadStatus {
    case none // HTTP 통신 시작하지 않은 상태
    case uploading // iOS -> Fap Server로 진행중인 상태
    case modeling  // FapServer -> Ai Server로 진행중인 상태
}

protocol ImageUploadManagerDelegate: AnyObject {
    func progressTaskDidCreate()
    func progressTaskIsProcessing(with ratio: Float)
    func progressTaskCompleted()
    
    func responseSuccess()
    func responseFail(willContinue: Bool)
    
    func didEnterBackground(_ status: UploadStatus)
    func willTerminate(_ status: UploadStatus)
}

final class ImageUploadManager: URLSessionTaskDelegate {
    
    static let shared = ImageUploadManager()
    weak var delegate: ImageUploadManagerDelegate?
    
    private var status: UploadStatus = .none
    private var request: URLRequest?
    private var fileURL: URL?
    private var progressTask: URLSessionTask?
    private var backgroundTask: URLSessionTask?
    
    //MARK: - Life Cycle
    
    private init() {
        // UIApplication.rx 관찰하며 분기처리
    }

    func setRequest(_ request: URLRequest, with body: Data) {
        // Request 정보 저장하기
    }
    
    func urlSession(_ session: URLSession,
                    task: URLSessionTask,
                    didSendBodyData bytesSent: Int64,
                    totalBytesSent: Int64,
                    totalBytesExpectedToSend: Int64) {
        
        // 진행도 Toast에게 알려주기
    }

    
    private func cancelProgressTask(_ task: URLSessionTask) {
        // 진행중인 task cancel 시키기
    }
    
    private func resumeWithBackground(with request: URLRequest, fromFile fileURL: URL) {
        // background 통신으로 task resume 하기
    }
}
  • 싱글톤 패턴입니다.
  • URLRequest 혹은 URLSessionTask를 옵셔널 형태로 저장하여, 추후 nil 여부에 따라 분기 처리합니다.
  • 생성자 호출 될 시 UIApplication.State를 관찰합니다.
  • UploadStatus를 통해 HTTP 통신 진행도를 관리합니다.
  • delegate를 통해 뷰의 반영하는 객체에서 해당 Delegate를 채택하여 구현하도록 위임합니다.

PetService에서 통신 시 URLRequest, FileURL 저장하기

// ImageUploadManager

func setRequest(_ request: URLRequest, with body: Data) {
    let fileURL = writeFile(with: body)
    self.request = request
    self.fileURL = fileURL
}

writeFile은 FileManager를 통해 cache 디렉토리에 body 데이터를 저장해주는 함수입니다. (이전 포스트 참고)

URLSessionTaskDelegate 채택하기

// ImageUploadManager
func urlSession(_ session: URLSession,
                    task: URLSessionTask,
                    didSendBodyData bytesSent: Int64,
                    totalBytesSent: Int64,
                    totalBytesExpectedToSend: Int64) {
    if self.progressTask == nil {
        self.progressTask = task
        delegate?.progressTaskDidCreate()
        status = .uploading
    }
    
    let progress = Float(totalBytesSent) / Float(totalBytesExpectedToSend)
    
    if progress < 1 {
    	delegate?.progressTaskIsProcessing(with: progress)
    } else { // HTTP 전송이 완료됐을 때
        delegate?.progressTaskCompleted()
        status = .modeling
    }        
      
}
  • progressTask == nil을 통해 최초 호출인지 아닌지 판단합니다.
  • delegate의 함수를 호출합니다.

진행중인 Task cancel 하기

// ImageUploadManager

private func cancelProgressTask(_ task: URLSessionTask) {
    print("🥰진행중인 task를 중지합니다.")
    task.cancel()
    progressTask = nil
}
  • 해당 함수는 유저가 앱을 종료 시에 호출됩니다.

Background Session으로 전환하기

// ImageUploadManager

private func resumeWithBackground(with request: URLRequest, fromFile fileURL: URL) {
    print("🥰백그라운드 task를 시작합니다.")
    let config = URLSessionConfiguration.background(withIdentifier: "com.meltsplit.fitapt.backgroundSession")
    let session = URLSession(configuration: config)
        
    backgroundTask = session.uploadTask(with: request, fromFile: fileURL)
    backgroundTask?.resume()
        
    self.request = nil
    self.fileURL = nil
}
  • URLSession을 .background로 설정합니다.
  • uploadTask(URLRequest, URL)을 통해 백그라운드 통신을 진행합니다.

앱 생명주기 관찰하기

// ImageUploadManager
private init() {
    
    UIApplication.rx.didEnterBackground
        .do(onNext: { print("😍didEnterBackground") })
        .map(getStatus)
        .do(onNext: delegate?.didEnterBackground)
        .filter { $0 == .uploading}
        .map { _ in self.progressTask }
        .filter { $0 != nil }
        .map { $0! }
        .map(cancelProgressTask)
        .filter { self.request != nil }
        .filter { self.fileURL != nil }
        .map { (self.request!, self.fileURL!) }
        .map(resumeWithBackground)
        .map { PushNotificationCase.resumeWithBackground.model }
        .bind(onNext: PushNotification.send)
        .disposed(by: disposeBag)
    
    UIApplication.rx.willTerminate
        .do(onNext: { print("😍willTerminate") })
        .map(getStatus)
        .do(onNext: delegate?.willTerminate)
        .filter{ $0 == .uploading}
        .map { _ in PushNotificationCase.warnningWhenTerminate.model }
        .bind(onNext: PushNotification.send)
        .disposed(by: disposeBag)
	}
}
  • 이미지 업로드 중 유저가 앱을 종료한다면 (didEnterBackground)
    • Toast에게 delegate로 알려주기
    • 진행중인 task를 취소
    • 해당 task background 세션으로 시작
  • 이미지 업로드 중 유저가 앱을 강제종료한다면 (willTerminate)
    • 강제종료 경고 푸시알림 발송
  • RxOperator를 활용하여 Stream이 Sequential하게 흐르도록 구현했습니다.
  • 옵셔널 언래핑을 하기 위해 .filter{ $0 != nil}.map { $0!} 와 같이 구현했는데 더 좋은 방법이 있을지 고민이 되네욥.

ImageUploadManager Q&A

디자인 패턴 적용 이유

왜 싱글톤 패턴을 적용하였는가?

싱글톤 패턴은 세가지 정도의 특징이 있다고 생각합니다.

  • 앱 내에 유일한 인스턴스라는 것
  • static이기에 메모리에 올라오면, 절대 메모리에서 해제되지 않는 것
  • 앱에서 전역적으로 접근이 가능한 것

저는 이 중 첫번째와 두번째 특징이 Manager에게 적용하기 적합하다고 생각했습니다.

Manager는 fileURLRequest를 저장하기에 새로운 인스턴스가 생성되면 안된다고 판단하였고, 앱의 생명주기를 관찰해야하기 때문에 항상 메모리에 올라와있어야 합니다. 절대 해제되어서는 안되죠.

단, 저는 세번째 특징인 전역적으로 접근 가능하다는 것은 꺼려하는 편인데요. 그 이유는 많은 객체가 Manager객체에 접근한다면, Manager 코드의 변화가 다른 객체들에게도 영향을 미칠 수 있기 때문입니다. 해당 문제는 다음 포스트에서 다루도록 하겠습니다 :)

왜 Delegate 패턴(ImageUploadManagerDelegate)을 적용하였는가?

Manager 객체는 HTTP의 진행도를 Toast에게 알려주어야 합니다.
Manager가 Toast를 의존하지 않고 Toast에게 알려주기 위해 Delegate 패턴을 사용했습니다.

다만, Manager 객체는 싱글톤이기에 여러 객체들이 delegate를 채택할 순 없다는 것 한계점이 있는데요. 추후 Manager가 여러 통신을 담당해야 하거나, 혹은 Manager가 trigger하는 이벤트 함수를 여러객체에서 전달받아야 할때에는 다른 방식으로 구현해야할 것 같습니다.

[ImageUploadManagerDelegate] 배열로 선언하여 여러 객체들이 append 하는 방식, 혹은 Observer 방식들을 사용할 수 있을 것 같네요.

RxSwift 적용 이유

왜 RxSwift를 통해 App의 생명주기를 관찰하였는가?

RxCocoa의 UIApplication+를 사용하면 앱 생명주기를 쉽게 관찰할 수 있는데요, AppDelegate 혹은 SceneDelgate에서 절차적으로 로직을 처리하는 것 보단, ImageUploadManager에서 선언적으로 로직을 처리하는 것이 코드 가독성 측면에서 이점이 있다고 판단했기 때문입니다.

최근에 RxSwift 사용시에 operator 인자로 클로저가 아닌 함수 자체를 집어넣는 방식으로 개발하는 것을 선호하고 있습니다.
또한 map{ }을 활용하여 함수의 Element들을 자유자재로 바꾸며 Stream을 이어지도록 구현하기도 했는데요, 과연 이러한 코드가 가독성이 좋은가? 라는 질문에는 선뜻 그렇다라고 답 할 순 없을 것 같습니다.

물론 모든 로직이 한 depth에서 볼 수 있기에 전체적인 로직파악을 쉬울 수 있다고 생각하나, 다른 개발자가 보기에 지나친 축약이 오히려 가독성을 저해시키진 않았나 라는 생각도 들었습니다.

프로토콜 추상화 이유

왜 protocol을 5개나 만들었나요?

해당 포스트에선 프로토콜에 대한 설명을 따로 하지 않았지만, 가장 하단에 참고한 전체 코드를 보면 총 5가지의 프로토콜이 있습니다. 이토록 추상화 시킨 이유는 객체간 의존 관계를 느슨하게 하기 위함입니다.

Manager객체는 많은 객체들과 협력해야 합니다. 다른 객체에서 Manager에게 이벤트를 알려줄 때도 있고, 반대로 Manager가 다른 객체에게 이벤트를 발생시킬 때도 있습니다.

이러한 상황에서 객체가 다른 객체에게 직접적으로 의존하고 있다면 한 객체 코드 변경이 다른 객체에게도 영향을 미치기 때문에 유지보수 하기 쉽지 않았을 것이라 생각하여 객체 간에 의존성을 느슨하게 하기 위해 protocol로 소통하도록 구현했습니다. 자세한 내용은 다음 포스트에서 다루겠습니다 :)

결론

Manager 객체를 구현했을 때에는 문제를 풀었다라는 성취감에 굉장히 뿌듯했는데요, 막상 아티클로 작성하다보니 더 좋은 방법으로 구현할 수 있지 않았을까 생각이 많이 드네요. 이래서 기록하는 것이 중요한 것 같습니다.

task를 재시작하는 것이 아닌 앱 생명주기를 통제할 수 있다면?

해당 포스트에서 구현한 방식은 진행중이던 task를 중단시키고, background세션으로 HTTP 통신을 처음부터 다시 진행하는 방식입니다.

이는 이미지 업로드가 99%가 됐을 때 유저가 앱을 잠깐이라도 나간다면 background세션으로 0%부터 다시 시작해야 한다는 단점이 있습니다.

이번 포스트를 쓰며 공식 문서를 읽다보니 background 진입을 지연시키는 함수를 사용하면 유저가 앱을 나가도 OS에게 background 진입을 조금만 더 달라고 요청할 수 있는 것 같더라구요. 해당 방법이 더 효율적인 방법이지 않았을 까 하는 아쉬움이 듭니다.

앱 생명주기 관찰을 Manager가 하지 않았더라면?

앱의 생명주기를 관찰하는 역할 Manager객체는 담당함으로써 Manager객체는 절대 메모리에서 해제되면 안되는 객체가 되었습니다. 이는 Manager를 싱글톤 객체로 만들게된 결정적인 이유이기도 합니다.

또한 manager 객체 내부에 URLRequest, fileURL과 같은 프로퍼티를 저장하고 관리하다보니 해당 프로퍼티의 nil값을 처리하는 게 까다롭기도 했어요.

추가로, Manager가 여러 HTTP 통신을 관리해야 한다면 문제는 더더욱 복잡해 질 것 같습니다.

현재 Manager 객체는 프로퍼티를 저장하는 기능도 하며 생명주기를 관찰하기도 하며, HTTP 통신을 백그라운드로 실행하는 로직도 담당하고 있네요. 꽤나 관심사가 많은 친구인 것 같습니다.

다른 설계 방법은 없었을까?

다른 방식으로 구현해본다면 Service 객체에서 통신시에 메모리 캐시에 특정 URLRequest 정보를 저장하고 AppDelegate에서 캐시 값 존재 여부에 따라 Background로 호출하도록 지시하는 것이 더 좋지 않았을 까 하는 생각이 드네요.

위와 같이 구현한다면 AppDelegate는 앱의 생명주기를, Service는 URLRequest를 캐시에 저장 기능을, Manager(Transformer)는 HTTP 통신을 변환해주는 역할만 담당하기에 관심사가 더 분리된 설계가 되지 않았을까 하는 생각이 드네요.


생각보다 아쉬운 부분이 많은 작업이었습니다. 하지만 개발자에게 중요한 것은 소프트웨어 설계 원칙을 모두 지킨 코드를 설계했는가? 보다는 그 코드로 어떤 비즈니스적 가치를 창출했는가? 라고 생각합니다. 물론 동시에 코드의 품질도 챙기면 더더욱 좋겠지만요.

가치 있는 코드란, 적절한 코드 품질을 유지하려고 하면서도 더 큰 가치를 만들 수 있는 좋은 요구 사항를 찾아 구현을 하고 주어진 일정을 지키고자 할때 만들어진다 -(SOPT 컨퍼런스 MIND23 테오님 강연 중)

"이미지 업로드 시 UploadingToast도 background통신도 가능하게 하여 유저의 이탈율을 방지하자" 라는 목표를 달성했으니 기쁜 마음으로 이번 포스트를 마무리 짓고자 합니다 😄

다음 포스트에선 위 코드 설계시 ISP와 DIP를 어떻게 적용시켰는지 소개드리고자 합니다! 그럼 안녕~

전체 코드

// ImageUploadManager

enum UploadStatus {
    case none
    case uploading // iOS -> Fap Server
    case modeling  // FapServer -> Ai Server
}

protocol ImageManagerSettable {
    func setRequest(_ request: URLRequest, with body: Data)
}

protocol ImageManagerResponseSendable {
    func responseSuccess()
    func responseFail(willContinue: Bool)
}

protocol ImageUploadProgressDelegate: AnyObject {
    func progressTaskDidCreate()
    func progressTaskIsProcessing(with ratio: Float)
    func progressTaskCompleted()
}

protocol ImageUploadResponseDelegate: AnyObject {
    func responseSuccess()
    func responseFail(willContinue: Bool)
}

protocol ImageUploadExceptionDelegate: AnyObject {
    func didEnterBackground(_ status: UploadStatus)
    func willTerminate(_ status: UploadStatus)
}

final class ImageUploadManager: NSObject {
    
    typealias ImageUploadManagerDelegate = ImageUploadProgressDelegate & ImageUploadResponseDelegate & ImageUploadExceptionDelegate
    
    //MARK: - Properties
    
    static let shared = ImageUploadManager()
    private let disposeBag = DisposeBag()
    
    weak var delegate: ImageUploadManagerDelegate?
    
    private var status: UploadStatus = .none
    private var request: URLRequest?
    private var fileURL: URL?
    private var progressTask: URLSessionTask?
    private var backgroundTask: URLSessionTask?
    
    //MARK: - Life Cycle
    
    private override init() {
        super.init()
        
        UIApplication.rx.didFinishLaunching
            .do(onNext: { print("😍didFinishLaunching") })
            .bind(onNext: reset)
            .disposed(by: disposeBag)
        
        UIApplication.rx.didEnterBackground
            .do(onNext: { print("😍didEnterBackground") })
            .map(getStatus)
            .do(onNext: delegate?.didEnterBackground)
            .filter { $0 == .uploading}
            .map { _ in self.progressTask }
            .filter { $0 != nil }
            .map { $0! }
            .map(cancelProgressTask)
            .filter { self.request != nil }
            .filter { self.fileURL != nil }
            .map { (self.request!, self.fileURL!) }
            .map(resumeWithBackground)
            .map { PushNotificationCase.resumeWithBackground.model }
            .bind(onNext: PushNotification.send)
            .disposed(by: disposeBag)
        
        UIApplication.rx.willTerminate
            .do(onNext: { print("😍willTerminate") })
            .map(getStatus)
            .do(onNext: delegate?.willTerminate)
            .filter{ $0 == .uploading}
            .map { _ in PushNotificationCase.warnningWhenTerminate.model }
            .bind(onNext: PushNotification.send)
            .disposed(by: disposeBag)
    }
    
    func getStatus() -> UploadStatus {
        return status
    }
    
    func reset() {
        request = nil
        fileURL = nil
        progressTask = nil
        status = .none
        backgroundTask?.cancel()
    }
}

extension ImageUploadManager: ImageManagerSettable {

    func setRequest(_ request: URLRequest, with body: Data) {
        reset()
        let fileURL = writeFile(with: body)
        self.request = request
        self.fileURL = fileURL
    }
}

extension ImageUploadManager: ImageManagerResponseSendable {
    func responseSuccess() {
        print("😎responseSuccess😎")
        delegate?.responseSuccess()
        reset()
    }
    
    func responseFail(willContinue: Bool) {
        print("😎responseFail😎")
        delegate?.responseFail(willContinue: willContinue)
        if !willContinue {
            reset()
        }
    }
}

extension ImageUploadManager: URLSessionTaskDelegate {
    
    func urlSession(_ session: URLSession,
                    task: URLSessionTask,
                    didSendBodyData bytesSent: Int64,
                    totalBytesSent: Int64,
                    totalBytesExpectedToSend: Int64) {
        
        if self.progressTask == nil {
            self.progressTask = task
            delegate?.progressTaskDidCreate()
            status = .uploading
        }
        
        let progress = Float(totalBytesSent) / Float(totalBytesExpectedToSend)
        
        if progress < 1 {
            delegate?.progressTaskIsProcessing(with: progress)
        } else {
            delegate?.progressTaskCompleted()
            status = .modeling
        }
    }
}

//MARK: - Private Method

extension ImageUploadManager {
    
    private func writeFile(with body: Data) -> URL {
        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()
        
        return fileURL
    }
    
    private func cancelProgressTask(_ task: URLSessionTask) {
        print("🥰진행중인 task를 중지합니다.")
        task.cancel()
        self.progressTask = nil
    }
    
    private func resumeWithBackground(with request: URLRequest, fromFile fileURL: URL) {
        print("🥰백그라운드 task를 시작합니다.")
        let config = URLSessionConfiguration.background(withIdentifier: "com.melsplit.ZOOC.backgroundSession")
        let session = URLSession(configuration: config)
        
        backgroundTask = session.uploadTask(with: request, fromFile: fileURL)
        backgroundTask?.resume()
        
        self.request = nil
        self.fileURL = nil
    }
}

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

profile
iOS Developer

0개의 댓글