캐시는 성능 설계에서 가장 먼저 손이 가지만, 가장 자주 사고를 내는 도구다. 무엇을 아끼려고 캐시를 두는지, 그 대가로 어떤 불일치를 떠안는지, 그리고 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)의 진실 원본을 캐시에 두고 그걸 기준으로 쓰지 마라.
- 무효화 스토리 없이 캐시를 추가하지 않는다. "이 항목은 무엇이 바뀌면, 어떻게 사라지나?"에 답 못 하면 아직 넣을 때가 아니다.
- 캐시 키에 스키마/빌드 버전을 섞어라. 앱 업데이트로 응답 포맷이 바뀌면 옛 캐시가 조용히 깨진 데이터를 준다.
- 보안·개인정보성 데이터는 만료 없이 캐시하지 않는다.
"캐시는 항상 앱을 빠르게 한다"는 반쪽 진실이다. miss일 때는 조회 계층을 더 거쳐 오히려 느리고, 지역성이 없으면 메모리만 먹다 압박으로 축출·재로딩을 반복하며, 무엇보다 stale 데이터는 "빠르게 틀린 답"을 준다. 캐시는 공짜가 아니라 일관성 관리 부채와 메모리 압박을 새로 짊어지는 선택이다.
꼬리 질문
- 쓰기가 읽기보다 많은 write-heavy 워크로드에서도 캐시가 이득인가? 언제 캐시가 오히려 손해인가?
- 잔액·재고처럼 강한 일관성이 필수인 데이터를 굳이 캐시한다면 어떤 정책(write-through, 짧은 TTL, 재검증)을 조합해야 하나?
- cache stampede(만료 순간 동시 미스가 원본으로 몰리는 현상)는 왜 생기고, single-flight·jitter·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 크기 증가를 추적한다.
프로젝트 적용
- 메모리에는 뜨겁고 작고 자주 쓰는 것, 디스크에는 크고 차갑고 재생성 비싼 것. 둘을 겹쳐 쓸 땐 조회 순서(메모리→디스크→원본)와 승격 규칙을 명시한다.
- 재생성 가능한 데이터는 반드시
Library/Caches(또는 tmp)에, 사용자 데이터는Documents에. 이 둘을 섞으면 백업 폭증이나 데이터 유실이 난다. - 디스크 캐시도 상한을 두고 LRU로 trim한다. 무한히 커지면 사용자 저장공간을 잡아먹어 "이 앱이 왜 이렇게 크냐"는 항의를 부른다.
"디스크 캐시니까 영구적"이라는 가정은 iOS에서 틀리다. Library/Caches는 저장 공간이 부족하면 OS가 앱 실행 중이 아닐 때 언제든 비울 수 있다. 지속을 보장받아야 하는 건 캐시가 아니라 저장(storage)이며, 그건 Documents 또는 명시적 영속 계층(Core Data 등)의 몫이다.
꼬리 질문
- memory-mapped file(mmap)로 읽는 데이터는 메모리 캐시로 세야 하나 디스크 캐시로 세야 하나? footprint 계산에서 어떻게 잡히나?
- 같은 데이터를
Documents에 두느냐Library/Caches에 두느냐가 백업 용량·앱 삭제 후 복구·저장공간 압박에서 각각 어떤 결과를 만드나? - 디스크 캐시 직렬화 포맷을 JSON, 바이너리(plist/Protobuf), SQLite 중 무엇으로 하느냐가 조회 속도·부분 무효화·마이그레이션에 어떻게 영향을 주나?
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을 보조로 둔다.
- 거의 안 바뀌는 설정·정적 목록은 긴 TTL(+재검증)로 충분하다. 여기에 명시적 무효화까지 배선하는 건 과설계다.
- 명시적 무효화만 믿지 마라. 앱 강제종료·푸시 미수신으로 이벤트는 유실될 수 있고, 그러면 그 항목은 영구 stale이 된다. TTL은 이 구멍을 메우는 최후의 방어선이다.
"명시적 무효화가 더 정확하니 TTL은 필요 없다"는 위험하다. 무효화는 모든 변경 경로를 빠짐없이 배선했을 때만 정확하다. 이벤트 하나만 유실돼도(버그·네트워크·앱 종료) 그 캐시는 스스로 회복하지 못한다. TTL은 정확도가 아니라 자가 치유를 위한 장치이므로, 둘은 대체재가 아니라 보완재다.
꼬리 질문
- TTL을 지나치게 짧게 잡으면 어떤 대가(원본 부하 급증, 배터리·데이터 소모)를 치르나? 적정 TTL은 무엇을 근거로 정하나?
- 명시적 무효화 이벤트가 유실될 수 있는 경로(앱 강제종료, silent push 미도착, 오프라인)를 어떻게 방어 설계하나?
- 한 사용자가 여러 기기·세션을 쓸 때, 한 기기의 변경을 다른 기기 캐시에 어떻게 전파(무효화)하나?
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로 캐시를 짜면 락·축출·메모리 경고 대응을 전부 직접 구현해야 한다.
- Swift 값 타입 키/값은
NSString이나 키 래퍼 클래스로 감싼다. 이 래핑 비용과 boxing을 감안한다. - NSCache를 신뢰 저장소로 착각하지 마라. 항목이 언제든 사라질 수 있으니 항상 "없으면 다시 만드는/받아오는" 경로를 함께 둔다.
"countLimit을 200으로 두면 정확히 200개가 유지된다"는 오해다. 이는 soft limit이라 시점에 따라 더 담기거나 더 일찍 비워질 수 있다. 마찬가지로 "메모리 경고 때 NSCache가 항상 전부 비운다"도 아니다 — 축출하긴 하지만 얼마나·언제는 보장되지 않는다. 그래서 NSCache에 든 값은 "있으면 이득, 없어도 정상"으로 다뤄야 한다.
꼬리 질문
- totalCostLimit의
cost에는 무엇을 기준(바이트? 개수? 임의 가중치?)으로 넣어야 하나? soft limit이 "강제되지 않는다"는 게 실무에서 어떤 버그로 나타나나? - Swift에서 값 타입 키/값을 NSCache에 넣을 때 왜 래핑이 필요하며,
NSString캐스팅과 커스텀 래퍼 중 무엇이 더 안전한가? - 스레드 안전한 Dictionary 기반 캐시를 직접 만든다면 락(직렬 큐/os_unfair_lock), 축출 정책, 메모리 경고 구독 중 무엇을 빠뜨리기 쉬운가?
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를 의도적으로 키운 뒤 다른 무거운 앱을 띄워 종료를 유도하고 재실행 시 상태가 복원되는지로 검증한다.
프로젝트 적용
- 메모리 경고: 재생성 가능한 메모리 캐시만 비운다. 디스크 캐시는 그대로 둔다(다시 채우는 비용이 크다).
- 백그라운드 진입: 먼저 상태를 영속화하고, 그다음 디코딩된 이미지·뷰 스냅샷·큰 버퍼를 놓아 footprint를 낮춘다.
- 저장 안 된 사용자 입력(작성 중 글, 폼)은 "캐시"가 아니다. 비우기 전에 반드시 draft로 저장한다.
- 직접 만든 캐시는 반드시 메모리 경고 알림을 구독하게 한다 — NSCache가 아니면 자동으로 비워지지 않는다.
"백그라운드로 가면 앱이 멈춘 채 안전하게 보존된다"는 착각이다. 백그라운드 앱은 jetsam의 1순위이고, footprint가 큰 앱이 먼저 죽는다 — 사용자에겐 "다시 켜니 처음 화면"으로 보인다. 마찬가지로 메모리 경고에 응답하지 않으면 확보가 저절로 생기는 게 아니라, 응답 못 한 그 앱이 종료 후보가 된다. 경고 대응은 선택이 아니라 생존 조건이다.
꼬리 질문
didReceiveMemoryWarning에 아무 대응도 안 하면 무슨 일이 일어나나? 포그라운드와 백그라운드에서 jetsam 종료 우선순위는 어떻게 다른가?- 백그라운드 진입 시 주어지는 실행 시간은 대략 얼마이고,
beginBackgroundTask(expirationHandler:)는 그 시간을 어떻게 바꾸나? - 저장되지 않은 사용자 입력을 캐시로 착각해 비우면 어떤 사고가 나나? 코드에서 "버려도 되는 것"과 "저장해야 하는 것"을 어떻게 구조적으로 구분하나?