본문 바로가기

Swift/개인프로젝트

[Swift] 개인프로젝트 회고 - 서버(Firebase) 를 아껴쓰기위한 노력

문제점: 처음 앱을 실행할때 피드를 불러오는중 얼마 지나지 않아 Firebase의 Storage가 동이나버림

하루만에 벌어진 어마어마한 요청들과 기본할당된 대역폭 1GB를 넘기려하는 앱의 위엄 ^^

해결을 위하여 문제점을 고안해 보았다.

첫번째, 각 피드에 보여지는 이미지는 썸네일이미지 하나이지만 서버에서 불러오는 이미지는 썸네일 이미지 를 포함한 피드에 저장된 모든 이미지들을 모두 긁어오고있다.

두번째, 사진속 피드의 이미지들은 현재 인기탭, 최신탭을 왔다갔다 할 때마다 중복된 피드들의 이미지를 서버에서 중복으로 불러오고 있다.

세번째, 당연히도 아직 이미지를 캐싱하지 않고있다.

내가 생각했던 문제점들은 이정도가 있었다. 첫번째는 현재의 로직을 리팩터링해야하고, 세번째는 새로운 기능을 추가해야되므로 첫번째 문제부터 해결하는 것이 더 효율적일것 같았다. 두번째 문제는 이미지 캐싱을 통하여 해결할 수 있을 것 같다.

첫번째 문제 해결하기 - 로직 수정

해당 앱은 CleanArchitecture를 기반으로한 MVVM으로 구성했고 기본적인 로직은 이렇다.

  1. Presentation Layer에서 피드를 요청한다
  2. FetchFeedUseCase에서 이미지를 제외한 나머지 피드의 정보들을 Firestore에 요청한다. (이미지는 Firebase Storage에서 불러온다)
  3. FirestoreRepository에서 피드데이터를 반환해준다.
  4. FetchFeedUseCase는 반환받은 피드데이터들중 유저ID, 피드ID 를 통하여 Firebase Storage에 피드에 저장된 모든이미지URL 을 요청한다. ← 여기서 썸네일 이미지만 받아오면 될 것 같다.
  5. FirebaseStorageRepository에서 반환받은 이미지URL들을 피드데이터와 함께 모델화하여 ViewModel에 반환한다.
  6. ViewModel에서 FeedCollectionView에 보여준다.
// FetchFeedUseCase
public func fetchFeeds(sortBy option: SortingOption) async -> [PetpionFeed] {
        return await withCheckedContinuation { continuation in
            Task {
                let feedDataFromFirestore: Result<[PetpionFeed], Error> = await 
                firestoreRepository.fetchFeedData(by: option)
                switch feedDataFromFirestore {
                case .success(let feedWithoutImageURL):
                    let feedWithImageURL: [PetpionFeed] = await addImageURL(feeds: feedWithoutImageURL)
                    continuation.resume(returning: sortResultFeeds(sortBy: option, with: 
                    feedWithImageURL))
                case .failure(let failure):
                    print(failure)
                }
            }
        }
    }

문제의 FetchFeedUseCase의 4번에 해당하는 로직이다.

처음 feedDataFromFirestore는 PetpionFeed라는 모델을 담고있는 배열타입이고 배열안에 데이터들은 이미지URL을 제외한 나머지 모든 데이터들을 포함하고 있다.

Firestore에서 이미지를 제외한 나머지 데이터를 불러온 뒤 addImageURL(feeds: ) 에서 각 피드의 썸네일 이미지URL만 불러오도록 수정하면 될 것 같다.

 

 

// FetchFeedUseCase
private func addImageURL(feeds: [PetpionFeed]) async -> [PetpionFeed] {
        return await withCheckedContinuation { continuation in
            Task {
                let result = await withTaskGroup(of: PetpionFeed.self) { taskGroup -> [PetpionFeed] in
                    for feed in feeds {
                        taskGroup.addTask {
                            let urlArr = await self.firebaseStorageRepository.fetchFeedImageURL(feed)
                            var withURLFeed = feed
                            withURLFeed.imageURLArr = urlArr
                            return withURLFeed
                        }
                    }
                    var resultFeedArr = [PetpionFeed]()
                    for await value in taskGroup {
                        resultFeedArr.append(value)
                    }
                    return resultFeedArr
                }
                continuation.resume(returning: result)
            }
        }
    }

