← iOS 개발자 CS 로드맵 1 / 35

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

예외: iPadOS 16.1부터 M1 이상 칩과 256GB 이상 저장공간을 가진 iPad는 옵션으로 디스크 스왑(Virtual Memory Swap)을 켤 수 있지만, iPhone에는 여전히 앱 dirty 메모리용 스왑이 없다.

메모리가 부족해지면 페이지 종류마다 운명이 다르다 Clean 페이지 앱 코드 · mmap한 파일 그냥 버린다 원본이 파일에 남아 있으니 필요할 때 다시 읽음 (hard fault) Dirty 페이지 힙 객체 · 디코딩된 이미지 커널 compressor가 압축 RAM 안에 압축된 채로 보관 다시 쓰면 풀어줌 (soft fault) 디스크로 스왑? ✗ — iPhone엔 앱 dirty 메모리용 스왑이 없다 그래도 부족하면 Jetsam — 페이지를 빼내는 대신 앱 프로세스를 통째로 종료 짐을 내보낼 창고가 없으니, 방을 비우는 유일한 방법이 "주인을 내보내는 것"이 된다
iOS에서 메모리가 마르면: 원본이 파일에 있는 clean은 버리고, 되살릴 수 없는 dirty는 RAM 안에서 압축한다. 디스크로 내보내는 길이 없어 마지막 수단이 앱 종료(Jetsam)다.

실험 · 도구

실기기에서 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)으로 두어 압박 시 커널이 회수하게 하라.

큰 리소스는 mmap으로 — dirty 대신 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 동작과 앱의 메모리 한계는 어떻게 달라지나?
iPadOS 16.1+의 M1 이상 기기에서 스왑을 켜면 dirty 익명 페이지도 디스크로 내보낼 수 있어, 전역 물리 부족으로 앱을 죽이기 전에 커널이 회수할 여지가 커진다. 다만 앱별 메모리 상한(per-process limit) 자체가 사라지는 것은 아니라서, footprint가 상한을 넘으면 여전히 그 앱만 개별 종료된다(per-process-limit). 스왑이 완화하는 건 주로 "시스템 전체가 말라서 죽는"(vm-pageshortage) 경로다. iPhone에는 이 옵션이 없으므로 이 완화는 특정 iPad 구성에만 해당한다.
쉽게 말하면 스왑을 켠다는 건 없던 창고가 하나 생기는 것이다. 방이 마르기 전에 짐을 창고로 옮길 수 있으니 "집 전체가 꽉 차서" 죽는 일은 줄어든다. 그런데 1인당 수하물 한도(per-process limit)는 창고가 생겨도 그대로라, 혼자 짐이 너무 많은 앱은 여전히 그 앱만 퇴장당한다.
같은 이미지를 여러 프로세스가 mmap하면 물리 메모리는 얼마나 절약되나(공유 clean 페이지)?
파일을 mmap하면 그 영역은 file-backed clean 페이지가 되고, 여러 프로세스가 같은 파일의 같은 범위를 매핑하면 커널은 하나의 물리 프레임을 각 프로세스의 페이지 테이블에서 공유시킨다. 그래서 N개 프로세스가 M바이트를 공유해도 물리 사용량은 N×M이 아니라 대략 M에 수렴한다(각자의 페이지 테이블 엔트리 오버헤드만 별도). dyld 공유 캐시가 바로 이 원리로 시스템 프레임워크 코드를 앱들 사이에 한 벌만 RAM에 둔다. 단 어느 프로세스가 그 페이지에 쓰면 COW로 그 프로세스만 사본을 갖게 돼 공유가 깨진다.
쉽게 말하면 같은 책을 열 명이 봐야 할 때 열 권을 사는 게 아니라 도서관 책 한 권을 다 같이 들여다보는 것이다. 그래서 시스템 프레임워크 코드가 앱마다 한 벌씩 RAM에 올라가지 않는다. 다만 누가 그 책에 낙서를 하려는 순간(쓰기), 그 사람만 복사본을 받아 들고 나가서 그때부터 공유가 아니게 된다.
앱을 백그라운드로 보내면 dirty 페이지에는 무슨 일이 일어나고, 왜 백그라운드 앱이 먼저 죽나?
스왑이 없으니 dirty 페이지는 디스크로 못 나가고 RAM에 남되, 오래 안 쓰이면 커널 compressor가 압축해 RAM 안에 보관한다. 그래서 백그라운드 앱은 "메모리를 붙든 채 잠든" 상태가 되고, 포그라운드 앱이 압박받으면 Jetsam이 우선순위 밴드에서 낮은 백그라운드/유휴부터 통째로 죽여 프레임을 회수한다. 지금 사용자가 보는 앱을 살리려고 안 보이는 앱을 먼저 희생시키는 정책이라, 백그라운드 진입 시 footprint를 줄여둔 앱일수록 오래 살아남는다.
쉽게 말하면 백그라운드 앱은 자리를 비운 게 아니라, 짐을 그대로 깔아둔 채 잠든 상태다. 내보낼 창고가 없으니 커널은 진공팩(압축)으로 눌러둘 뿐이고, 그래도 자리가 모자라면 지금 안 보이는 사람부터 내보낸다. 그러니 백그라운드로 들어갈 때 스스로 짐을 줄여둔 앱이 돌아왔을 때 살아 있을 확률이 높다.

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 영역별 크기·권한·공유 여부를 덤프한다.

