Starbucks Caramel Frappuccino
본문 바로가기
  • 그래 그렇게 조금씩
SwiftUI/SwiftUI(Intermediate)

23. 데이터 다운로드, 파일 매니저, 캐싱 종합

by Toughie 2023. 6. 16.

⭐️데이터 다운로드, 파일 매니저, 캐싱 종합⭐️

지금까지 배운 내용들을 종합해서 연습해 보는 예시를 살펴보자.

인터넷을 통해 json 데이터를 받아서 원하는 모델로 디코딩한 후에

해당 데이터들을 캐시에 저장해 관리하거나 파일 매니저를 통해 로컬 디바이스 스토리지에 저장하는 것이다.


어떤 데이터를 받아올 것인가?

https://jsonplaceholder.typicode.com/photos

 

이런 json 데이터를 받아올 것이다. (총 5000장의 사진)


어떤 형태로 보여줄 것인가?

이런 리스트의 형태로 보여줄 것이다.


Model

json형태에 맞게 모델을 만들어 준다.

import Foundation

struct PhotoModel: Identifiable, Codable {
    let albumId: Int
    let id: Int
    let title: String
    let url: String
    let thumbnailUrl: String
}

json데이터에 id가 있어서 Identifiable 프로토콜을 채택해 바로 활용하기 좋다.

또한 이 모델에 맞게 디코딩할 것이기에 Codable 프로토콜도 채택해 준다.


View

부모(상위)뷰의 네비게이션뷰, 그리고 안에서 List를 통해 ImageRowView를 생성하는 구조이다.

또한 ImageRowView의 이미지 자체도 뷰이기에 ImageView로 따로 분리해서 구현한다.

 

먼저 상위뷰를 살펴보자.

import SwiftUI

struct DownloadImagesPrac: View {
    //이 뷰를 위한 뷰모델
    @StateObject var vm = DownloadingImagesViewModel()

    var body: some View {
        NavigationView {
            
            List {
                ForEach(vm.dataArray) { model in
                    DownloadingImagesRow(model: model)
                }
            }
            .listStyle(.plain)
            .navigationTitle("Download Images")
        }
    }
}

뷰모델에서 데이터를 받아와서 ForEach를 통해 DownloadingImagesRow뷰를 그리고 있다.

그럼 데이터를 받아오기 위한 뷰모델을 살펴보자.

import Foundation
import Combine

class DownloadingImagesViewModel: ObservableObject {
    
    @Published var dataArray: [PhotoModel] = []
    
    var cancellables = Set<AnyCancellable>()

    let dataService = PhotoModelDataService.shared
    
    init() {
            addSubscribers()
    }
    
    func addSubscribers() {
        dataService.$photoModels
            .sink { [weak self] (returnedPhotoModels) in
                self?.dataArray = returnedPhotoModels
            }
            .store(in: &cancellables)
    }
}

이전에 만들어 놓은 PhotoModel 배열을 @Published로 선언해 두었다. 

또한 초기화 될 때 addSubscribers 메서드를 호출하는데,

해당 메서드를 살펴보면 dataService라는 싱글톤 클래스의 photoModels를 구독하는 것이다.

dataService의 photoModels를 구독해서 해당 배열을 dataArray에 할당하는 것이다.

 

그럼 dataService를 살펴보자.

import Foundation
import Combine

class PhotoModelDataService {
    
    static let shared = PhotoModelDataService() // Singleton
    //데이터를 받아와서 해당 배열에 저장
    @Published var photoModels: [PhotoModel] = []
    
    var cancellables = Set<AnyCancellable>()
    //초기화 시 데이터 다운로드 메서드 호출
    private init() {
        downloadData()
    }
    
