캐시는 성능 설계에서 가장 먼저 손이 가지만, 가장 자주 사고를 내는 도구다. 무엇을 아끼려고 캐시를 두는지, 그 대가로 어떤 불일치를 떠안는지, 그리고 iOS의 메모리·저장소·수명주기 제약 안에서 언제·무엇을 비워야 하는지를 5개 질문으로 정리한다.
Q1. 캐시는 어떤 비용을 줄이고 어떤 일관성 문제를 만드는가?
캐시는 "공간(또는 최신성)을 내주고 시간을 사는" 거래다. 비싼 연산·먼 곳의 데이터를 한 번 구한 결과를 소비자 가까이에 사본으로 두어 지연시간·대역폭·에너지·과금을 줄인다. 대가는 그 사본이 원본(source of truth)과 어긋난다는 것 — 원본이 바뀌는 순간부터 캐시는 낡는다(stale). 이게 캐시 일관성·무효화 문제이고, 핵심 지표는 원본 변경부터 캐시 반영까지의 시간인 staleness window다. 그래서 캐시를 넣기 전에 "언제 어떻게 이걸 무효화할지"를 먼저 정해야 한다.
CS 원리
캐시가 줄이는 비용은 하나가 아니다. 지연시간(원본까지의 round-trip), 처리량·대역폭(원본 호출 횟수), 에너지(네트워크 라디오와 CPU를 깨우는 횟수), 그리고 돈(백엔드 호출 과금)까지 함께 준다. 이 이득은 전부 참조 지역성(locality) 가정 위에 선다 — 최근에 쓴 것을 또 쓰고(temporal), 가까운 것을 이어서 쓴다(spatial). 가정이 깨지면(전부 유니크 키로 한 번씩만 접근) 캐시는 이득 없이 메모리만 먹는다.
대가는 구조적이다. 캐시는 정의상 원본의 사본이므로, 원본이 갱신되는 순간 두 값이 갈라진다. 이걸 다스리는 정책 축이 쓰기 경로에서는 write-through(캐시와 원본을 함께 갱신), write-back(캐시 먼저·원본은 지연), write-around(원본만 갱신하고 캐시는 무효화), 읽기 경로에서는 read-through다. 어디에 서느냐가 강한 일관성(캐시가 늘 원본을 반영)과 최종 일관성(잠깐 어긋나도 됨) 사이의 선택이다.
iOS에서는
iOS는 계층마다 다른 캐시를 쥐어준다. HTTP 응답은 URLCache가 Cache-Control·ETag·Last-Modified를 존중해 관리하고, 유효성 재검증 시 조건부 요청으로 304를 받아 본문 전송 없이 신선도만 확인한다. 인메모리 객체는 NSCache, 이미지는 대개 메모리+디스크 2단 캐시(Kingfisher·Nuke 등), 서버 데이터의 로컬 사본은 Core Data/SQLite가 맡는다. 일관성 문제는 이렇게 드러난다 — 사용자가 서버에서 프로필을 바꿨는데 앱은 캐시된 옛 아바타를 계속 그리거나, 다른 기기에서 추가된 목록 항목이 이 기기 캐시에는 없다.
실험 · 도구
캐시의 가치는 hit rate로 증명한다. 조회 지점에 hit/miss 카운터를 심어 실측하고, Instruments의 Network·Time Profiler로 캐시 도입 전후 원본 호출 수와 지연을 비교한다. 일관성 위험은 반대로 재현한다 — 백엔드에서 값을 바꾼 뒤 앱을 그대로 두고 stale UI가 얼마나 오래 유지되는지(=staleness window) 관찰하면, TTL·무효화 설계의 구멍이 그대로 보인다.
프로젝트 적용
① 캐시는 파생·재계산 가능한 데이터에만 둔다. 변경(mutation)의 진실 원본을 캐시에 두고 그걸 기준으로 쓰지 마라.
import Foundation
// 캐시는 언제든 사라질 수 있는 '사본'이다.
// 진실 원본(source of truth)을 캐시에 두면 그게 사라지는 순간 데이터 유실이다.
// ❌ 사용자가 작성 중인 글을 캐시에
final class BadDraftCache {
private let cache = NSCache<NSString, NSString>() // 💥 메모리 압박에 사라진다
func save(_ text: String, id: String) {
cache.setObject(text as NSString, forKey: id as NSString)
}
}
// ✅ 원본은 영속 저장소에, 캐시는 그 파생물만
final class DraftStore {
private let disk: URL // 원본
private let memory = NSCache<NSString, NSString>() // 빠른 조회용 사본
init(disk: URL) { self.disk = disk }
func save(_ text: String, id: String) throws {
try text.write(to: disk.appendingPathComponent(id),
atomically: true, encoding: .utf8) // ✅ 원본 먼저
memory.setObject(text as NSString, forKey: id as NSString)
}
func load(id: String) -> String? {
if let hit = memory.object(forKey: id as NSString) { return hit as String }
// ✅ 캐시가 비어도 원본에서 복구된다
guard let text = try? String(contentsOf: disk.appendingPathComponent(id),
encoding: .utf8) else { return nil }
memory.setObject(text as NSString, forKey: id as NSString)
return text
}
}
// ✅ 캐시해도 되는 것 판별
// · 서버에서 다시 받을 수 있다 → 캐시 OK
// · 원본 데이터로 다시 계산할 수 있다 → 캐시 OK
// · 사라지면 복구 경로가 없다 → 캐시 ✗ (저장소로)
//
// 예: 디코딩된 이미지 ✅ / 계산된 레이아웃 ✅ / 서버 응답 ✅
// 작성 중인 글 ✗ / 오프라인 큐 ✗ / 사용자 설정 ✗② 무효화 스토리 없이 캐시를 추가하지 않는다. "이 항목은 무엇이 바뀌면, 어떻게 사라지나?"에 답 못 하면 아직 넣을 때가 아니다.
import Foundation
// 캐시를 추가하기 전에 답해야 하는 질문:
// "이 항목은 무엇이 바뀌면, 어떻게 사라지나?"
// 답이 없으면 아직 넣을 때가 아니다.
/// 프로필 캐시
/// - 무효화 조건:
/// ① 사용자가 프로필을 수정 → 즉시 invalidate(userID)
/// ② 팔로우/언팔로우 → 즉시 invalidate(userID)
/// ③ 그 외 서버 변경 → TTL 5분으로 자연 만료 (안전망)
/// - 스코프: 로그아웃 시 전체 삭제
actor ProfileCache {
private struct Entry { let profile: Profile; let expiresAt: Date }
private var storage: [String: Entry] = [:]
private let ttl: TimeInterval = 300
func profile(for id: String) -> Profile? {
guard let e = storage[id], e.expiresAt > Date() else {
storage[id] = nil
return nil
}
return e.profile
}
func store(_ profile: Profile, for id: String) {
storage[id] = Entry(profile: profile,
expiresAt: Date().addingTimeInterval(ttl))
}
// ✅ 무효화 경로를 명시적으로 노출한다
func invalidate(_ id: String) { storage[id] = nil }
func invalidateAll() { storage.removeAll() }
}
// ✅ 무효화 호출 지점을 한곳에 모아 두면 빠뜨리기 어려워진다
enum CacheInvalidation {
static func onProfileUpdated(_ id: String) async {
await ProfileCacheRef.shared.invalidate(id)
await FeedCacheRef.shared.invalidateAuthor(id) // 연쇄 무효화도 여기서
}
static func onLogout() async {
await ProfileCacheRef.shared.invalidateAll()
await FeedCacheRef.shared.invalidateAll()
}
}
struct Profile {}
enum ProfileCacheRef { static let shared = ProfileCache() }
actor FeedCacheImpl {
func invalidateAuthor(_ id: String) {}
func invalidateAll() {}
}
enum FeedCacheRef { static let shared = FeedCacheImpl() }③ 캐시 키에 스키마/빌드 버전을 섞어라. 앱 업데이트로 응답 포맷이 바뀌면 옛 캐시가 조용히 깨진 데이터를 준다.
import Foundation
// 앱을 업데이트해 응답 포맷이 바뀌면, 옛 캐시가 새 코드에 잘못 디코딩되거나
// 조용히 이상한 값을 준다. 키에 버전을 넣으면 옛 캐시가 자동으로 미아가 된다.
enum CacheKey {
/// 응답 스키마를 바꿀 때마다 올린다
private static let schemaVersion = 3
/// 앱 빌드가 바뀌면 무효화하고 싶은 캐시용
private static var buildVersion: String {
Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "0"
}
static func feed(userID: String) -> String {
"v\(schemaVersion).feed.\(userID)"
}
static func layout(itemID: String, width: Int) -> String {
// 레이아웃 캐시는 빌드마다 무효화 (폰트·디자인 변경 반영)
"b\(buildVersion).layout.\(itemID).\(width)"
}
static func image(url: URL, size: CGSize, scale: CGFloat) -> String {
"v\(schemaVersion).img.\(url.absoluteString).\(Int(size.width * scale))x\(Int(size.height * scale))"
}
}
// ✅ 디스크 캐시는 버전이 바뀌면 옛 디렉터리를 통째로 지운다
func migrateDiskCache(currentVersion: Int, root: URL) throws {
let fm = FileManager.default
let current = root.appendingPathComponent("v\(currentVersion)")
try fm.createDirectory(at: current, withIntermediateDirectories: true)
// 다른 버전 디렉터리는 정리한다
for entry in try fm.contentsOfDirectory(at: root, includingPropertiesForKeys: nil) {
if entry.lastPathComponent != "v\(currentVersion)" {
try? fm.removeItem(at: entry)
}
}
}
// ⚠️ 사용자 데이터가 아니라 '캐시'이므로 통째로 지워도 안전하다.
// 이 판단이 성립하지 않으면 그건 캐시가 아니다(→ 첫 번째 항목).④ 보안·개인정보성 데이터는 만료 없이 캐시하지 않는다.
import Foundation
// 개인정보·인증 관련 데이터를 무기한 캐시하면, 로그아웃 후에도
// 기기에 남아 다음 사용자나 기기 탈취자에게 노출된다.
actor SensitiveCache {
private struct Entry { let value: Data; let expiresAt: Date }
private var storage: [String: Entry] = [:]
/// ✅ 민감 데이터는 TTL을 반드시 둔다 (그리고 짧게)
private let ttl: TimeInterval = 120
func value(for key: String) -> Data? {
guard let e = storage[key], e.expiresAt > Date() else {
storage[key] = nil; return nil
}
return e.value
}
func store(_ value: Data, for key: String) {
storage[key] = Entry(value: value, expiresAt: Date().addingTimeInterval(ttl))
}
/// ✅ 로그아웃·세션 종료 시 전체 삭제 경로가 반드시 있어야 한다
func purgeAll() { storage.removeAll() }
}
// ✅ 세션 종료 시 모든 민감 캐시를 한 번에 지운다
enum SessionTeardown {
static func onLogout() async {
await SensitiveCacheRef.shared.purgeAll()
URLCache.shared.removeAllCachedResponses() // HTTP 캐시도
HTTPCookieStorage.shared.removeCookies(since: .distantPast)
try? FileManager.default.removeItem(at: profileImageCacheDir)
}
}
// ⚠️ 놓치기 쉬운 자리들
// · URLCache — 인증된 응답이 디스크에 남는다
// · 쿠키 저장소
// · 웹뷰의 WKWebsiteDataStore
// · 프로필 이미지 등 개인 식별 가능한 파일 캐시
// · 앱 전환 스냅샷 (민감 화면은 가려야 한다)
// ✅ 애초에 캐시하지 않는 선택도 있다 — ephemeral 세션
func sensitiveSession() -> URLSession {
URLSession(configuration: .ephemeral) // 디스크 캐시·쿠키·자격증명 저장 없음
}
enum SensitiveCacheRef { static let shared = SensitiveCache() }
let profileImageCacheDir = URL(fileURLWithPath: NSTemporaryDirectory() + "profiles")"캐시는 항상 앱을 빠르게 한다"는 반쪽 진실이다. miss일 때는 조회 계층을 더 거쳐 오히려 느리고, 지역성이 없으면 메모리만 먹다 압박으로 축출·재로딩을 반복하며, 무엇보다 stale 데이터는 "빠르게 틀린 답"을 준다. 캐시는 공짜가 아니라 일관성 관리 부채와 메모리 압박을 새로 짊어지는 선택이다.
친구 전화번호를 매번 물어보기 귀찮아서 수첩에 적어뒀다고 하자. 다음부터는 수첩만 펴면 되니까 훨씬 빠르다. 이게 캐시다. 그런데 친구가 번호를 바꾸면 어떻게 될까. 수첩에는 여전히 옛 번호가 적혀 있고, 내가 그 사실을 알기 전까지는 계속 틀린 번호로 전화를 건다. 친구가 번호를 바꾼 순간부터 내가 수첩을 고칠 때까지의 그 구간이 바로 staleness window다. 그래서 수첩을 만들기 전에 "번호가 바뀌면 내가 어떻게 알지?"부터 정해 두는 게 순서다.
또 하나. 수첩에 없는 번호를 찾을 때는 수첩을 한참 뒤진 다음에 결국 친구한테 물어봐야 한다. 뒤진 시간이 통째로 손해다. 캐시 miss가 캐시 없을 때보다 오히려 느린 이유가 이거다. 비유가 어긋나는 지점 하나 — 수첩은 내가 찢기 전엔 안 없어지지만, 앱의 메모리 캐시는 기기 메모리가 부족하면 시스템이 나한테 안 물어보고 지워 버린다.
꼬리 질문
쓰기가 읽기보다 많은 write-heavy 워크로드에서도 캐시가 이득인가? 언제 캐시가 오히려 손해인가?
이득은 재사용에서 나오므로, 넣은 값을 읽기도 전에 덮어쓰는 write-heavy 워크로드에선 캐시의 hit이 별로 없어 이득이 얇다. 오히려 write마다 캐시를 갱신하거나 무효화하는 부가 작업, 그리고 write-through의 이중 쓰기 지연이 붙어 순손해가 되기 쉽다. 유니크 키를 한 번씩만 훑는 접근(지역성 없음)이나 캐시 유지 비용이 miss 절감분을 넘는 경우도 마찬가지다. 이럴 땐 읽기 캐시 대신 write-back 버퍼링이나 배치 쓰기로 쓰기 쪽을 아끼는 편이 낫다.
잔액·재고처럼 강한 일관성이 필수인 데이터를 굳이 캐시한다면 어떤 정책(write-through, 짧은 TTL, 재검증)을 조합해야 하나?
핵심은 캐시를 "표시용"으로만 쓰고 판단·차감은 항상 원본에서 하는 것이다. 즉 잔액을 화면에 빠르게 보여주되, 결제·주문 확정은 서버가 최신 값을 다시 읽어 처리(가능하면 조건부 업데이트로 낙관적 락)한다. 캐시 자체는 write-through로 쓰기와 함께 갱신하고, 짧은 TTL로 낡음 상한을 막고, ETag 재검증으로 "안 바뀐 경우"의 낭비를 줄이는 조합이 무난하다. 그래도 "캐시 값으로 돈을 움직이지 않는다"는 원칙이 어떤 TTL보다 우선한다.
cache stampede(만료 순간 동시 미스가 원본으로 몰리는 현상)는 왜 생기고, single-flight·jitter·stale-while-revalidate로 어떻게 막나?
인기 키의 TTL이 한 시점에 만료되면, 그 순간 도착한 요청이 전부 miss가 되어 동시에 원본을 때린다. 캐시가 있으나마나 한 부하 폭이 원본에 꽂히는 현상이다. single-flight는 같은 키에 대해 재계산을 딱 하나만 실행하고 나머지는 그 결과를 기다리게 해 원본 호출을 1회로 접는다. TTL에 무작위 jitter를 더하면 만료가 한 점에 몰리지 않게 흩어지고, stale-while-revalidate는 만료돼도 낡은 값을 즉시 돌려주면서 뒤에서 조용히 갱신해 "만료 절벽"을 없앤다. iOS에선 이미지·API 로더에 요청 병합(in-flight dedup)을 두는 형태로 자주 구현한다.
jitter, 반장 한 명만 대표로 받아 오고 나머지는 그 옆에서 기다리는 게 single-flight, 새 밥이 다 될 때까지 일단 아까 지은 밥을 내주는 게 stale-while-revalidate다.Q2. 메모리 캐시와 디스크 캐시는 어떻게 구분해야 하는가?
둘을 가르는 건 조회 방식이 아니라 휘발성·속도·용량·지속성이다. 메모리 캐시는 빠르지만(ns~µs) 작고, 프로세스가 죽으면 사라진다. 디스크 캐시는 느리지만(µs~ms, 직렬화 비용 포함) 크고 콜드 런치를 넘어 살아남는다. 그래서 뜨겁고 작고 자주 쓰는 데이터는 메모리, 크고 차갑고 다시 만들기 비싼 데이터는 디스크에 둔다. 흔히 둘을 L1(메모리)/L2(디스크)로 겹쳐 쓰고, 조회는 메모리 → 디스크 → 원본 순으로 내려간다.
CS 원리
이건 메모리 계층(memory hierarchy)의 축소판이다. 레지스터 → L1/L2/L3 캐시 → RAM → SSD/플래시 → 네트워크로 갈수록 커지고 느려진다는 그 원리를, 앱 안에서 메모리 캐시와 디스크 캐시로 다시 그린 것이다. 메모리 캐시는 살아 있는 객체를 그대로 들고 있어 접근이 즉각적이지만 프로세스 주소공간에 묶여 있어 종료와 함께 증발한다. 디스크 캐시는 값을 직렬화(encode/decode)해 저장하므로 그 변환 비용과 I/O 지연이 붙지만, 용량이 크고 재시작을 넘어 지속된다. memory-mapped file(mmap)은 이 경계를 흐리는 특수 사례로, 디스크에 있지만 페이지 폴트로 필요한 부분만 메모리로 올라온다.
iOS에서는
메모리 캐시의 대표는 NSCache로, 메모리 압박이 오면 시스템이 자동으로 비우고 디스크로 내려가지 않는다. 디스크 캐시는 파일로 남기는데, 어느 디렉터리에 두느냐가 성격을 결정한다. iOS File System 가이드에 따르면 Library/Caches는 iCloud/iTunes 백업에서 제외되고 저장 공간이 부족하면 OS가 임의로 비울 수 있는, 재생성 가능한 데이터 전용이다. 반면 Documents는 백업되고 함부로 삭제되지 않으므로 사용자가 만든 진짜 데이터를 둔다. URLCache 자체도 메모리·디스크 용량을 각각 파라미터로 받아 2단으로 동작한다.
실험 · 도구
구분이 실제로 먹는지는 콜드 런치와 웜 런치를 비교하면 드러난다. 앱을 완전히 종료(메모리 캐시 소멸) 후 재실행해 첫 로딩 시간을 재고, 이어서 백그라운드→포그라운드(메모리 캐시 생존) 로딩과 비교한다. 콜드에서만 느리면 그 데이터는 디스크 캐시로만 살아 있다는 뜻이다. Instruments Allocations로 메모리 캐시 실제 footprint를, du -sh나 파일 매니저로 Library/Caches 크기 증가를 추적한다.
프로젝트 적용
① 메모리에는 뜨겁고 작고 자주 쓰는 것, 디스크에는 크고 차갑고 재생성 비싼 것. 둘을 겹쳐 쓸 땐 조회 순서(메모리→디스크→원본)와 승격 규칙을 명시한다.
import UIKit
// L1(메모리) → L2(디스크) → 원본. 각 계층의 역할과 승격 규칙을 코드에 드러낸다.
actor TwoTierCache {
private let memory = NSCache<NSString, UIImage>() // 뜨겁고 작은 것
private let diskRoot: URL // 크고 차갑고 재생성 비싼 것
init(diskRoot: URL) {
self.diskRoot = diskRoot
memory.countLimit = 200
memory.totalCostLimit = 48 * 1024 * 1024
}
func image(for key: String, fetch: () async throws -> Data) async throws -> UIImage? {
let nsKey = key as NSString
// ① 메모리
if let hit = memory.object(forKey: nsKey) { return hit }
// ② 디스크 → 히트하면 메모리로 승격
let fileURL = diskRoot.appendingPathComponent(key)
if let data = try? Data(contentsOf: fileURL), let image = UIImage(data: data) {
// cost는 '메모리에 얹히는 크기' — 압축 파일 크기(data.count)를 넣으면
// 예산이 10~20배 어긋나 48MB 상한이 수백 MB로 무력화된다
let cost = Int(image.size.width * image.size.height * image.scale * image.scale * 4)
memory.setObject(image, forKey: nsKey, cost: cost) // ✅ 승격
return image
}
// ③ 원본 → 두 계층 모두에 채운다
let data = try await fetch()
guard let image = UIImage(data: data) else { return nil }
memory.setObject(image, forKey: nsKey,
cost: Int(image.size.width * image.size.height * image.scale * image.scale * 4))
try? data.write(to: fileURL, options: .atomic)
return image
}
/// 메모리만 비운다 (메모리 경고 시)
func purgeMemory() { memory.removeAllObjects() }
/// 디스크까지 비운다 (로그아웃·버전 변경 시)
func purgeAll() throws {
memory.removeAllObjects()
try FileManager.default.removeItem(at: diskRoot)
try FileManager.default.createDirectory(at: diskRoot, withIntermediateDirectories: true)
}
}
// 판단표
// 자주 쓰고 작고 재생성 싸다 → 메모리만
// 가끔 쓰고 크고 재생성 비싸다 → 디스크만
// 자주 쓰고 재생성 비싸다 → 둘 다 (승격 규칙 명시)② 재생성 가능한 데이터는 반드시 Library/Caches(또는 tmp)에, 사용자 데이터는 Documents에. 이 둘을 섞으면 백업 폭증이나 데이터 유실이 난다.
import Foundation
// 이 둘을 섞으면 두 방향으로 사고가 난다.
// 캐시를 Documents에 → 백업이 불필요하게 커지고 심사에서 지적된다
// 사용자 데이터를 Caches에 → 어느 날 사라지고 복구 불가
enum CachePaths {
private static let fm = FileManager.default
/// 이미지·응답 캐시 — 백업 제외 자동, OS가 지울 수 있다
static func imageCache() throws -> URL {
let caches = try fm.url(for: .cachesDirectory, in: .userDomainMask,
appropriateFor: nil, create: true)
let dir = caches.appendingPathComponent("images", isDirectory: true)
try fm.createDirectory(at: dir, withIntermediateDirectories: true)
return dir
}
/// 오프라인 지도처럼 '캐시지만 사라지면 곤란한' 것
/// → Documents에 두되 백업에서 명시적으로 제외한다
static func offlineMaps() throws -> URL {
let docs = try fm.url(for: .documentDirectory, in: .userDomainMask,
appropriateFor: nil, create: true)
var dir = docs.appendingPathComponent("maps", isDirectory: true)
try fm.createDirectory(at: dir, withIntermediateDirectories: true)
var values = URLResourceValues()
values.isExcludedFromBackup = true // ✅ 백업은 제외
try dir.setResourceValues(values) // 하지만 OS가 지우지도 않는다
return dir
}
/// 사용자가 만든 것 — 백업 대상
static func userDocuments() throws -> URL {
try fm.url(for: .documentDirectory, in: .userDomainMask,
appropriateFor: nil, create: true)
}
}
// ⚠️ 세 번째 경우가 헷갈린다: "캐시인데 사라지면 안 되는 것"
// (오프라인 다운로드 콘텐츠, 미리 받은 지도 타일 등)
// → Caches는 위험하다. Caches는 위험하다. 앱이 만든 지원 파일이면 Library/Application Support + isExcludedFromBackup이 맞고, 사용자가 직접 받아 관리하는 콘텐츠라면 Documents + isExcludedFromBackup을 쓴다.
// ✅ 현재 사용량을 확인하는 방법
func directorySize(_ url: URL) throws -> Int {
let keys: Set<URLResourceKey> = [.fileSizeKey]
guard let e = FileManager.default.enumerator(at: url,
includingPropertiesForKeys: Array(keys))
else { return 0 }
var total = 0
for case let f as URL in e {
total += (try? f.resourceValues(forKeys: keys).fileSize) ?? 0
}
return total
}③ 디스크 캐시도 상한을 두고 LRU로 trim한다. 무한히 커지면 사용자 저장공간을 잡아먹어 "이 앱이 왜 이렇게 크냐"는 항의를 부른다.
import Foundation
// 상한이 없으면 사용자 저장공간을 계속 먹는다.
// "이 앱이 왜 3GB나 차지하죠?"는 대부분 정리 없는 디스크 캐시 때문이다.
actor DiskCache {
private let root: URL
private let maxBytes: Int
init(root: URL, maxBytes: Int = 200 * 1024 * 1024) throws {
self.root = root
self.maxBytes = maxBytes
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
}
func store(_ data: Data, for key: String) throws {
try data.write(to: root.appendingPathComponent(key), options: .atomic)
}
func load(_ key: String) -> Data? {
let url = root.appendingPathComponent(key)
// ✅ 수정 시각을 touch해 LRU 기준을 갱신한다
// (FileAttributeKey에는 접근 시각용 키가 없으므로 trim도 같은 수정 시각으로 정렬해야 한다)
try? FileManager.default.setAttributes([.modificationDate: Date()],
ofItemAtPath: url.path)
return try? Data(contentsOf: url)
}
/// 오래 안 쓴 것부터 지워 상한 이하로 맞춘다
func trim() throws {
let keys: Set<URLResourceKey> = [.contentModificationDateKey, .fileSizeKey]
let files = try FileManager.default.contentsOfDirectory(
at: root, includingPropertiesForKeys: Array(keys))
var entries = files.compactMap { url -> (URL, Date, Int)? in
guard let v = try? url.resourceValues(forKeys: keys) else { return nil }
return (url, v.contentModificationDate ?? .distantPast, v.fileSize ?? 0)
}
var total = entries.reduce(0) { $0 + $1.2 }
guard total > maxBytes else { return }
entries.sort { $0.1 < $1.1 } // 오래된 것 먼저
for (url, _, size) in entries {
guard total > maxBytes else { break }
try? FileManager.default.removeItem(at: url)
total -= size
}
}
}
// ✅ trim을 부르는 시점: 앱 백그라운드 진입, 주기적 유지보수 작업
// ✅ 사용자에게 캐시 용량과 삭제 버튼을 제공하는 것도 좋은 관행이다"디스크 캐시니까 영구적"이라는 가정은 iOS에서 틀리다. Library/Caches는 저장 공간이 부족하면 OS가 앱 실행 중이 아닐 때 언제든 비울 수 있다. 지속을 보장받아야 하는 건 캐시가 아니라 저장(storage)이며, 그건 Documents 또는 명시적 영속 계층(Core Data 등)의 몫이다.
공부하는 책상을 떠올려 보자. 지금 펴놓고 보는 책은 손만 뻗으면 바로 잡힌다. 대신 저녁에 청소부가 와서 책상 위를 싹 치운다. 메모리 캐시가 딱 이거다. 빠르지만 앱이 완전히 꺼지면 그 위의 것은 전부 없어진다. 디스크 캐시는 지하 창고에 넣어둔 박스다. 가지러 내려갔다 와야 하고, 테이프를 뜯어 꺼내는 수고(직렬화 해제)도 든다. 대신 내일 와도 그대로 있다.
여기서 제일 헷갈리는 게 창고의 종류다. Library/Caches는 "건물이 좁아지면 관리인이 나한테 안 묻고 버려도 되는 공용 창고"다. 그러니 잃어버리면 큰일 나는 물건을 거기 두면 안 된다. 그런 건 Documents라는 내 개인 금고에 넣어야 한다. mmap은 좀 특이한 방식인데, 창고 박스를 통째로 들고 올라오는 대신 필요한 페이지 한 장씩만 그때그때 가져온다. 그 "지금 없네, 가져올게" 하는 순간이 page fault다.
꼬리 질문
memory-mapped file(mmap)로 읽는 데이터는 메모리 캐시로 세야 하나 디스크 캐시로 세야 하나? footprint 계산에서 어떻게 잡히나?
mmap은 둘의 경계에 있다. 파일은 디스크에 있지만 접근하는 순간 페이지 폴트로 필요한 페이지만 물리 메모리로 올라오므로, "디스크에 사는 데이터를 메모리처럼 읽는" 방식이다. iOS의 footprint 관점에서는 이 페이지들이 대부분 clean·file-backed라 메모리 압박 시 OS가 그냥 버리고 나중에 다시 읽으면 되기 때문에, 앱이 책임지는 dirty footprint로는 잡히지 않는다. 그래서 큰 리소스를 Data(contentsOf:options:.mappedIfSafe)로 mmap하면 Data(contentsOf:)로 통째 로드할 때보다 jetsam 압력이 훨씬 낮다. 단, 쓰기를 하거나 익명 매핑이면 dirty로 잡혀 얘기가 달라진다.
다시 받을 수 있지만 용량이 큰 데이터를 오프라인용으로 Documents에 저장해야 하는데 iCloud 백업은 부풀리고 싶지 않다면 어떻게 하나(isExcludedFromBackupKey)? 반대로 이 플래그를 남발하면 어떤 사고가 나나?
Documents에 둔 파일이라도 URLResourceValues의 isExcludedFromBackup을 true로 채워 url.setResourceValues(_:)로 쓰면(리소스 키 이름이 isExcludedFromBackupKey다) 그 항목만 iCloud/iTunes 백업에서 뺄 수 있다. 디렉터리에 걸면 그 아래 전체가 함께 제외된다. 이게 Library/Caches와 결정적으로 다른 점은 OS가 저장공간 압박 때 이 파일을 임의로 지우지 않는다는 것이라, 오프라인 재생을 위해 로컬에 반드시 살아 있어야 하지만 서버에서 다시 받을 수 있어 백업까지 부풀릴 이유는 없는 큰 미디어에 딱 맞는다. 실제로 재다운로드 가능한 큰 파일을 이 플래그 없이 백업되는 위치에 쌓는 건 앱스토어 리뷰 리젝의 단골 사유다. 반대로 이 플래그를 아무 데나 붙이면 정작 백업으로 지켜야 할 사용자 데이터가 기기 복원 시 소리 없이 사라지는, 더 위험한 유실 사고가 난다.
디스크 캐시 직렬화 포맷을 JSON, 바이너리(plist/Protobuf), SQLite 중 무엇으로 하느냐가 조회 속도·부분 무효화·마이그레이션에 어떻게 영향을 주나?
JSON은 사람이 읽기 쉽고 스키마 유연성이 좋지만 파싱 비용이 크고, 항목 하나만 바꾸려 해도 파일 전체를 다시 쓰기 쉬워 부분 무효화가 약하다. 바이너리 plist나 Protobuf는 인코딩·디코딩이 빠르고 크기가 작지만 여전히 "파일 단위" 갱신에 가깝고, Protobuf는 필드 번호 규약으로 스키마 진화(마이그레이션)를 잘 버틴다. SQLite는 인덱스로 개별 행 조회·갱신·삭제가 빨라 부분 무효화와 쿼리에 압도적으로 유리하고 PRAGMA user_version 기반 마이그레이션도 정착돼 있지만, 스키마 설계와 트랜잭션 관리라는 초기 비용이 든다. 캐시가 "키 하나 통째 저장/조회"면 바이너리 파일, "여러 항목을 부분 갱신·검색"이면 SQLite(또는 Core Data)가 맞다.
Q3. TTL과 명시적 무효화는 어떻게 선택하는가?
TTL(time-to-live)은 "일정 시간 지나면 알아서 만료"로, 단순하고 스스로 낫고 staleness 상한이 보장되지만 변경을 즉시 반영하진 못한다. 명시적 무효화는 쓰기·이벤트가 일어난 순간 해당 항목을 직접 지워 정확하지만, "그 캐시가 어디어디 있는지"를 다 알고 정확히 배선해야 하는 어려운 문제다. 실무 정답은 대개 둘의 결합이다 — 아는 변경은 명시적으로 무효화하고, 놓친 이벤트에 대비해 TTL을 안전망으로 깐다. 여기에 ETag 재검증을 얹으면 "만료됐지만 사실 안 바뀐" 경우의 낭비를 줄인다.
CS 원리
세 가지가 서로 다른 축이다. 만료(TTL)는 시간 기반 — 데이터가 얼마나 자주 바뀌는지 모르거나 약간의 낡음이 허용될 때 좋다. 무효화(invalidation)는 이벤트 기반 — 변경이 일어난 주체가 캐시를 능동적으로 지우거나 갱신한다. 정확도는 최고지만, 캐시 사본이 흩어져 있을수록 하나라도 빠뜨리면 그 항목은 영원히 stale이다(그래서 "캐시 무효화는 어렵다"는 말이 나온다). 재검증(validation)은 세 번째 축으로, 만료 대신 원본에 "이거 바뀌었어?"만 싸게 물어(ETag/If-None-Match) 안 바뀌었으면 304로 캐시를 그대로 연장한다. 무효화는 pull(클라이언트가 확인)과 push(서버가 알려줌)로 나뉜다.
iOS에서는
TTL은 URLCache에 Cache-Control: max-age로 자연스럽게 실린다. 재검증은 서버가 준 ETag를 다음 요청에 If-None-Match로 되보내 304를 받는 흐름이다. 명시적 무효화는 앱이 직접 짠다 — POST/PUT이 성공하면 로컬 Core Data를 갱신하고, 관련 캐시 키를 지우고, NotificationCenter나 Combine/RxSwift 스트림으로 화면에 "다시 그려라"를 알린다. 서버 주도(push) 무효화는 silent push나 소켓으로 "이 리소스 바뀜"을 내려보내 클라이언트 캐시를 지우게 한다.
실험 · 도구
Proxyman·Charles 같은 프록시로 응답 헤더를 들여다보면 TTL·재검증이 실제로 작동하는지 눈으로 확인된다. 두 번째 요청이 If-None-Match를 실어 나가고 서버가 304 Not Modified를 주는지, 아니면 매번 200으로 본문을 다시 받는지 보면 재검증 설정의 성패가 드러난다. 명시적 무효화는 "이벤트 유실"을 일부러 만들어 검증한다 — 무효화 알림을 한 번 건너뛴 뒤 TTL 안전망이 결국 화면을 복구하는지 확인한다.
프로젝트 적용
① 사용자가 방금 바꾼 것, 결제·주문 상태처럼 즉시성이 중요한 데이터는 명시적 무효화를 1순위로 하고, 짧은 TTL을 보조로 둔다.
import Foundation
// 사용자가 방금 바꾼 것이 화면에 안 보이면 그건 버그로 인식된다.
// 이런 데이터는 이벤트 발생 즉시 캐시를 지운다.
actor OrderCache {
private struct Entry { let order: Order; let expiresAt: Date }
private var storage: [String: Entry] = [:]
/// 결제·주문 상태는 TTL을 짧게 (안전망)
private let ttl: TimeInterval = 30
func order(_ id: String) -> Order? {
guard let e = storage[id], e.expiresAt > Date() else {
storage[id] = nil; return nil
}
return e.order
}
func store(_ order: Order) {
storage[order.id] = Entry(order: order,
expiresAt: Date().addingTimeInterval(ttl))
}
// ✅ 1순위 — 상태가 바뀌는 모든 경로에서 즉시 호출한다
func invalidate(_ id: String) { storage[id] = nil }
}
// ✅ 무효화 호출 지점을 빠짐없이 배선한다
@MainActor
final class OrderCoordinator {
private let cache = OrderCache()
func cancelOrder(_ id: String) async throws {
try await api.cancel(id)
await cache.invalidate(id) // ① 내 액션
}
func handlePush(_ payload: [String: Any]) async {
if let id = payload["orderID"] as? String {
await cache.invalidate(id) // ② 서버 푸시
}
}
func onForeground() async {
await cache.invalidate("current") // ③ 복귀 시 최신화
}
}
// ⚠️ ①②③ 중 하나라도 빠지면 그 경로에서 stale이 남는다.
// 그래서 짧은 TTL을 안전망으로 함께 둔다.
struct Order { let id: String }
enum api { static func cancel(_ id: String) async throws {} }② 거의 안 바뀌는 설정·정적 목록은 긴 TTL(+재검증)로 충분하다. 여기에 명시적 무효화까지 배선하는 건 과설계다.
import Foundation
// 국가 목록·약관·아이콘 세트처럼 몇 달에 한 번 바뀌는 데이터에
// 실시간 무효화를 배선하는 건 과설계다.
actor StaticDataCache {
private struct Entry<T> { let value: T; let expiresAt: Date }
private var countries: Entry<[Country]>?
/// 하루 — 이 정도면 충분하다
private let ttl: TimeInterval = 60 * 60 * 24
func countries(fetch: () async throws -> [Country]) async throws -> [Country] {
if let e = countries, e.expiresAt > Date() { return e.value }
let fresh = try await fetch()
countries = Entry(value: fresh, expiresAt: Date().addingTimeInterval(ttl))
return fresh
}
}
// ✅ 여기에 재검증(ETag)을 얹으면 "만료됐지만 사실 안 바뀐" 경우의 낭비도 없앤다
struct ConditionalFetch {
private var etag: String?
mutating func fetch(_ url: URL) async throws -> Data? {
var request = URLRequest(url: url)
if let etag { request.setValue(etag, forHTTPHeaderField: "If-None-Match") }
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse else { return data }
if http.statusCode == 304 { return nil } // ⚠️ URLCache가 붙은 세션은 304를 200 + 캐시 본문으로 바꿔 돌려준다.
// 직접 304를 다루려면 urlCache = nil 인 세션을 쓰거나 재검증을 URLSession에 맡긴다
etag = http.value(forHTTPHeaderField: "ETag")
return data
}
}
// ✅ URLSession의 HTTP 캐시가 이걸 자동으로 해 준다 (서버가 헤더를 주면)
func cacheAwareSession() -> URLSession {
let config = URLSessionConfiguration.default
config.requestCachePolicy = .useProtocolCachePolicy // Cache-Control·ETag 준수
config.urlCache = URLCache(memoryCapacity: 16 << 20, diskCapacity: 128 << 20)
return URLSession(configuration: config)
}
// 서버가 Cache-Control: max-age=86400을 주면 클라 코드 없이도 캐시된다
struct Country: Decodable {}③ 명시적 무효화만 믿지 마라. 앱 강제종료·푸시 미수신으로 이벤트는 유실될 수 있고, 그러면 그 항목은 영구 stale이 된다. TTL은 이 구멍을 메우는 최후의 방어선이다.
import Foundation
// 무효화는 '모든 변경 경로를 빠짐없이 배선했을 때만' 정확하다.
// 이벤트 하나만 유실돼도 그 항목은 영구 stale이 된다.
// 이벤트가 유실되는 실제 경로들
// · 푸시 알림 미수신 (권한 거부, 네트워크, 시스템 스로틀)
// · 앱 강제 종료 중 발생한 변경
// · 웹소켓 연결 끊김 구간
// · 다른 기기에서의 변경
// · 무효화 호출을 빠뜨린 새 코드 경로 (가장 흔하다)
actor ResilientCache<T> {
private struct Entry { let value: T; let storedAt: Date }
private var storage: [String: Entry] = [:]
/// 명시적 무효화가 1순위, TTL은 그게 실패했을 때의 안전망
private let safetyNetTTL: TimeInterval
init(safetyNetTTL: TimeInterval = 300) {
self.safetyNetTTL = safetyNetTTL
}
func value(for key: String) -> T? {
guard let e = storage[key] else { return nil }
// ✅ 이벤트를 놓쳤어도 여기서 스스로 회복한다
guard Date().timeIntervalSince(e.storedAt) < safetyNetTTL else {
storage[key] = nil
return nil
}
return e.value
}
func store(_ value: T, for key: String) {
storage[key] = Entry(value: value, storedAt: Date())
}
func invalidate(_ key: String) { storage[key] = nil }
}
// ✅ TTL 길이를 정하는 기준
// "이 데이터가 최대 얼마나 낡아 있어도 사용자가 납득하는가?"
// 주문 상태 → 30초
// 프로필 → 5분
// 피드 목록 → 2분
// 국가 목록 → 24시간
//
// ⚠️ TTL은 '정확도'를 위한 게 아니라 '자가 치유'를 위한 장치다.
// 둘은 대체재가 아니라 보완재다."명시적 무효화가 더 정확하니 TTL은 필요 없다"는 위험하다. 무효화는 모든 변경 경로를 빠짐없이 배선했을 때만 정확하다. 이벤트 하나만 유실돼도(버그·네트워크·앱 종료) 그 캐시는 스스로 회복하지 못한다. TTL은 정확도가 아니라 자가 치유를 위한 장치이므로, 둘은 대체재가 아니라 보완재다.
냉장고 우유로 생각해 보자. 우유갑에 찍힌 유통기한이 TTL이다. 아무도 안 알려줘도 날짜가 지나면 버리게 되니까, 잊어버려도 언젠가는 정리가 된다. 대신 어제 상했어도 날짜 전까지는 모른 채 마신다. 명시적 무효화는 엄마가 전화해서 "그 우유 상했으니까 지금 버려"라고 말해 주는 쪽이다. 훨씬 정확하다. 문제는 내가 전화를 못 받았을 때다. 유통기한이 안 찍혀 있으면 그 우유는 영원히 냉장고에 남는다.
그래서 실무에서는 둘 다 쓴다. 전화가 오면 바로 버리고, 전화를 놓쳐도 유통기한이 결국 치워 준다. 재검증(ETag)은 세 번째 방법인데, 버리기 전에 "이거 아직 그대로야?" 하고 한마디만 묻는 것이다. 그대로라는 답(304)이 오면 새 우유를 통째로 배달받을 필요가 없어 짐이 확 준다. 비유가 딱 맞진 않는 부분 — 우유는 시간이 지나면 진짜로 상하지만, 캐시 값은 TTL이 지나도 내용은 멀쩡할 수 있다. TTL은 "상했다"가 아니라 "이제부터는 못 믿겠다"는 선언에 가깝다.
꼬리 질문
TTL을 지나치게 짧게 잡으면 어떤 대가(원본 부하 급증, 배터리·데이터 소모)를 치르나? 적정 TTL은 무엇을 근거로 정하나?
TTL이 짧을수록 캐시 hit rate가 떨어져 원본 호출이 늘고, 모바일에선 그때마다 셀룰러 라디오와 CPU가 깨어나 배터리·데이터를 더 먹는다. 극단적으로 짧으면 캐시가 사실상 무력화되고 stampede 위험도 커진다. 적정값의 기준은 데이터가 실제로 얼마나 자주 바뀌는지(변경 주기)와 사용자가 얼마나 오래된 값을 참아줄 수 있는지(허용 staleness)다 — 하루에 한 번 바뀌는 목록에 30초 TTL은 낭비다. 실무에선 "허용 staleness ≒ TTL"로 잡되, 재검증(ETag)을 얹어 "만료됐지만 안 바뀐" 경우의 본문 전송을 304로 없애 짧은 TTL의 대가를 상쇄한다.
명시적 무효화 이벤트가 유실될 수 있는 경로(앱 강제종료, silent push 미도착, 오프라인)를 어떻게 방어 설계하나?
기본 전제는 "무효화 이벤트는 언젠가 유실된다"고 가정하는 것이다. 그래서 명시적 무효화만으로 정확성을 보장하려 하지 말고, 항상 TTL을 안전망으로 깔아 놓치면 시간이 지나 스스로 낫게 한다. 그 위에 앱이 포그라운드로 돌아올 때(willEnterForeground) 핵심 데이터를 재검증하는 "복귀 시 동기화"를 두면, 백그라운드에서 놓친 변경을 값싸게 따라잡는다. 더 강하게는 서버에 리소스별 버전/타임스탬프(예: updatedAt 커서)를 두고 재접속 시 "이후 바뀐 것만" 델타로 받아 맞추는 방식이 push 유실에 견고하다.
한 사용자가 여러 기기·세션을 쓸 때, 한 기기의 변경을 다른 기기 캐시에 어떻게 전파(무효화)하나?
기기 간엔 로컬 무효화가 통하지 않으므로 서버가 진실 원본이자 전파 허브가 돼야 한다. 흔한 조합은 (1) 변경 시 서버가 다른 기기에 silent push나 소켓으로 "이 리소스 바뀜"을 알려 즉시 무효화를 트리거하고, (2) 그게 유실될 것에 대비해 각 기기가 포그라운드 복귀 시 마지막 동기화 커서 이후의 델타를 pull해 맞추는 것이다. Apple 생태계 안이라면 CloudKit의 CKDatabaseSubscription+change token, 또는 Core Data + CloudKit 미러링이 이 "push 알림 + 델타 sync"를 프레임워크 수준에서 제공한다. 순서 충돌은 서버 타임스탬프나 버전 벡터로 정한다.
Q4. NSCache와 Dictionary는 무엇이 다른가?
둘 다 키-값 저장이지만 목적이 다르다. Dictionary는 무한히 자라는 값 타입 자료구조이고, 스레드 안전하지 않으며 축출 개념이 없다. NSCache는 예산이 있는 캐시다 — 스레드 안전하고, 메모리 압박이 오면 시스템이 자동으로 항목을 비우며, countLimit·totalCostLimit로 상한을 권고한다(강제는 아님). 대신 클래스(참조) 타입만 담아 Swift 값 타입은 래핑이 필요하고, 언제 무엇이 축출될지 결정적이지 않아 "저장소"가 아니라 "캐시"로만 써야 한다.
CS 원리
해시맵으로서의 조회 자체는 둘 다 O(1) 평균이고 다르지 않다. 진짜 차이는 축출(eviction)과 동시성(concurrency) 의미론이다. 일반 해시맵은 넣는 만큼 무한히 커져 결국 메모리를 다 먹지만, 캐시는 정해진 예산 안에서 우선순위가 낮은 항목을 버리는 축출 정책(LRU 등)을 내장한다. 이 "버릴 수 있다"는 성질이 캐시를 저장소와 구분 짓는 본질이다.
iOS에서는
NSCache는 락 없이 여러 스레드에서 넣고 빼고 조회해도 안전하고(내부 락 보유), 메모리 압박 시 시스템의 discardable memory 메커니즘과 연동해 스스로 항목을 비운다. countLimit/totalCostLimit은 soft limit이라 정확히 그 값에서 잘리는 걸 보장하지 않는 힌트다. 또 NSDictionary와 달리 키를 복사하지 않고 retain한다. 담는 값은 클래스 타입이어야 해서, Swift에서 String 키는 NSString으로, Data·구조체 값은 참조 타입 래퍼로 감싸야 한다. 반면 Swift Dictionary는 값 타입(copy-on-write)이고 스레드 안전하지 않으며 축출·메모리 경고 대응이 전혀 없다.
final class ImageCache {
// 값은 참조 타입(UIImage), 키는 NSString — 값 타입은 그대로 못 넣음
private let cache = NSCache<NSString, UIImage>()
init() {
cache.countLimit = 200 // soft limit: 권고치일 뿐 강제 아님
cache.totalCostLimit = 50 * 1024 * 1024 // cost 합의 상한(여기선 바이트로 해석)
}
func image(for key: String) -> UIImage? {
cache.object(forKey: key as NSString) // 락 없이 스레드 안전
}
func insert(_ image: UIImage, for key: String, bytes: Int) {
cache.setObject(image, forKey: key as NSString, cost: bytes)
}
}실험 · 도구
차이를 눈으로 보려면 NSCache와 Dictionary에 각각 큰 이미지를 가득 채운 뒤, 시뮬레이터에서 Debug ▸ Simulate Memory Warning을 실행한다. NSCache는 다음 조회에서 상당수 항목이 nil로 돌아오며 자동 축출됐음을 보여주고, Dictionary는 그대로 남아 계속 메모리를 물고 있다. Instruments Allocations로 두 경우의 footprint 곡선을 비교하면 "무한 성장 vs 압박 시 하강"이 선명하게 갈린다.
프로젝트 적용
① 메모리 압박 자동 대응과 스레드 안전이 필요하면 NSCache. 그냥 Dictionary로 캐시를 짜면 락·축출·메모리 경고 대응을 전부 직접 구현해야 한다.
import UIKit
// Dictionary로 캐시를 짜면 락·축출·메모리 경고 대응을 전부 직접 만들어야 한다.
// ❌ 직접 만들면 빠뜨리는 것들
final class NaiveCache {
private var storage: [String: UIImage] = [:]
// 스레드 안전 ✗ 축출 ✗ 메모리 경고 대응 ✗ 비용 기반 상한 ✗
func set(_ image: UIImage, _ key: String) { storage[key] = image }
}
// ✅ NSCache는 이 넷을 다 해 준다
final class ImageCache {
private let cache = NSCache<NSString, UIImage>()
init() {
cache.countLimit = 300
cache.totalCostLimit = 64 * 1024 * 1024
cache.name = "com.app.images"
// evictsObjectsWithDiscardedContent는 기본 true
}
func image(for key: String) -> UIImage? {
cache.object(forKey: key as NSString)
}
func store(_ image: UIImage, for key: String) {
// ✅ cost는 '바이트 수'로 넣어야 totalCostLimit이 의미를 갖는다
let bytes = Int(image.size.width * image.size.height
* image.scale * image.scale * 4)
cache.setObject(image, forKey: key as NSString, cost: bytes)
}
}
// ✅ 축출 시점을 관찰하고 싶으면 delegate를 붙인다
final class ObservableCache: NSObject, NSCacheDelegate {
private let cache = NSCache<NSString, UIImage>()
override init() {
super.init()
cache.delegate = self
}
func cache(_ cache: NSCache<AnyObject, AnyObject>, willEvictObject obj: Any) {
// ⚠️ 여기서 obj의 키는 알 수 없다. 통계 수집 정도로만 쓴다.
print("축출됨")
}
}
// ⚠️ NSCache는 '자동 정리'를 시스템에 맡긴다. 정확한 시점·개수는 보장되지 않는다.② Swift 값 타입 키/값은 NSString이나 키 래퍼 클래스로 감싼다. 이 래핑 비용과 boxing을 감안한다.
import Foundation
// NSCache는 참조 타입만 담는다(NSObject 기반). Swift 값 타입은 래핑이 필요하다.
// ✅ 범용 박스
final class Box<T>: NSObject {
let value: T
init(_ value: T) { self.value = value }
}
// ✅ 키도 NSString이어야 한다
final class TypedCache<Value> {
private let cache = NSCache<NSString, Box<Value>>()
init(countLimit: Int = 200, totalCostLimit: Int = 0) {
cache.countLimit = countLimit
if totalCostLimit > 0 { cache.totalCostLimit = totalCostLimit }
}
subscript(key: String) -> Value? {
get { cache.object(forKey: key as NSString)?.value }
set {
guard let newValue else {
cache.removeObject(forKey: key as NSString); return
}
cache.setObject(Box(newValue), forKey: key as NSString)
}
}
func set(_ value: Value, for key: String, cost: Int) {
cache.setObject(Box(value), forKey: key as NSString, cost: cost)
}
func removeAll() { cache.removeAllObjects() }
}
// 사용
struct Layout { let height: CGFloat; let lineCount: Int }
let layoutCache = TypedCache<Layout>(countLimit: 500)
func cachedLayout(for id: String, compute: () -> Layout) -> Layout {
if let hit = layoutCache[id] { return hit }
let fresh = compute()
layoutCache[id] = fresh
return fresh
}
// ⚠️ 래핑 비용을 감안한다 — 값마다 클래스 인스턴스 하나가 힙에 생긴다.
// 아주 작은 값을 수십만 개 캐시한다면 그 오버헤드가 무시할 수 없다.
// 그 경우엔 직접 만든 딕셔너리 + 락 + 수동 축출이 나을 수도 있다.
// ✅ Swift 6에서는 값 타입 전용 캐시를 actor로 만드는 것도 좋은 선택이다
actor ValueCache<Key: Hashable, Value> {
private var storage: [Key: Value] = [:]
private let limit: Int
private var order: [Key] = [] // 단순 LRU
init(limit: Int) { self.limit = limit }
func value(for key: Key) -> Value? {
guard let v = storage[key] else { return nil }
order.removeAll { $0 == key }; order.append(key)
return v
}
func store(_ value: Value, for key: Key) {
storage[key] = value
order.removeAll { $0 == key }; order.append(key)
while order.count > limit {
storage[order.removeFirst()] = nil
}
}
}③ NSCache를 신뢰 저장소로 착각하지 마라. 항목이 언제든 사라질 수 있으니 항상 "없으면 다시 만드는/받아오는" 경로를 함께 둔다.
import UIKit
// countLimit·totalCostLimit은 soft limit이다. 정확히 지켜지지 않고,
// 시스템 판단으로 언제든 전부 비워질 수 있다.
// ❌ 캐시에 있다고 가정하는 코드
final class BadViewModel {
private let cache = NSCache<NSString, UIImage>()
func image(for id: String) -> UIImage {
cache.object(forKey: id as NSString)! // 💥 언제든 nil이 될 수 있다
}
}
// ✅ 항상 "없으면 다시 만든다" 경로가 함께 있어야 한다
final class ViewModel {
private let cache = NSCache<NSString, UIImage>()
func image(for id: String, source: URL) async -> UIImage? {
if let hit = cache.object(forKey: id as NSString) { return hit }
// ✅ 미스는 정상 경로다 — 예외가 아니다
// ⚠️ 원격 URL에 동기 Data(contentsOf:)는 Apple이 명시적으로 금지한다
// (협력 스레드 풀을 통째로 블로킹한다)
guard let (data, _) = try? await URLSession.shared.data(from: source),
let image = UIImage(data: data) else { return nil }
cache.setObject(image, forKey: id as NSString,
cost: Int(image.size.width * image.size.height * image.scale * image.scale * 4))
return image
}
}
// ⚠️ 이런 기대는 전부 틀렸다
// · "countLimit을 200으로 뒀으니 정확히 200개가 유지된다"
// → 더 담길 수도, 더 일찍 비워질 수도 있다
// · "메모리 경고 때 전부 비운다"
// → 축출하긴 하지만 얼마나·언제는 보장되지 않는다
// · "내가 넣은 건 내가 지우기 전까지 있다"
// → 시스템이 언제든 가져간다
// ✅ 사라져도 정상인 것만 담는다
// 담아도 되는 것: 디코딩된 이미지, 계산된 레이아웃, 파싱 결과
// 담으면 안 되는 것: 작성 중인 글, 오프라인 큐, 결제 진행 상태
// ✅ 반드시 유지돼야 하는 것은 디스크나 DB로
func mustPersist(_ draft: String, to url: URL) throws {
try draft.write(to: url, atomically: true, encoding: .utf8)
}"countLimit을 200으로 두면 정확히 200개가 유지된다"는 오해다. 이는 soft limit이라 시점에 따라 더 담기거나 더 일찍 비워질 수 있다. 마찬가지로 "메모리 경고 때 NSCache가 항상 전부 비운다"도 아니다 — 축출하긴 하지만 얼마나·언제는 보장되지 않는다. 그래서 NSCache에 든 값은 "있으면 이득, 없어도 정상"으로 다뤄야 한다.
Dictionary는 내 방 서랍이다. 넣는 대로 쌓이고, 아무도 대신 치워 주지 않는다. 계속 넣다 보면 서랍이 아니라 방이 터진다. 앱으로 치면 메모리를 다 먹고 죽는 상황이다. NSCache는 지하철역 물품보관함에 가깝다. 자리가 모자라면 관리인이 알아서 짐을 빼 버린다 — 무엇을 어떤 순서로 뺄지는 관리인 마음이라 정해져 있지 않다. 그러니까 "아까 넣어둔 게 없어졌네"가 고장이 아니라 정상 동작이다. 물품보관함에 여권을 맡기면 안 되는 이유가 그거고, NSCache에 든 값은 항상 "없으면 다시 만든다"는 대비책과 짝을 이뤄야 한다.
- countLimit 200은 "칸이 정확히 200개"라는 뜻이 아니라 관리인에게 건넨 가이드다. 그래서 201개가 들어 있기도 하고, 150개일 때 미리 빼 가기도 한다(soft limit).
- 보관함은 여러 사람이 동시에 와도 안전하게 굴러가지만(내부 락), 서랍은 두 사람이 동시에 손을 넣으면 물건이 엉킨다(Dictionary는 스레드 안전하지 않다).
- 보관함은 짐을 맡길 때 "덩어리"만 받는다. Swift의 구조체 같은 값 타입을 그대로 못 넣고 참조 타입으로 감싸야 하는 게 이 제약이다.
꼬리 질문
totalCostLimit의 cost에는 무엇을 기준(바이트? 개수? 임의 가중치?)으로 넣어야 하나? soft limit이 "강제되지 않는다"는 게 실무에서 어떤 버그로 나타나나?
cost는 그 항목이 차지하는 메모리 크기의 근사치를 같은 단위로 일관되게 넣는 게 정석이다 — 이미지라면 실제 디코딩된 바이트(width*height*bytesPerPixel)이지 파일 크기가 아니다. 단위만 일관되면 totalCostLimit도 같은 단위(바이트)로 잡아 예산으로 쓸 수 있다. soft limit이라 "강제 아님"이 문제되는 건, 개발자가 이걸 hard cap으로 믿고 UI 로직을 짤 때다 — 이론상 200개까지인데 실제로는 순간적으로 더 담겨 예상보다 메모리를 많이 쓰거나, 반대로 시스템이 예산 도달 전에 미리 비워버려 "방금 넣었는데 바로 nil"이 되는 식이다. 그래서 항상 miss 경로(재생성)를 갖춰야 한다.
cost다. 이미지는 압축된 파일 크기가 아니라 화면에 펼쳤을 때 차지하는 크기를 적어야 맞다 — 돌돌 만 이불의 부피로 방 크기를 재는 것과 같은 실수다. 그리고 "200개까지"는 관리인과 한 약속이 아니라 부탁이라, 방금 맡긴 게 벌써 없어져 있어도 고장이 아니다.Swift에서 값 타입 키/값을 NSCache에 넣을 때 왜 래핑이 필요하며, NSString 캐스팅과 커스텀 래퍼 중 무엇이 더 안전한가?
NSCache는 Objective-C 타입이라 키·값 모두 AnyObject(클래스, 참조 타입)여야 한다. Swift의 String·Data·구조체는 값 타입이라 그대로 못 들어가서, 키는 as NSString으로 브리징하고 값은 참조 타입으로 감싼다. 키의 경우 NSString 캐스팅이 간편하지만, 복합 키(여러 필드 조합)나 커스텀 동치 규칙이 필요하면 NSString의 문자열 동일성에 의존하는 게 취약하다 — 이때는 Hashable을 제대로 구현한 커스텀 NSObject 래퍼(isEqual·hash 오버라이드)가 더 안전하고 의도가 분명하다. 단순 문자열 키면 NSString으로 충분하다.
String이나 구조체는 상자가 아니라 알맹이라서 그대로는 안 들어가고, 그래서 as NSString으로 상자에 옮겨 담는다. 키가 그냥 문자열 하나면 이걸로 끝인데, "유저ID + 이미지 크기"처럼 여러 조각을 합친 키라면 무엇을 같은 키로 볼지 규칙을 직접 써 줘야 해서 커스텀 래퍼가 안전하다.스레드 안전한 Dictionary 기반 캐시를 직접 만든다면 락(직렬 큐/os_unfair_lock), 축출 정책, 메모리 경고 구독 중 무엇을 빠뜨리기 쉬운가?
셋 다 흔히 빠뜨리지만, 실무에서 가장 자주 누락되는 건 메모리 경고 구독과 축출 정책이다. 락은 크래시·데이터 경합으로 티가 나서 비교적 빨리 잡지만, 축출과 메모리 대응이 없는 캐시는 평소엔 멀쩡하다가 오래 쓰면 무한 성장해 결국 jetsam으로 조용히 죽는다 — 재현이 어려워 놓치기 쉽다. 락도 함정이 있다 — 읽기는 동시 허용(concurrent queue + .barrier 쓰기)해야 성능이 나는데 단순 직렬 큐로 다 막으면 병목이고, os_unfair_lock은 재진입 불가라 콜백 안에서 다시 잠그면 데드락이다. 결국 이 모든 걸 이미 갖춘 NSCache를 쓰는 게 대부분 정답인 이유다.
Q5. Memory warning이나 Background 전환 시 어떤 캐시를 제거해야 하는가?
기준은 하나다 — 다시 만들 수 있는 것은 버리고, 없으면 안 되는 것은 저장하라. 메모리 경고 때는 재생성 가능한 메모리 캐시(디코딩된 이미지, 계산 버퍼)를 즉시 비우고 디스크 캐시는 남긴다. 백그라운드 진입 때는 먼저 상태를 영속화한 뒤 footprint를 줄여야 하는데, 백그라운드 앱이 jetsam(메모리 부족 시 OS의 강제 종료)의 1순위 표적이고 footprint가 큰 앱부터 죽기 때문이다. 저장되지 않은 사용자 입력은 절대 "캐시"로 취급해 버리면 안 된다.
CS 원리
메모리가 부족한 시스템은 압박이 오면 메모리를 회수한다. 이때 데이터를 세 부류로 나눠 다뤄야 한다 — (a) 재생성·재조회 가능한 캐시는 자유롭게 버리고, (b) 영속 가능한 상태는 저장한 뒤에 메모리에서 내리고, (c) 저장하지 못한 진행 중 데이터는 지켜야 한다. 요지는 "메모리 확보"와 "데이터 유실"을 혼동하지 않는 것이다. 버려도 되는 것과 잃으면 안 되는 것을 구분하지 못하면, 압박 대응이 곧 데이터 사고가 된다.
iOS에서는
OS는 시스템 전체가 메모리 압박을 받으면 footprint 기준으로 앱을 jetsam해 죽이는데, 백그라운드 앱이 우선 대상이라 백그라운드에서 큰 메모리를 쥐고 있으면 조용히 종료된다. 메모리 경고는 UIApplication.didReceiveMemoryWarningNotification과 뷰컨트롤러의 didReceiveMemoryWarning()으로 온다 — 이때 인메모리 캐시와 큰 디코딩 이미지, 재구성 가능한 버퍼를 놓는다. NSCache는 자동으로 비우지만 직접 만든 Dictionary 캐시는 이 알림을 구독해 수동으로 비워야 한다. 백그라운드 진입(앱 수명주기의 sceneDidEnterBackground, SwiftUI는 scenePhase == .background)에는 실행 시간이 짧게(대략 수 초, beginBackgroundTask로 잠시 연장) 주어지므로, 그 안에 상태를 저장하고 큰 메모리를 내려 jetsam 확률을 낮춘다. 디스크 캐시는 유지하되 지나치게 크면 trim한다.
final class MemoryImageCache {
private let cache = NSCache<NSString, UIImage>()
private var token: NSObjectProtocol?
init() {
// NSCache는 자동 축출되지만, 직접 만든 버퍼가 있다면 이렇게 수동 대응
token = NotificationCenter.default.addObserver(
forName: UIApplication.didReceiveMemoryWarningNotification,
object: nil, queue: .main
) { [weak self] _ in
self?.cache.removeAllObjects() // 재생성 가능 → 즉시 비움
}
}
}
// Scene 델리게이트
func sceneDidEnterBackground(_ scene: UIScene) {
persistDraft() // 저장 안 된 사용자 입력 = 절대 버리지 않고 먼저 저장
releaseDecodedImages() // footprint 축소 → jetsam 확률 감소
// 디스크 캐시는 유지, 너무 크면 trim
}실험 · 도구
시뮬레이터 Debug ▸ Simulate Memory Warning으로 경고를 인위적으로 쏘고, 캐시가 비워지는지와 화면이 무너지지 않고 복구되는지 확인한다. Instruments의 Allocations·VM Tracker로 footprint를 추적하고, Xcode Memory Graph로 경고 후에도 안 놓인 큰 객체(누수·강한 참조)를 찾는다. 백그라운드 jetsam은 재현이 까다로우니, 백그라운드에서 footprint를 의도적으로 키운 뒤 다른 무거운 앱을 띄워 종료를 유도하고 재실행 시 상태가 복원되는지로 검증한다.
프로젝트 적용
① 메모리 경고: 재생성 가능한 메모리 캐시만 비운다. 디스크 캐시는 그대로 둔다(다시 채우는 비용이 크다).
import UIKit
// 메모리 경고는 "지금 RAM을 내놓으라"는 신호다.
// 디스크 캐시까지 지우면 다시 채우는 비용만 크고 RAM 확보에는 도움이 안 된다.
@MainActor
final class CacheCoordinator {
private var memoryCaches: [MemoryPurgeable] = []
init() {
NotificationCenter.default.addObserver(
forName: UIApplication.didReceiveMemoryWarningNotification,
object: nil, queue: .main) { [weak self] _ in
self?.handleMemoryWarning()
}
}
func register(_ cache: MemoryPurgeable) { memoryCaches.append(cache) }
private func handleMemoryWarning() {
let before = os_proc_available_memory()
// ✅ 메모리 캐시만 비운다
memoryCaches.forEach { $0.purgeMemory() }
// ⚠️ 디스크 캐시는 건드리지 않는다 — RAM과 무관하고 재생성 비용만 크다
// diskCache.purge() ← 하지 말 것
let after = os_proc_available_memory()
print("메모리 경고 대응: \((after - before) / 1024 / 1024)MB 회수")
}
}
protocol MemoryPurgeable: AnyObject {
func purgeMemory()
}
// 각 캐시가 자기 역할만 구현한다
final class ImageCache: MemoryPurgeable {
private let memory = NSCache<NSString, UIImage>()
func purgeMemory() { memory.removeAllObjects() } // ✅ 메모리만
}
final class LayoutCache: MemoryPurgeable {
private var heights: [String: CGFloat] = [:]
func purgeMemory() { heights.removeAll() } // ✅ 다시 계산 가능
}
// ⚠️ 경고에 응답하지 않으면 확보가 저절로 생기는 게 아니라,
// 응답하지 못한 그 앱이 종료 후보가 된다.② 백그라운드 진입: 먼저 상태를 영속화하고, 그다음 디코딩된 이미지·뷰 스냅샷·큰 버퍼를 놓아 footprint를 낮춘다.
import UIKit
// 순서가 중요하다. 먼저 비우고 저장하려 하면 저장할 게 이미 없다.
@MainActor
final class BackgroundHandler {
func sceneDidEnterBackground() {
// ✅ ① 저장 안 된 사용자 입력을 먼저 영속화한다
DraftStore.shared.saveAll()
try? CoreDataStack.shared.saveIfNeeded()
UserDefaults.standard.set(currentScrollOffset, forKey: "feed.offset")
// ✅ ② 쥐고 있던 시스템 자원을 놓는다 (0xdead10cc 예방)
Database.shared.closeConnections()
// ✅ ③ 그다음 footprint를 낮춘다
// 백그라운드 앱이 jetsam 1순위이고, footprint가 큰 앱부터 죽는다
ImageCache.shared.purgeMemory()
snapshotCache.removeAll()
decodedBuffers.removeAll()
// ✅ ④ 큰 뷰 계층의 백킹스토어도 놓을 수 있다
window?.rootViewController?.view.layer.contents = nil // ⚠️ 이건 그 레이어 한 장에만 적용되고 복귀 시 빈 화면 위험이 있다.
// 실제로 큰 픽셀 버퍼를 쥔 캐시(디코딩 이미지·스냅샷)를 놓는 편이 낫다
}
func sceneWillEnterForeground() {
Database.shared.reopen()
// 캐시는 필요할 때 다시 채워진다 — 미리 복구하지 않는다
}
private var snapshotCache: [String: UIImage] = [:]
private var decodedBuffers: [Data] = []
private var currentScrollOffset: Double = 0
private var window: UIWindow?
}
// ⚠️ 백그라운드 진입 콜백은 시간이 제한적이다(수 초).
// 긴 작업이 필요하면 beginBackgroundTask로 유예를 요청하되,
// 그 안에서도 정리를 반드시 끝내야 한다.
enum DraftStore { static let shared = DraftStoreImpl() }
struct DraftStoreImpl { func saveAll() {} }
enum CoreDataStack { static let shared = CoreDataStackImpl() }
struct CoreDataStackImpl { func saveIfNeeded() throws {} }
enum Database { static let shared = DatabaseImpl() }
struct DatabaseImpl { func closeConnections() {}; func reopen() {} }
enum ImageCache { static let shared = ImageCacheImpl() }
struct ImageCacheImpl { func purgeMemory() {} }③ 저장 안 된 사용자 입력(작성 중 글, 폼)은 "캐시"가 아니다. 비우기 전에 반드시 draft로 저장한다.
import UIKit
// 작성 중인 글, 입력하던 폼, 그리던 그림은 '데이터'다.
// 비우기 전에 반드시 draft로 저장해야 한다.
@MainActor
final class ComposeViewModel: ObservableObject {
@Published var text: String = "" {
didSet { scheduleAutosave() }
}
@Published var attachments: [Attachment] = []
private var autosaveTask: Task<Void, Never>?
private let store: DraftStore
init(store: DraftStore) {
self.store = store
// ✅ 백그라운드 진입 시 즉시 저장 (디바운스를 기다리지 않는다)
NotificationCenter.default.addObserver(
forName: UIApplication.didEnterBackgroundNotification,
object: nil, queue: .main) { [weak self] _ in
self?.saveNow()
}
}
/// 입력 중에는 디바운스로 저장 — 매 타자마다 쓰지 않는다
private func scheduleAutosave() {
autosaveTask?.cancel()
autosaveTask = Task {
try? await Task.sleep(for: .seconds(2))
guard !Task.isCancelled else { return }
saveNow()
}
}
private func saveNow() {
autosaveTask?.cancel()
try? store.save(Draft(text: text, attachments: attachments))
}
/// 복구 — 앱을 다시 켰을 때
func restore() {
guard let draft = try? store.load() else { return }
text = draft.text
attachments = draft.attachments
}
}
// ⚠️ 판별 질문: "이걸 잃으면 사용자가 다시 만들어야 하는가?"
// 그렇다면 캐시가 아니라 데이터다. 영속 저장소로 간다.
struct Draft: Codable { let text: String; let attachments: [Attachment] }
struct Attachment: Codable {}
struct DraftStore {
func save(_ d: Draft) throws {}
func load() throws -> Draft? { nil }
}④ 직접 만든 캐시는 반드시 메모리 경고 알림을 구독하게 한다 — NSCache가 아니면 자동으로 비워지지 않는다.
import UIKit
// NSCache만 시스템이 자동으로 비워 준다.
// 직접 만든 딕셔너리·배열 캐시는 알림을 구독하지 않으면 절대 안 비워진다.
// ❌ 자동으로 비워질 거라 기대하는 코드
final class BadLayoutCache {
private var heights: [String: CGFloat] = [:]
// 메모리 경고가 와도 그대로 남는다 → jetsam의 밥
}
// ✅ 구독을 타입 안에 넣어 빠뜨릴 수 없게 만든다
class PurgeableCache<Key: Hashable, Value> {
private var storage: [Key: Value] = [:]
private var observer: NSObjectProtocol?
init() {
observer = NotificationCenter.default.addObserver(
forName: UIApplication.didReceiveMemoryWarningNotification,
object: nil, queue: .main
) { [weak self] _ in
self?.purge()
}
}
deinit {
if let observer { NotificationCenter.default.removeObserver(observer) }
}
subscript(key: Key) -> Value? {
get { storage[key] }
set { storage[key] = newValue }
}
func purge() { storage.removeAll() }
var count: Int { storage.count }
}
// 사용 — 이제 빠뜨릴 수가 없다
let layoutCache = PurgeableCache<String, CGFloat>()
let parseCache = PurgeableCache<String, [String]>()
// ✅ 커널 압박 신호까지 구독하면 UIKit 경고보다 이른 단계에서 대응할 수 있다
final class PressureAwareCache {
private var storage: [String: Data] = [:]
private var source: DispatchSourceMemoryPressure?
init() {
let s = DispatchSource.makeMemoryPressureSource(
eventMask: [.warning, .critical], queue: .main)
s.setEventHandler { [weak self] in
guard let self, let data = self.source?.data else { return }
if data.contains(.critical) { self.storage.removeAll() }
else if data.contains(.warning) { self.trimHalf() }
}
s.resume()
source = s // ⚠️ 강참조 보관 필수
}
private func trimHalf() {
let keys = Array(storage.keys.prefix(storage.count / 2))
keys.forEach { storage[$0] = nil }
}
}"백그라운드로 가면 앱이 멈춘 채 안전하게 보존된다"는 착각이다. 백그라운드 앱은 jetsam의 1순위이고, footprint가 큰 앱이 먼저 죽는다 — 사용자에겐 "다시 켜니 처음 화면"으로 보인다. 마찬가지로 메모리 경고에 응답하지 않으면 확보가 저절로 생기는 게 아니라, 응답 못 한 그 앱이 종료 후보가 된다. 경고 대응은 선택이 아니라 생존 조건이다.
작은 배에 짐을 너무 많이 실었다고 하자. 가라앉지 않으려면 뭔가는 버려야 한다. 이때 아무거나 집어 던지면 안 되고, 다시 살 수 있는 것부터 버려야 한다. 생수와 간식은 항구에서 다시 사면 된다. 이게 재생성 가능한 메모리 캐시다. 반면 여권과 쓰던 일기장은 잃으면 끝이다. 저장하지 않은 사용자 입력이 여기에 해당한다.
그래서 순서가 전부다. 여권을 먼저 방수팩에 넣어 두고(persist), 그다음에 짐을 던진다(purge). 순서를 뒤집으면 여권까지 같이 던진 셈이 된다. jetsam은 항구가 붐빌 때 "짐 제일 무거운 배부터 내보내는" 규칙이라고 보면 된다. 그러니 백그라운드에서 메모리를 잔뜩 쥐고 있는 앱이 먼저 종료된다. 메모리 경고를 무시하는 건 짐을 안 줄이겠다고 버티는 것이라, 결국 그 배가 제일 먼저 쫓겨난다. 사용자 눈에는 그저 "앱을 다시 켜니 처음 화면"으로 보일 뿐이다.
꼬리 질문
didReceiveMemoryWarning에 아무 대응도 안 하면 무슨 일이 일어나나? 포그라운드와 백그라운드에서 jetsam 종료 우선순위는 어떻게 다른가?
경고를 무시한다고 앱이 곧장 죽진 않지만, 메모리를 안 놓으니 footprint가 높은 상태로 남아 jetsam 후보 순위가 올라간다. 압박이 계속되면 OS는 경고를 보낸 뒤 반응 없는(=footprint 큰) 앱부터 회수 대상으로 삼는다. 우선순위는 대략 "포그라운드에서 사용자가 보고 있는 앱"이 가장 보호되고, 백그라운드 앱은 훨씬 쉽게 죽으며, 그 안에서도 footprint가 큰 순서로 먼저 종료된다. 그래서 포그라운드에선 경고 대응이 성능/안정성 문제지만, 백그라운드에선 곧 생존(재실행 시 상태 유지) 문제가 된다.
백그라운드 진입 시 주어지는 실행 시간은 대략 얼마이고, beginBackgroundTask(expirationHandler:)는 그 시간을 어떻게 바꾸나?
기본적으로 백그라운드 진입 콜백에서 쓸 수 있는 시간은 아주 짧아(수 초 규모, OS가 보장하지 않음) 그 안에 마무리 못 하면 앱이 곧 suspend된다. beginBackgroundTask(expirationHandler:)는 "잠깐 더 돌 시간이 필요하다"고 OS에 요청해 유예를 얻는 장치로, 과거엔 넉넉했지만 현재는 대략 30초 안팎(버전·상황에 따라 다름)으로 제한된다. 반드시 작업이 끝나면 endBackgroundTask로 토큰을 반납해야 하고, 안 그러면 expiration handler가 강제 호출된 뒤 앱이 종료될 수 있다. 즉 이건 "무제한 연장"이 아니라 저장 같은 마무리 작업을 끝낼 짧은 창을 확보하는 용도다.
endBackgroundTask). 그 말을 안 하면 주인이 불을 꺼 버린다.저장되지 않은 사용자 입력을 캐시로 착각해 비우면 어떤 사고가 나나? 코드에서 "버려도 되는 것"과 "저장해야 하는 것"을 어떻게 구조적으로 구분하나?
작성 중이던 글·폼·미전송 편집이 메모리 경고나 jetsam 때 그대로 증발해, 사용자에겐 "쓰던 게 사라졌다"는 최악의 데이터 유실로 나타난다. 이를 막으려면 타입 수준에서 성격을 갈라야 한다 — 재생성 가능한 캐시는 "언제든 버려도 되는 것"으로 명시적으로 분리(예: 별도 캐시 계층/프로토콜)하고, 사용자 입력은 진입 즉시 draft로 영속화하는 경로에 태운다. 실무 패턴은 백그라운드 진입/경고 핸들러에서 persist()(저장 대상)를 먼저 호출한 뒤에야 purge()(폐기 대상)를 부르는 순서를 강제하는 것이다. "저장해야 하는 것"이 캐시 API를 타고 흐르지 못하게 경계를 두는 게 핵심이다.