addImageURL메서드에서 전달받은 피드데이터 배열을 통하여 병렬적으로 firebaseStoraggeRepository에서 ImageURL을 불러온뒤, 전달받았던 피드데이터배열과 매핑하여 리턴해준다.

따라서 let urlArr = await self.firebaseStorageRepository.fetchFeedImageURL(feed) 로 선언된 부분에서 urlArr이 아닌 thumbnailURL만 받도록 바꾸면 될 것 같다!

 

 

// FirebaseStorageRepository
public func fetchFeedImageURL(_ feed: PetpionFeed) async -> [URL] {
        return await withCheckedContinuation{ continuation in
            Task {
                let feedImageRef: String = PetpionFeed.getImageReference(feed)
                
                var imageReferences: [String] = []
                for i in 0 ..< feed.imagesCount {
                    imageReferences.append(feedImageRef + "/\\(i)")
                }
                
                let imageURLs = await fetchSeveralImageURLs(from: imageReferences)
                var urlArr: [URL] = []
                for value in imageURLs {
                    switch value {
                    case .success(let url):
                        urlArr.append(url)
                    case .failure(let failure):
                        print(failure.localizedDescription)
                    }
                }
                let sortedURLArr = urlArr
                    .map{ $0.description }
                    .sorted(by: <)
                    .map{ URL(string: $0)! }
                
                continuation.resume(returning: sortedURLArr)
            }
        }
        
    }

FirebaseStorageRepository의 fetchFeedImageURL(_ feed: ) 로직인데 윗부분의 for i in 0 ..< feed.imagesCount { … } 로직은 FirebaseStorage에 저장된 주소 reference를 준비하기 위한 부분이다.

만약 4장의 이미지가 저장된다면 reference는 기본적으로 [”UserID/FeedID/0”, “UserID/FeedID/1”, “UserID/FeedID/2”, “UserID/FeedID/3”] 이런식으로 준비되고 이를 기반으로 서버에서 실제 이미지 URL을 가져오는 것이다.

즉 썸네일 url만 포함시키기 위해선 단순히 UserID/FeedID/0 의 이미지URL만 불러오게 하면 될 것 같다. 따라서 추가로 메서드를 만들어 주도록 했다.

 

 

// FirebaseStorageRepository
public func fetchFeedThumbnailImageURL(_ feed: PetpionFeed) async -> [URL] {
        return await withCheckedContinuation{ continuation in
            Task {
                let thumbnailFeedImageRef: String = "\\(PetpionFeed.getImageReference(feed))/0"
                let thumbnailFeedImageURL = await fetchSingleImageURL(from: thumbnailFeedImageRef)

                switch thumbnailFeedImageURL {
                case .success(let url):
                    continuation.resume(returning: [url])
                case .failure(let failure):
                    print(failure.localizedDescription)
                }
            }
        }
    }

끝났다! 간단히 0번째 이미지 URL만 리턴하게 해주도록 만들어줬다. 그리고 예전의 fetchFeedImageURL 메서드는 피드를 눌렀을 때 디테일 피드창에서 모든 이미지를 보여 줄 때 사용하도록 구상하였다. 추가로, 썸네일이미지를 제외한 나머지 이미지들을 불러오도록 수정하여 주었다.

 

 

// FirebaseStorageRepository
public func fetchFeedTotalImageURL(_ feed: PetpionFeed) async -> [URL] {
        return await withCheckedContinuation{ continuation in
            Task {
                let feedImageRef: String = PetpionFeed.getImageReference(feed)
                
                var imageReferences: [String] = []
                for i in 1 ..< feed.imagesCount {
                    imageReferences.append(feedImageRef + "/\\(i)")
                }
                
                let totalImageURLs = await fetchSeveralImageURLs(from: imageReferences)
                var urlArr: [URL] = []
                for value in totalImageURLs {
                    switch value {
                    case .success(let url):
                        urlArr.append(url)
                    case .failure(let failure):
                        print(failure.localizedDescription)
                    }
                }
                let sortedURLArr = urlArr
                    .map{ $0.description }
                    .sorted(by: <)
                    .map{ URL(string: $0)! }
                
                continuation.resume(returning: sortedURLArr)
            }
        }
    }