    func downloadData() {
    //데이터 받아올 url
        guard let url = URL(string: "https://jsonplaceholder.typicode.com/photos") else { return }
        
        URLSession.shared.dataTaskPublisher(for: url)
            .subscribe(on: DispatchQueue.global(qos: .background))
            .receive(on: DispatchQueue.main)
            //매핑(변환) 과정에서 예외를 throw할 수 있음.
            //예외 처리가 필요한 경우 .map 대신 .tryMap 사용
            .tryMap(handleOutput)
            //[PhotoModel] 형태로 디코딩
            .decode(type: [PhotoModel].self, decoder: JSONDecoder())
            
            .sink { (completion) in
                switch completion {
                case .finished:
                    break
                case .failure(let error):
                    print("Error downloading data. \(error)")
                }
            } receiveValue: { [weak self] (returnedPhotoModels) in
                self?.photoModels = returnedPhotoModels
            }
            .store(in: &cancellables)
    }
    
    private func handleOutput(output: URLSession.DataTaskPublisher.Output) throws -> Data {
        guard
            let response = output.response as? HTTPURLResponse,
            response.statusCode >= 200 && response.statusCode < 300 else {
            throw URLError(.badServerResponse)
        }
        return output.data
    }
}

Combine을 활용해서 비동기적으로 url에서 데이터를 받아 디코딩하고 @Publihsed 배열에 할당해서 관리하는 클래스이다.

싱글톤 패턴으로 구현되어 있다.

 

여기까지 정리

1. 뷰를 그리기 위해서는 우리가 만들어 놓은 PhotoModel 형태의 데이터가 필요함.

2. PhotoModelDataService라는 싱글톤 클래스를 만들어 json파싱을 수행.(@Published 배열에 저장)

3. DownloadImagesViewModel, 즉 상위뷰의 뷰모델은 PhotoModelDataService의 @Published 배열을 구독함.

4. 이제 뷰에서 뷰모델의 데이터 배열에 접근할 수 있음.(@StateObject)

 

            List {
                ForEach(vm.dataArray) { model in
                    DownloadingImagesRow(model: model)
                }
            }

이제 뷰모델의 배열에서 model 데이터를 받아와서 Row를 그려보자.

 

import SwiftUI

struct DownloadingImagesRow: View {

    let model: PhotoModel
    
    var body: some View {
        HStack {
            //Reusable
            DownloadingImageView(url: model.url, key: "\(model.id)")
                .frame(width: 75, height: 75)
            VStack(alignment: .leading) {
                Text(model.title)
                    .font(.headline)
                Text(model.url)
                    .foregroundColor(.gray)
                    .italic()
            }
            .frame(maxWidth: .infinity, alignment: .leading)
        }
    }
}

model을 파라미터로 받아서, 필요한 정보들을 활용하고 있다.

이미지 부분은 따로 분리해서 구현되어 있다.(역시 뷰이기 때문)

import SwiftUI

struct DownloadingImageView: View {
    @StateObject var loader: ImageLoadingViewModel
    
    init(url: String, key: String) {
        _loader = StateObject(wrappedValue: ImageLoadingViewModel(url: url, key: key))
    }
    
    var body: some View {
        ZStack {
            if loader.isLoading {
                ProgressView()
            } else if let image = loader.image {
                Image(uiImage: image)
                    .resizable()
                    .clipShape(Circle())
            }
        }
    }
}

이미지뷰에도 이미지뷰를 위한 뷰모델이 존재한다.

이미지를 로드해서 그릴 때 url과 key값이 필요하다.

따라서 뷰 초기화시에 생성자에서 url과 key를 받아서 loader도 초기화 해준다.

로딩중이면 ProgressView를 띄워주고, 이미지가 정상적으로 다운돼서 loader에 있다면 이미지를 보여주는 것이다.

그럼 이미지를 다운받는 loader를 살펴보자.

import SwiftUI
import Combine

class ImageLoadingViewModel: ObservableObject {
    //이미지를 받아와서 할당
    @Published var image: UIImage?
    //ProgressView를 위한 bool
    @Published var isLoading: Bool = false
    
    /*
    이미지를 다운받아서 스크롤을 하는 상황을 생각해 보자.
    만약 캐싱, 혹은 파일매니저를 통한 저장을 하지 않으면
    스크롤을 할 때마다 이미지를 다운받을 것이다. 이는 비효율적이다.
    이미 받은 이미지를 캐싱처리 하면 로딩 속도도 더 빨라져서
    쾌적한 사용자 경험을 제공할 수 있고 앱 퍼포먼스도 좋아진다.
 	하지만 캐시는 메모리에서 저장되기 때문에 너무 많은 양을 저장해서는 안되고,
    앱을 끄면 사라지는 문제가 있다.
    즉 다시 이미지들을 다운받을 것이라는것.
    */
    let cacheManager = PhotoModelCacheManager.shared
    