프로젝트 적용

페이지 크기를 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를 빼고 정규화한 오프셋으로 봐야 한다.

ASLR — 주소를 실행 간에 비교하지 않기
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이 한 벌만 든다.

같은 파일을 여러 곳에서 쓰면 mmap으로 공유
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로 어떻게 완화하나)?
TLB miss가 나면 MMU가 페이지 테이블을 하드웨어로 워크(ARM64는 다단계 translation table walk)해 번역을 구해 TLB에 채운 뒤 명령을 재실행한다 — 캐시 미스 성격이라 커널 개입도 예외도 없다. 컨텍스트 스위치가 문제인 건 프로세스마다 VA→PA 매핑이 다른데 TLB 엔트리에 소유자 표시가 없으면 전체를 flush해야 하고, 그러면 다음 프로세스가 콜드 TLB로 시작해 느려지기 때문이다. ARM64는 각 엔트리에 ASID(address space identifier)를 태깅해 여러 주소 공간의 번역이 TLB에 공존하게 하므로, 스위치 때 flush 없이 ASID만 바꿔 불필요한 miss 폭증을 막는다.
쉽게 말하면 TLB miss는 카운터 메모지에 안 적혀 있어서 서가까지 직접 확인하러 가는 것이다. 느릴 뿐 사고가 아니고, 사람(커널)을 부르지 않고 하드웨어가 알아서 다녀온다. 진짜 골치는 앱이 바뀔 때인데, 앱마다 주소 지도가 달라서 메모지를 통째로 지워야 하면 다음 앱이 매번 맨땅에서 시작한다. ASID는 메모지마다 "이건 몇 번 앱 것"이라고 이름표를 붙여두는 것 — 지울 필요 없이 이름표만 바꿔 읽으면 된다.
16KB 페이지는 4KB 대비 내부 단편화와 TLB 커버리지에서 각각 무엇을 얻고 잃나?
16KB 페이지는 같은 엔트리 수로 4배 넓은 영역을 커버해 TLB reach가 커지고 walk·miss가 줄어 워킹셋이 큰 워크로드에서 유리하다. 대신 할당·매핑이 16KB 단위로 반올림되므로 작은 객체가 흩어지면 페이지 안 남는 공간, 즉 내부 단편화와 최소 footprint(iOS가 16KB 단위로 회계)가 커진다. Apple이 ARM64에서 16KB를 택한 건 모바일 워크로드의 지역성과 정렬을 고려해 TLB 효율을 단편화보다 우선한 트레이드오프로 볼 수 있다.
쉽게 말하면 이삿짐을 큰 상자로 싸는 것과 같다. 상자 개수가 줄어드니 "몇 번 상자가 어디 있나" 목록(TLB)이 훨씬 적은 줄로 더 많은 짐을 감당한다. 대신 양말 한 켤레를 넣어도 상자 하나를 통째로 쓰게 돼 빈 공간이 남는다. 애플은 모바일에선 목록이 짧아지는 이득이 그 빈 공간 손해보다 크다고 본 것이다.
fork 없이 시작하는 iOS 앱에서 Copy-on-Write는 어디서 쓰이나(공유 캐시·mmap 파일)?
iOS 앱은 fork로 프로세스를 복제하진 않지만 COW는 여러 층위에서 쓰인다. 커널 레벨에선 dyld 공유 캐시와 mmap한 파일이 앱들 사이에 같은 물리 clean 페이지를 공유하다가, 어느 앱이 그 페이지를 수정하면(예: rebase/bind, 쓰기 가능 데이터 세그먼트) 그 앱만 사본을 받는다. 언어 레벨에선 Swift Array/String 같은 값 타입 컬렉션이 애플리케이션 COW로 "쓸 때까지 버퍼를 공유"한다 — 커널 페이지 COW와 계층은 다르지만 원리는 같다.
쉽게 말하면 "고칠 때까지는 같이 보고, 고치려는 사람만 사본을 받는다"는 요령이 아이폰에선 두 층에서 따로 돌아간다. 아래층에선 커널이 여러 앱에게 프레임워크 코드 페이지를 같이 보여주고, 위층에선 Swift Array를 다른 변수에 대입해도 진짜 복사를 안 한다. 서로 다른 사람이 만든 별개 장치인데 발상이 똑같은 것이지, 하나가 다른 하나를 쓰는 관계는 아니다.

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를 비교해 확인한다.