나중에 사용될 모든 이미지 불러오기 메서드

 

 

// FetchFeedUseCase
private func addThumbnailImageURL(feeds: [PetpionFeed]) async -> [PetpionFeed] {
        return await withCheckedContinuation { continuation in
            Task {
                let result = await withTaskGroup(of: PetpionFeed.self) { taskGroup -> [PetpionFeed] in
                    for feed in feeds {
                        taskGroup.addTask {
                            let urlArr = await self.firebaseStorageRepository.fetchFeedThumbnailImageURL(feed)
                            var withURLFeed = feed
                            withURLFeed.imageURLArr = urlArr
                            return withURLFeed
                        }
                    }
                    var resultFeedArr = [PetpionFeed]()
                    for await value in taskGroup {
                        resultFeedArr.append(value)
                    }
                    return resultFeedArr
                }
                continuation.resume(returning: result)
            }
        }
    }

이후에 간단하게 처음 문제의 4번메서드에서 addImage(feeds: ) 메서드를 위와 같이 바꿔주었다.

해결!

 

두번째 문제 해결하기 - 이미지 캐싱

먼저 Cache에 대해서 정확히 알아보았다.

Cache는 기본적으로 두가지 종류가 있다.

  • Memory Cache (NSCache)
    • iOS에서 자체적으로 제공해주는 캐시, 휘발성, 처리속도 빠르고 저장공간 적다
  • Disk Cache (FileManager)
    • FileManager를 통해 파일 경로에 저장한다, 비휘발성 - App을 껐다켜도 데이터가 사라지지 않고 App삭제시에도 저장된 데이터를 유지시킬 수도 있다.
    • 추가로 Core Data로도 같은 비슷하게 사용할 수 있는데 왜 Disk Cache를 FileManager로 하는지에 대해 알아봤는데 FileManager는 기본적으로 파일을 단일 엔티티로 조작하는 데 사용되고, Core Data는 보다 관계가 구조화 된 엔티티에 사용이 적합하므로 단일 이미지등과 같은 엔티티를 저장하기에 FileManager가 적합하다고 한다.

캐싱에대하여 기본적인 지식을 알아보았고 기본적인 이미지 캐싱과정은 이렇다.

  1. 메모리 캐시에서 저장된 이미지를 검색
  2. 없을 경우, 디스크 캐시에서 이미지를 검색
  3. 없을 경우, URL에서 비동기 fetch
  4. 메모리 캐시와 디스크 캐시에 해당 이미지를 저장
  5. 다음 번 요청시에는 메모리 캐시에서 이미지 fetch
  6. 프로세스 재시작 이후 요청에는 디스크캐시에서 fetch후 메모리 캐시에 이미지 추가

내 프로젝트에 적용을 한다고 했을 때 디스크 캐싱을 적용시키기는 어려울것같다는 생각이 들었다.

왜냐하면 위 이미지에서 보았듯 한페이지에는 최소 6개 가량의 이미지를 불러오고 스크롤할 때마다 서버에서 추가로 이미지를 계속 불러오고 있는데 이를 디스크 캐시에 저장을 하게되면 저장할 데이터가 매우 많아질 것이다.

또한 최신순으로 보여주는 기능이 있는데 이 경우에는 디스크 캐시를 거치는 것이 의미가 없어질것같다. (앱이 켜져있는 상태에서 위아래 스크롤시에는 메모리 캐시를 통하여 이미지를 가져오겠지만, 1시간 뒤에 이미지를 최신순으로 로드한다면 디스크캐시에는 1시간 사이의 피드들은 저장되어 있지 않을 것이기 때문에 거의 사용되기 어렵다고 판단)

 

그리고 구현해 보았다.

public final class ImageCache {
    
    public static let shared: ImageCache = .init()
    private let cachedImages: NSCache<NSURL, UIImage> = .init()
    