    /*
    만약 다운받은 이미지들을 파일매니저에 저장한다면?
    메모리에 저장하는 것이 아니라 아이폰 내부 저장소에 직접 저장하는 방식임.
    캐시에 비해 메모리를 덜먹고, 로딩 속도도 빠르다.
    하지만 유저의 내부저장소를 많이 차지할 수 있다는 문제가 있다.
    따라서 더 오랜기간, 중요한 파일들은 파일매니저를 통해 저장하고
    앱 사용중에만 중요한 파일들은 캐싱처리를 하는 것이 효율적일 것이다.
    */
//    let fileManager = PhotoModelFileManager.shared
    
    var cancellables = Set<AnyCancellable>()
    
    //DownloadingImageView 초기화 시 생성자를 통해 파라미터 받아와서 할당해줌.
    let urlString: String
    let imageKey: String
    
    init(url: String, key: String) {
        urlString = url
        imageKey = key
//        downloadImage()
        getImage()
    }
    
    func getImage() {
        if let savedImage = cacheManager.get(key: imageKey) {
            image = savedImage
            print("Getting saved image")
        } else {
            downloadImage()
            print("Download Image")
        }

    }
    
    func downloadImage() {
        print("Download Image Now")
        //이미지 다운로드 중 ProgressView를 보여준다.
        isLoading = true
        guard let url = URL(string: urlString) else { return }
        
        URLSession.shared.dataTaskPublisher(for: url)
        // 결과가 (data,response)의 형태로 반환됨.
        // data를 활용해서 UIImage로 매핑
//            .map { (data, response) in
//                return UIImage(data: data)
//            }
			//클로저 축약문법
            .map { UIImage(data: $0.data) }
            .receive(on: DispatchQueue.main)
            
            .sink { [weak self] (_) in
                self?.isLoading = false
            } receiveValue: { [weak self] (returnedImage) in
                guard
                    let self = self,
                    let image = returnedImage else { return }
                //받아온 이미지를 할당
                self.image = returnedImage
                //이미지를 캐시에도 저장.
                //키값과 벨류가 필요함 String, UIImage
                self.cacheManager.add(key: self.imageKey, value: image)
            }
            .store(in: &cancellables)
    }
}

CacheManager (캐싱)

ImageLoadingViewModel에서 받은 이미지를 캐싱처리하는 클래스

import SwiftUI

class PhotoModelCacheManager {
    static let shared = PhotoModelCacheManager()
    private init() { }
    
    var photoCache: NSCache<NSString, UIImage> = {
        var cache = NSCache<NSString, UIImage>()
        cache.countLimit = 200
        cache.totalCostLimit = 1024 * 1024 * 200
        return cache
    }()
    
    func add(key: String, value: UIImage) {
        photoCache.setObject(value, forKey: key as NSString)
    }
    
    func get(key: String) -> UIImage? {
        photoCache.object(forKey: key as NSString)
    }
}

FileManager

만약 파일매니저를 통해 데이터를 디바이스에 저장한다면

import SwiftUI

class PhotoModelFileManager {
    let foldername = "downloaded_photos"
    
    static let shared = PhotoModelFileManager()
    
    private init() {
        createFolderIfNeeded()
    }
    //폴더 생성
    private func createFolderIfNeeded() {
        guard let url = getFolderPath() else { return }
        
        if !FileManager.default.fileExists(atPath: url.path) {
            do {
                try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
                print("Folder Created")
            } catch let error {
                print("Error Creating Folder: \(error.localizedDescription)")
            }
        }
    }
    //아래와 같은 형태로 패스가 생성될 것
    // ... /downloaded_photos/image_name.png
    
