← Study Hub

01 · 메모리와 가상 메모리

가상 메모리·주소 변환·Stack/Heap·ARC·retain cycle·페이지 폴트·Jetsam을 CS 원리에서 Darwin/XNU 구현까지 잇는다.
진행률
0 / 0 완료

← 챕터 목록

메모리는 iOS 성능·안정성 질문의 뿌리다. 이 챕터는 가상 메모리·주소 변환·Stack/Heap·ARC·retain cycle·페이지 폴트·Jetsam까지, "앱이 메모리를 어떻게 쓰고 언제 죽는가"를 CS 원리에서 Darwin/XNU 구현까지 이어서 설명한다.

Q1. iOS 앱은 가상 메모리를 사용하는가?

🔑 30초 답변

쓴다. 모든 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의 분류가 실무 기준이다.

예외: 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에서 사인을 확인한다.

프로젝트 적용

⚠️ 흔한 오해

"iOS엔 가상 메모리가 없다"는 틀렸다. 가상 메모리·페이지 테이블·demand paging은 완전히 있다. 없는 것은 앱 dirty 메모리를 디스크로 내보내는 전통적 스왑일 뿐이다. 또 "메모리 압축은 스왑이다"도 부정확하다 — 압축은 RAM 안에서 일어나며 디스크 I/O가 없다.

꼬리 질문

Q2. 가상 주소와 물리 주소는 무엇이 다른가?

🔑 30초 답변

가상 주소(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 같은 플래그가 들어 있어, 접근 제어와 폴트 판단의 근거가 된다.

가상 주소 (VA) VPN (페이지 번호) offset 페이지 테이블 (다단계) VPN → PFN + valid/RWX 플래그 valid=1 물리 주소 (PA) PFN (프레임) offset offset는 번역 없이 그대로 복사 valid=0 → Page Fault → 커널 처리
VA는 VPN+offset으로 쪼개진다. VPN만 페이지 테이블로 번역돼 PFN이 되고, offset은 그대로 붙어 PA가 완성된다. 엔트리가 무효면 페이지 폴트가 난다.

iOS에서는

Apple Silicon과 A 시리즈(ARM64) 기기는 16KB 페이지를 쓴다. x86_64 macOS의 4KB와 다르며, 이 때문에 애플은 메모리 정렬·크기 가정을 페이지 크기에 하드코딩하지 말 것을 권한다(대신 vm_page_size·getpagesize()를 쓰라). 번역은 다단계 변환 테이블(translation table)을 커널이 프로세스마다 유지하고, MMU가 하드웨어로 워크한다. ASLR은 프로세스 시작 시 각 이미지(메인 바이너리·각 라이브러리)의 로드 주소에 무작위 slide를 더해(한 이미지의 모든 세그먼트는 같은 slide만큼 함께 이동) 익스플로잇을 어렵게 한다. 프레임워크(dyld 공유 캐시)나 같은 파일을 여러 앱이 매핑하면 서로 다른 VA가 동일한 물리 프레임을 가리켜 RAM을 공유한다.

실험 · 도구

페이지 크기 확인 (Swift, 실기기)
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)·비할당 등으로 관계가 유동적이다.

꼬리 질문

Q3. Stack과 Heap에는 각각 무엇이 저장되는가?

🔑 30초 답변

Stack에는 함수 호출 프레임 — 지역 변수(값 타입), 파라미터, 리턴 주소, 저장 레지스터 — 이 LIFO로 쌓인다. 할당은 스택 포인터를 옮기는 것뿐이라 극도로 빠르고 스코프를 벗어나면 자동 해제된다. Heap에는 수명이 스코프와 무관한 동적 객체 — Swift에선 class 인스턴스, closure 캡처 컨텍스트, actor 등 참조 타입 — 이 들어가며 ARC(내부적으로 malloc/free)가 관리한다. 핵심 규칙: 값 타입은 원칙적으로 인라인(주로 스택), 참조 타입은 힙. 단 값 타입도 힙 객체의 프로퍼티이거나 클로저에 캡처되거나 큰 existential로 박싱되면 힙에 얹힌다.

CS 원리

둘 다 프로세스 가상 주소 공간의 영역이다. 차이는 수명 관리 방식이다. 스택은 호출/반환이라는 규율에 묶여 있어 할당·해제가 포인터 증감 한 번으로 끝나고 단편화가 없다. 대신 크기가 스레드당 고정이고 함수가 반환하면 그 프레임의 데이터는 사라진다. 힙은 임의 순서로 할당·해제할 수 있어 유연하지만, 빈 공간을 찾고 관리하는 비용(할당자 자료구조, 락, 단편화)이 든다. "어디에 두느냐"는 곧 "이 데이터가 만든 함수보다 오래 살아야 하느냐"의 문제다.