    // MARK: - Public Method
    public func loadImage(url: NSURL) async -> UIImage {
        return await withCheckedContinuation { continuation in
            Task {
								// 1
                if let cachedImage = image(url: url) {
                    print("cached")
                    return continuation.resume(with: .success(cachedImage))
                }
                do {
								// 2
                    let fetchedImage = try await fetchImage(url: url)
										saveImageCache(image: fetchedImage, key: url)
                    print("fetched")
                    return continuation.resume(with: .success(fetchedImage))
                } catch ImageDownloadError.invalidServerResponse {
                    print("ImageDownloadError - invalidServerResponse")
                } catch ImageDownloadError.unsupportedImage {
                    print("ImageDownloadError - unsupportedImage")
                }
            }
        }
    }

    // MARK: - Private Method
    private func image(url: NSURL) -> UIImage? {
        cachedImages.object(forKey: url)
    }

    private func fetchImage(url: NSURL) async throws -> UIImage {
        
        let (data, response) = try await URLSession.shared.data(from: url as URL)
        guard let httpResponse = response as? HTTPURLResponse,
              httpResponse.statusCode == 200 else {
            throw ImageDownloadError.invalidServerResponse
        }
        
        guard let image = UIImage(data: data) else {
            throw ImageDownloadError.unsupportedImage
        }
        
        return image
    }

		private func saveImageCache(image: UIImage, key: NSURL) {
        guard let imageDataCount = (image.jpegData(compressionQuality: 1.0)?.count) else { return }
        self.cachedImages.setObject(image, forKey: key, cost: imageDataCount)
    }
    
}

public enum ImageDownloadError: String, Error {
    case invalidServerResponse = "invalidServerResponse"
    case unsupportedImage = "unsupportedImage"
}

코드를 간단히 설명하자면 loadImage(url: NSURL) 메서드를 통하여 각 셀에서 필요한 이미지를 불러온다.

메서드내에 주석 1번 섹션은 처음 캐싱 정책에서 1번을 의미한다. cachedImages 안에 이미 캐싱이 된 이미지가 있으면 바로 리턴을 하고 아닐시에 넘어간다. 2번 섹션은 캐싱정책에서 3번을 의미한다. 이미지를 URLSession을 통하여 불러온 뒤, 불러온 이미지를 url을 키값으로 cachedImages 배열 안에 저장한다.

print는 실행 여부를 확인하기 위해 임시로 추가했다.

 

 

// PetCollectionViewCell 
private func configureThumbnailImageView(_ url: URL) {
        Task {
            let thumbnailImage = await ImageCache.shared.loadImage(url: url as NSURL)
            await MainActor.run {
                thumbnailImageView.image = thumbnailImage
            }
        }
    }

셀에서 이미지를 set하는 부분

 

 

처음 실행시에 모든셀의 이미지들은 fetch된다
이후로는 cached된 이미지들로 사용한다

 

이렇게 이미지를 불러오는 과정에서의 과도한 서버에 대한 부담을 최소화 해보았다. 서버에 이미지를 요청하는 부분은 금전적인 부분과 직결되기 때문에 필요한 최소한의 요청을 하도록 해줘야 할 것 같다. 여담으로 두번째 프로젝트이기도 한 현재 프로젝트에서 비동기작업을 Async/await 을 통하여 구현하고 있는데 에러 핸들링부분을 포함해서 메서드가 훨씬 가독성이 좋아져서 매우 만족스럽다.

 

정확히 얼마나 서버에서 이미지를 불러오는데 소요되는 리소스가 줄었는지 체크는 하지 못했지만 체감상, 내 두뇌로 계산해봤을때 5배는 줄지 않았을까? 라고 생각한다. 하지만 서버에 저장된 이미지 자체의 용량이 작지 않기 때문에 아직 서버 부담에 대한 고민은 계속 가져가야 될 것 같다. 

 

끝까지 읽어주셨다면 정말 감사드리고, 제 글에서 잘못된 부분이나 또 다른, 혹은 더 좋은 서버를 아끼기 위한 방법이 있다면 추가로 댓글로 알려주시면 정말 감사하겠습니다 🙇🏻‍♂️