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

(좌측은 UploadingToast, 우측은 background 세션 구현 영상)
다만, .background는 URLSessionTaskDelegate를 지원하지 않아 두 기능을 모두 챙길 수 없는 상황이 발생했어요.
.default를 사용하면
.background를 사용하면
fitapat iOS팀은 베이스오일과 텅스텐을 고민하는 이성민 선배처럼 깊은 고민에 빠졌습니다.

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

아니!!!! 더 강해야 유저를 끝까지 지킬 수 있어!
background 통신 그 강력함이 기술이야!
엔진오일의 마모를 줄이기 위해 둘 중 하나도 포기할 수 없는 이성민 선배님을 보니 저희의 상황과 정말 유사하다고 느껴지네요.

둘 다... 가질 순 없는거야..?
이성민 선배님이 무언가 깨달은 것 처럼, 저희도 둘 다 가질 수 있는 아이디어가 필요했습니다.
.default세션으로 통신하여 UploadingToast를 보여준다..background 세션으로 전환한다.위와 같이 앱의 생명주기에 따라 HTTP 통신 환경을 전환한다면 둘 다 챙길 수 있을 것 같습니다. 해당 아이디어를 구현하기 위해선 아래와 같은 로직을 수행해야 했어요.
조금 더 디테일 한 부분까지 말해보자면

이번 포스트에선 위 아이디어를 수행해줄 ImageUploadManager라는 객체를 구현해보도록 하겠습니다.
앱이 현재 어떤 상태인지 알 수 있는 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를 설계해볼까요?

앞서 말했던 구현 아이디어를 도식화 해보면 위와 같습니다.
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 하기
}
}
// ImageUploadManager
func setRequest(_ request: URLRequest, with body: Data) {
let fileURL = writeFile(with: body)
self.request = request
self.fileURL = fileURL
}
writeFile은 FileManager를 통해 cache 디렉토리에 body 데이터를 저장해주는 함수입니다. (이전 포스트 참고)
// 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을 통해 최초 호출인지 아닌지 판단합니다.// ImageUploadManager
private func cancelProgressTask(_ task: URLSessionTask) {
print("🥰진행중인 task를 중지합니다.")
task.cancel()
progressTask = nil
}
// 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
}
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)
}
}
.filter{ $0 != nil}.map { $0!} 와 같이 구현했는데 더 좋은 방법이 있을지 고민이 되네욥.왜 싱글톤 패턴을 적용하였는가?
싱글톤 패턴은 세가지 정도의 특징이 있다고 생각합니다.
static이기에 메모리에 올라오면, 절대 메모리에서 해제되지 않는 것저는 이 중 첫번째와 두번째 특징이 Manager에게 적용하기 적합하다고 생각했습니다.
Manager는 fileURL과 Request를 저장하기에 새로운 인스턴스가 생성되면 안된다고 판단하였고, 앱의 생명주기를 관찰해야하기 때문에 항상 메모리에 올라와있어야 합니다. 절대 해제되어서는 안되죠.
단, 저는 세번째 특징인 전역적으로 접근 가능하다는 것은 꺼려하는 편인데요. 그 이유는 많은 객체가 Manager객체에 접근한다면, Manager 코드의 변화가 다른 객체들에게도 영향을 미칠 수 있기 때문입니다. 해당 문제는 다음 포스트에서 다루도록 하겠습니다 :)
왜 Delegate 패턴(
ImageUploadManagerDelegate)을 적용하였는가?
Manager 객체는 HTTP의 진행도를 Toast에게 알려주어야 합니다.
Manager가 Toast를 의존하지 않고 Toast에게 알려주기 위해 Delegate 패턴을 사용했습니다.
다만, Manager 객체는 싱글톤이기에 여러 객체들이 delegate를 채택할 순 없다는 것 한계점이 있는데요. 추후 Manager가 여러 통신을 담당해야 하거나, 혹은 Manager가 trigger하는 이벤트 함수를 여러객체에서 전달받아야 할때에는 다른 방식으로 구현해야할 것 같습니다.
[ImageUploadManagerDelegate] 배열로 선언하여 여러 객체들이 append 하는 방식, 혹은 Observer 방식들을 사용할 수 있을 것 같네요.
왜 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에서 만나보세요!