높은 주소 Stack 콜 프레임 · 지역 value · 파라미터 · 리턴 주소 아래로 증가 위로 증가 Heap class 인스턴스 · closure 캡처 박스 · actor · malloc 전역/정적 (Data · BSS) 코드 __TEXT (읽기전용 · 파일 매핑 clean) 낮은 주소 (+ ASLR slide)
스택과 힙은 주소 공간의 양 끝에서 서로를 향해 자란다. 스택은 스코프에 묶여 자동 해제, 힙은 ARC/할당자가 관리한다.

iOS에서는

스레드마다 스택이 따로 있다. 애플 문서 기준 메인 스레드 스택은 1MB로 고정이고 변경 불가, 보조 스레드는 기본 512KB(최소 16KB, 4KB 배수)다. Swift에서 배치 결정은 대략 이렇다.

값 타입 vs 참조 타입 배치 (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-silalloc_stack vs alloc_box/alloc_ref를 비교해 확인한다.

프로젝트 적용

⚠️ 흔한 오해

"struct는 항상 스택, class는 항상 힙"은 정확하지 않다. class 인스턴스는 항상 힙이 맞지만, struct는 담기는 위치와 캡처/박싱 여부에 따라 힙에도 간다. "값 타입이라 복사가 항상 비싸다"도 오해다 — Array/String 같은 표준 컬렉션은 COW로 실제 복사를 지연한다.

꼬리 질문

Q4. ARC는 Garbage Collection과 무엇이 다른가?

🔑 30초 답변

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라 메모리 누수가 불가능하다"도 오해다 — 순환 참조는 여전히 누수를 만든다.

꼬리 질문

Q5. strong, weak, unowned는 무엇을 의미하는가?

🔑 30초 답변

세 가지는 "이 참조가 대상을 살려두느냐, 그리고 대상이 먼저 죽으면 어떻게 되느냐"를 정한다. strong(기본): 참조 수를 +1 해 대상을 살려둔다. weak: 참조 수를 올리지 않고, 대상이 해제되면 자동으로 nil이 된다(그래서 Optional·var 필수). unowned: 참조 수를 올리지 않고 nil이 되지도 않는다 — "대상이 나보다 오래 산다"고 가정하며, 그 가정이 깨진 뒤 접근하면 크래시(unowned(safe)는 trap). 수명이 확실히 더 긴 경우엔 unowned, 불확실하면 weak.

CS 원리

참조 카운팅에서 "소유(strong)"는 곧 카운트에 대한 지분이다. 순환을 끊으려면 최소 한 방향은 지분을 갖지 않는 참조여야 한다. 그 비소유 참조가 대상 해제 후를 어떻게 다루느냐로 weak/unowned가 갈린다. weak는 안전(자동 nil화)을 위해 부가 기록을 유지하고, unowned는 그 기록을 줄여 더 싸지만 안전을 프로그래머 책임으로 넘긴다.

strongweakunowned
참조 수 증가+1안 함안 함
대상 해제 후자동 nil해제 후 접근 시 trap (unowned(safe); unowned(unsafe)는 UB)
타입보통Optional·var 필수비Optional 가능·let 가능
비용retain/releaseside table 관리(약간 더 비쌈)weak보다 저렴

iOS에서는

Swift의 zeroing weak 참조는 런타임 side table에 등록된다. 대상의 strong 카운트가 0이 되면 deinit이 돌고, 그 대상을 가리키던 모든 weak 슬롯이 nil로 채워진다. unowned(safe)는 대상이 deinit되었는지를 표시해 두었다가 접근 시 trap을 발생시키고(안전), unowned(unsafe)는 그 검사조차 없어 진짜 dangling 포인터가 된다(정의되지 않은 동작). 대표 관용구는 클로저 캡처 리스트다.

캡처 리스트에서의 선택 (Swift)
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는 애초에 카운트에 참여하지 않는다.

꼬리 질문

Q6. Retain cycle은 메모리 수준에서 왜 발생하는가?

🔑 30초 답변

두 개(이상)의 힙 객체가 서로를 strong으로 가리키면, 바깥의 모든 참조가 사라져도 서로가 서로의 참조 수를 1로 붙들어 참조 수가 0이 되지 못한다. ARC는 트레이싱 GC와 달리 "루트에서 도달 불가"를 판단하지 못하고 오직 카운트만 보므로, 이 고립된 섬을 해제하지 못해 영구 누수가 된다. 그래서 사이클의 한 변을 weak/unowned로 만들어 카운트를 붙들지 않게 끊어야 한다.

CS 원리

참조 카운팅의 근본 한계다. 카운팅은 "나를 가리키는 지분이 몇 개냐"만 알 뿐, 그 지분을 준 객체가 정작 살아있는지(도달 가능한지)는 모른다. A↔B가 서로에게 지분을 주면, 외부에서 둘 다 버려도 둘의 카운트는 상호 지분 때문에 1로 남는다. 트레이싱 GC라면 루트에서 A·B로 가는 경로가 없으므로 통째로 수거하지만, 카운팅은 이 판단을 못 한다.

root (지역변수) 스코프 종료로 제거 ✗ A refCount = 1 B refCount = 1 strong strong 외부 참조가 사라져도 서로의 strong이 카운트를 1로 유지 → deinit 안 됨 해결: 한 변을 weak/unowned로 → 그 변은 카운트를 붙들지 않음
A와 B가 서로 strong으로 순환하면 외부 참조 제거 후에도 카운트가 0이 못 되어 누수된다. 사이클의 한 변만 비소유로 바꾸면 풀린다.

iOS에서는

실무의 3대 발생원은 (1) self를 strong 캡처하는 이스케이핑 클로저, (2) strong으로 잡은 delegate, (3) 부모-자식 양방향 strong 참조다. 특히 클로저는 캡처 컨텍스트가 힙 박스로 self에 대한 지분을 갖기 때문에, self가 그 클로저를 프로퍼티로 보유하면 즉시 사이클이 된다.

전형적 클로저 순환과 해결 (Swift)
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가 더 신뢰할 만하다.

프로젝트 적용

⚠️ 흔한 오해

"weak를 여기저기 붙이면 순환은 무조건 해결된다"는 위험하다. 엉뚱한 변을 weak로 만들면 필요한 객체가 조기 해제돼 nil·크래시가 난다. 또 "순환은 클로저에서만 난다"도 틀렸다 — 순수 객체 그래프(부모↔자식, 노드↔노드)에서도 얼마든지 난다. "Leaks 계측이 다 잡아준다"는 것도 오해다.

꼬리 질문

Q7. Page, Page Table, Page Fault란 무엇인가?

🔑 30초 답변

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로도 폴트를 타임라인에서 볼 수 있다

프로젝트 적용

⚠️ 흔한 오해

"페이지 폴트는 곧 디스크 접근이라 항상 느리다"는 틀렸다. 대부분의 폴트는 RAM 안에서 끝나는 soft fault다(압축 해제·공유·최초 접근 zero-fill). 또 "폴트=버그"라는 오해도 흔하다 — demand paging에서 폴트는 정상 동작이고, 문제는 그 빈도와 종류(hard fault 폭증)다.

꼬리 질문

Q8. iOS의 Memory pressure와 Jetsam은 일반적인 OOM과 어떻게 다른가?

🔑 30초 답변

리눅스식 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는 사용자 체감(누가 지금 화면에 있나)을 우선한 우선순위로 판단한다.

Jetsam 우선순위 밴드 Foreground 앱 (사용자가 보는 중) 우선순위 높음 · 가장 마지막에 종료 Suspended 백그라운드 앱 백그라운드 데몬 · 유휴(idle) 부족 시 아래→위 순서로 kill 별도 축: 앱별 메모리 상한 (per-process limit) 전역 메모리가 남아 있어도 한 앱이 한계 초과 시 → 그 앱만 Jetsam으로 개별 종료 (per-process-limit)
Jetsam은 우선순위 밴드로 포그라운드를 보호하고 유휴/백그라운드부터 죽인다. 여기에 더해 앱별 메모리 상한이라는 독립 축이 있어, 시스템에 여유가 있어도 상한을 넘긴 앱은 개별 종료된다.

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 같은 사유로 남는다.

메모리 압박에 반응하기 (Swift)
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()로 남은 여유를 런타임에 조회해 방어적으로 동작할 수도 있다.

프로젝트 적용

⚠️ 흔한 오해

"iOS 앱은 시스템 램이 부족할 때만 죽는다"는 틀렸다. 앱별 상한을 넘으면 시스템에 여유가 넘쳐도 그 앱만 종료된다. 또 "Jetsam은 catch할 수 있는 크래시다"도 오해다 — 시그널로 잡히지 않는 강제 종료이고, 예방(footprint 관리)만이 대책이다. "메모리 경고를 받으면 OS가 알아서 내 캐시를 비워준다"도 대체로 틀리다(NSCache 등 일부 예외를 빼면 앱이 직접 비워야 한다).

꼬리 질문

출처 · 참고자료