apple/Docs, iOS, Swift

[Swift] NSCache (swift-corelibs-foundation)

lgvv 2024. 10. 11. 02:30

[Swift] NSCache (swift-corelibs-foundation)

 

NSCache 문서를 읽어보다가 `eviction`이란 단어를 보게 되었는데, 대학 수업을 원서로 배울때, `eviction`과 `expiration`에 대해서 깊게 탐구했던 생각이 났음.

애플에서 최근에 Foundation 부분을 오픈소스로 공개했는데, NSCache와 관련해서 구현된 코드를 직접 확인하면서 제대로 이해해보고자 함.

 

글의 순서

  • NSCache Overview
  • iOS에서 Cache 종류
  • 왜 메모리 캐시가 디스크 캐시보다 빠를까?
  • 캐시와 관련한 몇가지 정책 및 알고리즘
  • Foundation에서 구현되어 있는 NSCache에 대해서 코드로 알아보기
    • NSCache 선언부
    • NSCache Insert
    • NSCache remove
    • NSCacheEntry와 NSCacheKey (NSCache가 키 객체에 저장되는 객체를 복사하지 않는 이유)
  • NSCache를 사용했을 때와 Dictionary를 이용했을 때의 시간 차이 비교
    • 차이점
    • 공통점 (이미지 라이브러리 분석과 인사이트)

 

NSCache Overview

NSCache Overview

 

  • NSCache는 다양한 자동 제거(eviction) 정책을 구현을 구현해, 시스템이 메모리 과도하게 사용하지 않도록 보장. 다른 애플리케이션에서 메모리가 필요할 경우 캐시에서 일부 항목을 제거해 메모리 사용량 최소화
  • NSCahce를 사용하면 캐시의 항목을 추가, 제거, 쿼리할 수 있으며 다양한 스레드에서 사용할 수 있고 사용자가 명시적으로 lock을 구현하지 않아도 됨.
  • 딕셔너리와 달리 NSCache는 키 객체에 저장되는 객체를 복사하지 않음.
    • 포스팅 뒤에 작성되어 있겠지만, Key를 value 기반으로 저장하는게 아니라 Ref로 저장하여 사용. 구현체는 뒷 부분에서 확인 가능

 

iOS에서 Cache 종류

디스크 캐시

  • 저장 위치: 디스크
  • 속도: 메모리에 비해 상대적으로 느리며, 읽기보다 일반적으로 쓰기 작업이 더 느림
    • CRUD 성능에 대한 부분은 물리 장치 및 방식에 따른 차이가 존재
  • iOS 사용: FileManager, CoreData, UserDefault, SwiftData
  • 일반적으로 메모리보다 저렴하여 훨씬 더 많은 용량을 저장할 수 있지만, 속도가 느림.

메모리 캐시

  • 저장 위치: 메모리 (RAM)
  • 속도: 매우 빠름.
  • iOS 사용: NSCache

 

왜 메모리 캐시가 디스크 캐시보다 빠를까?

 

간략하게 이야기해자면, CPU 내에 L1, L2 캐시가 위치하고, 메모리, 디스크에 접근하기 위해서는 버스를 통해서 이동해야 함.

다른 물리장치에 접근하기 위해 버스를 타고 이동하는 행위보다 CPU 내에서 가까운 거리를 이동하는게 훨씬 빠를 수 밖에 없음.

 

L1, L2, L3 캐시를 그럼 디스크 만큼 늘리면 되지 않을까?

> 가격이 상대적으로 매우 비싸서, 현실적으로 어려움. 히트율 등 알고리즘을 통해 관리중

 

캐시에 대한 자세한 부분은 컴퓨터 구조 및 운영체제 이론에서 자세히 확인할 수 있음.

 

캐시와 관련한 몇가지 정책 및 알고리즘

 

  • Eviction
    • 공간이 부족해졌을 때 데이터를 지움
  • Expiration
    • 캐시가 만료되는 조건을 명시하는 정책
  • Passivation
    • eviction에 의해서 데이터가 지워질 때 디스크 캐싱하도록 설정
    • 같은 데이터에 대한 요청을 디스크 캐시에서 찾아서 반환
  • LRU (Least Recently Used)
    • 가장 사용한지 오래된 것들을 삭제
  • LFU (Least Frequetly Used)
    • 가장 사용하지 않는 것을 삭제
  • Random
    • 랜덤으로 교체
  • MFU (Most Frequently Used)
    • 가장 많이 사용한거 교체

 

 

 

 

