
fitapat(전 ZOOC)은 나만의 반려동물 AI 굿즈를 구매할 수 있는 서비스입니다.
fitapat에서 제품을 구매하기 위해선 반려동물을 등록해야 합니다.
반려동물 등록을 위해선 유저가 이미지 8장 이상 업로드 해야하고, 업로드시 아래와 같은 서버 통신을 진행하게 됩니다.
위 처럼 업로드 통신은 두 서버를 거쳐야하는 작업이라 시간이 꽤나 소요되더라구요.
만약 AI Server로 부터 응답을 받기전에, 유저가 앱을 나간다면 서버통신이 끊기는 문제가 발생했습니다.
반려동물 등록이 원활하게 되지 않는다면 유저는 불편함을 겪을 것이고, 이는 구매율에 좋지 않은 영향을 끼칠 것이라고 판단했습니다.
해당 포스트에서는 업로드의 진행도를 유저에게 알려주어 앱을 나가지 않도록 권장한다를 다룰 예정입니다.
최근 학교 수업을 들을 때 클로바 노트를 자주 사용하는데 딱 클로바 노트와 저희 서비스가 비슷한 상황인 것 같더라구요
클로바노트는 위와 같이 로딩뷰를 구현하였습니다.
클로바노트에 영감을 받아 업로드 Task의 진행도를 나타내는 UploadingToast를 구현해보고자 합니다.
HTTP 통신의 진행도를 나타내는 로딩뷰의 구현 아이디어는 다음과 같습니다.
보낸 bytes / 총 bytes 를 통해 진행도를 백분율로 나타낸다.생각보다 단순하죠?!
앱에서 보내는 image를 어떻게 서버가 받을 수 있을까?
저희는 HTTP에서 정의해둔 규칙 중 mime Type으로 body에 이미지를 싣어서 보낸다는 규칙을 따르고 있기 때문입니다.
먼저 HTTP 통신이 무엇인지 살펴봅시다.
HTTP 구조는 크게 header와 body로 나뉘게 되는데,
header에는 host 주소, 날짜, Content-Type(body 형식)등이 담기게 되고,
body에는 실제 우리가 보낼 데이터가 들어가게 됩니다.
일반적으로 서버 통신할 때에는
header의 Content-Type을 application/json으로 설정하고,
body에는 우리가 보낼 struct 데이터를 Json으로 인코딩하여 전송합니다.
하지만 여러장의 이미지를 전송할 때에는 HTTP 통신 방식이 다릅니다.
header의 Content-Type을 multipart/form-data로 설정하여 여러 데이터를 보낼 수 있도록 설정하고,
body에는 각각의 이미지를 Data(UIImage -> JPEG형식의 바이트로 변환된) 타입으로 변환하고, Content-Type을 image/jpeg로 설정한 데이터를 담으면 됩니다.
이렇게 HTTP 통신 규약을 지킨 URL Request가 바이트로 변환되어 요청한 URL을 향해 보내지게 되는 것이죠!
이제 구현을 해봅시다!
fitapat iOS팀은 MVVM 아키텍처에 Repository패턴과 네트워크 담당 Service 객체를 사용하고 있습니다.
먼저 PetRepository에서 UIImage를 Data로 변환해주는 작업을 아래와 같이 해주었어요.
// PetRepository
final class DefaultPetRepository {
private let petService: PetService
init(petService: PetService) {
self.petService = petService
}
func postPetImages(datasetID: String,
images: [UIImage]) async throws -> SimpleResponse {
let data = images.map {
$0.jpegData(compressionQuality: 0.99) ?? Data()
}
return try await petService.postPetImages(datasetID: datasetID, files: data)
}
}
그 후 Service에서 async await을 통해 통신을 진행했습니다.
원래 Moya 라이브러리를 사용했지만, 해당 작업은 URLSession을 사용하여 구현했습니다.
// PetService
final class DefaultPetService: Networking {
private var urlSession: URLSession = URLSession(configuration: URLSessionConfiguration.default,
delegate: nil,
delegateQueue: nil)
func postPetImages(datasetID: String,
files: [Data]) 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)
return try validataDataResponse(data, response: response, to: SimpleResponse.self)
}
참고로 Networking은 서버통신을 도와주는 함수를 제공해주는 프로토콜입니다. (makeMultipartFormImageBody(), validataDataResponse() 등등..)
다시 본론으로 돌아와서, 저희는 URL Session의 upload 진행도를 파악하여 로딩뷰를 구현해야 합니다.
진행도를 파악하는 것은 생각보다 간단합니다.
URLSessionTaskDelegate를 채택하면 아래 함수를 사용할 수 있거든요!
func urlSession(_ session: URLSession,
task: URLSessionTask,
didSendBodyData bytesSent: Int64,
totalBytesSent: Int64,
totalBytesExpectedToSend: Int64)
공식 문서는 해당 함수를 아래와 같이 설명합니다.
Periodically informs the delegate of the progress of sending body content to the server.
특정기간마다 body의 content를 서버에게 얼마나 보냈는지 delegate를 통해 알려준다고 하네요. 각 인자의 정보는 아래와 같습니다.
didSendBodyData는 특정 기간에 얼마나 보냈는지를 뜻하고,
저희가 궁금한 것은 총 보낼 바이트가 몇이고, 그 중 얼마나 보냈는지가 궁금한 것이니 totalBytesSent, totalBytesExpectedToSend만 사용하면 될 것 같네요.
func urlSession(_ session: URLSession, task: URLSessionTask, didSendBodyData bytesSent: Int64, totalBytesSent: Int64, totalBytesExpectedToSend: Int64)
let progress = Float(totalBytesSent) / Float(totalBytesExpectedToSend)
print("🤩 보낸 데이터: \(bytesSent)")
print("🤩 총 보낸 데이터: \(totalBytesSent)")
print("🤩 총 보내야할 데이터: \(totalBytesExpectedToSend)")
print("🤩 진행도: \(progress)\n\n")
return
}
함수를 위와 같이 작성하고 로그를 찍어봤습니다.