프로젝트 적용

뜨거운 경로(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 논이스케이핑 클로저에서 캡처 저장 위치는 어떻게 달라지나?
논이스케이핑 클로저는 호출이 끝나면 사라지므로 캡처를 호출자 스택 프레임에 그대로 두거나 스택에 잡을 수 있어 힙 할당이 없다. 이스케이핑 클로저는 함수 반환 뒤에도 살아야 하니 캡처한 변수들을 힙에 할당된 캡처 박스(참조 카운팅되는 컨텍스트)에 담고, 클로저는 그 박스를 참조로 붙든다. 그래서 이스케이핑 캡처는 ARC 대상이 되고, 여기서 self를 캡처하면 retain cycle 위험이 생기는 것이다.
쉽게 말하면 함수가 끝나면 같이 사라지는 클로저는 접시 위(스택)에 그냥 얹어두면 된다. 그런데 나중에 실행될 클로저는 함수가 끝난 뒤에도 살아 있어야 하니, 캡처한 값들을 담을 상자를 힙에 하나 만들어 옮겨 싣는다. 그 상자가 self를 담는 순간 self를 붙드는 손이 하나 생기고, 그 손 때문에 retain cycle — 서로를 붙들어 아무도 못 죽는 상태 — 이야기가 시작된다.
Copy-on-Write는 값 타입의 "복사=힙 공유"를 어떻게 구현하나(isKnownUniquelyReferenced)?
COW 값 타입은 실제 데이터를 힙 버퍼(클래스 인스턴스)에 두고, struct는 그 버퍼에 대한 참조만 값으로 갖는다. 복사(대입·전달)는 버퍼를 복제하지 않고 참조 카운트만 올려 공유하고, 변경(mutating) 직전에 isKnownUniquelyReferenced(&buffer)로 "나만 이 버퍼를 참조하는가"를 검사한다 — true면 제자리 수정, false(공유 중)면 그때 버퍼를 깊은 복사한 뒤 수정한다. 그래서 읽기만 하거나 유일 소유일 때는 복사 비용이 0이고, 실제 복사는 공유 상태에서 쓸 때만 일어난다.
쉽게 말하면 배열을 다른 변수에 넣어도 실제로는 같은 문서를 둘이 같이 보고 있을 뿐이다. 고치려는 그 순간에만 "지금 이거 나 혼자 보고 있나?" 하고 한 번 물어보고, 혼자면 그 자리에서 고치고, 옆에 같이 보는 사람이 있으면 그때서야 복사본을 뜬다. 그래서 읽기만 하는 코드는 아무리 큰 배열을 넘겨도 복사 비용이 0이다.
큰 existential(프로토콜 타입) 값의 3워드 인라인 버퍼 규칙이 성능에 주는 함의는?
프로토콜 타입 값(existential container)의 값 저장부는 3워드(약 24바이트)로 고정돼, 페이로드가 3워드 이하면 인라인으로 담기지만 초과하면 힙 버퍼를 할당해 박싱하고 컨테이너엔 포인터만 둔다. 따라서 큰 struct를 any Protocol로 담으면 생성·복사마다 힙 할당과 ARC가 붙고, witness table을 통한 간접 디스패치까지 겹쳐 뜨거운 경로에서 비싸진다. 이를 피하려면 제네릭(<T: Protocol>, 정적 디스패치·특수화)을 쓰거나 값 자체를 3워드 이하로 유지하는 설계를 택한다.
쉽게 말하면 any Protocol은 24바이트짜리 작은 봉투라고 보면 된다. 내용물이 봉투에 들어가면 그대로 넣지만, 넘치면 물건은 창고(힙)에 두고 봉투엔 주소만 적는다 — 그 순간부터 만들 때마다 힙 할당이 붙고 ARC가 따라온다. 1초에 수천 번 도는 코드라면 봉투를 쓰지 말고 제네릭으로 "이건 무슨 타입이다"를 컴파일러에게 미리 알려주는 쪽이 훨씬 싸다.

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 시점 불명확
"마지막 참조가 사라진 순간"부터 실제로 메모리가 풀릴 때까지 마지막 참조가 사라진 지점 ARC 바로 여기서 deinit + free 기다림 0 · 위치가 예측 가능 이후엔 남아 있는 게 없다 GC 쓰레기지만 아직 메모리를 붙들고 있는 구간 수집 사이클이 돌 때 도달 못 하는 것들을 몰아서 회수 GC는 이 지연 덕에 서로 껴안은 순환도 회수한다. ARC는 지연이 없는 대신 순환을 영영 못 알아챈다.
ARC는 마지막 참조가 풀리는 바로 그 호출 지점에서 해제하고, 트레이싱 GC는 나중에 도는 수집 사이클에서 몰아서 회수한다. 이 "나중"이 GC가 순환을 풀 수 있는 이유이기도 하다.

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 언어에서는 못 하는 것).