    private func getFolderPath() -> URL? {
        return FileManager
            .default
            .urls(for: .cachesDirectory, in: .userDomainMask)
            .first?
            .appendingPathComponent(foldername)
    }
    
    private func getImagePath(key: String) -> URL? {
        guard let folder = getFolderPath() else { return nil }
        return folder.appendingPathComponent(key + ".png")
    }
    
    func add(key: String, value: UIImage) {
        guard
            let data = value.pngData(),
            let url = getImagePath(key: key) else { return }
        
        do {
            try data.write(to: url)
        } catch let error {
            print("Error Saving to FileManager: \(error.localizedDescription)")
        }
    }
    
    func get(key: String) -> UIImage? {
        guard
            let url = getImagePath(key: key),
            FileManager.default.fileExists(atPath: url.path) else {
            return nil
        }
        return UIImage(contentsOfFile: url.path)
    }
}

NSCache를 사용하는 방식, FileManager를 활용하는 방식 둘 다

다운 받은 이미지 정보를 저장해둬서 매 번 다운로드 하는 대신 빠르게 로딩할 수 있도록 하는 것이다.

 

(* 다른 파일들도 당연히 캐싱이 가능하지만, 여기서는 이미지 파일만을 가지고 설명)

메모리 캐싱

이미지를 메모리에 캐싱해서 엑세스가 빠르다. 

이미지에 대한 요청이 있을 때 메모리에서 검색하기 때문에 디스크(아이폰 로컬 저장소)에 엑세스하는 것보다 훨씬 빠름.

다만 메모리 용량을 먹기 때문에 적절하게 관리해줘야함. 

또한 앱이 종료되면 캐시가 자동으로 삭제됨. 메모리 캐시는 앱이 실행 중일 때만 유지되기 때문.

만약 앱이 종료되어도 해당 파일이 남아있어야 한다면, FileManager를 통해 로컬 파일 캐싱을 할 수 있다.

로컬 파일 캐싱

이미지를 로컬 파일 시스템에 저장해서 캐싱하는 방식.

메모리 캐싱에 비해 엑세스 속도가 느림. 다만 앱이 종료되도 파일시스템에 유지되기 때문에,

앱을 재실행하거나 아이폰을 껐다가 켜도 이미지 파일이 로컬에 있기 때문에 빠르게 로드 가능.

하지만 아이폰의 용량을 직접 차지하기 때문에 모든 파일을 로컬 파일 캐싱하는 것은 바람직하지 못함.

 

이렇게 url을 통해 json 파싱하고,

해당 데이터를 캐싱하는 방법을 연습해 보았다.

또한 MVVM 아키텍쳐를 유지하기 위한 연습도.

'뷰마다 뷰모델이 존재'한다는 것은 당연하지만 이번 예시에서 더욱 돋보였던 것 같다.

 

뷰는 모델의 데이터를 표시하고,

뷰모델은 모델에서 데이터를 가져와서 필요한 형태로 가공해 뷰에 전달한다.

결국 MVVM 같은 아키텍쳐는 '코드를 분리' 하기 위한 것이라는 것..

테스터블하게, 유지보수가 편하게..

 

데이터 다운로드 비동기 처리, 그리고 앱 퍼포먼스 및 UX 향상을 위해서 '캐싱'이 필요하다는 점이 핵심이라 생각한다.

메모리 캐싱과 로컬 파일 캐싱은 장단점이 있기 때문에, 파일의 크기나 사용 빈도, 중요도에 따라 적절한 캐싱 방법을 택해야 한다.

복잡하고 방대한 코드이기 때문에 여러번 살펴봤고,

추후 다른 프로젝트에서 json 파싱을 하거나 캐싱이 필요한 경우 참고할 수 있겠다 :)

'SwiftUI > SwiftUI(Intermediate)' 카테고리의 다른 글

22. NSCache 캐시/ 이미지 캐싱  (0) 2023.06.15
21. FileManager 파일매니저  (0) 2023.06.15
20. @Published / Subscriber / Combine  (0) 2023.06.14
19. Timer 타이머  (0) 2023.06.13
18. Combine 컴바인 .feat JSON  (0) 2023.06.13