실제 로그를 찍어보니 Periodically가 시간 단위는 아닌 것 같습니다.
작업 단위마다 delegate를 호출해주는 것 같아요.
왜냐면 작업이 오래걸리는 작업은 delegate호출의 주기가 길지만, 작업이 빨리 걸리는 작업은 위 사진처럼 1초 안에 5번이상 호출되니까 말이죠.
아무튼 해당 함수를 활용하면 toast에 진행도를 알려줄 수 있겠네요.
이제 TabBarController에서 해당 delegate를 채택하여 구현해봅시다.
// FapTabBarController
final class FapTabBarController: URLSessionTaskDelegate {
private var progressTask: URLSessionTask?
private let uploadingToast: UploadingToast()
// 생략 ...더보기
func urlSession(_ session: URLSession,
task: URLSessionTask,
didSendBodyData bytesSent: Int64,
totalBytesSent: Int64,
totalBytesExpectedToSend: Int64) {
if self.progressTask == nil {
self.progressTask = task
Task { @MainActor in
let window = UIApplication.shared.firstWindow
window?.bringSubviewToFront(uploadingToast)
uploadingToast.updateUI(.willStart, progress: 0)
uploadingToast.alpha = 1
}
}
let progress: Float = Float(totalBytesSent) / Float(totalBytesExpectedToSend)
if progress < 1 {
Task { @MainActor in
uploadingToast.updateUI(.uploading, progress: progress)
}
} else {
Task { @MainActor in
uploadingToast.updateUI(.modeling)
}
}
}
사실 UploadingToast는 UIWindow 위에 addSubview가 될 View 라서 해당 작업을 꼭 TabBarController에서 할 필요는 없습니다. (유저가 화면 전환을 하더라도 토스트뷰가 보이길 원해서 window위에 뷰를 띄웠습니다)
다만, TabBarController가 유저가 존재하는 가장 밑단의 UI 관련 객체이기에 TabBarController에서 구현하게 되었습니다. (물론 다다음 포스트쯤 위치가 바뀌게 됩니당 ㅎ)
코드 중간중간 Task { @MainActor ... }를 사용한 이유는 해당 delegate 메소드는 Main Thread에서 호출이 되지 않기 때문에,
UI 작업을 다루는 함수인uploadingToast.updateUI()를 Main Thread 에서 실행시키기 위해 작성하였습니다.
// UploadingToast
final class UploadingToast: UIView {
enum State: Equatable {
case willStart
case uploading
case modeling
case done
case fail
var title: String {
switch self {
case .willStart: return "이미지를 업로드 하고 있어요"
case .uploading: return "이미지를 업로드 하고 있어요"
case .modeling: return "반려동물의 AI 모델을 생성하고 있어요"
case .done: return "반려동물 등록에 성공했어요"
case .fail: return "이미지 업로드에 실패했어요"
}
}
var description: String? {
switch self {
case .willStart: return "이미지 업로드 도중 화면을 닫거나, 앱을 중단하면 업로드가 중단될 수 있어요"
case .uploading: return "이미지 업로드 도중 화면을 닫거나, 앱을 중단하면 업로드가 중단될 수 있어요"
case .modeling: return nil
case .done: return "반려동물 커스텀 굿즈를 지금 만들어보세요"
case .fail: return "인터넷 연결 상태를 확인 후 다시 시도해주세요"
}
}
}
//MARK: - UI Components
private let contentView = UIView()
private let titleLabel = UILabel()
private let descriptionLabel = UILabel()
private let vStackView = UILabel()
private let percentageLabel = UILabel()
private let progressView = UILabel()
private let xButton = UIButton()
private let checkImageView = UIImageView(image: .icMinicheck)
private let hStackView = UIStackView()
// 생략 ...더보기
private func hierarchy() {
self.addSubview(contentView)
contentView.addSubviews(vStackView, xButton)
vStackView.addArrangedSubViews(hStackView, descriptionLabel, percentageLabel, progressView)
hStackView.addArrangedSubViews(checkImageView, titleLabel)
}
private func layout() {
contentView.snp.makeConstraints {
$0.edges.equalToSuperview()
}
vStackView.snp.makeConstraints {
$0.edges.equalToSuperview().inset(28)
}
xButton.snp.makeConstraints {
$0.top.equalToSuperview().inset(10)
$0.trailing.equalToSuperview().inset(10)
$0.size.equalTo(36)
}
checkImageView.snp.makeConstraints {
$0.size.equalTo(20)
}
progressView.snp.makeConstraints {
$0.height.equalTo(5)
}
}
//MARK: - Public Method
func updateUI(_ state: UploadingToast.State, progress: Float? = nil) {
self.titleLabel.text = state.title
self.descriptionLabel.text = state.description
self.percentageLabel.textColor = state != .fail ? .zw_lightgray : .zw_red
self.progressView.progressTintColor = state != .fail ? .zw_point : .zw_red
self.percentageLabel.isHidden = state == .done
self.checkImageView.isHidden = state != .done
self.progressView.isHidden = state == .done
self.xButton.isHidden = state != .fail
guard let progress else { return }
self.percentageLabel.text = String(format: "%.0f", progress * 100) + "%"
self.progressView.setProgress(progress, animated: state == .uploading)
}
}
해당 포스트는 에러 처리와 다른 처리들도 한 후에 작성하는 지라, fail, modeling 같은 다른 state가 있지만 크게 신경쓰지 않으셔도 됩니다.
UploadingToast 는 View이기 때문에 data 저장하거나 다루는 작업을 하지 않도록 구현했습니다.
class 내부에 State라는 enum을 정의만 하였고, 뷰의 업데이트는 오로지 updateUI() 함수를 통해서만 변경되도록 구현했어요.
또한 StackView를 사용하여 state에 따라 적절한 hidden 처리를 해주어 추가적인 Layout을 잡을 필요없이 뷰가 변경되도록 구현했답니다.
이때, isHidden에 State를 비교연산한 값을 대입해주고 싶어, State에 Equatable 프로토콜을 채택하였어요.
색상에 관한 분기처리는 삼항 연산자를 활용해서 구현했습니다 :)
이제 빌드를 해볼까요?