deinit의 결정적 시점을 자원 관리에 쓰기
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가 원자적 연산이라 공짜가 아니다. 참조를 담지 않는 값 타입으로 바꾸면 카운팅 트래픽 자체가 사라진다.

hot path의 ARC 트래픽 줄이기
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).

하지 말 것 — “ARC라서 누수가 없다”
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엔 왜 그 개념이 없는지?
세대별 GC는 "대부분의 객체는 금방 죽는다"는 약한 세대 가설에 기대, 새로 만든 객체가 모인 young 영역만 자주 훑고 오래 산 객체는 드물게 검사해 매 수집이 훑는 양을 줄인다. 트레이싱 GC는 "살아있는 것을 추적"하는 구조라 스캔 범위를 좁히는 게 곧 성능이기 때문이다. ARC는 애초에 힙을 훑지 않고 각 객체가 자기 카운트만 관리하므로 "세대로 나눠 스캔을 줄인다"는 개념이 성립할 여지가 없다 — 대신 카운트 조작(retain/release) 비용을 실행 전반에 잘게 분산해서 낸다.
쉽게 말하면 트레이싱 GC는 청소부가 창고를 돌며 살아 있는 물건을 일일이 확인하는 방식이라, "새로 들어온 칸만 자주 보자"는 요령이 먹힌다(새 물건이 대부분 금방 버려지니까). ARC는 애초에 창고를 돌지 않는다 — 물건에 붙은 스티커 숫자만 보니까 "어느 칸부터 볼까"라는 고민 자체가 생길 자리가 없다. 대신 스티커를 +1/−1 하는 잔비용을 앱이 도는 내내 조금씩 낸다.
ARC의 원자적 retain/release 비용을 줄이는 방법(값 타입, borrow, ~Copyable)은?
참조를 전혀 포함하지 않는 값 타입(struct/enum)은 복사가 인라인이라 retain/release가 붙지 않으니 참조 카운팅 트래픽을 줄이는 1차 수단이다. 단 값 타입이라도 클래스 참조 멤버나 COW 버퍼(Array/String 등)를 담으면 복사 시 그 참조에는 retain/release가 발생한다. Swift의 소유권 파라미터 borrowing/consuming은 값을 넘길 때 불필요한 retain/release 쌍을 생략하게 하고, ~Copyable(non-copyable) 타입은 복사가 언어 차원에서 금지돼 소유권 이전만 일어나므로 카운팅이 아예 필요 없다. 컴파일러의 ARC 최적화(중복 쌍 제거)도 있지만, 원자적 연산 비용이 남는 뜨거운 경로에선 이런 타입 설계가 더 확실하다.
쉽게 말하면 retain/release는 여러 스레드가 동시에 건드려도 숫자가 안 틀리게 만든 계산이라 공짜가 아니다. 참조를 하나도 안 담은 struct는 그 숫자 자체가 없으니 계산이 붙을 자리도 없다. borrowing은 "가져가는 게 아니라 잠깐 빌려 볼게"라고 알려줘 도장을 찍었다 지우는 헛수고를 생략하는 것이고, ~Copyable은 복사가 아예 금지라 셀 것이 남지 않는다.
객체 그래프가 클 때 ARC와 GC 중 해제 지연 스파이크가 어디서 더 크게 나타나나?
트레이싱 GC는 큰 그래프가 도달 불가가 되면 수집 사이클이 돌 때 몰아서 훑고 회수하므로 stop-the-world나 회수 페이즈에서 스파이크가 나기 쉽다. ARC는 참조 수가 0이 되는 즉시 해제하지만, 거대한 그래프의 루트 하나가 release되면 그 안의 수많은 객체 deinit이 그 스레드에서 연쇄적으로 동기 실행돼 바로 그 지점에 스파이크가 몰린다. 즉 GC는 "언젠가 몰아서", ARC는 "마지막 참조가 풀리는 그 호출 지점에서" 스파이크가 나며, ARC 쪽은 위치가 예측 가능하다는 차이가 있다.
쉽게 말하면 나무 맨 위 가지를 놓는 순간, ARC는 그 자리에서 밑에 매달린 수천 개를 줄줄이 다 정리한다. 그래서 딱 그 한 줄에서 앱이 멈칫한다. GC는 그 정리를 나중에 몰아서 하니 멈칫이 언제 올지 모른다. 일해야 할 총량은 비슷한데, 언제 어디서 몰리느냐가 다른 것이다 — 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보다 저렴
대상이 사라진 "뒤"에 내 손에 뭐가 남는가 strong weak unowned ① 대상이 살아 있는 동안 참조 수 +1 — 내가 붙든다 내가 놓기 전엔 안 죽는다 참조 수 그대로 (안 붙듦) side table에 내 슬롯을 등록 참조 수 그대로 (안 붙듦) "쟤가 나보다 오래 산다"고 약속 ② 그 대상의 strong 카운트가 0이 되어 해제된 뒤 그런 일이 안 생긴다 내 지분이 남아 있는 한 카운트가 0에 닿지 못함 내 슬롯이 자동으로 nil 읽으면 nil이 보인다 → guard let으로 안전하게 빠짐 슬롯은 그대로 남는다 nil이 되지 않으므로 검사 불가 접근하는 순간 trap (즉사)
세 참조의 진짜 차이는 "대상이 죽은 다음"에 드러난다. strong은 애초에 그 상황이 안 오고, weak는 자동으로 nil이 되어 물어볼 수 있고, unowned는 아무 표시도 남지 않아 접근하는 순간 터진다.

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.

