관리 메뉴

김종권의 iOS 앱 개발 알아가기

[Clean Architecture] 8. 코드로 알아보는 SOLID - 캐싱 Disk Cache (UserDefeaults, CoreData) 본문

Clean Architecture/Clean Architecture 코드

[Clean Architecture] 8. 코드로 알아보는 SOLID - 캐싱 Disk Cache (UserDefeaults, CoreData)

jake-kim 2021. 9. 26. 23:43

0. 코드로 알아보는 SOLID - 클래스 다이어그램 필수 표현

1. 코드로 알아보는 SOLID - SRP(Single Responsibility Principle) 단일 책임 원칙

2. 코드로 알아보는 SOLID - OCP(Open Close Principle) 개방 폐쇄 원칙

3. 코드로 알아보는 SOLID - LSP(Liskov Substitution Principle) 리스코프 치환 원칙

4. 코드로 알아보는 SOLID - ISP(Interface Segregation Principle) 인터페이스 분리 원칙

5. 코드로 알아보는 SOLID - DIP(Dependency Inversion Principle, testable) 의존성 역전 원칙

6. 코드로 알아보는 SOLID - Coordinator 패턴 화면전환

7. 코드로 알아보는 SOLID - Network, REST (URLSession, URLRequest, URLSessionDataTask)

8. 코드로 알아보는 SOLID - 캐싱 Disk Cache (UserDefeaults, CoreData)

Cache

  • memory cache는 따로 개념 참고
  • Network 호출을 통해서 얻은 데이터를 disk cache하여 로컬에 저장
  • Network호출 전에 디스크 캐싱을 통해 로컬에서 데이터를 불러오고, 그 다음 network호출한 결과가 오면 dataSource에 append하는 방식
  • UserDefaults, Core Data 모두 사용 가능

예제 앱

  • disk cache와 network 호출을 사용하여 tableView에 데이터를 뿌리는 앱

설계

  • useCase의 repository모듈에서 fetchCities(request) 호출하면, repository 구현체에서 disk cache, 네트워크 하는 코드 존재
  • Repository는 Interface이므로 내부에서의 데이터 저장소는 쉽게 변경 가능 (DIP)
  • 내부 저장소를 바꾸어도 사용하는 쪽인 UseCase에 영향을 주지 않는 관점

주요 코드

  • 도메인에서 사용하는 모델인 City와 UserDefaults 캐시 저장소에서 사용하는 모델을 구분하기 위해서 UDS(UserDefaultsStorage)관련 모델 정의
// UDS: UserDefaultsStorage
struct CityListUDS: Codable {
    var list: [CityUDS]
}

struct CityUDS: Codable {
    let name: String
    let subName: String

    init(city: City) {
        name = city.name
        subName = city.subName
    }
}

extension CityUDS {
    func toDomain() -> City {
        return .init(id: name, name: name, subName: subName)
    }
}
  • UserDefaults 저장소 구현: CityResponseStorage 인터페이스를 준수하여 UseCase에서 사용할 수 있도록 구현
final class UserDefaultsCityStorage {
    private let maxStorageLimit: Int
    private let cityStorageKey = "cityStorage"
    private var userDefaults: UserDefaults

    init(maxStorageLimit: Int, userDefaults: UserDefaults = UserDefaults.standard) {
        self.maxStorageLimit = maxStorageLimit
        self.userDefaults = userDefaults
    }

    private func fetchCities() -> [City] {
        if let citiesData = userDefaults.object(forKey: cityStorageKey) as? Data {
            if let cities = try? JSONDecoder().decode(CityListUDS.self, from: citiesData) {
                return cities.list.map { $0.toDomain() }
            }
        }
        return []
    }

    private func persist(cities: [City]) {
        let encoder = JSONEncoder()
        let cityListUDS = cities.map(CityUDS.init)
        if let encoded = try? encoder.encode(CityListUDS(list: cityListUDS)) {
            userDefaults.set(encoded, forKey: cityStorageKey)
        }
    }
}

extension UserDefaultsCityStorage: CityResponseStorage {
    func getResponse(maxCount: Int, completion: @escaping (Result<[City], Error>) -> Void) {
        DispatchQueue.main.async { [weak self] in
            guard var cities = self?.fetchCities() else { return }

            let maxStorageLimit = self?.maxStorageLimit ?? 0
            cities = cities.count < maxStorageLimit ? cities : Array(cities[0..<maxCount])
            completion(.success(cities))
        }
    }

