Caching

Dophi·2024년 1월 3일

개발 기술

목록 보기
11/12

소개글

오랜만에 UIKit을 다시 써보면서 새로운 개념들도 알아가고 있고 소마에서 배웠던 기술들도 적용해보고 있습니다. 이러한 것들에 대해 개념을 자세히 설명하기보다는 실제로 어떻게 썼는지, 어떤 점이 좋았는지 정리해보고자 합니다!

Caching

캐싱이 무엇인지는 아마 알고 계실 것이라고 생각됩니다. 간단하게 말하자면 빠른 처리를 위해 데이터 일부를 미리 복사해서 저장해놓는 기술이죠.

캐싱의 개념은 여러곳에서 쓰이는데 예를 들자면 아래와 같습니다.

  • 운영체제에서는 메모리의 데이터를 가져오는 작업이 오래 걸리므로, 이를 보완하기 위해 메모리의 일부 데이터를 SRAM 캐시에 복사해놓습니다.
  • 앱에서는 네트워크 작업이 오래 걸리므로, 이를 보완하기 위해 일부 데이터를 로컬 캐시에 복사해놓습니다.

이외에도 캐싱이 쓰이는 곳은 다양할 수 있는데, 그 중에서도 앱에서 사용하는 캐싱에 대해 얘기하고자 합니다.

우선 어떤 데이터에 캐싱을 적용할 수 있을까요?

아마 가장 많이 적용되는 데이터 중 하나는 이미지일 것입니다. URL을 사용하여 네트워크 요청을 하면 이미지를 다운받을 수 있는데, 보통 URL이 동일하다면 이미지도 동일할 확률이 매우 높기 때문이죠.

그 외에도 개인적으로 생각하기에 조회 기능에도 캐싱이 적용될 수 있다고 생각합니다. 즉 response로 받아오는 JSON 데이터를 캐싱하는거죠. 조회 기능 또한 짧은 시간 내라면 결과가 동일할 확률이 높기 때문입니다. 실제로 HTTP에 GET 메소드에도 캐시를 설정할 수 있습니다.

다음으로는 어떤 라이브러리를 사용할 수 있을까요?

사실 "데이터를 저장한다"라는 관점에서 봤을 때, iOS에서는 크게 3가지가 있다고 생각합니다. 각각의 특징에 대해 간단히 설명해보겠습니다.

NSCache

  • Key, Value 형태로 데이터를 저장하는 메모리 캐시입니다.
  • Linked List로 Key를 관리하고, Dictionary로 Value를 참조합니다.
  • 내부적으로 Mutex와 같은 동기화 처리를 해뒀기에 Thread-Safe합니다.
  • 메모리 공간이 부족해지면 자동으로 캐시된 객체를 제거합니다.

메모리 캐시는 앱이 꺼지면 데이터가 사라지는 캐시이고, 디스크 캐시는 앱이 꺼져도 데이터가 유지되는 캐시입니다.

UserDefault

  • Key, Value 형태로 데이터를 저장하는 디스크 캐시입니다.
  • Plist 파일에서 관리되며, 앱이 실행되면 Plist 파일이 메모리에 올라옵니다.
  • 그렇기에 무거운 데이터를 저장하면 파일의 크기가 커져서 성능에 영향을 줄 수도 있습니다.

CoreData

  • 기본적으로 디스크 캐시이며 메모리 캐시로도 작동할 수 있습니다.
  • DB가 아닌, 객체 그래프 관리 프레임워크입니다.
  • 복잡한 데이터 모델을 형태 그대로 저장 가능합니다.

"객체 그래프 관리 프레임워크" 라는 것을 찾아보면 이해하기가 좀 어렵다고 느껴졌습니다. 그래도 제가 이해한 바를 정말 간단하게 예를 들어 설명하자면 이렇습니다.

struct Person {
	let name: String
    let house: House
}

struct House {
	let builtDate: String
}

Person 객체를 저장할 때, Person 객체와 House 객체를 서로 그래프 형태로 연결해서 관리해주는 것이죠. 그러다보니 복잡한 데이터 모델도 저장하기 수월하다고 하는 것 같습니다.

코드

저는 이 중에서 이미지와 검색에 대한 JSON을 캐싱하기 위해 메모리 캐시인 NSCache를 사용했습니다.

아무래도 이미지같은 경우는, 디스크에 이미지가 쌓여서 용량이 과도하게 늘어나는 일을 막기 위해서는 메모리 캐시가 적절하다고 생각했습니다.

JSON같은 경우는, 검색이라는 것이 시간이 지나면 결과가 변할 수 있기에, 영구적으로 보관하는 디스크 캐시보다는 메모리 캐시가 적절하다고 생각했습니다.

코드로 어떻게 구현했는지 설명드리겠습니다. 프로젝트 전체 보기

참고로 클린아키텍처와 RxSwift를 사용했습니다.

NSCache

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)]")))
			}
		}
	}
}

JSON 캐싱

// 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에 대해 알아봤습니다. 저도 현재 배우고 있는 입장이기 때문에 틀린 개념, 부족한 개념들이 있을 수 있습니다. 혹시라도 그런 부분이 있다면 언제든지 지적해주셔도 됩니다. 긴 글 읽어주셔서 감사합니다. 😊

profile
개발을 하며 경험한 것들을 이것저것 작성해보고 있습니다!

0개의 댓글