오랜만에 UIKit을 다시 써보면서 새로운 개념들도 알아가고 있고 소마에서 배웠던 기술들도 적용해보고 있습니다. 이러한 것들에 대해 개념을 자세히 설명하기보다는 실제로 어떻게 썼는지, 어떤 점이 좋았는지 정리해보고자 합니다!
캐싱이 무엇인지는 아마 알고 계실 것이라고 생각됩니다. 간단하게 말하자면 빠른 처리를 위해 데이터 일부를 미리 복사해서 저장해놓는 기술이죠.
캐싱의 개념은 여러곳에서 쓰이는데 예를 들자면 아래와 같습니다.
이외에도 캐싱이 쓰이는 곳은 다양할 수 있는데, 그 중에서도 앱에서 사용하는 캐싱에 대해 얘기하고자 합니다.
아마 가장 많이 적용되는 데이터 중 하나는 이미지일 것입니다. URL을 사용하여 네트워크 요청을 하면 이미지를 다운받을 수 있는데, 보통 URL이 동일하다면 이미지도 동일할 확률이 매우 높기 때문이죠.
그 외에도 개인적으로 생각하기에 조회 기능에도 캐싱이 적용될 수 있다고 생각합니다. 즉 response로 받아오는 JSON 데이터를 캐싱하는거죠. 조회 기능 또한 짧은 시간 내라면 결과가 동일할 확률이 높기 때문입니다. 실제로 HTTP에 GET 메소드에도 캐시를 설정할 수 있습니다.
사실 "데이터를 저장한다"라는 관점에서 봤을 때, iOS에서는 크게 3가지가 있다고 생각합니다. 각각의 특징에 대해 간단히 설명해보겠습니다.
메모리 캐시는 앱이 꺼지면 데이터가 사라지는 캐시이고, 디스크 캐시는 앱이 꺼져도 데이터가 유지되는 캐시입니다.
"객체 그래프 관리 프레임워크" 라는 것을 찾아보면 이해하기가 좀 어렵다고 느껴졌습니다. 그래도 제가 이해한 바를 정말 간단하게 예를 들어 설명하자면 이렇습니다.
struct Person {
let name: String
let house: House
}
struct House {
let builtDate: String
}
Person 객체를 저장할 때, Person 객체와 House 객체를 서로 그래프 형태로 연결해서 관리해주는 것이죠. 그러다보니 복잡한 데이터 모델도 저장하기 수월하다고 하는 것 같습니다.
저는 이 중에서 이미지와 검색에 대한 JSON을 캐싱하기 위해 메모리 캐시인 NSCache를 사용했습니다.
아무래도 이미지같은 경우는, 디스크에 이미지가 쌓여서 용량이 과도하게 늘어나는 일을 막기 위해서는 메모리 캐시가 적절하다고 생각했습니다.
JSON같은 경우는, 검색이라는 것이 시간이 지나면 결과가 변할 수 있기에, 영구적으로 보관하는 디스크 캐시보다는 메모리 캐시가 적절하다고 생각했습니다.
코드로 어떻게 구현했는지 설명드리겠습니다. 프로젝트 전체 보기
참고로 클린아키텍처와 RxSwift를 사용했습니다.
final class CacheManager {
static let imageCache = NSCache<NSString, NSData>()
static let jsonCache = NSCache<NSString, NSData>()
private init() { }
}
// Data, String 타입을 일일이 NSData, NSString 타입으로 변환하기 번거로워서 만든 extension
extension NSCache<NSString, NSData> {
func setObject(_ data: Data, forKey key: String) {
self.setObject(NSData(data: data), forKey: NSString(string: key))
}
func object(forKey key: String) -> Data? {
guard let nsData = self.object(forKey: NSString(string: key)) else { return nil }
return Data(referencing: nsData)
}
}
// Data 계층 - DefaultImageDatasource
final public class DefaultImageDatasource: ImageDatasource {
public init() { }
public func downloadImage(url: String) -> Single<Data> {
// url을 Key로 사용
if let cachedImage = CacheManager.imageCache.object(forKey: url) {
return Single.just(cachedImage)
} else {
// 클로저로 캐시 저장 로직 넘겨주기
return URLSession.shared.call(url: url) { imageData in
if CacheManager.imageCache.object(forKey: url) == nil {
CacheManager.imageCache.setObject(imageData, forKey: url)
}
}
}
}
}
// Data 계층 - URLSession
extension URLSession {
func call(url: String, caching: ((Data) -> Void)? = nil) -> Single<Data> {
...
let task = self.dataTask(with: url) { data, response, error in
if let data = data {
// 캐싱 클로저가 파라미터로 들어왔다면 실행
if let caching = caching {
caching(data)
}
single(.success(data))
} else if let error = error {
single(.failure(CustomError.NetworkError(detail: "URLSession 에러 발생\n[\(error.localizedDescription)]")))
}
}
}
}
// Data 계층 - DefaultSearchDatasource
final public class DefaultSearchDatasource: SearchDatasource {
private let moyaProvider = MoyaProvider<SearchAPI>()
public init() { }
public func searchShopping(query: String) -> Single<ShoppingResultDTO> {
// 검색어 (query) 를 Key로 사용
if let cachedJSON = CacheManager.jsonCache.object(forKey: query) {
// JSON 자체이기 때문에 디코딩 작업 필요
return Single.just(cachedJSON)
.map { try JSONDecoder().decode(ShoppingResultDTO.self, from: $0)}
} else {
// 클로저로 캐시 저장 로직 넘겨주기
return moyaProvider.call(target: .shopping(query: query)) { jsonData in
if CacheManager.jsonCache.object(forKey: query) == nil {
CacheManager.jsonCache.setObject(jsonData, forKey: query)
}
}
}
}
}
// Data 계층 - MoyaProvider
extension MoyaProvider {
func call<Value>(target: Target, caching: ((Data) -> Void)? = nil) -> Single<Value> where Value: Decodable {
return self.rx.request(target)
.map { response in
// 캐싱 클로저가 파라미터로 들어왔다면 실행
if let caching = caching {
caching(response.data)
}
return try response.map(Value.self)
}
...
}
}
클린아키텍처 관점에서 본다면, 캐싱은 Data 관련 작업이기 때문에 이렇게 Data 계층 내에서 로직을 모두 구현할 수 있습니다.
이상으로 Caching에 대해 알아봤습니다. 저도 현재 배우고 있는 입장이기 때문에 틀린 개념, 부족한 개념들이 있을 수 있습니다. 혹시라도 그런 부분이 있다면 언제든지 지적해주셔도 됩니다. 긴 글 읽어주셔서 감사합니다. 😊