와 맛있다! 유저가 절대 안나갈 것만 같아요!
근데 delegate를 채택만 하고 delegate 지정을 안하지 않았나?
네! 사실 해당 포스트에서 빼고 얘기한 부분있습니다.
// PetService
final class PetService: Networking {
private var urlSession: URLSession = URLSession(configuration: URLSessionConfiguration.default,
delegate: nil,
delegateQueue: nil)
func postPetImages(datasetID: String,
files: [Data]) async throws -> SimpleResponse {
// 생략 ...더보기
let (data, response) = try await urlSession.upload(for: request,
from: body,
--> 요기요기 --> 쫌더 오른쪽으로 와보세유 --> delegate: <TabBarController를 주입시켜주어야함>)
return try validataDataResponse(data, response: response, to: SimpleResponse.self)
}
URLSessionTaskDelegate는 당연히 session 사용 객체(PetService)에서 delegate를 TabBarController를 지정해주어야 발동할텐데요.
해당 부분은 객체 간의 프로토콜 주입을 통해 해결하였습니다.
이 내용은 다다다음 포스트쯤에서 다루도록 할게요.
물론 urlSession(..., totalBytesSent: Int64, totalBytesExpectedToSend: Int64) 함수는 전송에 대한 진행도이지 요청부터 응답까지의 전 작업에 대한 진행도가 아닙니다.
즉, progress 100%가, 반려동물 등록이 되었다는 것을 보장하는 것이 아니라는 것 입니다. 서버에게 response가 와야지만 반려동물 등록이 완료되었다고 띄워야 할 것입니다.
그렇다면 response를 처리하는 ViewModel 측에서도 UploadingToast에게 알려주어야 할 것 입니다. ViewModel도 UploadingToast를 의존해야겠네요. 이 역시 다다음 포스트에서 다시 다루겠습니다.
유저의 구매율을 높히기 위해? 구매율을 낮추는 것을 방지하기 위해가 더 맞겠네요, 유저에게 진행과정 UploadingToast를 보여주여 해당 문제를 개선해보았습니다. 사용자 경험을 개선하기 위해 기술로서 해결해보니 꽤나 보람차더라구요!
또한 여러 객체를 넘나드는 이벤트들을 전달해야 하다보니 객체간의 분리가 얼마나 중요한지도 알게 되었습니다.
다음 포스트에서는 이미지 업로드를 백그라운드 환경에서도 돌아가도록 구현하는 법을 다루겠습니다.
그럼 안녕~
나만의 커스텀 반려동물 굿즈를 구매하고 싶으시다면,
지금 바로 AppStore에서 만나보세요!