계정을 전환했는데도 같은 정보 카드에 이전 내용이 표시된다면, 원인이 반드시 네트워크 캐시에 있는 것은 아닙니다. 메모리의 객체를 계정 식별 정보 없이 리소스 ID만으로 저장했을 수 있습니다. ArmShell 클라우드 Mac에서 이런 회귀 테스트를 실행할 때는 메모리 압박을 억지로 만들 필요가 없습니다. 먼저 키의 식별 기준, 저장 시점의 스냅샷, 명시적인 비우기 기능, 캐시 미스 동작을 테스트로 작성하고, 시스템에 의한 항목 제거는 별도로 관찰하세요.
먼저 캐시 계약을 명확히 정의하기
이 글에서는 프로세스 내부의 NSCache만 테스트하며, 디스크 캐시나 네트워크 응답 캐시는 다루지 않습니다. XCTest가 활성화된 iOS 단위 테스트 타깃을 준비하고, 아래 타입과 테스트를 같은 테스트 파일에 넣으세요. 테스트 프로세스가 시작될 때마다 캐시를 새로 만들고, 전역 싱글턴을 재사용하지 않습니다.
키는 계정, 리소스 ID, 콘텐츠 버전으로 구성합니다. 리소스 ID가 같아도 계정이 다르면 서로의 캐시 항목이 조회되어서는 안 됩니다. 버전이 바뀌면 이전 데이터가 새 버전의 결과로 사용되어서도 안 됩니다. 값은 Data로 저장합니다. 저장 후 호출 측에서 자신의 변수를 계속 수정하더라도 캐시에 저장된 스냅샷은 바뀌지 않아야 합니다.
캐시는 언제든 사라질 수 있는 가속 계층이지, 기준 데이터의 원천이 아닙니다. 모든 읽기에서 캐시 미스가 발생할 수 있음을 전제로 해야 하며, 캐시 객체가 계속 남아 있어야만 비즈니스 로직이 올바르게 동작하는 구조여서는 안 됩니다.
여기서는 비밀번호, 토큰, 전체 사용자 프로필을 테스트하지 않습니다. 픽스처에는 짧은 텍스트만 사용하고, 실패 로그에도 테스트 케이스 이름과 캐시 적중 여부만 기록합니다.
전체 식별 정보로 키 구성하기
세 필드를 단순히 하이픈으로 이어 붙이지 마세요. 필드 자체에 구분자가 들어 있으면 서로 다른 조합을 구별하지 못할 수 있습니다. 아래 구현은 객체의 동등성으로 키의 동일 여부를 판단합니다. 해시 충돌은 허용되지만, 동등성 비교에서는 반드시 필드를 하나씩 비교해야 합니다. NSCache는 키를 대신 복사해 주지 않으므로 키의 필드는 불변으로 유지합니다.
import Foundation
import XCTest
final class MemoryKey: NSObject {
let account: String
let resource: String
let revision: String
init(_ account: String, _ resource: String, _ revision: String) {
self.account = account
self.resource = resource
self.revision = revision
}
override var hash: Int {
var hasher = Hasher()
hasher.combine(account)
hasher.combine(resource)
hasher.combine(revision)
return hasher.finalize()
}
override func isEqual(_ object: Any?) -> Bool {
guard let other = object as? MemoryKey else { return false }
return account == other.account
&& resource == other.resource
&& revision == other.revision
}
}
@MainActor
final class SessionMemoryCache {
private let storage = NSCache<MemoryKey, NSData>()
init() {
storage.totalCostLimit = 4 * 1024 * 1024
}
func store(_ data: Data, for key: MemoryKey) {
storage.setObject(
NSData(data: data),
forKey: key,
cost: data.count
)
}
func read(_ key: MemoryKey) -> Data? {
guard let value = storage.object(forKey: key) else { return nil }
return Data(referencing: value)
}
func clear() {
storage.removeAllObjects()
}
}
이 예제에서는 UI 상태와 일관된 실행 맥락에서 사용할 수 있도록 래퍼를 메인 액터로 격리했습니다. NSCache 자체를 메인 스레드에서만 접근할 수 있다는 뜻은 아닙니다. 백그라운드 접근이 필요하다면 제약을 무작정 제거하지 말고, 별도의 격리 방식을 정의해야 합니다.
격리, 버전, 스냅샷 테스트하기
첫 번째 테스트는 서로 다른 계정에 의도적으로 같은 리소스 ID를 사용하고, 동등한 키를 새로 만들어 조회합니다. 이렇게 해야 단순히 “같은 키 객체로 다시 읽을 수 있다”는 사실만 검증하는 데 그치지 않습니다. 두 번째 테스트는 원본 Data를 수정한 뒤 캐시 값과 명시적인 비우기 동작을 확인합니다.
final class SessionMemoryCacheTests: XCTestCase {
@MainActor
func testIdentityIsolation() {
let cache = SessionMemoryCache()
cache.store(Data([1]), for: MemoryKey("a", "card", "v1"))
cache.store(Data([2]), for: MemoryKey("b", "card", "v1"))
XCTAssertEqual(
cache.read(MemoryKey("a", "card", "v1")), Data([1])
)
XCTAssertEqual(
cache.read(MemoryKey("b", "card", "v1")), Data([2])
)
XCTAssertNil(cache.read(MemoryKey("a", "card", "v2")))
}
@MainActor
func testSnapshotAndClear() {
let cache = SessionMemoryCache()
let key = MemoryKey("a", "card", "v1")
var source = Data([1, 2])
cache.store(source, for: key)
source.append(3)
XCTAssertEqual(cache.read(key), Data([1, 2]))
cache.clear()
XCTAssertNil(cache.read(key))
}
}
이 작은 객체들은 리소스가 정상적인 상태에서 기능 회귀를 확인하기 위한 것입니다. 마침 시스템이 항목을 제거하면 캐시 적중을 확인하는 단언문도 실패할 수 있습니다. 비즈니스 로직을 엄격하게 결정론적으로 테스트하려면 딕셔너리로 구현한 캐시 테스트 대역을 주입해야 합니다. 전체 테스트 스위트가 시스템의 객체 보존 여부에 의존하게 만들지 마세요.
같은 키의 값을 덮어쓰는 테스트도 추가해야 합니다. 두 값을 연속으로 저장한 뒤 읽으면 두 번째로 저장한 데이터가 나와야 합니다. 빈 Data와 캐시 미스도 구분해야 합니다. 전자는 유효한 값이고, 후자는 nil입니다.
용량 힌트를 항목 제거 알고리즘으로 해석하지 않기
이 예제의 비용 단위는 바이트입니다. 저장할 때마다 data.count를 전달하기 때문입니다. totalCostLimit의 단위는 호출 측에서 정하는 것이며, 자동으로 집계한 프로세스 메모리 사용량이 아닙니다. 객체, 키, 컨테이너 자체에도 추가 오버헤드가 있습니다. 이미지를 디코딩된 형태로 저장한다면 압축 파일의 크기를 실제 비용으로 사용할 수도 없습니다.
countLimit과 totalCostLimit은 모두 엄격한 상한이 아닙니다. 객체를 몇 개 넣은 뒤 가장 먼저 저장한 객체가 반드시 사라졌다고 단언해서는 안 되며, NSCache가 결정론적인 LRU 정책을 구현한다고 가정해서도 안 됩니다. 메모리 압박 실험에서는 캐시 적중률과 프로세스 메모리 변화만 보고하고, 매번 병합할 때 반드시 통과해야 하는 고정된 결과로 삼지 않습니다.
마찬가지로 큰 배열을 끝없이 할당해 시스템 정리를 유도하지 마세요. 캐시가 정리되기 전에 테스트 프로세스가 종료되기 쉽습니다. 그렇게 얻는 결과는 리소스 고갈일 뿐, 캐시 계약을 검증하는 근거가 아닙니다.
캐시 미스와 로그아웃 흐름을 인수 테스트에 포함하기
비즈니스 로직의 읽기 계층에 항상 nil을 반환하는 캐시 테스트 대역을 주입하고, 로더가 여전히 기준 데이터를 가져올 수 있는지 확인합니다. 이어서 값이 들어 있는 테스트 대역을 주입해, 캐시 사용이 허용된 분기에서 데이터를 중복으로 불러오지 않는지 확인합니다. 이렇게 하면 실제 항목 제거를 기다리지 않고도 두 경로를 직접 검증할 수 있습니다.
로그아웃할 때는 먼저 이전 계정의 요청을 중단하고 세션 식별 정보를 갱신한 다음 캐시를 비웁니다. 캐시를 비우는 것만으로는 충분하지 않습니다. 이전 요청이 나중에 완료되면 데이터를 다시 써 넣을 수 있기 때문입니다. 저장하기 전에 요청이 캡처한 세션 세대 값과 현재 세대 값을 비교하고, 다르면 저장을 포기해야 합니다. 이 규칙은 로딩 조정 계층의 책임이며, 별도로 테스트해야 합니다.
마지막으로 네 가지를 점검하세요. 계정 키가 신뢰할 수 있는 세션에서 오는지, 콘텐츠가 갱신될 때 버전 필드도 바뀌는지, 로그아웃 후 이전 요청이 데이터를 다시 저장할 수 있는지, 캐시 미스가 나도 로딩을 완료할 수 있는지 확인합니다. 안정적인 비즈니스 단언은 일상적인 회귀 테스트에 넣고, 결과가 불안정한 메모리 압박 관찰은 진단용으로 남겨 두세요. 그래야 캐시가 간헐적 장애를 일으키는 숨은 전제 조건이 되지 않습니다.
자주 묻는 질문
countLimit으로 캐시 객체 수를 정확히 보장할 수 있나요?
아니요. countLimit과 totalCostLimit은 정확한 개수 검증에 사용할 수 있는 엄격한 상한이 아닙니다. 캐시 미스 복구를 테스트하고, 엄격한 용량과 제거 순서가 필요하면 별도의 결정적 정책을 구현해야 합니다.
다른 계정의 캐시를 읽지 않는지 어떻게 검증하나요?
키에 계정 네임스페이스를 포함하고 로그아웃 시 캐시를 명시적으로 비웁니다. 같은 리소스 식별자를 사용하는 두 계정에 다른 값을 저장한 뒤 읽기 분리와 초기화 후 미스를 확인합니다.