메모리는 iOS 성능·안정성 질문의 뿌리다. 이 챕터는 가상 메모리·주소 변환·Stack/Heap·ARC·retain cycle·페이지 폴트·Jetsam까지, "앱이 메모리를 어떻게 쓰고 언제 죽는가"를 CS 원리에서 Darwin/XNU 구현까지 이어서 설명한다.
Q1. iOS 앱은 가상 메모리를 사용하는가?
쓴다. 모든 iOS 프로세스는 자신만의 64비트 가상 주소 공간을 가지며, MMU(Memory Management Unit, 주소 변환 전용 하드웨어)가 페이지 단위로 물리 RAM에 매핑한다(ASLR로 주소는 무작위화). 다만 데스크톱과 결정적으로 다른 점은 앱의 dirty(수정된) 익명 메모리를 디스크로 스왑하지 않는다는 것이다. 코드처럼 파일에 뒤받침된 clean 페이지는 버렸다가 다시 읽어오지만, dirty 페이지는 커널 메모리 압축기로 압축할 뿐 디스크 스왑이 없다. 그래서 메모리가 부족하면 페이지 아웃 대신 Jetsam이 앱을 통째로 죽인다.
CS 원리
가상 메모리는 각 프로세스에 "나 혼자 연속된 큰 메모리를 쓴다"는 착각을 주는 하드웨어(MMU)+OS의 협업 기법이다. 목적은 세 가지다. (1) 격리 — 프로세스는 남의 물리 메모리를 볼 수 없다. (2) 추상화 — 물리 메모리가 조각나 있어도 가상 주소는 연속으로 보인다. (3) 초과 사용(overcommit) — 실제 RAM보다 큰 주소 공간을 약속하고, 실제 접근하는 페이지만 물리 프레임을 붙인다(demand paging).
iOS에서는
XNU 커널의 Mach VM 서브시스템이 이를 담당한다. 애플이 밝힌 iOS Memory Deep Dive의 분류가 실무 기준이다.
- Clean 페이지: 파일에 뒤받침됨(앱 바이너리의
__TEXT, mmap한 파일, 메모리 매핑된 프레임워크). 압박 시 그냥 버리고, 다시 필요하면 디스크에서 재적재 → 사실상 iOS의 "페이징"은 여기서만 일어난다. - Dirty 페이지: 힙 할당, 디코딩된 이미지 등 앱이 수정한 익명 메모리. 디스크로 스왑되지 않는다.
- Compressed 메모리: iOS 7부터 커널 compressor가 오래 안 쓰인 dirty 페이지를 압축해 RAM 안에 보관한다. 다시 접근하면 압축 해제(=소프트 폴트). 앱의 "메모리 사용량(footprint)"은 dirty + compressed의 합으로 본다.
예외: iPadOS 16.1부터 M1 이상 칩과 256GB 이상 저장공간을 가진 iPad는 옵션으로 디스크 스왑(Virtual Memory Swap)을 켤 수 있지만, iPhone에는 여전히 앱 dirty 메모리용 스왑이 없다.
실험 · 도구
실기기에서 Xcode를 붙이고 이미지를 대량 디코딩하며 메모리를 늘려보라. Instruments의 Allocations/VM Tracker로 dirty vs clean vs compressed 구분을 볼 수 있고, Debug Gauge의 Memory 그래프가 footprint를 보여준다. 스왑이 없다는 증거: 메모리를 계속 늘리면 페이지 아웃으로 느려지는 게 아니라 어느 순간 앱이 즉사(Jetsam)한다. 기기의 설정 > 개인정보 보호 및 보안 > 분석 및 향상 > 분석 데이터에서 JetsamEvent_<날짜> 리포트를 열어 사인을 확인한다(Jetsam 이벤트는 Xcode Organizer의 Crashes에는 올라오지 않는다. 배포 후 집계가 필요하면 MetricKit의 MXAppExitMetric을 쓴다).
프로젝트 적용
① "RAM보다 큰 데이터는 스왑되겠지"라는 데스크톱 감각을 버려라. iOS에선 그 순간이 곧 종료다. 큰 리소스는 mmap(파일 뒤받침 clean)으로 두어 압박 시 커널이 회수하게 하라.
import Foundation
// ❌ 파일 전체가 dirty 힙으로 올라온다. iOS엔 스왑이 없으니
// 이 순간 footprint가 파일 크기만큼 뛰고, 상한을 넘으면 그대로 종료다.
func loadBad(_ url: URL) throws -> Data {
try Data(contentsOf: url) // 500MB 파일 → dirty 500MB
}
// ✅ mmap: file-backed clean 페이지가 되어 '실제로 만진 페이지'만 올라온다.
// 압박이 오면 커널이 알아서 버리고, 다시 필요하면 파일에서 재적재한다.
func loadMapped(_ url: URL) throws -> Data {
try Data(contentsOf: url, options: .mappedIfSafe)
}
// 사용 예 — 헤더 1KB만 읽으면 그 페이지(16KB) 하나만 물리 메모리에 올라온다
func readHeader(_ url: URL) throws -> Data {
let mapped = try loadMapped(url)
return mapped.prefix(1024)
}
// ⚠️ .mappedIfSafe는 '안전할 때만' 매핑한다(네트워크 볼륨 등은 통째로 읽는다).
// 그리고 매핑했더라도 전 영역을 무작위로 훑으면 결국 다 올라와 이점이 사라진다.
// 검증: 남은 예산을 직접 찍어 보면 두 방식의 차이가 바로 보인다
func logHeadroom(_ tag: String) {
let mb = os_proc_available_memory() / 1024 / 1024
print("[\(tag)] 남은 예산 \(mb)MB")
}② 이미지는 UIImage가 아니라 디코딩된 비트맵이 dirty를 잡아먹는다. 다운샘플링으로 실제 표시 크기만 디코딩하라.
import UIKit
import ImageIO
// UIImage를 만들었다고 디코딩이 끝난 게 아니다. dirty를 잡아먹는 건
// '디코딩된 비트맵'이고, 그 크기는 표시 크기가 아니라 원본 픽셀 수로 정해진다.
// 4000×3000 원본 → 4000*3000*4B ≈ 48MB (100×100 뷰에 그려도 똑같이 48MB)
func downsample(at url: URL,
to pointSize: CGSize,
scale: CGFloat) -> UIImage? {
// 디코딩 캐시를 끄고 소스만 연다 — 여기서는 아직 픽셀을 만들지 않는다
let srcOptions = [kCGImageSourceShouldCache: false] as CFDictionary
guard let src = CGImageSourceCreateWithURL(url as CFURL, srcOptions) else {
return nil
}
// 목표는 point가 아니라 '픽셀' — Retina 배율을 곱해야 뭉개지지 않는다
let maxPixel = max(pointSize.width, pointSize.height) * scale
let options = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceShouldCacheImmediately: true, // 이 시점에 디코딩까지 완료
kCGImageSourceCreateThumbnailWithTransform: true, // EXIF 회전 반영
kCGImageSourceThumbnailMaxPixelSize: maxPixel
] as CFDictionary
guard let cg = CGImageSourceCreateThumbnailAtIndex(src, 0, options) else {
return nil
}
return UIImage(cgImage: cg, scale: scale, orientation: .up)
}
// 셀에서 쓸 때 — 100×100 셀이면 비트맵도 300×300(3x) 수준으로 끝난다
func configure(cell: UICollectionViewCell, imageView: UIImageView, url: URL) {
imageView.image = downsample(at: url,
to: cell.bounds.size,
scale: cell.traitCollection.displayScale)
}③ didReceiveMemoryWarning/메모리 압박 소스에서 캐시를 실제로 비워라 — 안 비우면 다음 차례에 죽는 건 내 앱이다.
import UIKit
final class ImageCache {
static let shared = ImageCache()
private let cache = NSCache<NSString, UIImage>()
private var pressureSource: DispatchSourceMemoryPressure?
private init() {
cache.countLimit = 200
cache.totalCostLimit = 64 * 1024 * 1024
// 커널이 주는 압박 신호를 직접 구독한다.
// UIKit의 didReceiveMemoryWarning보다 이른 단계(.warning)에서도 온다.
let source = DispatchSource.makeMemoryPressureSource(
eventMask: [.warning, .critical],
queue: .main
)
source.setEventHandler { [weak self] in
guard let self, let data = self.pressureSource?.data else { return }
if data.contains(.critical) {
self.cache.removeAllObjects() // 전부 비운다
} else if data.contains(.warning) {
self.cache.countLimit = 50 // 예산을 줄여 축출을 유도
}
}
source.resume()
// ⚠️ 소스는 반드시 강한 프로퍼티로 붙들어야 한다.
// 지역 변수로 두면 스코프를 벗어나며 취소되어 핸들러가 한 번도 안 불린다.
pressureSource = source
}
}
final class FeedViewController: UIViewController {
// ❌ 여기서 아무것도 안 하면 경고는 그냥 로그 한 줄이다.
// 다음 압박에서 죽는 건 비우지 않은 내 앱이다.
override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
ImageCache.shared.purge() // ✅ 실제로 비운다
}
}
extension ImageCache {
func purge() { cache.removeAllObjects() }
}"iOS엔 가상 메모리가 없다"는 틀렸다. 가상 메모리·페이지 테이블·demand paging은 완전히 있다. 없는 것은 앱 dirty 메모리를 디스크로 내보내는 전통적 스왑일 뿐이다. 또 "메모리 압축은 스왑이다"도 부정확하다 — 압축은 RAM 안에서 일어나며 디스크 I/O가 없다.
방 하나에 짐을 쌓아둔다고 생각해보자. 보통 컴퓨터는 방이 꽉 차면 잘 안 쓰는 짐을 마당 창고(디스크)로 내보낸다. 이게 스왑이다. 그런데 아이폰에는 그 창고가 없다. 그래서 짐을 두 부류로 나눠서 버틴다.
- 원본이 어딘가에 남아 있는 짐 — 앱 코드나 mmap한 파일처럼 파일에서 그대로 다시 읽어올 수 있는 것(clean 페이지) — 은 그냥 버린다. 아쉬우면 다시 읽어오면 되니까.
- 내가 직접 만들고 고친 짐(dirty 페이지 — 힙에 만든 객체, 디코딩한 이미지)은 버리면 되살릴 방법이 없다. 그래서 커널이 진공팩처럼 꾹 눌러 압축해 방 안에 그대로 둔다.
그것마저 안 되면 방법이 없다. 짐을 뺄 창고가 없으니 방 주인 하나를 통째로 내보내는 수밖에 — 그게 Jetsam이다. 비유가 깨지는 곳 하나: 압축은 짐이 방 밖으로 나가는 게 아니다. 여전히 RAM을 차지하고 있으므로 "디스크에 저장해뒀다"고 이해하면 안 된다.
꼬리 질문
iPad에서 스왑을 켜면 Jetsam 동작과 앱의 메모리 한계는 어떻게 달라지나?
per-process-limit). 스왑이 완화하는 건 주로 "시스템 전체가 말라서 죽는"(vm-pageshortage) 경로다. iPhone에는 이 옵션이 없으므로 이 완화는 특정 iPad 구성에만 해당한다.같은 이미지를 여러 프로세스가 mmap하면 물리 메모리는 얼마나 절약되나(공유 clean 페이지)?
mmap하면 그 영역은 file-backed clean 페이지가 되고, 여러 프로세스가 같은 파일의 같은 범위를 매핑하면 커널은 하나의 물리 프레임을 각 프로세스의 페이지 테이블에서 공유시킨다. 그래서 N개 프로세스가 M바이트를 공유해도 물리 사용량은 N×M이 아니라 대략 M에 수렴한다(각자의 페이지 테이블 엔트리 오버헤드만 별도). dyld 공유 캐시가 바로 이 원리로 시스템 프레임워크 코드를 앱들 사이에 한 벌만 RAM에 둔다. 단 어느 프로세스가 그 페이지에 쓰면 COW로 그 프로세스만 사본을 갖게 돼 공유가 깨진다.앱을 백그라운드로 보내면 dirty 페이지에는 무슨 일이 일어나고, 왜 백그라운드 앱이 먼저 죽나?
Q2. 가상 주소와 물리 주소는 무엇이 다른가?
가상 주소(VA)는 CPU 명령어와 프로세스가 보는 주소이고, 물리 주소(PA)는 실제 RAM 칩 위의 위치다. 프로세스는 오직 VA만 쓰며, 하드웨어 MMU가 페이지 테이블을 참조해 매 접근마다 VA→PA로 번역한다. 최근 번역은 TLB에 캐싱된다. 같은 VA라도 프로세스마다 다른 PA를 가리키고(격리), 여러 VA가 하나의 PA를 가리킬 수도 있다(공유 메모리). Apple ARM64 기기의 페이지 크기는 4KB가 아니라 16KB다.
CS 원리
주소 변환은 페이지 단위로 이뤄진다. VA는 상위 비트인 가상 페이지 번호(VPN)와 하위 비트인 페이지 내 오프셋으로 나뉜다. 오프셋은 번역되지 않고 그대로 통과하고, VPN만 페이지 테이블을 통해 물리 프레임 번호(PFN)로 바뀐다. 따라서 PA = (PFN × 페이지크기) + 오프셋. 페이지 테이블 엔트리(PTE)에는 PFN 외에 valid/permission(R/W/X)/dirty/accessed 같은 플래그가 들어 있어, 접근 제어와 폴트 판단의 근거가 된다.
iOS에서는
Apple Silicon과 A 시리즈(ARM64) 기기는 16KB 페이지를 쓴다. x86_64 macOS의 4KB와 다르며, 이 때문에 애플은 메모리 정렬·크기 가정을 페이지 크기에 하드코딩하지 말 것을 권한다(대신 vm_page_size·getpagesize()를 쓰라). 번역은 다단계 변환 테이블(translation table)을 커널이 프로세스마다 유지하고, MMU가 하드웨어로 워크한다. ASLR은 프로세스 시작 시 각 이미지(메인 바이너리·각 라이브러리)의 로드 주소에 무작위 slide를 더해(한 이미지의 모든 세그먼트는 같은 slide만큼 함께 이동) 익스플로잇을 어렵게 한다. 프레임워크(dyld 공유 캐시)나 같은 파일을 여러 앱이 매핑하면 서로 다른 VA가 동일한 물리 프레임을 가리켜 RAM을 공유한다.
실험 · 도구
import Foundation
print(vm_page_size) // 실기기(ARM64): 16384
print(sysconf(Int32(_SC_PAGESIZE))) // 동일하게 16384
// iOS 시뮬레이터(맥의 아키텍처 따름)나 x86 macOS에선 4096이 나올 수 있음LLDB에서 image list로 로드된 이미지의 slide(ASLR 오프셋)를 확인할 수 있고, memory region <addr>로 특정 VA가 속한 영역의 권한을 볼 수 있다. vmmap <pid>(터미널)는 프로세스의 VA 영역별 크기·권한·공유 여부를 덤프한다.
프로젝트 적용
① 페이지 크기를 4096 같은 상수로 박지 마라. 실기기 ARM64는 16KB라 정렬·버퍼 크기 가정이 어긋난다 — vm_page_size를 런타임에 읽어라.
import Foundation
// ❌ 4096을 상수로 박으면 실기기(16KB)에서 정렬·크기 가정이 전부 어긋난다
let badPageSize = 4096
// ✅ 런타임 값을 쓴다 — 시뮬레이터든 실기기든 항상 맞다
let pageSize = Int(vm_page_size) // 실기기 ARM64: 16384
let pageSizeAlt = sysconf(Int32(_SC_PAGESIZE)) // 같은 값
// 페이지 경계로 올림 — 매핑·정렬 계산의 기본
func pageAligned(_ bytes: Int) -> Int {
(bytes + pageSize - 1) / pageSize * pageSize
}
// 예: 1MB를 요청해도 실제 매핑 단위는 페이지 배수로 맞춰진다
let chunk = pageAligned(1_000_000) // 16KB 배수로 반올림
// footprint 회계도 16KB 단위로 잡힌다는 점이 실무에서 체감된다:
// 16KB 회계는 페이지 단위 매핑(vm_allocate·mmap)에서 체감된다. 1KB를 vm_allocate로
// 1000번 따로 잡으면 실제 점유가 1MB가 아니라 16MB에 가까워진다. 반면 보통의
// malloc/Swift 힙 할당은 작은 요청을 큰 리전에서 잘라 쓰므로 1MB 남짓에 수렴하고,
// 내부 단편화는 리전 꼬리에서만 생긴다.
func demonstrateRounding() {
let logical = 1_000 * 1_024 // 약 1MB
let worstCase = 1_000 * pageSize // 페이지마다 하나씩 흩어졌을 때
print("논리 \(logical / 1024)KB vs 최악 \(worstCase / 1024)KB")
}② 로그에 찍힌 주소를 실행 간에 비교하지 마라. ASLR slide 때문에 같은 코드도 매번 다른 VA에 올라온다. 크래시 주소를 대조하려면 image list로 slide를 빼고 정규화한 오프셋으로 봐야 한다.
import Foundation
import MachO
// 같은 코드도 실행할 때마다 다른 가상 주소에 올라온다(ASLR slide).
// 그래서 "지난번 로그의 0x104a2c000과 이번 0x10b31c000이 같은 함수인가?"는
// 주소를 그대로 비교해서는 답할 수 없다.
// ✅ 메인 실행 이미지의 slide를 빼서 '정규화된 오프셋'으로 바꾼다
func slideOfMainImage() -> Int {
for i in 0..<_dyld_image_count() {
guard let raw = _dyld_get_image_name(i) else { continue }
let exe = Bundle.main.executableURL?.lastPathComponent ?? ""
if String(cString: raw).hasSuffix("/" + exe) {
return _dyld_get_image_vmaddr_slide(i)
}
}
return 0
}
func normalized(_ runtimeAddress: UInt) -> UInt {
UInt(Int(runtimeAddress) - slideOfMainImage())
}
// 이 정규화된 값이라야 빌드가 같은 두 실행 사이에서 비교가 성립한다
func logSymbolAddress(_ address: UInt) {
print("runtime 0x\(String(address, radix: 16)) "
+ "→ 정규화 0x\(String(normalized(address), radix: 16))")
}
/* LLDB에서 같은 일을 할 때:
(lldb) image list -o -f # 각 이미지의 slide와 경로
(lldb) image lookup -a 0x104a2c1f4
크래시 리포트는 "Binary Images" 섹션에 로드 주소가 찍혀 있으니
그 값을 빼면 정규화 오프셋이 된다. */③ 큰 읽기 전용 리소스를 여러 곳에서 쓴다면 각자 Data(contentsOf:)로 읽지 말고 mmap하라 — 서로 다른 VA가 같은 물리 프레임을 공유해 RAM이 한 벌만 든다.
import Foundation
// ❌ 두 컴포넌트가 각자 읽으면 같은 내용이 물리 메모리에 두 벌 올라온다
final class BadLoader {
let dictionary: Data
let index: Data
init(url: URL) throws {
dictionary = try Data(contentsOf: url) // 40MB dirty
index = try Data(contentsOf: url) // 또 40MB dirty → 합계 80MB
}
}
// ✅ mmap하면 서로 다른 가상 주소가 '같은 물리 프레임'을 가리킨다.
// N개가 매핑해도 물리 사용량은 대략 한 벌에 수렴한다.
final class SharedResource {
static let shared = try? SharedResource(url: Bundle.main.url(
forResource: "dictionary", withExtension: "bin")!)
let data: Data
init(url: URL) throws {
data = try Data(contentsOf: url, options: .mappedIfSafe)
}
}
// 프로세스 경계를 넘어도 마찬가지다 — 앱과 확장이 App Group의 같은 파일을
// 매핑하면 커널이 페이지를 공유시킨다.
func sharedFileURL() -> URL? {
FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: "group.com.app")?
.appendingPathComponent("dictionary.bin")
}
// ⚠️ 단, 그 페이지에 쓰기를 하면 COW가 발동해 쓴 쪽만 사본을 갖는다 —
// 그 순간부터 공유가 아니다. 공유 이점은 '읽기 전용'일 때 유지된다.
// 시스템도 같은 원리를 쓴다: dyld 공유 캐시가 프레임워크 코드를
// 앱들 사이에 한 벌만 RAM에 두는 것이 정확히 이 메커니즘이다."iOS도 페이지는 4KB"라고 답하면 감점 포인트다. 실기기 ARM64는 16KB이며, 이 차이는 앱 footprint 회계(최소 16KB 단위로 잡힘)와 정렬 요구에 실제 영향을 준다. 또 "가상 주소가 곧 물리 주소에 1:1로 대응한다"도 틀렸다 — 매핑은 페이지 단위이고, 공유·복사(COW)·비할당 등으로 관계가 유동적이다.
도서관에서 책을 찾는다고 해보자. 네가 말하는 주소는 "3번 책의 47쪽"이다. 이게 가상 주소다. 사서는 그걸 "지하 2층 B서가 12칸에 꽂힌 책의 47쪽"으로 바꿔준다. 이게 물리 주소다. 여기서 핵심은 책 번호만 바뀌고 쪽 번호는 그대로라는 것 — 책을 통째로 옮기는 것이지 책장을 찢어 옮기는 게 아니니까, 책 안에서의 위치는 손댈 이유가 없다. VPN만 페이지 테이블을 통과하고 offset은 그냥 따라붙는 이유가 이거다.
사서가 매번 지하까지 확인하러 내려가면 느리니, 자주 찾는 것은 카운터 위 메모지에 적어둔다. 그게 TLB다. 그리고 아이폰에서 "책 한 권"의 두께는 4KB가 아니라 16KB다 — 이걸 4KB로 답하면 감점이다.
꼬리 질문
TLB miss가 나면 어떤 일이 벌어지고, 컨텍스트 스위치 시 TLB는 왜 문제가 되나(ASID로 어떻게 완화하나)?
ASID(address space identifier)를 태깅해 여러 주소 공간의 번역이 TLB에 공존하게 하므로, 스위치 때 flush 없이 ASID만 바꿔 불필요한 miss 폭증을 막는다.ASID는 메모지마다 "이건 몇 번 앱 것"이라고 이름표를 붙여두는 것 — 지울 필요 없이 이름표만 바꿔 읽으면 된다.16KB 페이지는 4KB 대비 내부 단편화와 TLB 커버리지에서 각각 무엇을 얻고 잃나?
fork 없이 시작하는 iOS 앱에서 Copy-on-Write는 어디서 쓰이나(공유 캐시·mmap 파일)?
fork로 프로세스를 복제하진 않지만 COW는 여러 층위에서 쓰인다. 커널 레벨에선 dyld 공유 캐시와 mmap한 파일이 앱들 사이에 같은 물리 clean 페이지를 공유하다가, 어느 앱이 그 페이지를 수정하면(예: rebase/bind, 쓰기 가능 데이터 세그먼트) 그 앱만 사본을 받는다. 언어 레벨에선 Swift Array/String 같은 값 타입 컬렉션이 애플리케이션 COW로 "쓸 때까지 버퍼를 공유"한다 — 커널 페이지 COW와 계층은 다르지만 원리는 같다.Array를 다른 변수에 대입해도 진짜 복사를 안 한다. 서로 다른 사람이 만든 별개 장치인데 발상이 똑같은 것이지, 하나가 다른 하나를 쓰는 관계는 아니다.Q3. Stack과 Heap에는 각각 무엇이 저장되는가?
Stack에는 함수 호출 프레임 — 지역 변수(값 타입), 파라미터, 리턴 주소, 저장 레지스터 — 이 LIFO로 쌓인다. 할당은 스택 포인터를 옮기는 것뿐이라 극도로 빠르고 스코프를 벗어나면 자동 해제된다. Heap에는 수명이 스코프와 무관한 동적 객체 — Swift에선 class 인스턴스, closure 캡처 컨텍스트, actor 등 참조 타입 — 이 들어가며 ARC(내부적으로 malloc/free)가 관리한다. 핵심 규칙: 값 타입은 원칙적으로 인라인(주로 스택), 참조 타입은 힙. 단 값 타입도 힙 객체의 프로퍼티이거나 클로저에 캡처되거나 큰 existential로 박싱되면 힙에 얹힌다.
CS 원리
둘 다 프로세스 가상 주소 공간의 영역이다. 차이는 수명 관리 방식이다. 스택은 호출/반환이라는 규율에 묶여 있어 할당·해제가 포인터 증감 한 번으로 끝나고 단편화가 없다. 대신 크기가 스레드당 고정이고 함수가 반환하면 그 프레임의 데이터는 사라진다. 힙은 임의 순서로 할당·해제할 수 있어 유연하지만, 빈 공간을 찾고 관리하는 비용(할당자 자료구조, 락, 단편화)이 든다. "어디에 두느냐"는 곧 "이 데이터가 만든 함수보다 오래 살아야 하느냐"의 문제다.
iOS에서는
스레드마다 스택이 따로 있다. 애플 문서 기준 메인 스레드 스택은 1MB로 고정이고 변경 불가, 보조 스레드는 기본 512KB(최소 16KB, 4KB 배수)다. Swift에서 배치 결정은 대략 이렇다.
struct Point { var x, y: Double } // 값 타입
class Node { var value = 0 } // 참조 타입
func demo() {
let p = Point(x: 1, y: 2) // 스택 프레임에 인라인 저장
let n = Node() // 인스턴스는 힙, n(포인터)만 스택
// 값 타입이라도 힙으로 가는 경우:
let closure = { print(p, n.value) } // p, n 캡처. 이 클로저가 이스케이프하면 캡처는 힙 박스로
// 큰 프로토콜 타입(existential)도 박싱되어 힙에 갈 수 있음
_ = closure
}구조체라도 (1) 힙 객체(class)의 저장 프로퍼티이면 그 객체 안(힙)에, (2) 이스케이핑 클로저에 캡처되면 힙 캡처 박스에, (3) 3워드를 초과하는 페이로드를 가진 프로토콜 타입 값으로 담기면 별도 힙 버퍼에 박싱된다. 반대로 컴파일러 최적화(escape analysis)로 힙 할당이 스택으로 승격되거나 아예 제거되기도 한다.
실험 · 도구
스택 오버플로를 재현하려면 깊은 재귀를 돌려보라 — iOS에선 EXC_BAD_ACCESS로 즉사한다(가드 페이지에 닿음). 힙 할당 추적은 Instruments Allocations로, 힙 손상·해제 후 사용은 Address Sanitizer로 잡는다. 값 타입이 예상외로 힙에 가는지는 Xcode의 malloc 로그나 -emit-sil로 alloc_stack vs alloc_box/alloc_ref를 비교해 확인한다.
프로젝트 적용
① 뜨거운 경로(hot path)에서 값 타입을 남발해도 캡처·박싱으로 몰래 힙에 갈 수 있다. 성능이 중요하면 SIL로 실제 배치를 확인하라.
import Foundation
struct Point { var x, y: Double }
// struct라고 항상 스택이 아니다. 아래 세 경우엔 힙으로 간다.
final class Holder { var origin = Point(x: 0, y: 0) } // ① 클래스 프로퍼티 → 힙
func captureCases() -> () -> Void {
let p = Point(x: 1, y: 2)
// ② 이스케이핑 클로저 캡처 → 힙 캡처 박스로 이동
let escaping: () -> Void = { print(p) }
return escaping
}
protocol Shape {}
struct BigShape: Shape { // 3워드(24B) 초과 페이로드
var a, b, c, d: Double
}
// ③ 큰 existential로 담기면 별도 힙 버퍼에 박싱된다
let boxed: any Shape = BigShape(a: 0, b: 0, c: 0, d: 0)
/* 실제 배치를 눈으로 확인하는 법 — SIL을 찍어 할당 명령을 본다:
swiftc -emit-sil -O MyFile.swift | grep -E 'alloc_stack|alloc_box|alloc_ref'
alloc_stack : 스택에 잡힘 (원하는 결과)
alloc_box : var를 캡처한 이스케이핑 클로저의 힙 박스 (let 캡처는 여기 안 잡힌다)
alloc_ref : 클래스 인스턴스 힙 할당
⚠️ let 캡처 이스케이핑 클로저의 컨텍스트와 큰 existential 박싱은 SIL에
alloc_box/alloc_ref로 나타나지 않는다(각각 partial_apply,
init_existential_addr로 보이고 실제 할당은 IRGen 단계다). 이 둘은
swiftc -emit-ir -O MyFile.swift | grep swift_allocObject 로 확인해야 한다.
Debug(-Onone)와 Release(-O)의 결과가 다르다는 점이 중요하다.
최적화가 켜지면 escape analysis로 힙 할당이 스택으로 승격되거나
아예 사라지기도 하므로, 판단은 반드시 -O로 해야 한다. */
// 3워드 규칙 확인
func layoutCheck() {
print(MemoryLayout<BigShape>.size) // 32 — 3워드(24) 초과 → 박싱
print(MemoryLayout<Point>.size) // 16 — 인라인 가능
}② 거대한 값을 스택에 두지 마라 — 스택은 작고 고정이다. 큰 버퍼는 힙(예: Array, UnsafeMutableBufferPointer)에 둔다.
import Foundation
// 스택은 작고 고정이다: 메인 스레드 1MB, 보조 스레드 기본 512KB.
// 넘기면 가드 페이지에 닿아 EXC_BAD_ACCESS로 즉사하고, 이건 catch할 수 없다.
// ❌ 고정 크기 튜플·거대한 struct는 진짜로 스택 프레임에 얹힌다
func bad() {
// 128KB짜리 값을 지역 변수로 — 몇 겹만 중첩해도 1MB를 넘는다
var buffer = (0, 0, 0, 0 /* … 수천 개 … */)
_ = buffer
}
// ✅ Array는 요소를 힙 버퍼에 담는다 — 크기가 커도 스택엔 참조만 남는다
func good() {
var samples = [Float](repeating: 0, count: 200_000) // 800KB, 힙
samples.withUnsafeMutableBufferPointer { buf in
for i in buf.indices { buf[i] *= 0.5 }
}
}
// ✅ 수명이 짧고 크기를 아는 임시 버퍼는 이 API가 가장 명확하다
func processTemporary(count: Int) {
withUnsafeTemporaryAllocation(of: Float.self, capacity: count) { buf in
for i in buf.indices { buf[i] = Float(i) }
// 작은 크기는 스택, 큰 크기는 힙 — 런타임이 알아서 고른다
}
}
// 보조 스레드 스택을 명시적으로 키워야 한다면(최후의 수단)
func makeDeepThread() {
let thread = Thread { /* 깊은 작업 */ }
thread.stackSize = 4 * 1024 * 1024 // 4MB · 4KB 배수여야 한다
thread.start()
}③ 재귀 깊이를 신뢰 입력에 의존시키지 마라. 스택은 잡을 수 없는 크래시로 이어진다.
import Foundation
final class Node {
var children: [Node] = []
}
// ❌ 서버 JSON이나 사용자 데이터가 깊이를 정하면, 깊이 하나로 앱이 죽는다.
// 스택 오버플로는 do/catch로 잡을 수 없다 — 즉시 EXC_BAD_ACCESS다.
func walkRecursive(_ node: Node, visit: (Node) -> Void) {
visit(node)
for child in node.children {
walkRecursive(child, visit: visit) // 깊이 = 신뢰할 수 없는 입력
}
}
// ✅ ① 명시적 스택으로 바꾸면 깊이가 힙에서 자란다 — 스택 한계와 무관해진다
func walkIterative(_ root: Node, visit: (Node) -> Void) {
var stack = [root]
while let node = stack.popLast() {
visit(node)
stack.append(contentsOf: node.children)
}
}
// ✅ ② 재귀를 유지해야 한다면 깊이 상한을 계약으로 못 박는다
enum ParseError: Error { case tooDeep(Int) }
func walkBounded(_ node: Node,
depth: Int = 0,
maxDepth: Int = 64,
visit: (Node) -> Void) throws {
guard depth <= maxDepth else { throw ParseError.tooDeep(depth) }
visit(node)
for child in node.children {
try walkBounded(child, depth: depth + 1, maxDepth: maxDepth, visit: visit)
}
}
// ⚠️ JSONDecoder처럼 내부적으로 재귀하는 파서도 같은 위험이 있다.
// 신뢰할 수 없는 소스의 중첩 깊이는 파싱 전에 크기·깊이로 먼저 거른다."struct는 항상 스택, class는 항상 힙"은 정확하지 않다. class 인스턴스는 항상 힙이 맞지만, struct는 담기는 위치와 캡처/박싱 여부에 따라 힙에도 간다. "값 타입이라 복사가 항상 비싸다"도 오해다 — Array/String 같은 표준 컬렉션은 COW로 실제 복사를 지연한다.
스택은 뷔페 입구에 쌓아둔 접시 더미다. 맨 위에만 올리고 맨 위에서만 뺀다. 함수가 시작하면 접시를 몇 장 올리고, 끝나면 딱 그만큼 걷어간다. 그래서 빠르고, 치우는 걸 잊어버릴 수가 없다. 대신 더미를 놓을 자리가 좁게 정해져 있어서(메인 스레드는 1MB) 큰 걸 얹으면 무너진다.
힙은 짐 보관소다. 아무 칸이나 빌려서 원하는 만큼 두고, 다 쓰면 반납한다. 유연한 대신 "빈 칸 찾기"와 "언제 반납할지"를 누군가 관리해야 하는데, Swift에선 ARC가 그 관리인이다. 여기서 제일 많이 미끄러지는 지점은 "struct면 스택"이라는 말이다. 정확히는 struct는 자기가 담긴 곳에 그대로 얹힌다. 지역 변수면 접시 위에 얹히지만, class 인스턴스의 프로퍼티라면 그 인스턴스가 보관소에 있으니 struct도 보관소 안에 같이 있는 셈이다. 이스케이핑 클로저가 붙잡아 가도 마찬가지다.
꼬리 질문
이스케이핑 vs 논이스케이핑 클로저에서 캡처 저장 위치는 어떻게 달라지나?
self를 캡처하면 retain cycle 위험이 생기는 것이다.self를 담는 순간 self를 붙드는 손이 하나 생기고, 그 손 때문에 retain cycle — 서로를 붙들어 아무도 못 죽는 상태 — 이야기가 시작된다.Copy-on-Write는 값 타입의 "복사=힙 공유"를 어떻게 구현하나(isKnownUniquelyReferenced)?
isKnownUniquelyReferenced(&buffer)로 "나만 이 버퍼를 참조하는가"를 검사한다 — true면 제자리 수정, false(공유 중)면 그때 버퍼를 깊은 복사한 뒤 수정한다. 그래서 읽기만 하거나 유일 소유일 때는 복사 비용이 0이고, 실제 복사는 공유 상태에서 쓸 때만 일어난다.큰 existential(프로토콜 타입) 값의 3워드 인라인 버퍼 규칙이 성능에 주는 함의는?
any Protocol로 담으면 생성·복사마다 힙 할당과 ARC가 붙고, witness table을 통한 간접 디스패치까지 겹쳐 뜨거운 경로에서 비싸진다. 이를 피하려면 제네릭(<T: Protocol>, 정적 디스패치·특수화)을 쓰거나 값 자체를 3워드 이하로 유지하는 설계를 택한다.any Protocol은 24바이트짜리 작은 봉투라고 보면 된다. 내용물이 봉투에 들어가면 그대로 넣지만, 넘치면 물건은 창고(힙)에 두고 봉투엔 주소만 적는다 — 그 순간부터 만들 때마다 힙 할당이 붙고 ARC가 따라온다. 1초에 수천 번 도는 코드라면 봉투를 쓰지 말고 제네릭으로 "이건 무슨 타입이다"를 컴파일러에게 미리 알려주는 쪽이 훨씬 싸다.Q4. ARC는 Garbage Collection과 무엇이 다른가?
ARC는 컴파일 타임에 컴파일러가 retain/release 호출을 코드에 심어두는 참조 카운팅이다. 참조 수가 0이 되는 순간 즉시·결정적으로 해제되고, 별도 런타임 추적이나 stop-the-world 정지가 없다. 반면 (트레이싱) GC는 런타임에 루트에서 도달 가능한 객체를 추적해 도달 불가한 것을 나중에 회수한다 — 해제 시점이 비결정적이고 순환 참조를 자동으로 수거하지만 별도 스레드/정지·메모리 여유가 필요하다. ARC의 약점은 순환을 스스로 못 푸는 것이라 weak/unowned로 사람이 끊어줘야 한다.
CS 원리
CS 분류상 참조 카운팅도 GC의 한 종류다. 하지만 면접에서 "GC"라 하면 보통 JVM/Go/CLR 식 트레이싱 GC를 뜻한다. 둘의 근본 차이는 "쓰레기를 언제, 어떻게 판정하느냐"다.
| 축 | ARC (참조 카운팅) | 트레이싱 GC |
|---|---|---|
| 판정 시점 | 참조 수 0 → 즉시 결정적 해제 | 주기적 추적 → 비결정적 |
| 순환 참조 | 자동 수거 불가 (weak/unowned 필요) | 도달 불가면 자동 수거 |
| 런타임 비용 | retain/release(원자적 연산)가 실행 내내 분산 | 수집 시 집중 (정지·CPU·메모리 여유) |
| 메모리 오버헤드 | 여유 힙 거의 불필요 | 보통 힙의 수배 여유 권장 |
| deinit 타이밍 | 정확히 예측 가능(RAII 유사) | finalizer 시점 불명확 |
iOS에서는
Swift/Objective-C는 ARC를 쓴다(macOS Objective-C엔 과거 트레이싱 GC가 있었으나 폐기됨). 컴파일러는 소유권 규칙에 따라 retain/release를 삽입하고, 최적화 단계에서 불필요한 쌍을 제거한다. Swift 힙 객체는 헤더에 참조 카운트 비트를 갖고, weak/unowned 참조가 붙거나 카운트가 넘치면 side table로 승격된다. retain/release는 멀티스레드 안전을 위해 원자적 연산이라 완전 공짜는 아니며, 이것이 "값 타입을 선호하라"는 조언의 한 근거다.
// 원본
let a = Node()
use(a)
// ARC가 개념적으로 삽입 (실제론 swift_retain/swift_release)
let a = Node() // refCount = 1 (생성 시)
swift_retain(a) // 필요 시 +1
use(a)
swift_release(a) // -1, 0이면 deinit 후 free — 즉시·결정적프로젝트 적용
① ARC의 해제 시점이 결정적이라는 성질을 자원 관리에 활용하라 — 파일 핸들·소켓·옵저버 해제를 deinit에 두면 스코프를 벗어나는 즉시 반납된다(GC 언어에서는 못 하는 것).
import Foundation
// ARC는 마지막 참조가 풀리는 '그 지점'에서 즉시 deinit한다.
// 트레이싱 GC와 달리 시점이 예측 가능하므로, 자원 반납을 여기에 걸 수 있다.
final class LogFile {
private let handle: FileHandle
init(url: URL) throws {
if !FileManager.default.fileExists(atPath: url.path) {
FileManager.default.createFile(atPath: url.path, contents: nil)
}
handle = try FileHandle(forWritingTo: url)
}
func write(_ line: String) throws {
try handle.write(contentsOf: Data((line + "\n").utf8))
}
// ✅ 스코프를 벗어나는 즉시 fd가 반납된다 — 파일 디스크립터 누수가 구조적으로 불가능
deinit {
try? handle.close()
}
}
func writeReport() throws {
let log = try LogFile(url: URL(fileURLWithPath: "/tmp/report.log"))
try log.write("시작")
// 함수가 끝나는 순간 log의 참조 수가 0 → deinit → close()
} // ← 여기서 확정적으로 닫힌다. GC 언어라면 "언젠가"였을 지점.
// 같은 패턴이 옵저버·구독 해제에도 쓰인다
final class PriceWatcher {
private var token: NSObjectProtocol?
init() {
token = NotificationCenter.default.addObserver(
forName: .init("PriceChanged"), object: nil, queue: .main
) { _ in /* 갱신 */ }
}
deinit {
if let token { NotificationCenter.default.removeObserver(token) }
}
}② 거대한 객체 그래프의 루트를 메인 스레드에서 놓지 마라. 그 자리에서 수천 개의 deinit이 동기로 연쇄 실행돼 프레임을 놓친다 — 해제도 비용이다.
import UIKit
final class Tile { var pixels = [UInt8](repeating: 0, count: 64 * 64 * 4) }
final class Layer { var tiles: [Tile] = [] }
final class Document { var layers: [Layer] = [] }
final class EditorViewController: UIViewController {
private var document: Document?
// ❌ 마지막 참조를 메인에서 놓으면, 그 자리에서 수만 개의 deinit이
// 동기로 연쇄 실행된다. 해제도 비용이라 프레임을 놓친다.
func closeBad() {
document = nil // 여기서 앱이 멈칫한다
}
// ✅ 소유권을 백그라운드로 넘기고 거기서 놓는다
func close() {
guard let doc = document else { return }
document = nil // 메인에서는 참조만 끊고
Task.detached(priority: .utility) {
_ = doc // 실제 연쇄 해제는 여기서 일어난다
}
}
}
// 측정: 해제 비용이 실제로 메인에 있는지 보려면 signpost를 건다
import os.signpost
let releaseLog = OSLog(subsystem: "com.app.memory", category: "release")
func measuredClose(_ doc: inout Document?) {
let id = OSSignpostID(log: releaseLog)
os_signpost(.begin, log: releaseLog, name: "document release", signpostID: id)
doc = nil
os_signpost(.end, log: releaseLog, name: "document release", signpostID: id)
}
// Instruments의 os_signpost 트랙에서 이 구간이 프레임 예산을 먹는지 바로 보인다③ 뜨거운 경로에서는 retain/release가 원자적 연산이라 공짜가 아니다. 참조를 담지 않는 값 타입으로 바꾸면 카운팅 트래픽 자체가 사라진다.
import Foundation
// retain/release는 멀티스레드 안전을 위해 '원자적 연산'이라 공짜가 아니다.
// 초당 수만 번 도는 경로에서는 이 비용이 실제로 보인다.
// ❌ 참조 타입 배열 — 순회·복사마다 retain/release가 붙는다
final class ParticleRef {
var x: Float = 0, y: Float = 0
}
func stepRef(_ ps: [ParticleRef]) {
for p in ps { p.x += 1 } // 요소 접근마다 참조 카운팅 트래픽
}
// ✅ 참조를 하나도 담지 않는 값 타입 — 셀 숫자 자체가 없다
struct Particle {
var x: Float = 0, y: Float = 0
}
func step(_ ps: inout [Particle]) {
for i in ps.indices { ps[i].x += 1 } // ARC 연산 0회
}
// ✅ 소유권 파라미터는 '참조를 담은' 값에서 의미가 있다 — 일반 파라미터는 이미 기본이
// borrowing이라, Particle처럼 참조가 없는 타입엔 생략할 카운팅 자체가 없다.
// String·클래스를 품은 타입에서라야 retain/release 쌍이 실제로 줄어든다.
func inspect(_ item: borrowing Particle) -> Float {
item.x // "잠깐 빌려 볼 뿐" — 카운팅 없음
}
func consumeOnce(_ item: consuming Particle) {
// 소유권을 넘겨받았으므로 호출자 쪽 release가 생략된다
_ = item
}
// ✅ 복사 자체를 금지해 카운팅이 성립할 여지를 없앤다
struct FileToken: ~Copyable {
let fd: Int32
deinit { close(fd) }
}
// ⚠️ 단, 값 타입이라도 내부에 클래스 참조나 COW 버퍼(Array·String)를 담으면
// 복사 시 그 필드에는 여전히 retain/release가 발생한다.
struct HasReference {
var name: String // String은 내부 버퍼가 참조 카운팅된다
}④ 하지 말 것: "ARC라서 누수가 없다"고 가정하는 것. 순환 참조는 여전히 사람이 끊어야 한다(→ Q6).
import Foundation
// ARC는 카운트만 본다. 서로를 가리키는 섬은 외부에서 도달 불가여도
// 카운트가 0이 되지 않아 영영 해제되지 않는다 — 이게 누수다.
final class Parent {
var child: Child?
deinit { print("Parent 해제") }
}
final class Child {
var parent: Parent? // ❌ 양방향 strong → 순환
deinit { print("Child 해제") }
}
func leaks() {
let p = Parent()
let c = Child()
p.child = c
c.parent = p
} // ← 아무 deinit도 찍히지 않는다. 두 객체는 영구히 남는다.
// ✅ 한 변을 비소유로 끊는다 — 소유 방향을 명시적으로 정한다
final class SafeChild {
weak var parent: Parent? // 또는 unowned (수명이 구조적으로 보장될 때)
deinit { print("SafeChild 해제") }
}
// 검증 습관: 화면·모델 단위로 해제 테스트를 둔다
import XCTest
final class LeakTests: XCTestCase {
func test_문서가_해제된다() {
weak var weakDoc: Parent?
autoreleasepool {
let doc = Parent()
doc.child = Child()
weakDoc = doc
}
XCTAssertNil(weakDoc, "순환 참조로 해제되지 않았다")
}
}
// 도구: Xcode Memory Graph Debugger(디버그 바의 그래프 아이콘)에서
// 보라색 ⚠️ 배지가 붙은 노드가 순환 후보다."ARC는 런타임 GC의 일종이라 백그라운드에서 돈다"는 틀렸다. ARC엔 별도 수집 스레드도, stop-the-world 정지도 없다. 반대로 "ARC엔 오버헤드가 전혀 없다"도 틀렸다 — retain/release는 원자적 연산이라 뜨거운 경로에선 유의미하다. 또 "ARC라 메모리 누수가 불가능하다"도 오해다 — 순환 참조는 여전히 누수를 만든다.
ARC는 물건마다 "지금 몇 명이 쓰는 중"이라고 적힌 스티커를 붙여두는 방식이다. 쓰기 시작하면 +1, 다 쓰면 −1, 숫자가 0이 되는 그 순간 바로 버린다. 여기서 오해가 많은 지점 하나 — 이 도장을 찍으러 돌아다니는 사람은 없다. +1/−1 하는 코드를 컴파일할 때 컴파일러가 미리 코드 안에 박아둔다. 그래서 백그라운드 스레드도, 앱이 멈칫하는 순간도 없다.
트레이싱 GC는 반대다. 가끔 청소부가 나타나 창고 전체를 돌면서 "현관에서 출발해 걸어갈 수 없는 물건"을 찾아 한꺼번에 치운다. 언제 올지 예측이 어렵고 도는 동안 잠깐 멈출 수 있지만, 서로 껴안고 있는 물건 뭉치라도 아무도 닿을 수 없으면 알아서 치워준다. ARC는 타이밍은 칼같은데 스티커 숫자만 보기 때문에, 두 물건이 서로에게 스티커를 붙여준 경우를 끝내 못 알아챈다. 바로 다음 문항의 retain cycle 이야기다.
꼬리 질문
트레이싱 GC의 세대별 수집(generational)이 왜 유리한지, ARC엔 왜 그 개념이 없는지?
ARC의 원자적 retain/release 비용을 줄이는 방법(값 타입, borrow, ~Copyable)은?
Array/String 등)를 담으면 복사 시 그 참조에는 retain/release가 발생한다. Swift의 소유권 파라미터 borrowing/consuming은 값을 넘길 때 불필요한 retain/release 쌍을 생략하게 하고, ~Copyable(non-copyable) 타입은 복사가 언어 차원에서 금지돼 소유권 이전만 일어나므로 카운팅이 아예 필요 없다. 컴파일러의 ARC 최적화(중복 쌍 제거)도 있지만, 원자적 연산 비용이 남는 뜨거운 경로에선 이런 타입 설계가 더 확실하다.borrowing은 "가져가는 게 아니라 잠깐 빌려 볼게"라고 알려줘 도장을 찍었다 지우는 헛수고를 생략하는 것이고, ~Copyable은 복사가 아예 금지라 셀 것이 남지 않는다.객체 그래프가 클 때 ARC와 GC 중 해제 지연 스파이크가 어디서 더 크게 나타나나?
deinit이 그 스레드에서 연쇄적으로 동기 실행돼 바로 그 지점에 스파이크가 몰린다. 즉 GC는 "언젠가 몰아서", ARC는 "마지막 참조가 풀리는 그 호출 지점에서" 스파이크가 나며, ARC 쪽은 위치가 예측 가능하다는 차이가 있다.Q5. strong, weak, unowned는 무엇을 의미하는가?
세 가지는 "이 참조가 대상을 살려두느냐, 그리고 대상이 먼저 죽으면 어떻게 되느냐"를 정한다. strong(기본): 참조 수를 +1 해 대상을 살려둔다. weak: 참조 수를 올리지 않고, 대상이 해제되면 자동으로 nil이 된다(그래서 Optional·var 필수). unowned: 참조 수를 올리지 않고 nil이 되지도 않는다 — "대상이 나보다 오래 산다"고 가정하며, 그 가정이 깨진 뒤 접근하면 크래시(unowned(safe)는 trap). 수명이 확실히 더 긴 경우엔 unowned, 불확실하면 weak.
CS 원리
참조 카운팅에서 "소유(strong)"는 곧 카운트에 대한 지분이다. 순환을 끊으려면 최소 한 방향은 지분을 갖지 않는 참조여야 한다. 그 비소유 참조가 대상 해제 후를 어떻게 다루느냐로 weak/unowned가 갈린다. weak는 안전(자동 nil화)을 위해 부가 기록을 유지하고, unowned는 그 기록을 줄여 더 싸지만 안전을 프로그래머 책임으로 넘긴다.
| strong | weak | unowned | |
|---|---|---|---|
| 참조 수 증가 | +1 | 안 함 | 안 함 |
| 대상 해제 후 | — | 자동 nil | 해제 후 접근 시 trap (unowned(safe); unowned(unsafe)는 UB) |
| 타입 | 보통 | Optional·var 필수 | 비Optional 가능·let 가능 |
| 비용 | retain/release | side table 관리(약간 더 비쌈) | weak보다 저렴 |
iOS에서는
Swift의 zeroing weak 참조는 런타임 side table에 등록된다. 대상의 strong 카운트가 0이 되면 deinit이 돌고, 그 대상을 가리키던 모든 weak 슬롯이 nil로 채워진다. unowned(safe)는 대상이 deinit되었는지를 표시해 두었다가 접근 시 trap을 발생시키고(안전), unowned(unsafe)는 그 검사조차 없어 진짜 dangling 포인터가 된다(정의되지 않은 동작). 대표 관용구는 클로저 캡처 리스트다.
class ViewController {
var onDone: (() -> Void)?
func setup() {
// self가 클로저를 소유하고, 클로저가 self를 strong 캡처하면 순환
onDone = { [weak self] in
guard let self else { return } // 이미 죽었으면 조용히 종료
self.refresh()
}
}
}
// 수명이 확실히 더 긴 부모를 가리킬 때만 unowned
class Child {
unowned let parent: Parent // parent는 child보다 항상 오래 산다는 계약
init(parent: Parent) { self.parent = parent }
}실험 · 도구
weak가 언제 nil이 되는지 보려면 대상을 nil로 만든 뒤 weak 참조를 출력하라. unowned 오용은 Xcode의 Zombies 계측이나 Address Sanitizer로 "해제된 객체 접근"을 잡을 수 있다. 순환 여부는 Memory Graph Debugger(디버그 바의 그래프 아이콘)로 보라 — 해제됐어야 할 객체가 살아 있고 서로를 가리키면 보라색 순환으로 표시된다.
프로젝트 적용
① 판단 기준은 하나다 — "이 클로저/참조가 쓰이는 시점에 대상이 이미 죽어 있을 수 있는가?" 조금이라도 그렇다면 weak, 구조적으로 불가능하면 unowned.
import UIKit
final class ProfileViewController: UIViewController {
private let api = ProfileAPI()
private var items: [Item] = []
func load() {
// ① 네트워크 콜백 — 응답이 오기 전에 사용자가 뒤로가기를 누를 수 있다
// → self가 먼저 죽을 수 있다 → weak
api.fetch { [weak self] profile in
guard let self else { return } // 죽었으면 조용히 종료
self.render(profile)
}
// ② 지연 실행 — 마찬가지로 그 사이 화면이 닫힐 수 있다 → weak
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
self?.showTooltip()
}
// ③ 즉시 동기 실행 — self가 죽을 방법이 없다 → 캡처 리스트 불필요
items.sort { self.rank($0) < self.rank($1) }
}
// ④ self가 소유하고, self보다 오래 살 수 없는 하위 객체가 되짚어 볼 때
// → unowned (언랩 비용 없이 계약을 코드로 드러낸다)
private lazy var layout: GridLayout = {
GridLayout(dataSource: self)
}()
private func render(_ p: Profile) {}
private func showTooltip() {}
private func rank(_ i: Item) -> Int { 0 }
}
struct Profile {}
struct Item {}
final class ProfileAPI { func fetch(_ done: @escaping (Profile) -> Void) {} }
final class GridLayout {
unowned let dataSource: ProfileViewController // 계약: 소유자가 나보다 오래 산다
init(dataSource: ProfileViewController) { self.dataSource = dataSource }
}② delegate는 기본을 weak로 둔다. 소유자를 거꾸로 strong으로 붙들면 그 자체가 순환이다.
import UIKit
// delegate는 보통 '소유자'다. 그걸 strong으로 되짚으면 그 자체로 순환이 된다.
protocol ImagePickerDelegate: AnyObject { // ✅ AnyObject 제약이 있어야
func picker(_ picker: ImagePicker, // weak를 붙일 수 있다
didPick image: UIImage)
}
final class ImagePicker {
// ✅ weak — picker를 소유한 쪽을 붙들지 않는다
weak var delegate: ImagePickerDelegate?
func finish(with image: UIImage) {
delegate?.picker(self, didPick: image)
}
}
final class ComposeViewController: UIViewController, ImagePickerDelegate {
private let picker = ImagePicker() // VC가 picker를 소유
override func viewDidLoad() {
super.viewDidLoad()
picker.delegate = self // picker → VC 는 weak라 순환 없음
}
func picker(_ picker: ImagePicker, didPick image: UIImage) {}
deinit { print("ComposeViewController 해제") } // 순환이면 안 찍힌다
}
// ⚠️ 예외: 클로저 기반 콜백 프로퍼티는 delegate와 성격이 다르다.
// 아래는 'picker가 클로저를 소유'하므로, 클로저 쪽에서 self를 weak로 잡아야 한다.
final class ClosurePicker {
var onPick: ((UIImage) -> Void)?
}
func wire(_ vc: ComposeViewController, _ p: ClosurePicker) {
p.onPick = { [weak vc] image in vc?.picker(ImagePicker(), didPick: image) }
}③ 습관적인 [weak self] 남발은 피하라. 즉시 실행되고 저장되지 않는 클로저에서는 불필요하고, 오히려 필요한 순간 self가 nil이 되는 로직 버그를 만든다.
import Foundation
final class Report {
private var rows: [Int] = []
private var total = 0
func compute() {
// ❌ 동기·비저장 클로저에 weak를 붙일 이유가 없다.
// 옵셔널 체이닝만 늘고, 최악의 경우 '필요한 순간 nil'이 된다.
rows.forEach { [weak self] row in
self?.total += row
}
// ✅ map/filter/forEach 같은 non-escaping 클로저는 함수가 끝나기 전에
// 반드시 실행·폐기되므로 self를 잡아도 사이클이 생기지 않는다
rows.forEach { row in
self.total += row
}
}
// ❌ 더 나쁜 경우: 반드시 실행돼야 하는 정리 작업에 weak를 걸면
// self가 마지막 참조일 때 정리가 통째로 건너뛰어진다
func flushBad(queue: DispatchQueue) {
queue.async { [weak self] in
self?.writeToDisk() // self가 이미 nil이면 저장이 사라진다
}
}
// ✅ 이 작업이 끝날 때까지는 살아 있어야 한다면 strong 캡처가 '옳다'
func flush(queue: DispatchQueue) {
queue.async {
self.writeToDisk() // 의도적으로 수명을 연장한다
}
}
private func writeToDisk() {}
}
// 판단은 "순환이 생기는가"이지 "weak가 안전해 보이는가"가 아니다.
// 순환은 self가 그 클로저를 '보유'할 때만 생긴다.④ 하지 말 것: 수명이 애매한 곳에 unowned를 쓰는 것. 크래시 폭탄이며, unowned(unsafe)는 그 크래시조차 안 나고 엉뚱한 메모리를 읽는다.
import Foundation
final class Session {
var onExpire: (() -> Void)?
}
final class Dashboard {
private let session = Session()
func setupCrashy() {
// ❌ Dashboard가 session보다 먼저 죽을 수 있는데 unowned를 걸었다.
// 죽은 뒤 onExpire가 불리면 그 자리에서 trap → 크래시.
session.onExpire = { [unowned self] in
self.reload()
}
}
func setupSafe() {
// ✅ 수명이 불확실하면 weak가 정답이다
session.onExpire = { [weak self] in
self?.reload()
}
}
private func reload() {}
}
// unowned(unsafe)는 그 trap 검사조차 없다 — 해제된 메모리를 그냥 읽는다.
final class Danger {
// ❌ 실패해도 크래시가 안 나고 엉뚱한 값이 읽히거나, 한참 뒤 전혀 다른 곳에서 터진다
unowned(unsafe) var target: AnyObject?
}
/* 오용을 잡는 도구:
Scheme → Diagnostics → Zombie Objects (해제된 객체 접근 시 즉시 정지)
Address Sanitizer (use-after-free 검출)
unowned가 정당한 유일한 경우는 "소유 관계가 구조적으로 한 방향이고,
그 계약이 코드로 강제되는" 때다. 예: 부모가 자식을 소유하고
자식은 부모보다 절대 오래 살 수 없는 경우. */"weak가 unowned보다 항상 안전하니 무조건 weak를 써라"는 반은 맞고 반은 틀리다. weak는 매 접근마다 Optional 언랩과 side table 비용이 있고, 수명 계약이 확실한 곳에 unowned를 쓰면 의도가 더 분명하고 빠르다. 반대로 수명이 조금이라도 불확실하면 unowned는 크래시 폭탄이다. 또 "weak는 참조 수를 0으로 만든다"는 틀렸다 — weak는 애초에 카운트에 참여하지 않는다.
물건 하나를 여러 사람이 쳐다보고 있다고 하자. strong은 그 물건을 손으로 꽉 잡은 상태다. 내가 놓기 전까지는 절대 치워지지 않는다. weak는 물건이 놓인 자리 번호를 적어둔 쪽지인데, 물건이 치워지면 그 쪽지가 스스로 지워져 빈칸(nil)이 된다. 그래서 쓸 때마다 "아직 있나?" 하고 한 번 확인하는 절차가 붙고, 대신 없어졌으면 조용히 물러날 수 있다.
unowned도 쪽지지만 지워지지 않는다. 물건이 이미 치워졌는데 그 쪽지를 믿고 찾아가면, 비어 있는 자리를 여는 셈이라 앱이 거기서 멈춰 선다(trap). 그러니 unowned는 "얘는 나보다 확실히 오래 산다"가 구조적으로 보장될 때만 쓰는 것이고, 조금이라도 애매하면 weak가 맞다. 반대로 수명이 확실한 곳까지 습관적으로 weak를 붙이면, 매번 확인하는 비용에 코드에서 의도도 흐려진다.
꼬리 질문
[weak self] guard let self와 [unowned self]를 각각 언제 쓰나, 판단 기준은?
[weak self] + guard let self로 죽었을 때 안전하게 빠져나온다 — 네트워크 콜백, 지연 실행, 어딘가 저장되는 클로저처럼 수명이 불확실한 경우다. [unowned self]는 클로저가 self보다 절대 오래 살지 않는다는 계약이 성립할 때만 쓴다(예: self가 소유하고 동기적으로만 실행되는 클로저). 판단 기준은 "self가 이 클로저 실행 전에 해제될 수 있는가"이며, 조금이라도 그렇다면 weak, 확실히 아니라면 unowned가 언랩 비용 없이 의도를 더 분명히 드러낸다.weak다. 반대로 self가 그 클로저를 들고 있고 지금 그 자리에서 바로 실행되는 경우엔 self가 먼저 죽을 방법이 없으니 unowned로 두고 매번 확인하는 절차를 없애는 게 낫다.weak 참조가 nil로 바뀌는 그 순간의 스레드 안전성은 어떻게 보장되나?
unowned(unsafe)는 왜 존재하고 언제라도 정당화되나?
unowned(unsafe)는 해제 여부 검사(safe 버전의 trap 로직)조차 없는 순수 dangling 가능 포인터로, Objective-C의 __unsafe_unretained에 대응하며 side table 등록·검사 비용이 0이다. 존재 이유는 수명이 다른 수단으로 확실히 보장되는 극한 성능 경로나 레거시 상호운용에서, unowned(safe)의 검사 오버헤드마저 없애기 위해서다. 정당화되는 경우는 매우 드물다 — 측정으로 그 오버헤드가 실제 병목임을 입증하고 수명 계약이 구조적으로 깨질 수 없을 때뿐이며, 대부분은 unowned(safe)나 weak가 옳다.Q6. Retain cycle은 메모리 수준에서 왜 발생하는가?
두 개(이상)의 힙 객체가 서로를 strong으로 가리키면, 바깥의 모든 참조가 사라져도 서로가 서로의 참조 수를 1로 붙들어 참조 수가 0이 되지 못한다. ARC는 트레이싱 GC와 달리 "루트에서 도달 불가"를 판단하지 못하고 오직 카운트만 보므로, 이 고립된 섬을 해제하지 못해 영구 누수가 된다. 그래서 사이클의 한 변을 weak/unowned로 만들어 카운트를 붙들지 않게 끊어야 한다.
CS 원리
참조 카운팅의 근본 한계다. 카운팅은 "나를 가리키는 지분이 몇 개냐"만 알 뿐, 그 지분을 준 객체가 정작 살아있는지(도달 가능한지)는 모른다. A↔B가 서로에게 지분을 주면, 외부에서 둘 다 버려도 둘의 카운트는 상호 지분 때문에 1로 남는다. 트레이싱 GC라면 루트에서 A·B로 가는 경로가 없으므로 통째로 수거하지만, 카운팅은 이 판단을 못 한다.
iOS에서는
실무의 3대 발생원은 (1) self를 strong 캡처하는 이스케이핑 클로저, (2) strong으로 잡은 delegate, (3) 부모-자식 양방향 strong 참조다. 특히 클로저는 캡처 컨텍스트가 힙 박스로 self에 대한 지분을 갖기 때문에, self가 그 클로저를 프로퍼티로 보유하면 즉시 사이클이 된다.
final class Downloader {
var completion: (() -> Void)?
func start() {
// ❌ self ↔ completion 순환: self가 completion 보유, completion이 self 캡처
completion = { self.finish() }
// ✅ 캡처 리스트로 지분을 끊는다
completion = { [weak self] in self?.finish() }
}
func finish() {}
}delegate는 관례적으로 weak var delegate: SomeDelegate?로 선언하는 이유가 바로 이 순환 방지다.
실험 · 도구
가장 확실한 도구는 Memory Graph Debugger다. 화면을 열고 닫은 뒤에도 해당 ViewController가 그래프에 남아 있으면 누수 신호이고, 클릭하면 그를 붙들고 있는 참조 체인이 보인다. 보조로 deinit에 로그를 심어 화면을 닫아도 안 찍히면 순환을 의심한다. Instruments Leaks는 도달 불가한 할당을 잡지만, ARC 순환은 다른 객체 그래프에 여전히 매달려 "도달 가능하지만 영영 안 죽는" 형태일 수 있어 Leaks에 항상 잡히진 않으니 Memory Graph가 더 신뢰할 만하다.
프로젝트 적용
① 이스케이핑 클로저에서 self를 쓰면 반사적으로 캡처 리스트를 검토하라. 다만 모든 클로저에 [weak self]를 붙이는 것도 과잉이다 — 즉시 실행되고 자신을 보유하지 않는 클로저엔 불필요하다.
import Foundation
final class Downloader {
var completion: (() -> Void)? // self가 클로저를 '보유'한다
private var retries = 0
func startLeaky() {
// ❌ self → completion → self 순환. 외부 참조가 사라져도 둘 다 안 죽는다.
completion = {
self.finish()
}
}
func start() {
// ✅ 한 변을 끊는다
completion = { [weak self] in
guard let self else { return }
self.finish()
}
}
// ✅ 필요한 값만 캡처하면 self를 아예 안 잡아도 된다 — 가장 깔끔한 해법
func startCapturingValues() {
let currentRetries = retries // 값만 복사
completion = {
print("재시도 \(currentRetries)회 후 완료")
}
}
private func finish() {
// ⚠️ 여기서 completion?()을 부르면 start()가 심은 클로저가 다시 finish()를
// 불러 무한 재귀가 된다. 발화는 호출자가 하고, 여기서는 참조만 끊는다.
completion = nil // ✅ 발화 후 비워 참조를 끊는다
}
}
// ⚠️ 반대로 과잉도 문제다. 아래는 self를 '보유하지 않는' 클로저라 순환이 없다.
func noCycle(queue: DispatchQueue, worker: Downloader) {
queue.async {
worker.start() // 클로저는 실행 후 사라진다 — weak 불필요
}
}② delegate·closure 프로퍼티·양방향 링크는 순환 후보로 보고 한쪽을 비소유로.
import UIKit
// ① delegate
protocol RouterDelegate: AnyObject { func didFinish() }
final class Router {
weak var delegate: RouterDelegate? // ✅ weak
}
// ② 클로저 프로퍼티 (self가 보유하는 콜백)
final class SearchBar: UIView {
var onQueryChanged: ((String) -> Void)?
}
final class SearchScreen: UIViewController {
private let bar = SearchBar()
override func viewDidLoad() {
super.viewDidLoad()
bar.onQueryChanged = { [weak self] q in // ✅ 캡처 리스트로 끊기
self?.search(q)
}
}
private func search(_ q: String) {}
}
// ③ 양방향 링크 (부모 ↔ 자식)
final class Folder {
var files: [File] = [] // 부모가 자식을 strong으로 소유
}
final class File {
unowned let folder: Folder // ✅ 자식은 비소유로 되짚는다
init(folder: Folder) { self.folder = folder }
}
// 소유 방향을 한 줄로 정리해 두면 리뷰에서 바로 판별된다:
// "누가 누구를 소유하는가?" → 소유자만 strong, 반대 방향은 전부 weak/unowned
//
// Combine·Notification 구독도 같은 범주다
import Combine
final class Widget {
private var bag = Set<AnyCancellable>()
func bind(_ publisher: AnyPublisher<Int, Never>) {
publisher
.sink { [weak self] v in self?.apply(v) } // ✅ sink 클로저도 self를 잡는다
.store(in: &bag)
}
private func apply(_ v: Int) {}
}③ 화면 단위로 deinit 로그 또는 테스트를 두어 회귀를 조기에 잡아라.
import UIKit
import XCTest
// ① 개발 중엔 화면 단위로 deinit 로그를 남긴다 — 닫았는데 안 찍히면 즉시 의심
final class FeedViewController: UIViewController {
#if DEBUG
deinit { print("✅ deinit: \(type(of: self))") }
#endif
}
// ② 공통 베이스에 한 번만 넣어 두면 전 화면에 적용된다
class TrackedViewController: UIViewController {
#if DEBUG
deinit { print("✅ deinit: \(type(of: self))") }
#endif
}
// ③ 로그는 사람이 놓친다 — 테스트로 고정한다
final class LeakTests: XCTestCase {
func test_피드화면이_해제된다() {
weak var weak: FeedViewController?
autoreleasepool {
let vc = FeedViewController()
vc.loadViewIfNeeded() // 뷰 계층까지 만들어 실제와 가깝게
weak = vc
}
XCTAssertNil(weak, "FeedViewController가 해제되지 않았다 — 순환 참조 의심")
}
// 재사용 가능한 헬퍼로 만들어 두면 화면마다 한 줄로 검증된다
func assertDeallocated<T: AnyObject>(_ make: () -> T,
file: StaticString = #filePath,
line: UInt = #line) {
weak var weak: T?
autoreleasepool {
let object = make()
weak = object
}
XCTAssertNil(weak, "\(T.self)가 해제되지 않았다", file: file, line: line)
}
func test_여러_화면() {
assertDeallocated { FeedViewController() }
}
}"weak를 여기저기 붙이면 순환은 무조건 해결된다"는 위험하다. 엉뚱한 변을 weak로 만들면 필요한 객체가 조기 해제돼 nil·크래시가 난다. 또 "순환은 클로저에서만 난다"도 틀렸다 — 순수 객체 그래프(부모↔자식, 노드↔노드)에서도 얼마든지 난다. "Leaks 계측이 다 잡아준다"는 것도 오해다.
객체 하나가 가진 정보는 딱 하나다. "내 이름을 명단에 올려둔 사람이 몇 명인가." 관리인(ARC)은 방 안을 들여다보지 않는다. 오직 이 숫자만 본다. 0이면 치우고, 1 이상이면 그대로 둔다.
그런데 A가 자기 명단에 B를 올리고, B도 자기 명단에 A를 올리면 어떻게 될까. 바깥 세상에서 아무도 둘을 찾지 않게 된 뒤에도 둘 다 숫자가 1로 남는다. 서로가 서로를 붙들고 있으니 영영 0에 못 닿고, 관리인은 "아직 누가 쓰나 보다" 하며 방을 계속 비워두지 않는다. 트레이싱 GC였다면 "현관에서 출발해 걸어갈 수 있나"를 보기 때문에 이 외딴섬을 발견해 통째로 치웠을 것이다. 숫자만 세는 ARC는 그걸 못 본다. 그래서 사람이 직접 화살표 하나를 골라 weak/unowned로 바꾸고 "나는 명단에 안 올릴게"라고 선언해줘야 한다. 단, 아무 화살표나 끊으면 안 된다 — 소유 방향(오래 사는 쪽 → 짧게 사는 쪽)은 남기고, 되돌아오는 화살표를 끊는 게 원칙이다.
꼬리 질문
3개 이상의 객체가 얽힌 사이클은 어느 변을 끊어야 하나, 판단 원칙은?
weak로 바꾼다. 거꾸로 끊으면 자식을 붙들어 주는 사람이 아무도 없어져서, 쓰려고 보면 이미 사라져 있는 nil 크래시가 난다.Timer.scheduledTimer(target:selector:)나 NotificationCenter 옵저버가 만드는 순환은 클로저 순환과 무엇이 다르며, 왜 [weak self]만으로는 안 풀리고 어떻게 끊나(invalidate/removeObserver)?
[weak self]로 한 변을 끊으면 풀린다. 그런데 target:selector: 계열 API에는 애초에 내가 작성하는 클로저가 없어 [weak self]를 끼워 넣을 자리 자체가 없다 — Timer.scheduledTimer(target:selector:)는 Timer가 target을 strong으로 붙들고 그 Timer는 self가 아니라 런루프가 소유하므로, self의 timer 프로퍼티를 nil로 밀어도 런루프→Timer→self 경로로 self가 계속 살아남는다. 그래서 deinit이 아니라 화면이 사라지는 명시적 시점(viewDidDisappear 등)에 timer.invalidate()로 런루프에서 떼어내야 하며, self가 안 죽으니 deinit에서 부르는 invalidate는 영영 실행되지 않는다. 블록 기반 scheduledTimer(withTimeInterval:repeats:block:)로 바꾸면 [weak self]를 쓸 수 있지만 런루프가 타이머를 붙드는 건 그대로라 invalidate는 여전히 필요하다. NotificationCenter는 결이 다르다 — iOS 9 이후 addObserver(_:selector:name:object:)로 등록한 옵저버는 zeroing weak로 보관돼 애초에 순환을 만들지 않으므로, deinit의 removeObserver는 메모리 필수 조치가 아니라 중복 등록·원치 않는 수신을 막는 위생 조치다. 정작 self를 붙드는 쪽은 센터가 블록을 strong으로 보관하는 addObserver(forName:object:queue:using:)라, 여기서는 블록에서 [weak self]로 캡처를 끊고 반환된 토큰을 removeObserver로 해제해야 등록 자체가 사라진다.[weak self]는 내가 직접 쓴 클로저 안에서만 쓸 수 있는 도구다. target:selector: 타이머엔 내가 쓴 클로저가 없어서 끼워 넣을 자리부터 없고, 게다가 그 타이머를 붙들고 있는 건 내가 아니라 런루프다 — 내 프로퍼티를 nil로 밀어도 런루프 → 타이머 → self 줄이 그대로 남아 self가 안 죽는다. 그래서 화면이 사라지는 시점에 손으로 invalidate()를 불러 줄을 끊어야 하고, 이걸 deinit에 넣으면 deinit이 안 불려서 영영 실행되지 않는 함정이 된다.Combine의 sink/assign에서 흔한 순환 패턴과 store(in:)·[weak self]의 역할은?
sink/assign(to:on:)의 클로저나 대상이 self를 strong 캡처하고 그 구독(AnyCancellable)을 다시 self가 보관하면 self ↔ 구독 순환이 된다 — 특히 assign(to:on: self)는 self를 strong으로 잡아 흔한 누수원이다. 그래서 sink { [weak self] in ... }로 캡처 지분을 끊고, store(in: &cancellables)로 구독 수명을 self의 Set<AnyCancellable>에 묶어 self가 죽으면 cancellable도 함께 해제되며 구독이 취소되게 한다. @Published로 흘려보내는 파이프라인엔 self를 캡처하지 않는 assign(to: &$prop) 형태를 쓰면 순환 자체를 피할 수 있다.sink 클로저가 self를 붙들고, 그 구독증(AnyCancellable)을 self가 다시 자기 주머니에 넣으면 둘이 서로 붙든 고리가 완성된다. [weak self]로 클로저 쪽 손을 놓게 하고, store(in:)으로 구독증을 self 주머니에 묶어 self가 죽을 때 구독도 같이 끝나게 한다. 이 둘은 세트로 써야 의미가 있다 — 하나만 해두면 고리가 남거나 구독이 영영 안 끊긴다.Q7. Page, Page Table, Page Fault란 무엇인가?
Page는 가상 메모리를 관리하는 고정 크기 단위(Apple ARM64 기기는 16KB), 그에 대응하는 물리 쪽 조각을 프레임(frame)이라 한다. Page Table은 프로세스마다 커널이 유지하는 "가상 페이지 → 물리 프레임 + 권한/유효 플래그" 지도이며 다단계로 구성된다. Page Fault는 CPU가 접근한 가상 주소의 페이지가 지금 물리 프레임에 붙어 있지 않을 때(유효 비트 꺼짐) MMU가 일으키는 예외로, 커널이 페이지를 붙여주고 재실행시킨다. 압축 해제·공유처럼 RAM 안에서 끝나면 soft fault, 디스크에서 읽어와야 하면 hard fault다.
CS 원리
페이지 단위로 나누는 이유는 관리 비용과 유연성의 절충이다. 바이트 단위로 매핑하면 지도가 너무 커지고, 세그먼트처럼 통째로 다루면 유연성이 없다. 페이지 폴트는 demand paging의 심장이다 — OS는 모든 페이지를 미리 올리지 않고, "실제로 건드릴 때" 폴트를 계기로 프레임을 붙인다. 이 덕에 RAM보다 큰 주소 공간을 약속(overcommit)할 수 있다. 폴트 처리 흐름: 예외 → 커널이 원인 판정(잘못된 접근이면 SIGSEGV/EXC_BAD_ACCESS, 유효하지만 미적재면 프레임 확보·적재) → PTE 갱신 → 원래 명령 재실행.
| 폴트 종류 | 원인 | 비용 |
|---|---|---|
| Soft (minor) | 페이지가 RAM에 있음(압축됨/공유/이미 물리적으론 존재) — 매핑만 필요 | 빠름 (디스크 X) |
| Hard (major) | 파일·바이너리에서 읽어와야 함(clean 코드 페이지 재적재 등) | 느림 (디스크 I/O) |
| Invalid | 매핑 없는/권한 위반 주소 접근 | EXC_BAD_ACCESS로 종료 |
iOS에서는
MMU가 다단계 변환 테이블을 하드웨어로 워크하고, 최근 번역은 TLB가 캐싱한다. iOS의 특수성은 폴트의 방향에 있다. 앱 코드(__TEXT)는 파일에 뒤받침된 clean 페이지라 메모리가 부족하면 버려졌다가, 다시 실행될 때 hard fault로 바이너리에서 재적재된다. 반대로 압축된 dirty 페이지에 다시 접근하면 커널 compressor가 풀어주는 soft fault가 난다. iOS엔 앱 dirty 메모리용 디스크 스왑이 없으므로, "스왑 인" 성격의 hard fault는 주로 파일/코드 계열에서만 나타난다.
# 특정 프로세스의 VM 리전별 크기·dirty/swapped 스냅샷 (폴트 카운트는 나오지 않는다)
$ vmmap --summary <pid>
# 시스템 전역 폴트/페이지인 카운터
$ vm_stat 1
# ... faults, pageins, "swapins" 등 컬럼을 시간에 따라 관찰
# Instruments의 VM Tracker/System Trace로도 폴트를 타임라인에서 볼 수 있다프로젝트 적용
① 앱 시작이 느리면 코드 페이지 hard fault(콜드 스타트 페이지인)를 의심하라. 순서 최적화(dyld order file)나 코드 크기 축소가 폴트 수를 줄인다.
# 앱 시작이 느릴 때, 원인이 '코드 페이지를 디스크에서 끌어오는 비용'인지 먼저 잰다.
# 실행 파일의 __TEXT는 file-backed clean 페이지라 처음 만질 때마다 hard fault가 난다.
# ① 런치 단계별 시간을 먼저 본다
# Xcode → Scheme → Run → Arguments → Environment Variables
DYLD_PRINT_STATISTICS = 1
# total time: 512.30 ms
# dylib loading time: 240.11 ms ← 동적 프레임워크가 많다
# rebase/binding time: 96.40 ms ← 심볼·포인터가 많다
# ObjC setup time: 41.02 ms
# ② 실기기 앱의 VM 구성을 본다 — vmmap·vm_stat은 맥 로컬 프로세스용 CLI라
# 실기기 앱은 Xcode의 Debug Memory Graph로 .memgraph를 뜬 뒤 맥에서 연다
vmmap --summary MyApp.memgraph | grep -E '__TEXT|Physical footprint'
# 폴트 수 자체는 실기기에 붙인 Instruments(System Trace / VM Tracker)의 타임라인에서 본다
# ③ 처방
# - 동적 프레임워크를 정적 링크로 합치면 dylib loading·binding이 함께 준다
# - 사용하지 않는 코드를 지워 __TEXT 자체를 줄인다 (폴트 수는 코드 크기에 비례)
# - 런치 경로에서 실제로 쓰는 함수를 앞쪽 페이지에 모으면 폴트가 줄어든다
# (order file — 링커에 심볼 순서를 지정)
# Build Settings → Order File → $(SRCROOT)/launch.order
# ④ Instruments의 App Launch 템플릿이 위 항목을 한 화면에서 보여준다.
# 감으로 고치지 말고, 어느 구간이 큰지 먼저 확인할 것.② 큰 파일은 한 번에 Data(contentsOf:)로 읽어 dirty로 잡기보다, mmap(파일 뒤받침 clean)으로 두고 필요한 페이지만 폴트로 올라오게 하면 메모리 압박에 강하다.
import Foundation
// 같은 파일이라도 '어떻게 읽느냐'에 따라 메모리 성격이 완전히 달라진다.
// Data(contentsOf:) → 익명 dirty. 스왑이 없으니 회수 불가. 상한 넘으면 종료.
// .mappedIfSafe → file-backed clean. 압박 시 커널이 버리고 다시 읽는다.
final class TileStore {
private let mapped: Data
init(url: URL) throws {
mapped = try Data(contentsOf: url, options: .mappedIfSafe)
}
// 필요한 범위만 만지면 그 페이지만 폴트로 올라온다
func tile(at index: Int, tileSize: Int) -> Data {
let start = index * tileSize
return mapped[start ..< min(start + tileSize, mapped.count)]
}
}
// ✅ 스트리밍이 더 나은 경우도 있다 — 앞에서 뒤로 한 번만 훑는다면
func parseSequentially(_ url: URL) throws {
let handle = try FileHandle(forReadingFrom: url)
defer { try? handle.close() }
while let chunk = try handle.read(upToCount: 1 << 20), !chunk.isEmpty {
process(chunk) // 상수 메모리로 끝난다
}
}
func process(_ chunk: Data) {}
// 선택 기준
// 임의 접근 · 일부만 읽음 → mmap
// 앞에서 뒤로 한 번만 읽음 → 스트리밍
// 파일이 작다(수 MB 이하) → 그냥 Data(contentsOf:)
//
// ⚠️ mmap한 파일을 다른 주체가 truncate하면 접근 순간 SIGBUS로 죽는다.
// 앱 컨테이너 밖의 신뢰할 수 없는 위치는 매핑하지 않는다.③ 무작위 접근으로 페이지를 흩뿌리면 폴트·TLB miss가 는다. 지역성(locality)을 살려 자료를 배치하라.
import Foundation
// 페이지 폴트와 TLB miss는 '얼마나 많이 읽었나'가 아니라
// '얼마나 흩어져 읽었나'에 비례한다.
struct Particle { var x, y, vx, vy: Float }
// ✅ 순차 접근 — 한 페이지를 다 쓰고 다음 페이지로 넘어간다
func stepSequential(_ ps: inout [Particle]) {
for i in ps.indices {
ps[i].x += ps[i].vx
ps[i].y += ps[i].vy
}
}
// ❌ 무작위 접근 — 매 반복이 다른 페이지를 건드려 폴트·TLB miss가 폭증한다
func stepRandom(_ ps: inout [Particle], order: [Int]) {
for i in order {
ps[i].x += ps[i].vx
}
}
// ✅ 일부 필드만 훑는다면 SoA로 재배치해 워킹셋 자체를 줄인다
struct ParticlesSoA {
var x: [Float] = []
var y: [Float] = []
var vx: [Float] = []
var vy: [Float] = []
}
func drawPositions(_ p: ParticlesSoA) {
// vx·vy는 아예 읽지 않으므로 그 페이지들이 올라올 일이 없다
for i in p.x.indices { plot(p.x[i], p.y[i]) }
}
func plot(_ x: Float, _ y: Float) {}
// 16KB 페이지 기준으로 감을 잡으면 판단이 쉬워진다:
// Particle 16B × 1024개 = 16KB = 정확히 한 페이지
// → 이 1024개는 순서를 어떻게 섞어도 페이지가 하나뿐이라 폴트는 최대 1회다.
// 차이는 데이터가 여러 페이지에 걸칠 때 생긴다: 100만 개(16MB = 1024페이지)를
// 순차로 훑으면 페이지당 1회씩 1024회로 끝나지만, 무작위로 훑으면 같은 페이지를
// 오가며 TLB miss가 폭증한다(이미 상주 중이면 폴트 자체는 안 난다).
// 압박으로 회수된 페이지를 다시 건드릴 때에야 폴트가 추가된다."페이지 폴트는 곧 디스크 접근이라 항상 느리다"는 틀렸다. 대부분의 폴트는 RAM 안에서 끝나는 soft fault다(압축 해제·공유·최초 접근 zero-fill). 또 "폴트=버그"라는 오해도 흔하다 — demand paging에서 폴트는 정상 동작이고, 문제는 그 빈도와 종류(hard fault 폭증)다.
지금 책상 위에 펼쳐놓은 서류만 바로 읽을 수 있다고 하자. Page는 서류를 묶는 고정 크기 묶음이고(아이폰은 한 묶음이 16KB), Page Table은 "몇 번 묶음이 책상 어느 자리에 있나"를 적어둔 대장이다. 대장을 봤는데 "지금 책상엔 없음"이라고 적혀 있으면 그 순간 벨이 울린다. 이게 page fault다. 그러면 커널이 서류를 찾아 책상에 올려주고, CPU는 방금 실패한 그 동작을 처음부터 다시 한다. 다시 한다는 게 중요하다 — 중간부터 이어서 하는 게 아니라 아예 재실행이라, 앱 코드 입장에선 아무 일도 없었던 것처럼 보인다.
여기서 가장 흔한 착각은 "fault = 에러"라고 생각하는 것이다. 아니다. 그냥 "아직 안 올려놨을 뿐"이고, 앱이 쓰는 메모리는 원래 대부분 이렇게 처음 만지는 순간에 붙는다(그래서 malloc으로 크게 잡아놔도 안 건드린 부분은 RAM을 안 먹는다). 진짜 문제는 폴트가 나느냐가 아니라 어디까지 다녀오느냐다. 옆 서랍에서 꺼내는 정도(soft fault)와 지하 서고까지 내려갔다 오는 것(hard fault)은 속도 차이가 자릿수 단위로 난다.
꼬리 질문
TLB miss와 page fault는 어떻게 다른가(둘 다 "번역 실패"인데 처리 주체가 다르다)?
SIGSEGV로 종료(잘못된 접근)시킨다. 즉 TLB miss는 하드웨어가 조용히, page fault는 소프트웨어(커널)가 처리한다.zero-fill on demand란 무엇이고 왜 새 힙 페이지가 처음엔 물리 프레임을 안 잡나?
malloc으로 큰 영역을 잡아도 만지지 않은 페이지는 물리 RAM을 소비하지 않으며(overcommit), footprint는 "약속한 크기"가 아니라 "실제 터치한 페이지"에 비례한다.malloc하는 건 "이만큼 자리를 예약해주세요"라는 약속서일 뿐이다. 실제로 방을 내주는 건 그 자리를 처음 만지는 순간이고, 그때 커널이 새 페이지를 0으로 싹 지워서 붙여준다 — 아까 다른 앱이 쓰던 찌꺼기가 그대로 보이면 안 되니까. 그래서 크게 잡아놓고 앞부분만 쓰면, 안 만진 뒷부분은 메모리 사용량에 아예 안 잡힌다.cold 스타트에서 페이지인을 줄이는 방법(prefault, order file, 바이너리 슬림화)은?
order file(정적 링커 ld에 -order_file로 넘기는 심볼 배치 순서 — dyld는 런타임에 그 배치 덕분에 페이지인이 줄어드는 수혜자일 뿐 order file을 직접 소비하는 주체는 아니다)로 폴트할 페이지 수와 흩어짐을 줄이고, dead code 제거·바이너리 슬림화로 애초에 매핑할 페이지 양을 줄인다. 필요하면 시작에 쓸 데이터를 미리 만지는 prefault(예: mmap 후 madvise(MADV_WILLNEED))로 지연을 앞당겨 흡수할 수 있으나, 남용하면 오히려 초기 메모리 압박을 키운다.Q8. iOS의 Memory pressure와 Jetsam은 일반적인 OOM과 어떻게 다른가?
리눅스식 OOM killer는 메모리가 바닥나면(대개 스왑까지 소진 후) 사후에 oom_score로 희생자를 골라 죽인다. iOS의 Jetsam(XNU memorystatus)은 (1) 스왑이 없다는 전제 아래 페이지 아웃 대신 앱을 통째로 죽이고, (2) 우선순위 밴드로 포그라운드 앱을 보호하며 백그라운드/유휴부터 죽이고, (3) 전역 부족과 별개로 앱별 메모리 상한(per-process limit)을 초과하면 그 앱만 콕 집어 죽인다. 그 전에 memory pressure 알림으로 앱에게 "지금 비워라" 기회를 먼저 준다. 즉 iOS는 선제적·협조적·우선순위 기반이고 OOM은 반응적·전역적이다.
CS 원리
메모리가 부족할 때 커널의 선택지는 "회수(reclaim)"와 "종료(kill)"다. 스왑이 있으면 페이지를 디스크로 밀어 회수하고 최후에만 죽인다(전형적 OOM). 스왑이 없으면 dirty 페이지를 회수할 방법이 없으니 종료가 1차 수단이 된다. 이때 무엇을 죽일지 정책이 관건인데, 리눅스는 대체로 메모리 점유·nice 등을 반영한 점수로, iOS는 사용자 체감(누가 지금 화면에 있나)을 우선한 우선순위로 판단한다.
iOS에서는
메모리 압박은 두 계층으로 앱에 전달된다. UIKit의 applicationDidReceiveMemoryWarning/didReceiveMemoryWarning, 그리고 더 세밀한 DispatchSource memory pressure 소스(normal/warning/critical)다. 앱이 여기서 캐시를 비워 footprint를 줄이면 종료를 피할 수 있다. 그래도 부족하거나 앱이 자기 상한을 넘으면 memorystatus가 개입한다. 앱별 상한은 기기 RAM에 따라 다르며, 큰 메모리가 꼭 필요한 앱은 Increased Memory Limit 같은 entitlement로 상한을 올릴 수 있다(무제한은 아니다). Jetsam으로 죽은 사건은 일반 크래시처럼 try/catch로 못 잡고, 기기의 설정 > 개인정보 보호 및 보안 > 분석 및 향상 > 분석 데이터에 남는 JetsamEvent_<날짜> 리포트(Xcode Organizer의 Crashes에는 올라오지 않는다)에per-process-limit 또는 vm-pageshortage 같은 사유로 남는다.
final class MemoryPressureWatcher {
// 이 source는 반드시 강한 프로퍼티에 보관해야 핸들러가 유지된다.
// 지역 let에만 두면 스코프를 벗어나며 즉시 해제·취소돼(resume은 retain이 아님)
// 핸들러가 한 번도 안 불린다.
private var pressureSource: DispatchSourceMemoryPressure?
func start() {
let source = DispatchSource.makeMemoryPressureSource(
eventMask: [.warning, .critical], queue: .main)
pressureSource = source // 강참조로 보관 (유일 소유자)
source.setEventHandler { [weak self] in
guard let event = self?.pressureSource?.data else { return }
if event.contains(.critical) {
ImageCache.shared.removeAll() // 지금 비우지 않으면 다음 차례는 종료
} else if event.contains(.warning) {
ImageCache.shared.trimToLimit()
}
}
source.resume()
}
}
// UIKit 레벨: NSCache는 압박 시 자동으로 비워지도록 설계됨실험 · 도구
시뮬레이터에선 Debug 메뉴의 Simulate Memory Warning로 경고를 유발할 수 있다(단, 실제 Jetsam 상한·종료는 실기기에서만 정확히 재현된다). 실기기에서 메모리를 계속 늘리며 Debug Gauge를 보면 상한 근처에서 앱이 즉사하는 걸 관찰할 수 있고, 사후엔 기기 설정의 분석 데이터에 남는 JetsamEvent_<날짜> 리포트로 사유를 확인한다(Organizer의 Crashes에는 올라오지 않는다). os_proc_available_memory()로 남은 여유를 런타임에 조회해 방어적으로 동작할 수도 있다.
프로젝트 적용
① 메모리 경고를 "선택"이 아니라 "생존 신호"로 다뤄라 — 실제로 비우는 코드가 없으면 경고는 무의미하다.
import UIKit
// 경고에 반응하지 않으면 확보가 저절로 생기는 게 아니라,
// 응답하지 못한 그 앱이 다음 종료 후보가 된다.
final class MemoryResponder {
static let shared = MemoryResponder()
private var handlers: [() -> Void] = []
private init() {
NotificationCenter.default.addObserver(
forName: UIApplication.didReceiveMemoryWarningNotification,
object: nil, queue: .main
) { [weak self] _ in
self?.purgeAll()
}
}
func register(_ purge: @escaping () -> Void) {
handlers.append(purge)
}
private func purgeAll() {
let before = os_proc_available_memory()
handlers.forEach { $0() } // ✅ 실제로 비운다
let after = os_proc_available_memory()
print("경고 대응: \((after - before) / 1024 / 1024)MB 회수")
}
}
// 각 캐시가 스스로 등록한다
final class ThumbnailCache {
init() {
MemoryResponder.shared.register { [weak self] in self?.removeAll() }
}
func removeAll() {}
}
// ⚠️ 비우는 대상은 '재생성 가능한 것'만.
// 저장 안 된 사용자 입력은 캐시가 아니라 데이터다 — 먼저 draft로 저장할 것.② 캐시는 가능하면 NSCache나 압박에 반응하는 구조로. 무한히 자라는 딕셔너리 캐시는 Jetsam의 밥이다.
import UIKit
// ❌ 무한히 자라는 딕셔너리 캐시 — 축출도 없고 압박에 반응하지도 않는다
final class BadCache {
private var storage: [String: UIImage] = [:] // 스레드 안전 ✗
func set(_ i: UIImage, _ k: String) { storage[k] = i }
}
// ✅ NSCache — 스레드 안전 + 압박 시 시스템이 자동으로 비운다
final class ThumbCache {
private let cache = NSCache<NSString, UIImage>()
init() {
cache.countLimit = 300
// cost는 '바이트 수'로 넣어야 totalCostLimit이 의미를 갖는다
cache.totalCostLimit = 48 * 1024 * 1024
}
func image(for key: String) -> UIImage? {
cache.object(forKey: key as NSString)
}
func store(_ image: UIImage, for key: String) {
let bytes = Int(image.size.width * image.size.height
* image.scale * image.scale * 4)
cache.setObject(image, forKey: key as NSString, cost: bytes)
}
}
// ⚠️ countLimit·totalCostLimit은 soft limit이다. 정확히 지켜지지 않으므로
// NSCache에 든 값은 항상 "있으면 이득, 없어도 정상"으로 다룬다.
func thumbnail(_ key: String, cache: ThumbCache, make: () -> UIImage) -> UIImage {
if let hit = cache.image(for: key) { return hit }
let fresh = make() // ✅ 없으면 다시 만드는 경로가 반드시 있어야 한다
cache.store(fresh, for: key)
return fresh
}
// ✅ Swift 값 타입을 담아야 하면 클래스로 감싼다 (NSCache는 참조 타입만 받는다)
final class Box<T>: NSObject {
let value: T
init(_ value: T) { self.value = value }
}③ 전역 메모리가 넉넉해도 앱별 상한이 있다는 걸 잊지 마라 — "기기에 램 8GB인데 왜 죽지?"의 답이 여기 있다.
import Foundation
import UIKit
// "기기에 램이 8GB인데 왜 죽지?" — 전역 여유와 무관하게 앱별 상한이 따로 있다.
// 상한은 기기·OS·앱 종류(앱 / 확장 / 위젯)에 따라 다르고 문서화되어 있지 않다.
func currentBudget() -> (usedMB: Int, remainingMB: Int) {
// 지금 이 프로세스가 더 쓸 수 있는 대략적 바이트
let remaining = os_proc_available_memory()
// 현재 footprint (dirty + compressed)
var info = task_vm_info_data_t()
var count = mach_msg_type_number_t(MemoryLayout<task_vm_info_data_t>.size
/ MemoryLayout<integer_t>.size)
let kr = withUnsafeMutablePointer(to: &info) {
$0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) {
task_info(mach_task_self_, task_flavor_t(TASK_VM_INFO), $0, &count)
}
}
let used = kr == KERN_SUCCESS ? Int(info.phys_footprint) : 0
return (used / 1024 / 1024, remaining / 1024 / 1024)
}
// 큰 작업 전에 예산을 확인하고, 부족하면 규모를 줄인다
func batchSize(for itemBytes: Int) -> Int {
let budget = currentBudget().remainingMB * 1024 * 1024
let safe = Int(Double(budget) * 0.5) // 절반만 쓴다 (여유 확보)
return max(1, safe / max(1, itemBytes))
}
// ⚠️ 앱 확장(공유 시트·위젯)은 상한이 앱 본체보다 훨씬 낮다.
// 본체에서 되던 이미지 처리가 확장에서 죽는 이유가 대개 이것이다.
// 특별히 큰 메모리가 필요한 앱은 entitlement로 상한을 올릴 수 있다(무제한은 아니다):
// com.apple.developer.kernel.increased-memory-limit
"iOS 앱은 시스템 램이 부족할 때만 죽는다"는 틀렸다. 앱별 상한을 넘으면 시스템에 여유가 넘쳐도 그 앱만 종료된다. 또 "Jetsam은 catch할 수 있는 크래시다"도 오해다 — 시그널로 잡히지 않는 강제 종료이고, 예방(footprint 관리)만이 대책이다. "메모리 경고를 받으면 OS가 알아서 내 캐시를 비워준다"도 대체로 틀리다(NSCache 등 일부 예외를 빼면 앱이 직접 비워야 한다).
공연장을 떠올려보자. 리눅스식 OOM killer는 자리가 완전히 동날 때까지 기다렸다가, 그제서야 점수를 매겨 누군가를 끌어낸다. 사고가 난 뒤에 수습하는 방식이다. iOS는 순서가 다르다. 먼저 안내 방송을 한다 — "짐 좀 줄여주세요"가 memory pressure 알림이고, 이때 캐시를 실제로 비운 앱은 살아남는다. 코드만 있고 안 비우면 방송을 안 들은 것과 똑같다.
그래도 부족하면, 지금 무대를 보고 있는 사람(포그라운드) 말고 로비에서 졸고 있는 사람(백그라운드·유휴)부터 내보낸다. 여기에 축이 하나 더 있다. 1인당 수하물 무게 제한이다. 공연장이 텅 비어 있어도 혼자 짐이 너무 많으면 그 사람만 퇴장당한다 — 이게 per-process limit이고, "기기 램이 8GB인데 왜 내 앱만 죽지?"의 답이 여기 있다. 마지막으로, 이 퇴장은 붙잡고 사정할 수 없다. try/catch로 잡히는 크래시가 아니라 그냥 끌려나가는 거라, 미리 짐을 줄여두는 것 말고는 대책이 없다.
꼬리 질문
포그라운드 앱도 죽는 경우는 언제인가(자기 상한 초과·시스템 극한 압박)?
per-process-limit). 또 시스템 전체가 회수·백그라운드 kill로도 못 버티는 극한 압박에 이르면 최후에는 포그라운드 앱까지 vm-pageshortage로 죽을 수 있다. "내가 화면에 있으니 안 죽는다"는 보장은 없다.앱 익스텐션(share/notification service 등)의 메모리 상한이 본 앱보다 훨씬 낮은(수십 MB) 이유는 무엇이고, 이 상한을 넘겼을 때의 종료가 본 앱의 Jetsam과 어떻게 다른가?
EXC_RESOURCE … exceeded mem limit: ActiveHard 24 MB)와 커뮤니티에서 반복 관측된 값이라 종류와 iOS 버전에 따라 다른데, 위젯은 대략 30MB 안팎, share처럼 UI를 띄우는 익스텐션은 그보다 넉넉하지만 여전히 본 앱 상한의 일부에 불과하다. 본 앱 상한이 기기 RAM에 따라 커지는 것과 달리 익스텐션 상한은 기기와 거의 무관하게 고정돼 있다는 점도 다르다. 종료를 일으키는 메커니즘 자체는 본 앱과 같은 memorystatus의 per-process-limit이지만, 죽는 대상이 익스텐션 프로세스뿐이라 호스트 앱은 멀쩡하고 사용자는 '동작 실패'(예: 알림이 원본 그대로 표시, 공유 시트가 사라짐)로만 체감한다. 그래서 익스텐션에선 이미지 원본을 통째로 디코딩하는 식의 큰 작업을 피하고 다운샘플·스트리밍으로 footprint를 그 낮은 상한 아래로 눌러야 한다.NSCache와 직접 만든 딕셔너리 캐시가 메모리 압박에 반응하는 방식의 차이는?
NSCache는 시스템 메모리 압박에 반응하도록 설계돼, 압박이 오면 내부 항목을 자동으로 방출(evict)하고 countLimit/totalCostLimit 상한도 지원하며 스레드 안전하다. 직접 만든 Dictionary 캐시는 이런 훅이 전혀 없어 메모리 경고가 와도 스스로 비우지 않고 무한히 자라 Jetsam의 표적이 된다 — 개발자가 memory pressure 소스나 didReceiveMemoryWarning에서 명시적으로 비워야 한다. 그래서 압박 대응이 필요한 캐시는 NSCache나 압박 구독을 직접 붙인 구조가 안전하다.NSCache는 "자리가 모자라면 알아서 짐을 버려라"는 지시를 이미 받아둔 보관함이다. 내가 만든 Dictionary 캐시는 그런 지시를 받은 적이 없어서, 시스템이 아무리 다급하게 신호를 보내도 아무 반응 없이 계속 불어난다. 누군가 코드로 직접 비워주지 않으면, 다음번에 끌려 나갈 앱은 내 앱이다.