`eviction` vs `expiration`

  • `expiration`: 사용자에 의해 명시적으로 지정되어, 주체가 개발자
  • `eviction`: 시스템이 주체로, 메모리가 부족한 경우에 알아서 정리됨.

 

이 외에 다른 방법이나 구체적인 부분은 컴퓨터 구조에서 더 자세히 확인할 수 있음.

 

Foundation에서 구현되어 있는 NSCache에 대해서 코드로 알아보기

최근 애플에서 Foundation을 깃허브에 오픈소스로 공개하면서 NSCache의 구현부를 확인할 수 있게 되었음.

  • NSCache는 thread-safe하다고 알려져 있는데, 내부 구현에도 이것이 반영되어 있음.

 

현재 XCode 16.0에서 NSCache에서 제공되는 인터페이스는 다음과 같음.

NSCache

 

 

 

 

NSCache 선언부

기본적으로 위와 같이 선언되어 있음.

딕셔너리 캐시와 NSCache를 비교하곤 하는데, entries에 보면 딕셔너리로 구형하고 있음.

 

 

 

NSCache Insert 

  • setObject 부분을 보면 lock으로 thread-safe에 대한 근거를 코드 레벨에서 파악할 수 있음.
  • 아래 사진의 insert 함수를 보면 헤드를 교체하는 부분에서 Linked-List로 구현된 것을 짐작할 수 있음.
  • 가장 최근것을 head로 설정함

insert 로직



우리가 실제로 쓰는건 insert함수를 직접 쓰는게 아니라 setObject일탠데, 아래 이미지처럼 되어있고, 라인에 부분은 흐름을 따라가면서 하나하나 정리.

 

object set

open func setObject(_ obj: ObjectType, forKey key: KeyType, cost g: Int) {
    // Step 1: 비용 g가 음수가 되지 않도록 보정
    let g = max(g, 0)
    
    // Step 2: 키를 NSCacheKey로 래핑하여 해시 및 비교 기능을 제공
    let keyRef = NSCacheKey(key)
    
    // Step 3: 동시성 문제를 막기 위해 락을 사용하여 캐시에 대한 접근을 보호
    _lock.lock()
    
    let costDiff: Int
    
    // Step 4: 캐시에 이미 해당 키에 대한 항목이 있는지 확인
    if let entry = _entries[keyRef] {
        // Step 5: 새로운 비용과 기존 비용의 차이를 계산하고 항목의 비용을 업데이트
        costDiff = g - entry.cost
        entry.cost = g
        
        // Step 6: 항목에 연결된 값을 새로운 값으로 업데이트
        entry.value = obj
        
        // Step 7: 비용이 변경되었을 경우, 항목을 다시 삽입하여 리스트 내 위치를 조정
        if costDiff != 0 {
            remove(entry) // 현재 항목을 연결 리스트에서 제거
            insert(entry) // 비용에 따라 올바른 위치에 다시 삽입
        }
    } else {
        // Step 8: 캐시에 해당 키가 없을 경우, 새 항목을 생성하여 캐시에 추가
        let entry = NSCacheEntry(key: key, value: obj, cost: g)
        _entries[keyRef] = entry
        insert(entry) // 새로운 항목을 비용에 따라 적절한 위치에 삽입
        
        costDiff = g
    }
    
    // Step 9: 총 비용에 costDiff를 더해 갱신
    _totalCost += costDiff
    
    // Step 10: 만약 총 비용이 제한을 초과한 경우, 초과된 비용만큼 항목을 제거
    var purgeAmount = (totalCostLimit > 0) ? (_totalCost - totalCostLimit) : 0
    while purgeAmount > 0 {
        if let entry = _head {
            delegate?.cache(unsafeDowncast(self, to: NSCache<AnyObject, AnyObject>.self), willEvictObject: entry.value)
            
            _totalCost -= entry.cost
            purgeAmount -= entry.cost
            
            remove(entry) // _head가 remove()에서 다음 항목으로 갱신됨
            _entries[NSCacheKey(entry.key)] = nil
        } else {
            break
        }
    }
    
    // Step 11: 항목 개수가 제한을 초과한 경우, 초과된 항목을 제거
    var purgeCount = (countLimit > 0) ? (_entries.count - countLimit) : 0
    while purgeCount > 0 {
        if let entry = _head {
            delegate?.cache(unsafeDowncast(self, to: NSCache<AnyObject, AnyObject>.self), willEvictObject: entry.value)
            
            _totalCost -= entry.cost
            purgeCount -= 1
            
            remove(entry) // _head가 remove()에서 다음 항목으로 갱신됨
            _entries[NSCacheKey(entry.key)] = nil
        } else {
            break
        }
    }
    
    // Step 12: 락 해제
    _lock.unlock()
}

 

 