판단 기준 — 쓰이는 시점에 대상이 죽어 있을 수 있는가
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으로 붙들면 그 자체가 순환이다.

delegate는 기본을 weak로
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] 남발은 피하라. 즉시 실행되고 저장되지 않는 클로저에서는 불필요하고, 오히려 필요한 순간 selfnil이 되는 로직 버그를 만든다.

습관적 [weak self] 남발 피하기
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)는 그 크래시조차 안 나고 엉뚱한 메모리를 읽는다.

하지 말 것 — 수명이 애매한 곳의 unowned
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]를 각각 언제 쓰나, 판단 기준은?
self가 클로저보다 먼저 사라질 수 있으면 [weak self] + guard let self로 죽었을 때 안전하게 빠져나온다 — 네트워크 콜백, 지연 실행, 어딘가 저장되는 클로저처럼 수명이 불확실한 경우다. [unowned self]는 클로저가 self보다 절대 오래 살지 않는다는 계약이 성립할 때만 쓴다(예: self가 소유하고 동기적으로만 실행되는 클로저). 판단 기준은 "self가 이 클로저 실행 전에 해제될 수 있는가"이며, 조금이라도 그렇다면 weak, 확실히 아니라면 unowned가 언랩 비용 없이 의도를 더 분명히 드러낸다.
쉽게 말하면 물어볼 건 딱 하나다 — "이 클로저가 실행되는 순간에 self가 이미 죽어 있을 수 있나?" 네트워크 응답처럼 몇 초 뒤에 오는 일은 그 사이 사용자가 뒤로가기를 누를 수 있으니 weak다. 반대로 self가 그 클로저를 들고 있고 지금 그 자리에서 바로 실행되는 경우엔 self가 먼저 죽을 방법이 없으니 unowned로 두고 매번 확인하는 절차를 없애는 게 낫다.
weak 참조가 nil로 바뀌는 그 순간의 스레드 안전성은 어떻게 보장되나?
Swift의 zeroing weak는 대상 객체의 side table에 자신을 등록해 두고, 대상의 strong 카운트가 0이 되어 해제될 때 런타임이 그 side table에 걸린 모든 weak 슬롯을 nil로 채운다. 이 로드/스토어와 해제의 경합은 런타임이 원자적 연산과 내부 락으로 보호해, weak를 읽는 스레드는 "유효한 강참조" 아니면 "nil" 중 하나만 보고 중간의 dangling 포인터는 절대 관측하지 못한다. 구현상 weak 읽기는 잠깐 strong으로 승격해 읽는 형태라, 읽는 도중 대상이 사라져도 그 접근 동안은 살아 있음이 보장된다.
쉽게 말하면 무서운 시나리오는 "내가 쪽지를 읽는 바로 그 찰나에 다른 스레드가 물건을 치워버리는" 경우다. 런타임은 weak를 읽을 때 잠깐 손으로 꽉 붙잡고 읽기 때문에, 읽는 동안에는 치워질 수가 없다. 그래서 우리 눈에 보이는 건 언제나 "멀쩡한 객체" 아니면 "nil" 둘 중 하나뿐이고, 반쯤 사라진 상태는 애초에 볼 수 없다.
unowned(unsafe)는 왜 존재하고 언제라도 정당화되나?
unowned(unsafe)는 해제 여부 검사(safe 버전의 trap 로직)조차 없는 순수 dangling 가능 포인터로, Objective-C의 __unsafe_unretained에 대응하며 side table 등록·검사 비용이 0이다. 존재 이유는 수명이 다른 수단으로 확실히 보장되는 극한 성능 경로나 레거시 상호운용에서, unowned(safe)의 검사 오버헤드마저 없애기 위해서다. 정당화되는 경우는 매우 드물다 — 측정으로 그 오버헤드가 실제 병목임을 입증하고 수명 계약이 구조적으로 깨질 수 없을 때뿐이며, 대부분은 unowned(safe)나 weak가 옳다.
쉽게 말하면 이건 안전장치를 떼어낸 unowned다. 보통 unowned는 죽은 물건에 손대면 "여긴 비었어!" 하고 앱을 그 자리에 세워주기라도 하는데, unsafe는 그 확인조차 안 해서 엉뚱한 메모리를 읽고도 아무 일 없는 척 굴러간다 — 나중에 전혀 다른 데서 터지니 훨씬 찾기 어렵다. 있는 이유는 Objective-C 호환과 극한 성능뿐이라, 직접 측정해서 그 확인 비용이 진짜 병목임을 증명한 게 아니면 손댈 일이 없다.

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가 더 신뢰할 만하다.