    func save(cities: [City]) {
        persist(cities: cities)
    }
}
  • CityRepository에서 UserDefaults 캐시 저장소를 사용하기위해 CitySceneDIContainer에서 주입
// CitySceneDIContainer

private let cityResponseCache = UserDefaultsCityStorage(maxStorageLimit: 10)

private func makeCityRepository() -> CityRepository {
    return CityRepositoryImpl(dataTransferService: dependencies.cityDataTransferService, cache: cityResponseCache)
}
  • 캐시 사용 시 useCase에 cached파라미터 추가: ViewModel에서 dataSource에 새로 얻은 데이터를 append하는 메소드를 주입
    • 보통 네트워크 response 받을 경우 completion의 success를 보고 직접 호출하지만, 캐시를 통해 append가 호출되게끔 하는 일은 repository에 위임하여 각자 기능 분리에 적합하여 이처럼 구현
// CityViewModel.swift

func viewDidLoad() {
    let _ = cityUseCase.execute(requestValue: .init(order: .revenue, sortOrder: .desc),
                                cached: appendCities(_:)) { [weak self] result in
        switch result {
        case .success(let cities):
            self?.appendCities(cities)
        case .failure(let error):
            self?.handle(error: error)
        }
    }
}

// Private

private func appendCities(_ cities: [City]) {
    let newCities = cities.filter { !self.items.value.contains($0) }
    items.accept(newCities)
}
  • CityUseCase: ViewModel이 의존하고 있는 모듈이고, 이곳에서 repository 객체를 통해 데이터 획득
protocol CityUseCase {
    @discardableResult
    func execute(requestValue: CityUseCaseRequestValue,
                 cached: @escaping (([City]) -> Void),
                 completion: @escaping (Result<[City], Error>) -> Void) -> Cancellable?
}

final class CityUseCaseImpl: CityUseCase {

    /// DIP: CityRepository에서 network가 바뀌든, CityRepository는 protocol이므로 json이 xml로 되든간에 영향을 받지 않는 코드
    private let cityRepository: CityRepository

    init(cityRepository: CityRepository) {
        self.cityRepository = cityRepository
    }

    func execute(requestValue: CityUseCaseRequestValue,
                 cached: @escaping (([City]) -> Void),
                 completion: @escaping (Result<[City], Error>) -> Void) -> Cancellable? {

        return cityRepository.fetchCities(order: requestValue.order,
                                          sortOrder: requestValue.sortOrder,
                                          cached: cached) { result in
            if case .success = result {
                // TODO: cache 저장
            }

            switch result {
            case .success(let response):
                return completion(.success(response))
            case .failure(let error):
                return completion(.failure(error))
            }
        }
    }
}

struct CityUseCaseRequestValue {
    let order: Order
    let sortOrder: SortOrder
}
  • Repository의 구현체에서 cache를 시도하고 network호출까지 진행
final class CityRepositoryImpl {
    private let dataTransferService: DataTransferService
    private let cache: CityResponseStorage

    init(dataTransferService: DataTransferService, cache: CityResponseStorage) {
        self.dataTransferService = dataTransferService
        self.cache = cache
    }
}

extension CityRepositoryImpl: CityRepository {
    func fetchCities(order: Order,
                     sortOrder: SortOrder,
                     cached: @escaping (([City]) -> Void),
                     completion: @escaping (Result<[City], Error>) -> Void) -> Cancellable? {

        let endpoint = APIEndpoints.getCities(with: CityRequestDTO(order: order, sortOrder: sortOrder))
        let task = RepositoryTask()

        cache.getResponse(maxCount: 10) { result in
            if case let .success(response) = result {
                cached(response)
            }

            /// ViewModel에서 네트워크 호출 cancel요청을 받은 경우 (ex - 사용자가 검색 중 취소 버튼 탭)
            guard !task.isCancelled else { return}

            task.networkTask = self.dataTransferService.request(with: endpoint, completion: { result in
                switch result {
                case .success(let response):
                    self.cache.save(cities: response.toDomain())
                    completion(.success(response.toDomain()))
                case .failure(let error):
                    completion(.failure(error))
                }
            })

        }
        return task
    }
}

 

* 전체 소스 코드: https://github.com/JK0369/CleanArchitectureCache

 

Comments