메모리는 iOS 성능·안정성 질문의 뿌리다. 이 챕터는 가상 메모리·주소 변환·Stack/Heap·ARC·retain cycle·페이지 폴트·Jetsam까지, "앱이 메모리를 어떻게 쓰고 언제 죽는가"를 CS 원리에서 Darwin/XNU 구현까지 이어서 설명한다.
Q1. iOS 앱은 가상 메모리를 사용하는가?
쓴다. 모든 iOS 프로세스는 자신만의 64비트 가상 주소 공간을 가지며, MMU가 페이지 단위로 물리 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)한다. Xcode Organizer의 Jetsam event reports에서 사인을 확인한다.
프로젝트 적용
- "RAM보다 큰 데이터는 스왑되겠지"라는 데스크톱 감각을 버려라. iOS에선 그 순간이 곧 종료다. 큰 리소스는 mmap(파일 뒤받침 clean)으로 두어 압박 시 커널이 회수하게 하라.
- 이미지는
UIImage가 아니라 디코딩된 비트맵이 dirty를 잡아먹는다. 다운샘플링으로 실제 표시 크기만 디코딩하라. didReceiveMemoryWarning/메모리 압박 소스에서 캐시를 실제로 비워라 — 안 비우면 다음 차례에 죽는 건 내 앱이다.
"iOS엔 가상 메모리가 없다"는 틀렸다. 가상 메모리·페이지 테이블·demand paging은 완전히 있다. 없는 것은 앱 dirty 메모리를 디스크로 내보내는 전통적 스왑일 뿐이다. 또 "메모리 압축은 스왑이다"도 부정확하다 — 압축은 RAM 안에서 일어나며 디스크 I/O가 없다.
꼬리 질문
- iPad에서 스왑을 켜면 Jetsam 동작과 앱의 메모리 한계는 어떻게 달라지나?
- 같은 이미지를 여러 프로세스가 mmap하면 물리 메모리는 얼마나 절약되나(공유 clean 페이지)?
- 앱을 백그라운드로 보내면 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 영역별 크기·권한·공유 여부를 덤프한다.
"iOS도 페이지는 4KB"라고 답하면 감점 포인트다. 실기기 ARM64는 16KB이며, 이 차이는 앱 footprint 회계(최소 16KB 단위로 잡힘)와 정렬 요구에 실제 영향을 준다. 또 "가상 주소가 곧 물리 주소에 1:1로 대응한다"도 틀렸다 — 매핑은 페이지 단위이고, 공유·복사(COW)·비할당 등으로 관계가 유동적이다.
꼬리 질문
- TLB miss가 나면 어떤 일이 벌어지고, 컨텍스트 스위치 시 TLB는 왜 문제가 되나(ASID로 어떻게 완화하나)?
- 16KB 페이지는 4KB 대비 내부 단편화와 TLB 커버리지에서 각각 무엇을 얻고 잃나?
- fork 없이 시작하는 iOS 앱에서 Copy-on-Write는 어디서 쓰이나(공유 캐시·mmap 파일)?
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로 실제 배치를 확인하라.
- 거대한 값을 스택에 두지 마라 — 스택은 작고 고정이다. 큰 버퍼는 힙(예:
Array,UnsafeMutableBufferPointer)에 둔다. - 재귀 깊이를 신뢰 입력에 의존시키지 마라. 스택은 잡을 수 없는 크래시로 이어진다.
"struct는 항상 스택, class는 항상 힙"은 정확하지 않다. class 인스턴스는 항상 힙이 맞지만, struct는 담기는 위치와 캡처/박싱 여부에 따라 힙에도 간다. "값 타입이라 복사가 항상 비싸다"도 오해다 — Array/String 같은 표준 컬렉션은 COW로 실제 복사를 지연한다.
꼬리 질문
- 이스케이핑 vs 논이스케이핑 클로저에서 캡처 저장 위치는 어떻게 달라지나?
- Copy-on-Write는 값 타입의 "복사=힙 공유"를 어떻게 구현하나(isKnownUniquelyReferenced)?
- 큰 existential(프로토콜 타입) 값의 3워드 인라인 버퍼 규칙이 성능에 주는 함의는?
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는 런타임 GC의 일종이라 백그라운드에서 돈다"는 틀렸다. ARC엔 별도 수집 스레드도, stop-the-world 정지도 없다. 반대로 "ARC엔 오버헤드가 전혀 없다"도 틀렸다 — retain/release는 원자적 연산이라 뜨거운 경로에선 유의미하다. 또 "ARC라 메모리 누수가 불가능하다"도 오해다 — 순환 참조는 여전히 누수를 만든다.
꼬리 질문
- 트레이싱 GC의 세대별 수집(generational)이 왜 유리한지, ARC엔 왜 그 개념이 없는지?
- ARC의 원자적 retain/release 비용을 줄이는 방법(값 타입, borrow, ~Copyable)은?
- 객체 그래프가 클 때 ARC와 GC 중 해제 지연 스파이크가 어디서 더 크게 나타나나?
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보다 항상 안전하니 무조건 weak를 써라"는 반은 맞고 반은 틀리다. weak는 매 접근마다 Optional 언랩과 side table 비용이 있고, 수명 계약이 확실한 곳에 unowned를 쓰면 의도가 더 분명하고 빠르다. 반대로 수명이 조금이라도 불확실하면 unowned는 크래시 폭탄이다. 또 "weak는 참조 수를 0으로 만든다"는 틀렸다 — weak는 애초에 카운트에 참여하지 않는다.
꼬리 질문
[weak self] guard let self와[unowned self]를 각각 언제 쓰나, 판단 기준은?- weak 참조가 nil로 바뀌는 그 순간의 스레드 안전성은 어떻게 보장되나?
unowned(unsafe)는 왜 존재하고 언제라도 정당화되나?
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]를 붙이는 것도 과잉이다 — 즉시 실행되고 자신을 보유하지 않는 클로저엔 불필요하다. - delegate·closure 프로퍼티·양방향 링크는 순환 후보로 보고 한쪽을 비소유로.
- 화면 단위로
deinit로그 또는 테스트를 두어 회귀를 조기에 잡아라.
"weak를 여기저기 붙이면 순환은 무조건 해결된다"는 위험하다. 엉뚱한 변을 weak로 만들면 필요한 객체가 조기 해제돼 nil·크래시가 난다. 또 "순환은 클로저에서만 난다"도 틀렸다 — 순수 객체 그래프(부모↔자식, 노드↔노드)에서도 얼마든지 난다. "Leaks 계측이 다 잡아준다"는 것도 오해다.
꼬리 질문
- 3개 이상의 객체가 얽힌 사이클은 어느 변을 끊어야 하나, 판단 원칙은?
- 클로저에
[weak self]대신[unowned self]를 써도 되는 조건은? - Combine의
sink/assign에서 흔한 순환 패턴과store(in:)·[weak 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는 주로 파일/코드 계열에서만 나타난다.
# 특정 프로세스의 페이지 폴트 통계 스냅샷
$ vmmap <pid>
# 시스템 전역 폴트/페이지인 카운터
$ vm_stat 1
# ... faults, pageins, "swapins" 등 컬럼을 시간에 따라 관찰
# Instruments의 VM Tracker/System Trace로도 폴트를 타임라인에서 볼 수 있다프로젝트 적용
- 앱 시작이 느리면 코드 페이지 hard fault(콜드 스타트 페이지인)를 의심하라. 순서 최적화(dyld order file)나 코드 크기 축소가 폴트 수를 줄인다.
- 큰 파일은 한 번에
Data(contentsOf:)로 읽어 dirty로 잡기보다, mmap(파일 뒤받침 clean)으로 두고 필요한 페이지만 폴트로 올라오게 하면 메모리 압박에 강하다. - 무작위 접근으로 페이지를 흩뿌리면 폴트·TLB miss가 는다. 지역성(locality)을 살려 자료를 배치하라.
"페이지 폴트는 곧 디스크 접근이라 항상 느리다"는 틀렸다. 대부분의 폴트는 RAM 안에서 끝나는 soft fault다(압축 해제·공유·최초 접근 zero-fill). 또 "폴트=버그"라는 오해도 흔하다 — demand paging에서 폴트는 정상 동작이고, 문제는 그 빈도와 종류(hard fault 폭증)다.
꼬리 질문
- TLB miss와 page fault는 어떻게 다른가(둘 다 "번역 실패"인데 처리 주체가 다르다)?
- zero-fill on demand란 무엇이고 왜 새 힙 페이지가 처음엔 물리 프레임을 안 잡나?
- cold 스타트에서 페이지인을 줄이는 방법(prefault, order file, 바이너리 슬림화)은?
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로 못 잡고, Xcode Organizer의 Jetsam event report에 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를 보면 상한 근처에서 앱이 즉사하는 걸 관찰할 수 있고, 사후엔 Organizer의 Jetsam 리포트로 사유를 확인한다. os_proc_available_memory()로 남은 여유를 런타임에 조회해 방어적으로 동작할 수도 있다.
프로젝트 적용
- 메모리 경고를 "선택"이 아니라 "생존 신호"로 다뤄라 — 실제로 비우는 코드가 없으면 경고는 무의미하다.
- 캐시는 가능하면
NSCache나 압박에 반응하는 구조로. 무한히 자라는 딕셔너리 캐시는 Jetsam의 밥이다. - 전역 메모리가 넉넉해도 앱별 상한이 있다는 걸 잊지 마라 — "기기에 램 8GB인데 왜 죽지?"의 답이 여기 있다.
"iOS 앱은 시스템 램이 부족할 때만 죽는다"는 틀렸다. 앱별 상한을 넘으면 시스템에 여유가 넘쳐도 그 앱만 종료된다. 또 "Jetsam은 catch할 수 있는 크래시다"도 오해다 — 시그널로 잡히지 않는 강제 종료이고, 예방(footprint 관리)만이 대책이다. "메모리 경고를 받으면 OS가 알아서 내 캐시를 비워준다"도 대체로 틀리다(NSCache 등 일부 예외를 빼면 앱이 직접 비워야 한다).
꼬리 질문
- 포그라운드 앱도 죽는 경우는 언제인가(자기 상한 초과·시스템 극한 압박)?
- iPadOS 스왑이 켜진 기기에서 Jetsam의 종료 문턱은 어떻게 달라지나?
NSCache와 직접 만든 딕셔너리 캐시가 메모리 압박에 반응하는 방식의 차이는?