프로젝트 적용

이스케이핑 클로저에서 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 프로퍼티·양방향 링크는 순환 후보로 보고 한쪽을 비소유로.

순환 후보 3종 — delegate · 클로저 프로퍼티 · 양방향 링크
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 로그 또는 테스트를 두어 회귀를 조기에 잡아라.

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개 이상의 객체가 얽힌 사이클은 어느 변을 끊어야 하나, 판단 원칙은?
원칙은 소유 방향(생명주기상 오래 사는 쪽 → 짧게 사는 쪽)을 strong으로 남기고, 그 반대 방향의 back-reference(하위가 상위를 되가리키는 참조)를 비소유로 끊는 것이다. 사이클을 방향 그래프로 그려 "누가 누구를 논리적으로 소유하는가"를 정하면, 소유 관계를 거스르는 변이 끊을 후보로 드러난다. 후보가 여럿이면 back-reference 성격이 가장 뚜렷한 한 변만 weak/unowned로 바꿔 순환을 깨되, 소유 사슬(strong 경로)은 끊지 않아야 필요한 객체가 조기 해제되지 않는다.
쉽게 말하면 고리를 끊긴 끊어야 하는데, 아무 화살표나 자르면 안 된다. 먼저 "누가 누구를 데리고 다니는 사람인가"를 정한다. 부모가 자식을 데리고 있는 화살표는 남기고, 자식이 부모를 되돌아보는 화살표를 weak로 바꾼다. 거꾸로 끊으면 자식을 붙들어 주는 사람이 아무도 없어져서, 쓰려고 보면 이미 사라져 있는 nil 크래시가 난다.
Timer.scheduledTimer(target:selector:)NotificationCenter 옵저버가 만드는 순환은 클로저 순환과 무엇이 다르며, 왜 [weak self]만으로는 안 풀리고 어떻게 끊나(invalidate/removeObserver)?
클로저 순환은 self가 클로저를, 클로저가 self를 서로 strong으로 붙드는 구조라 캡처 리스트의 [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로 보관돼 애초에 순환을 만들지 않으므로, deinitremoveObserver는 메모리 필수 조치가 아니라 중복 등록·원치 않는 수신을 막는 위생 조치다. 정작 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란 무엇인가?

🔑 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로 종료
CPU가 어떤 가상 주소를 건드린다 TLB(번역 메모지)에 있나? 있음 → 즉시 접근 (끝) 없음 MMU가 페이지 테이블을 워크 valid=1 → TLB 채우고 접근 (커널 개입 없음) valid=0 또는 권한 위반 Page Fault 예외 → 커널이 원인 판정 soft fault 이미 RAM 안에 있음 압축 해제 · 공유 · zero-fill hard fault 파일·바이너리에서 읽어옴 디스크 I/O → 훨씬 느림 invalid 매핑 없음 · 권한 위반 EXC_BAD_ACCESS로 종료 PTE 갱신 후, 실패했던 그 명령을 처음부터 다시 실행
대부분의 접근은 위 두 단계에서 조용히 끝난다. 폴트는 그다음부터 커널이 개입하는 경로이고, 세 갈래 중 앞의 둘은 정상 동작이라 페이지를 붙인 뒤 원래 명령을 다시 실행한다.

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)나 코드 크기 축소가 폴트 수를 줄인다.

콜드 스타트의 코드 페이지 hard fault 줄이기
# 앱 시작이 느릴 때, 원인이 '코드 페이지를 디스크에서 끌어오는 비용'인지 먼저 잰다.
# 실행 파일의 __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)으로 두고 필요한 페이지만 폴트로 올라오게 하면 메모리 압박에 강하다.

