안녕하세요, iOS 개발하는 루피입니다.
오늘은 최근 SwiftUI를 기반으로 컴퓨터종합설계 프로젝트를 진행 중인데, 느린 사진 업로드 문제를 해결했던 트러블 슈팅 경험을 정리해보려 합니다.
바로 시작합니다.
1. 문제 발생 배경
최근 SwiftUI 기반 사진 공유 앱의 사진 업로드 기능에 성능 문제가 발생했습니다. 사용자가 사진첩에서 사진을 선택해 S3 버킷에 업로드하는 기능이었는데, 10장의 사진을 업로드하는 데 12초 이상 걸렸습니다. 이렇게 될 경우 사용성이 많이 떨어질 것으로 판단하여, 기능 개선을 위해 노력했습니다.
느린 업로드 문제를 진단하고, 최적화 과정을 통해 업로드 시간을 단축한 트러블슈팅 과정을 공유합니다.
2. 문제 상황 : 느린 사진 업로드
앱의 PhotoListViewModel은 사용자의 사진첩(PHAsset)에서 사진을 가져와 UIImage로 변환한 뒤, presigned URL을 통해 S3에 업로드하는 역할을 맡았습니다. 다음은 원래 코드의 모습입니다.
func uploadSelectedPhotos(onComplete: ((UploadCompleteResponse) -> Void)? = nil) async {
guard hasSelectedPhotos, !isUploading else { return }
isUploading = true
let selectedAssetModels = photoAssets.filter { $0.isSelected }
let selectedAssets = selectedAssetModels.map { $0.asset }
var images: [UIImage] = []
var imageInfos: [[String: String]] = []
// 순차적 이미지 변환
for asset in selectedAssets {
if let image = await requestUIImage(from: asset) {
images.append(image)
let id = asset.localIdentifier.components(separatedBy: "/").first ?? "img"
let timestamp = Int(Date().timeIntervalSince1970)
let fileExtension = getImageFileExtension(from: asset)
let fileName = "\\(id)_\\(timestamp).\\(fileExtension)"
let contentType = fileExtension == "png" ? "image/png" : "image/jpeg"
imageInfos.append([
"fileName": fileName,
"contentType": contentType
])
}
}
// Presigned URL 요청 및 S3 업로드 (병렬)
let presignedURLs = try await uploadService.requestPresignedURLs(imageInfos: imageInfos)
try await withThrowingTaskGroup(of: Void.self) { group in
for (index, presignedURL) in presignedURLs.enumerated() where index < images.count {
group.addTask {
try await self.uploadService.uploadImageToS3(image: images[index], presignedURL: presignedURL)
}
}
try await group.waitForAll()
}
// ... (업로드 완료 알림, 상태 업데이트)
}
private func requestUIImage(from asset: PHAsset) async -> UIImage? {
await withCheckedContinuation { continuation in
let options = PHImageRequestOptions()
options.isSynchronous = false
options.deliveryMode = .highQualityFormat
options.isNetworkAccessAllowed = true
PHImageManager.default().requestImage(
for: asset,
targetSize: PHImageManagerMaximumSize,
contentMode: .aspectFit,
options: options
) { image, _ in
continuation.resume(returning: image)
}
}
}
대락적으로 10장의 사진을 업로드하는데 12초 정도의 시간이 소요되었습니다.
3. 문제 진단: 가설 세우기
문제를 해결하기 위해 과거 경험을 바탕으로 세 가지 가설을 세웠습니다.
(1) 순차적인 이미지 변환
최근 사진 관련 앱 회사 면접에서 배운 점이 떠올랐습니다. 사진 처리 방식에 따라 성능이 크게 달라진다는 거였죠. 스타트업 과제에서 필터 적용 시 순차 처리가 병목이었던 경험이 있어, PHAsset → UIImage 변환을 병렬화하면 시간을 줄일 수 있을 거라 생각했습니다.
(2) 사진 파일 크기
스타트업 과제에서 사진 크기가 업로드 시간에 영향을 준다는 걸 알았습니다. 처음엔 “사진이 얼마나 크겠어?” 했지만, 고해상도 이미지가 기기 성능과 네트워크에 부담을 준다는 걸 깨달았죠. 썸네일 방식처럼 파일 크기를 줄이면 S3 업로드 속도가 빨라질 거라 판단했습니다.
(3) iCloud 지연
공유 앨범 기능을 추가하며 iCloud 지원을 넣었지만, 불필요한 네트워크 지연을 유발한다고 판단했습니다. 로컬 이미지만 처리하면 지연을 없앨 수 있을 거라 봤습니다.
병목 요인
- for 루프의 순차 변환.
- PHImageManagerMaximumSize로 가져온 원본 해상도(4000x3000, 10MB).
- isNetworkAccessAllowed = true로 인한 iCloud 지연.
4. 트러블 슈팅 과정
(1) 병렬 변환으로 이미지 변환 최적화
기존의 방식은 for 루프가 PHAsset을 순차적으로 변환하기 때문에 최소 n의 시간이 걸렸습니다.
for asset in selectedAssets {
if let image = await requestUIImage(from: asset) {
images.append(image)
// ...
}
}
이를 withThrowingTaskGroup를 사용하면서, 멀티코어 CPU를 활용해 변환을 병렬화 했습니다.
try await withThrowingTaskGroup(of: (UIImage, [String: String], String).self) { group in
for asset in selectedAssets {
group.addTask {
guard let image = await self.requestUIImage(from: asset) else {
throw NSError(domain: "PhotoListViewModel", code: -1, userInfo: [NSLocalizedDescriptionKey: "이미지 변환 실패"])
}
let id = asset.localIdentifier.components(separatedBy: "/").first ?? "img"
let timestamp = Int(Date().timeIntervalSince1970)
let fileExtension = self.getImageFileExtension(from: asset)
let fileName = "\(id)_\(timestamp).\(fileExtension)"
let contentType = fileExtension == "png" ? "image/png" : "image/jpeg"
return (image, ["fileName": fileName, "contentType": contentType], asset.localIdentifier)
}
}
for try await (image, info, assetId) in group {
images.append(image)
imageInfos.append(info)
assetIds.append(assetId)
}
}
(2) 이미지 파일 크기 축소
기존 requestUIImage는 PHImageManagerMaximumSize로 원본 해상도(예: 4000x3000, ~10MB) 이미지를 가져왔습니다.
이를 최대 리사이징 크기를 조절하고, 압축률을 0.8로 변경했습니다.
private func requestUIImage(from asset: PHAsset) async -> UIImage? {
let targetSize = CGSize(width: 1920, height: 1080) // 최대 1080p
let options = PHImageRequestOptions()
options.isSynchronous = false
options.deliveryMode = .highQualityFormat
options.resizeMode = .fast // 빠른 리사이징
options.isNetworkAccessAllowed = false // 로컬 이미지 우선
options.normalizedCropRect = CGRect(x: 0, y: 0, width: 1, height: 1)
return await withCheckedContinuation { continuation in
PHImageManager.default().requestImage(
for: asset,
targetSize: targetSize,
contentMode: .aspectFit,
options: options
) { image, _ in
if let image = image,
let data = image.jpegData(compressionQuality: 0.8),
let compressedImage = UIImage(data: data) {
continuation.resume(returning: compressedImage)
} else {
continuation.resume(returning: nil)
}
}
}
}
(3) iCloud 지연 제거
iCloud 다운로드를 비활성화해 로컬 이미지만 처리하도록 설정했습니다:
options.isNetworkAccessAllowed = false // 로컬 이미지 우선
5. 성능 개선 및 결과
기존 12초를 초과하던 업로드 시간이 4초 미만으로 줄어든 결과를 확인할 수 있었습니다.
배운 점
결국 개발자의 기본기가 가장 중요하다는 것을 다시 한번 경험할 수 있었던 기회였습니다.
현업에서 일을 하게 되면 결국 앱의 반응성 향상과 안정성을 중심으로 개발을 할 것이고, 1초라는 시간을 단축하기 위해서 코드를 작성할 것이라고 생각합니다. 그리고 이때, 1초를 단축시키기 위해서 필요한 것은 자료구조의 개념과 기본 CS개념이 될 것이라는 생각을 갖게 되었습니다.
또한, 아쉬운 점이 있다면, 조치했던 방법들을 단계별로 기록하지 못한 것입니다.
"내가 이러한 코드를 작성하니깐, 이러한 결과가 생겼다"가 아닌 "이러한 방식들을 다 적용해 보니깐 이러한 결과가 생겼다."라고 기록을 한 것에 대해 아쉬움이 들어 다음부터는 좀 더 세세하게 기록해 놓는 습관을 들이고자 합니다.
오늘도 화이팅입니다!
'트러블 슈팅' 카테고리의 다른 글
| [트러블 슈팅] ImageCache 에서 KingFisher 도입 (2) | 2025.06.12 |
|---|---|
| [트리블 슈팅] API 호출 대신 이미지 캐싱으로 성능 최적화하기 (0) | 2025.05.29 |
| [트러블 슈팅] SplashView로 UX 개선하기 (0) | 2025.05.29 |
| [트러블 슈팅] 오렌지 마켓 : Navigation Splash 화면 구현 (0) | 2025.01.09 |