NSCache remove

  • 삭제도 lock을 통해서 thread-safe함.
  • removeAllObject의 경우에는 캐시가 Linked-list로 구현되어 있어서 하나씩 순회하며 삭제하는 로직

 

NSCacheEntry와 NSCacheKey (NSCache가 키 객체에 저장되는 객체를 복사하지 않는 이유)

 

애플 오픈소스 구현부

 

 

엔트리에 저장하는 부분을 보면 클래시 인스턴스를 키 값으로 사용하고 있음.

즉, Reference 형태로 저장

_entries[keyRef] = entry

 

아래 코드를 보면 value 프로퍼트로 전달된 cacheKey 객체를 참조로 저장함.

class NSCacheKey: NSObject {
    var value: AnyObject

    init(_ value: AnyObject) {
        self.value = value
        super.init()
    }
}



NSCache를 사용했을 때와 Dictionary를 이용했을 때의 시간 차이 비교

여기까지 알아보았다면 NSCache와 Dictionary의 장단점에 대해서 고려해서 캐시 로직을 주도할 수 있음.

NSCache는 thread-safe 하다는 점을 생각하니 이 지점에서 오버헤드가 발생하지 않을까 생각함.

 

// 성능 테스트 함수
func performanceTest() {
    let iterations = 1_000_000
    let dictionaryCache = DictionaryCache()
    let nsCache = NSCacheWrapper()

    let nsCacheStartTime = CFAbsoluteTimeGetCurrent()
    for i in 0..<iterations {
        let key = NSString(string: "key\(i)")
        let value = NSString(string: "value\(i)")
        nsCache.setObject(key: key, value: value)
    }
    let nsCacheEndTime = CFAbsoluteTimeGetCurrent()
    print("NSCache Time: \(nsCacheEndTime - nsCacheStartTime) seconds")
    
    // Dictionary 캐시 테스트
    let dictionaryStartTime = CFAbsoluteTimeGetCurrent()
    for i in 0..<iterations {
        let key = "key\(i)"
        let value = "value\(i)"
        dictionaryCache.setObject(key: key, value: value)
    }
    let dictionaryEndTime = CFAbsoluteTimeGetCurrent()
    print("Dictionary Cache Time: \(dictionaryEndTime - dictionaryStartTime) seconds")

}

시간 비교

 

차이점

NSCache

  • 장점: 스레드 세이프 함, 메모리 정리 시스템에서 알아서 해줌, 메모리 최적화해줌
  • 단점: 느림

Dictionary

  • 장점: 스레드 세이프 하지 않아서 개발단에서 직접 컨트롤 해야하며, 객체 복사에 대한 부분으로 인하여 메모리 최적화도 고려해야 함.
  • 단점: 비교적 빠름

 

공통점

 

  • 둘다 객체가 ARC 등에 의해서 해제되는 경우 메모리에서 깔끔하게 정리된다.

최근에 이미지 라이브러리를 분석했었는데, NSCache 등에 없는 `expiration` 정책을 구현한 것들을 보면서 `NSCache`의 부족한 부분을 확장했다고 느낌.

또한 그리고 싱글톤으로 구현된 부분이 있었는데, 객체가 해제되면 nil이 되는 부분에 대한 해결책으로 보임.

 

 

 

 

소감

구현된 코드를 직접 보니까 NSCache 구현된 부분에 있어서 몇가지 개선 가능한 부분들이 보이는데, 좀 덜 바빠지면 오픈소스에 기여해 봐야겠다 ~

 

 

 

 

 

(참고 링크)

 

애플 문서

https://developer.apple.com/documentation/foundation/nscache

 

NSCache | Apple Developer Documentation

A mutable collection you use to temporarily store transient key-value pairs that are subject to eviction when resources are low.

developer.apple.com

 

애플 NSCache 구현체

https://github.com/swiftlang/swift-corelibs-foundation/blob/main/Sources/Foundation/NSCache.swift

 

swift-corelibs-foundation/Sources/Foundation/NSCache.swift at main · swiftlang/swift-corelibs-foundation

The Foundation Project, providing core utilities, internationalization, and OS independence - swiftlang/swift-corelibs-foundation

github.com