큰 파일은 dirty로 읽지 말고 매핑하기
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)을 살려 자료를 배치하라.

지역성을 살려 폴트·TLB miss 줄이기
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는 어떻게 다른가(둘 다 "번역 실패"인데 처리 주체가 다르다)?
둘 다 "VA 번역이 지금 바로 안 된다"이지만 층위와 처리 주체가 다르다. TLB miss는 번역 캐시에 엔트리가 없을 뿐 페이지 테이블엔 유효 매핑이 있는 경우로, ARM64에선 MMU가 하드웨어로 페이지 테이블을 워크해 채우며 커널 개입도 예외도 없다. page fault는 페이지 테이블 엔트리 자체가 무효(valid=0)이거나 권한 위반이라 CPU가 예외를 일으키고, 커널이 개입해 프레임을 붙이거나(정상) SIGSEGV로 종료(잘못된 접근)시킨다. 즉 TLB miss는 하드웨어가 조용히, page fault는 소프트웨어(커널)가 처리한다.
쉽게 말하면 둘 다 "지금 당장은 주소를 못 바꾼다"지만 급이 다르다. TLB miss는 카운터 메모지에 안 적혀 있어서 대장을 뒤지러 가는 것 — 대장에는 답이 있고, 하드웨어가 혼자 다녀오니 커널은 이런 일이 있었는지도 모른다. page fault는 대장에 "여기 없음"이라고 적혀 있어 커널을 불러야 하는 상황이고, 커널이 페이지를 붙여주거나 잘못된 접근이면 앱을 끝낸다.
zero-fill on demand란 무엇이고 왜 새 힙 페이지가 처음엔 물리 프레임을 안 잡나?
새로 얻은 익명 메모리(힙 확장, BSS 등)는 할당 시점엔 물리 프레임을 붙이지 않고 페이지 테이블에 "유효하지만 zero-fill 대상"으로만 표시해 둔다. 그 페이지를 실제로 처음 건드리는 순간 page fault가 나고, 커널이 그때 0으로 채운 물리 프레임 하나를 붙여준다(이전 프로세스 데이터가 새지 않도록 0으로 초기화). 그래서 malloc으로 큰 영역을 잡아도 만지지 않은 페이지는 물리 RAM을 소비하지 않으며(overcommit), footprint는 "약속한 크기"가 아니라 "실제 터치한 페이지"에 비례한다.
쉽게 말하면 100MB를 malloc하는 건 "이만큼 자리를 예약해주세요"라는 약속서일 뿐이다. 실제로 방을 내주는 건 그 자리를 처음 만지는 순간이고, 그때 커널이 새 페이지를 0으로 싹 지워서 붙여준다 — 아까 다른 앱이 쓰던 찌꺼기가 그대로 보이면 안 되니까. 그래서 크게 잡아놓고 앞부분만 쓰면, 안 만진 뒷부분은 메모리 사용량에 아예 안 잡힌다.
cold 스타트에서 페이지인을 줄이는 방법(prefault, order file, 바이너리 슬림화)은?
콜드 스타트에선 실행 경로의 코드 페이지들이 바이너리에서 hard fault로 처음 올라오므로, 페이지인 수를 줄이는 게 곧 시작 시간 단축이다. 시작에 쓰이는 함수들을 물리적으로 인접하게 배치하는 order file(정적 링커 ld에 -order_file로 넘기는 심볼 배치 순서 — dyld는 런타임에 그 배치 덕분에 페이지인이 줄어드는 수혜자일 뿐 order file을 직접 소비하는 주체는 아니다)로 폴트할 페이지 수와 흩어짐을 줄이고, dead code 제거·바이너리 슬림화로 애초에 매핑할 페이지 양을 줄인다. 필요하면 시작에 쓸 데이터를 미리 만지는 prefault(예: mmap 후 madvise(MADV_WILLNEED))로 지연을 앞당겨 흡수할 수 있으나, 남용하면 오히려 초기 메모리 압박을 키운다.
쉽게 말하면 앱을 처음 켤 때 느린 건, 실행할 코드를 저장공간에서 한 묶음씩 꺼내오느라 그렇다(이게 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로 못 잡고, 기기의 설정 > 개인정보 보호 및 보안 > 분석 및 향상 > 분석 데이터에 남는 JetsamEvent_<날짜> 리포트(Xcode Organizer의 Crashes에는 올라오지 않는다)에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를 보면 상한 근처에서 앱이 즉사하는 걸 관찰할 수 있고, 사후엔 기기 설정의 분석 데이터에 남는 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의 밥이다.

NSCache 또는 압박에 반응하는 캐시 구조
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인데 왜 죽지?"의 답이 여기 있다.

전역 RAM이 아니라 '앱별 상한'이 기준
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로 잡히는 크래시가 아니라 그냥 끌려나가는 거라, 미리 짐을 줄여두는 것 말고는 대책이 없다.

꼬리 질문

포그라운드 앱도 죽는 경우는 언제인가(자기 상한 초과·시스템 극한 압박)?
포그라운드는 Jetsam 우선순위 밴드에서 가장 보호받지만 면제는 아니다. 대표적으로 그 앱이 자기 per-process 메모리 상한을 초과하면 시스템 여유와 무관하게 그 앱만 개별 종료된다(per-process-limit). 또 시스템 전체가 회수·백그라운드 kill로도 못 버티는 극한 압박에 이르면 최후에는 포그라운드 앱까지 vm-pageshortage로 죽을 수 있다. "내가 화면에 있으니 안 죽는다"는 보장은 없다.
쉽게 말하면 화면에 떠 있다는 건 "가장 나중에 불린다"는 뜻이지 면제권이 아니다. 내 앱 혼자 짐 한도를 넘기면 공연장이 텅 비어 있어도 나만 끌려 나가고, 기기 전체가 정말로 바닥나면 순서가 마지막일 뿐 결국 내 차례도 온다.
앱 익스텐션(share/notification service 등)의 메모리 상한이 본 앱보다 훨씬 낮은(수십 MB) 이유는 무엇이고, 이 상한을 넘겼을 때의 종료가 본 앱의 Jetsam과 어떻게 다른가?
익스텐션은 본 앱과 별개의 단명 프로세스이고, 자신이 붙는 호스트(메일·Springboard 등)의 응답성을 지키려고 시스템이 용도별로 훨씬 낮은 per-process footprint 상한을 못 박아 둔다 — 애플이 공식 문서로 못 박은 수치는 사실상 없고, notification service의 24MB는 기기 로그(EXC_RESOURCE … exceeded mem limit: ActiveHard 24 MB)와 커뮤니티에서 반복 관측된 값이라 종류와 iOS 버전에 따라 다른데, 위젯은 대략 30MB 안팎, share처럼 UI를 띄우는 익스텐션은 그보다 넉넉하지만 여전히 본 앱 상한의 일부에 불과하다. 본 앱 상한이 기기 RAM에 따라 커지는 것과 달리 익스텐션 상한은 기기와 거의 무관하게 고정돼 있다는 점도 다르다. 종료를 일으키는 메커니즘 자체는 본 앱과 같은 memorystatus의 per-process-limit이지만, 죽는 대상이 익스텐션 프로세스뿐이라 호스트 앱은 멀쩡하고 사용자는 '동작 실패'(예: 알림이 원본 그대로 표시, 공유 시트가 사라짐)로만 체감한다. 그래서 익스텐션에선 이미지 원본을 통째로 디코딩하는 식의 큰 작업을 피하고 다운샘플·스트리밍으로 footprint를 그 낮은 상한 아래로 눌러야 한다.
쉽게 말하면 익스텐션은 남의 집에 잠깐 들어와 일하고 나가는 손님이라, 들고 들어올 수 있는 짐이 주인집보다 훨씬 적다(알림 익스텐션은 24MB). 넘겼을 때 쫓겨나는 방식은 본 앱과 똑같은데, 쫓겨나는 게 손님뿐이라 주인집은 멀쩡하고 사용자 눈엔 크래시가 아니라 "알림에 사진이 안 뜨네" 정도로만 보인다. 그래서 여기서 원본 사진을 통째로 디코딩하면 거의 확실히 죽는다.
NSCache와 직접 만든 딕셔너리 캐시가 메모리 압박에 반응하는 방식의 차이는?
NSCache는 시스템 메모리 압박에 반응하도록 설계돼, 압박이 오면 내부 항목을 자동으로 방출(evict)하고 countLimit/totalCostLimit 상한도 지원하며 스레드 안전하다. 직접 만든 Dictionary 캐시는 이런 훅이 전혀 없어 메모리 경고가 와도 스스로 비우지 않고 무한히 자라 Jetsam의 표적이 된다 — 개발자가 memory pressure 소스나 didReceiveMemoryWarning에서 명시적으로 비워야 한다. 그래서 압박 대응이 필요한 캐시는 NSCache나 압박 구독을 직접 붙인 구조가 안전하다.
쉽게 말하면 NSCache는 "자리가 모자라면 알아서 짐을 버려라"는 지시를 이미 받아둔 보관함이다. 내가 만든 Dictionary 캐시는 그런 지시를 받은 적이 없어서, 시스템이 아무리 다급하게 신호를 보내도 아무 반응 없이 계속 불어난다. 누군가 코드로 직접 비워주지 않으면, 다음번에 끌려 나갈 앱은 내 앱이다.

출처 · 참고자료