앱을 짜다 보면 "백그라운드로 갔다", "메모리 부족으로 죽었다", "샌드박스 때문에 접근 안 된다" 같은 말을 자주 쓴다. 이 챕터는 그 말들이 운영체제 수준에서 실제로 무슨 일인지를 CS 원리와 Darwin/XNU 구현 양쪽에서 정확히 맞춰 본다.
Q1. iOS 앱은 운영체제에서 Process, Task, Thread 중 무엇으로 표현되는가?
실행 중인 iOS 앱 하나는 BSD 프로세스 1개 = Mach task 1개이고, 그 안에 여러 Mach thread가 들어 있다. Process/Task는 "자원을 담는 그릇"(주소공간·포트권한·파일 디스크립터)이고, Thread는 "실제로 CPU에서 도는 실행 단위"다. XNU는 커널 아래층에 Mach의 task, 위층에 BSD의 proc를 두는 하이브리드라서 두 이름이 사실상 같은 대상을 가리킨다. 주의: Swift의 Task { }는 이것과 전혀 다른, 비동기 작업 단위다.
CS 원리
전통적 OS 이론에서 프로세스는 실행 중인 프로그램의 인스턴스이며 주소공간·열린 파일·시그널 상태 같은 자원의 소유 단위다. 스레드는 그 프로세스 안에서 프로그램 카운터·레지스터·스택을 가진 스케줄링(실행)의 단위다. 한 프로세스는 자원을 공유하는 여러 스레드를 담을 수 있다. "Task"는 표준 용어가 아니라 OS마다 다르게 쓰인다 — 어떤 곳에선 프로세스의 동의어, 어떤 곳(리눅스 커널 내부)에선 스레드까지 포함한 스케줄 가능한 실체를 task_struct로 부른다.
iOS에서는
iOS 커널 XNU는 Mach 마이크로커널 + BSD의 하이브리드다. 자원 컨테이너는 Mach의 task(가상 주소공간, Mach 포트 권한, 메모리 자원의 소유자)이고, 실행 단위는 Mach의 thread다. 그 위에 BSD 계층이 struct proc를 씌워 PID·시그널·POSIX 파일 디스크립터 같은 유닉스 의미론을 붙인다. 그래서 실행 중인 앱 하나는 "BSD proc ↔ Mach task"가 1:1로 대응하고, 최소 1개의 스레드(main thread)를 갖는다. GCD의 dispatch queue나 Swift Concurrency의 Task는 커널 스레드가 아니라 그 위에서 스케줄되는 작업 추상화이며, 런타임이 이를 실제 스레드 풀에 매핑한다.
실험 · 도구
Mach 자원과 스레드를 직접 들여다볼 수 있다. LLDB에서 image list로 로드된 다이립을, thread list로 현재 스레드를, Xcode의 Debug Navigator로 스레드 목록과 스택을 본다. 코드로는 Mach API가 그대로 열려 있다.
import Darwin
var threadList: thread_act_array_t?
var count: mach_msg_type_number_t = 0
// mach_task_self_가 곧 "이 앱 프로세스" = 현재 Mach task
let kr = task_threads(mach_task_self_, &threadList, &count)
if kr == KERN_SUCCESS {
print("이 앱 task의 커널 스레드 수: \(count)")
}
// 관찰 지표: 앱 시작 직후엔 main + GCD/런타임 워커 몇 개 →
// URLSession/이미지 디코딩이 돌면 스레드 수가 늘었다 줄었다 한다.프로젝트 적용
- "스레드를 직접 만든다"(
Thread/pthread)보다 GCD·Swift Concurrency로 작업을 던지고 스레드 관리는 런타임에 맡긴다. 스레드는 비싼 커널 자원(보조 스레드 기본 스택 512KB, main은 1MB급)이라 수백 개를 만들면 thread explosion으로 컨텍스트 스위칭이 앱을 잡아먹는다. - UIKit/SwiftUI 갱신은 반드시
mainthread(= thread 0). 백그라운드 스레드에서 UI를 건드리면 크래시 또는 미정의 동작. - 변수명·로그에서 Mach
task와 SwiftTask를 섞어 쓰지 말 것. 리뷰에서 혼란의 원천이다.
"iOS는 프로세스 개념이 없고 스레드만 있다"는 틀렸다. 앱은 엄연히 독립 프로세스(각자 PID·주소공간)다. 또 "Swift의 Task가 곧 Mach task"라는 것도 틀렸다 — Swift Task는 커널 자원이 아니라 async 작업 단위이고, 여러 Task가 스레드 하나를 나눠 쓰거나 한 Task가 여러 스레드를 오갈 수도 있다.
꼬리 질문
- App Extension(공유 시트, 위젯)은 호스트 앱과 같은 프로세스인가, 별도 프로세스인가?
fork()가 iOS 앱에서 사실상 금지된 이유는 Mach task 구조와 어떻게 얽혀 있나?- GCD의 dispatch queue가 늘 새 스레드를 만들지 않는데, 그럼 큐와 스레드의 관계는 몇 대 몇인가?
Q2. User mode와 Kernel mode는 왜 분리되어 있는가?
아무 코드나 하드웨어·다른 프로세스 메모리·전체 물리 메모리를 직접 만지게 두면 버그 하나가 시스템 전체를 망가뜨린다. 그래서 CPU가 특권 수준을 하드웨어로 강제한다. 유저 코드는 낮은 특권(제한된 명령·자기 주소공간만)에서 돌고, 커널만 특권 명령을 쓴다. 경계를 넘으려면 반드시 trap(system call·인터럽트·예외)을 통해 커널이 검사하는 정문으로만 들어가야 한다. iOS(ARM64)에선 이게 Exception Level EL0(앱) / EL1(XNU 커널)로 구현된다.
CS 원리
보호(protection)의 핵심은 격리 + 매개된 접근이다. CPU는 최소 두 개의 특권 수준을 갖고, 현재 수준을 나타내는 상태 비트를 하드웨어가 관리한다. 유저 수준에서는 I/O 명령, MMU 페이지 테이블 조작, 인터럽트 마스킹 같은 특권 명령이 금지된다. 시도하면 CPU가 예외를 발생시켜 커널로 제어가 넘어간다. 덕분에 (1) 프로세스 간 메모리 격리(MMU가 각 프로세스에 다른 페이지 테이블), (2) 자원 중재(커널이 스케줄러·파일·네트워크 게이트키퍼), (3) 안정성(유저 크래시가 커널을 죽이지 못함)이 성립한다.
iOS에서는
ARM64(AArch64)는 4개의 Exception Level을 정의한다.
| Level | 무엇이 도는가 | 권한 |
|---|---|---|
| EL0 | 모든 앱·App Extension (유저 공간) | 특권 명령·MMU 조작 불가 |
| EL1 | XNU 커널·드라이버 | 페이지 테이블·스케줄러·디바이스 |
| EL2 | 하이퍼바이저 (가상화) | EL1 위 관리 |
| EL3 | Secure Monitor (TrustZone) | 보안/일반 세계 전환 |
앱 코드는 EL0에서만 돈다. 커널로 들어가려면 SVC(Supervisor Call) 명령으로 동기 예외를 일으켜 EL1의 예외 벡터로 점프한다. Apple은 여기에 하드웨어 방어를 더 쌓는다: PAN(Privileged Access Never — 커널이 실수로 유저 메모리를 직접 못 읽게), PAC(Pointer Authentication — 리턴 주소 위변조 탐지), KTRR/CTRR(부팅 후 커널 텍스트를 하드웨어로 read-only 고정)가 대표적이다. 이것이 iOS 커널을 통째로 갈아엎기 어렵게 만드는 근간이다.
실험 · 도구
모드 전환 비용은 실측 가능하다. 아무 일도 안 하는 순수 syscall(예: getpid())을 수백만 번 반복해 평균 시간을 재면 user↔kernel 왕복 오버헤드의 하한이 나온다. Instruments의 System Trace 템플릿은 각 syscall과 VM fault, 스레드 상태 전환을 타임라인에 그려 준다.
프로젝트 적용
- syscall은 공짜가 아니다. 타이트한 루프에서
read()를 바이트 단위로 부르지 말고 버퍼링하라 — 모드 전환 횟수를 줄이는 게 곧 성능이다. - 앱은 EL0라서 커널 메모리·다른 앱 메모리를 절대 직접 못 본다. "옆 앱 데이터를 메모리에서 읽자" 같은 발상은 하드웨어가 막는다(→ Q7 샌드박스와 결합).
"user/kernel 분리는 소프트웨어(OS)가 소프트웨어적으로 지키는 규칙"이라는 오해. 실제로는 CPU 하드웨어가 강제한다. EL0에서 특권 명령을 실행하려 하면 OS가 착해서가 아니라 칩이 즉시 예외를 던진다. 또 "커널 모드면 뭐든 다 할 수 있다"도 반쪽이다 — Apple은 KTRR·PAC 등으로 커널조차 못 하는 것(자기 코드 수정 등)을 하드웨어로 만들어 뒀다.
꼬리 질문
- 인터럽트(하드웨어)와 system call(소프트웨어)은 둘 다 EL1로 trap하는데, 커널은 이 둘을 어떻게 구분해 처리하나?
- Secure Enclave는 EL3에서 도는가, 아니면 별도 코프로세서인가?
- Rosetta나 시뮬레이터처럼 x86 코드가 도는 환경에서 특권 경계는 어떻게 유지되나?
Q3. System call은 무엇이며 Foundation API와 어떤 관계인가?
System call은 유저 코드가 커널 서비스(파일·네트워크·메모리·시간)를 요청하는 유일한 공식 관문이다. ARM64에선 SVC #0x80 명령으로 EL0→EL1 trap해서 들어간다. Foundation은 이 syscall을 직접 부르지 않는다. Foundation → libSystem(특히 libsystem_kernel.dylib)의 얇은 래퍼 → SVC → XNU 순서다. 즉 FileManager.copyItem 한 줄 뒤에는 open/read/write 같은 BSD syscall이 줄줄이 깔려 있다. 앱이 raw svc 명령을 손으로 만들 일은 없고, open 같은 POSIX 함수는 공개 API라 써도 되지만 보통은 Foundation 같은 상위 프레임워크를 쓴다.
CS 원리
커널은 자기가 관리하는 자원을 함부로 못 만지게 하면서도 유저에게 서비스는 줘야 한다. 그 타협이 system call — 미리 정의된 번호가 붙은 진입점 집합이다. 유저는 (1) syscall 번호와 인자를 약속된 레지스터에 채우고 (2) trap 명령을 실행한다. 커널은 벡터에서 받아 번호로 핸들러를 찾고, 인자를 검증한 뒤 수행하고, 결과를 레지스터에 담아 유저로 복귀시킨다. 이 "번호+검증"이 API 경계이자 보안 경계다.
iOS에서는
XNU의 syscall은 두 갈래다. BSD syscall(양수 번호 — open, read, mmap 등 POSIX)과 Mach trap(음수 번호 — mach_msg, 포트 조작 등 IPC). 앱은 이 중 어느 것도 손으로 svc를 쓰지 않는다. 대신 Foundation이 오브젝트 지향 API를 제공하고, 그 내부(iOS에서는 Apple의 Foundation 구현 — 과거 ObjC 기반, 최신 OS는 새 swift-foundation; swift-corelibs-foundation은 Linux 등 비-Apple 플랫폼용)가 C 라이브러리와 libSystem을 호출하며, 최종적으로 libsystem_kernel.dylib의 스텁이 svc #0x80을 실행한다. 예: Data(contentsOf:)는 open 후 파일 크기에 따라 read 또는 mmap으로, URLSession은 socket/connect 계열로 내려간다. 중요한 구분: open·read·socket 같은 POSIX libc 함수는 공개 API라 App Store 앱에서도 정상적으로 쓸 수 있다. 하지만 그 아래 raw syscall 번호와 svc ABI 자체는 안정된 계약이 아니어서 Apple이 언제든 바꿀 수 있으니, 앱은 손으로 trap을 만들지 말고 libSystem 함수나 상위 프레임워크를 통해서만 접근해야 한다.
실험 · 도구
Foundation 호출이 어떤 syscall로 내려가는지 직접 볼 수 있다. LLDB에서 저수준 함수에 심볼릭 브레이크포인트를 걸면 Foundation → C 함수 호출 스택이 드러난다.
# open(2) syscall 스텁에 브레이크포인트
(lldb) breakpoint set -n open
(lldb) continue
# FileManager.default.copyItem(...) 이 실행되는 순간 멈춘다.
(lldb) bt # 백트레이스: open ← ... ← Foundation ← 내 코드
# 관찰: Foundation 한 호출이 open/fstat/read/close로 분해됨macOS라면 dtruss -f ./myapp(dtrace 기반)로 프로세스가 발생시키는 모든 syscall을 실시간으로 덤프해 같은 사실을 확인할 수 있다. iOS 기기에선 fs_usage/System Trace가 유사 역할을 한다.
프로젝트 적용
- syscall은 왕복 비용이 크다(Q2). 루프에서 파일을 한 바이트씩 읽지 말고
Data(contentsOf:)나 버퍼드 스트림으로 syscall 횟수 자체를 줄여라. - 비공개 프레임워크나 dlsym으로 문서화되지 않은 심볼을 직접 부르지 말 것 — App Store 리뷰 거부 + iOS 버전마다 깨진다. 공개 프레임워크·공개 POSIX API가 유일한 안정 계약이다.
"Foundation은 순수 Swift/Objective-C로 자족적으로 구현돼 있다"는 오해. Foundation의 파일·네트워크·시간·프로세스 기능은 결국 C 라이브러리와 syscall 위에 얹힌 얇은 껍데기다. 반대로 "그럼 raw syscall을 직접 만들면 더 빠르겠네"도 오해다 — 오버헤드는 대부분 trap 자체가 아니라 실제 I/O에 있고, libSystem을 우회해 손으로 svc를 쓰면 이식성·안정성만 해친다.
꼬리 질문
mach_msg가 왜 Mach trap(음수)이고read는 BSD syscall(양수)인가 — 이 구분이 XNU 구조와 어떻게 연결되나?- vDSO/commpage처럼 trap 없이 커널 데이터를 읽는 최적화(예: 시간 조회)가 iOS에도 있나?
- Swift Concurrency의
await가 걸린 I/O는 어느 시점에 syscall로 내려가고, 그동안 스레드는 무엇을 하나?
Q4. 앱이 Active, Background, Suspended 상태가 될 때 Process와 Thread는 어떻게 되는가?
앱 생명주기 상태는 OS의 스케줄링 정책일 뿐, 프로세스의 생사와 다르다. Active·Background·Suspended 모두 프로세스(Mach task)는 메모리에 그대로 살아 있다. 차이는 스레드에 CPU를 주느냐다. Active/Background에선 스레드가 실행되고(Background는 제한된 시간·용도), Suspended에선 모든 스레드가 얼어붙어 CPU를 한 톨도 못 받는다(task_suspend 유사, SIGSTOP처럼). 메모리 이미지는 보존되므로 재개(resume)는 즉각적이다.
CS 원리
스케줄러 관점에서 스레드는 여러 상태를 오간다: Running(CPU 점유), Runnable/Ready(줄 서 있음), Blocked/Waiting(I/O 등 대기). 여기에 OS는 정지(stopped/suspended)라는 상태를 추가로 둘 수 있다 — 프로세스는 메모리에 존재하지만 스케줄러가 선택 대상에서 아예 제외하는 상태다. 유닉스의 SIGSTOP/SIGCONT가 고전적 예다. 이때 주소공간·레지스터·스택은 그대로 보존되므로 재개 시 끊긴 지점부터 이어진다.
iOS에서는
UIKit 생명주기 상태는 Not running → Inactive → Active → Background → Suspended다(SwiftUI의 ScenePhase는 .active/.inactive/.background로 이를 노출).
| 상태 | 프로세스(task) | 스레드 |
|---|---|---|
| Active (foreground) | 메모리에 상주 | 정상 스케줄, 이벤트 수신 |
| Background | 메모리에 상주 | 제한적 실행 — 보통 몇 초, background mode/BGTask면 연장 |
| Suspended | 메모리에 상주 (동결) | 전면 정지 — CPU 배정 없음 |
| Not running | 메모리에 없음 | 없음 |
포그라운드→백그라운드 전환 시 sceneDidEnterBackground(_:)가 불리고, 남은 정리를 할 짧은 시간이 주어진다. 이 시간이 끝나면 OS가 앱을 Suspended로 내려 모든 스레드를 동결한다. 음악 재생·위치 추적 같은 특정 background mode나 beginBackgroundTask로 연장을 요청한 경우에만 스레드가 계속 돈다.
프로젝트 적용
- 백그라운드 진입 콜백에서 오래 걸리는 작업을 새로 시작하지 말 것. 곧 Suspended로 동결되어 작업이 중간에 멈춘다. 진짜 필요하면
BGTaskScheduler로 OS에 정식 예약하라. - Suspended 직전에 열어 둔 파일·SQLite 락·소켓을 정리하라. 이걸 안 쥐고 있으면 종료 사유가 된다(→ Q6의
0xdead10cc). - 타이머·
DispatchSourceTimer는 Suspended 동안 진행되지 않는다. "백그라운드에서 N초마다"를 스레드로 구현하려 하지 말 것.
"백그라운드로 가면 앱이 계속 코드를 돈다"는 오해. 기본값은 정반대다 — 짧은 유예 시간 뒤 Suspended로 동결되어 스레드가 한 줄도 실행되지 않는다. 반대로 "Suspended면 앱이 죽은 것"도 오해다(→ Q5) — 프로세스는 메모리에 그대로 있고 즉시 되살아난다.
꼬리 질문
- Suspended 상태에서 푸시 알림이 오면 스레드는 어떻게 다시 깨어나 코드를 실행하나?
beginBackgroundTask(expirationHandler:)의 expiration handler는 왜 "반드시 빨리 끝나야" 하나?- SwiftUI
ScenePhase에는 왜 별도의 "suspended" 케이스가 없고.background만 있나?
Q5. Suspended 상태와 종료된 상태는 무엇이 다른가?
한 문장: Suspended는 "잠든" 것이고 종료(Terminated)는 "사라진" 것이다. Suspended면 프로세스(Mach task)가 RAM에 그대로 상주하고 주소공간·스레드 스택·전역 상태가 다 보존되어, 되돌아올 때 warm resume(수 ms)으로 원래 지점에서 이어진다. 종료면 프로세스가 메모리에서 완전히 제거되어 PCB/task도 없고, 다시 켜면 cold launch(process spawn → 다시 초기화)를 해야 한다. 그래서 사용자에겐 둘 다 "다시 앱 화면"처럼 보여도 속에서 벌어지는 일이 완전히 다르다.
CS 원리
Suspended는 스케줄링 상태다 — 프로세스 제어 블록(PCB), 페이지 테이블, 매핑된 메모리가 전부 유지된 채 스케줄러 대상에서만 빠진다. 종료는 존재 상태의 변화다 — 커널이 자원을 회수하고(메모리 해제, fd 닫기, PCB 파기) 그 프로세스는 더 이상 존재하지 않는다. 핵심 비대칭: Suspended→재개는 상태 복원이 필요 없지만, 종료→재실행은 처음부터 다시 만들어야 한다.
iOS에서는
| 구분 | Suspended | Terminated |
|---|---|---|
| 메모리(RAM) | 그대로 상주 | 회수됨 |
| 주소공간·전역 변수·스택 | 보존 | 사라짐 |
| 재진입 비용 | warm resume, 수 ms | cold launch, 재초기화 |
applicationWillTerminate | 해당 없음 | Suspended에서 죽으면 대개 안 불림 |
| 다음 진입 콜백 | willEnterForeground | didFinishLaunching |
가장 자주 깨지는 포인트: 앱이 Suspended 상태에서 시스템이 메모리 회수를 위해 죽이면 applicationWillTerminate(_:)는 호출되지 않는다. 이 콜백은 앱이 실행 중(비-Suspended)일 때 시스템이 종료시키는 드문 경로에서만 불린다. 앱 스위처 스와이프는 보통 이미 Suspended된 앱을 죽이는 것이라 호출되지 않는다. 그래서 "종료 직전에 저장하자"는 applicationWillTerminate에만 의존하면 데이터를 잃는다. 대신 백그라운드 진입 시점에 저장하고, State Restoration(NSUserActivity, scene 복원)으로 종료 후에도 "이어서 하는 것처럼" 보이게 설계한다.
실험 · 도구
둘을 눈으로 구분하려면: Xcode로 앱을 띄워 홈으로 보내면 Suspended(디버거 연결 유지, 다시 열면 이어짐). 반면 Xcode의 Stop 또는 앱 스위처에서 스와이프로 죽이면 Terminated다. 로그에 didFinishLaunching가 다시 찍히면 cold launch(=종료됐던 것)라는 증거다. Instruments의 App Launch 템플릿으로 warm vs cold 시간 차를 정량 비교할 수 있다.
프로젝트 적용
- 중요한 상태 저장은 백그라운드 진입 콜백(
sceneDidEnterBackground)에서 하라.applicationWillTerminate는 "올 수도 있는 보너스"로만 취급. - 사용자 경험 목표는 "Suspended였든 Terminated였든 이어서 하는 것처럼": 스크롤 위치·입력 중이던 폼을 복원하도록 state restoration을 붙여라.
- cold launch 시간(첫 화면까지)을 예산으로 관리하라 — 종료 후 재실행은 사용자가 체감하는 "느린 앱"의 주범이다.
"앱이 종료될 땐 항상 applicationWillTerminate가 불린다"는 위험한 오해다. Suspended 상태에서 시스템이 조용히 죽이는 경우엔 불리지 않는다. 또 "앱 스위처에 카드가 보이면 그 앱은 실행 중"도 오해 — 카드는 최근 앱 목록일 뿐, 실제로는 Suspended거나 이미 종료된 상태일 수 있다.
꼬리 질문
- 사용자가 앱 스위처에서 스와이프로 앱을 죽이는 것과, 시스템이 메모리 때문에 죽이는 것은 이후 백그라운드 갱신·푸시 동작에서 어떻게 다른가?
- State restoration으로 복원한 화면이 실제 데이터와 불일치하면(그새 서버 데이터가 바뀜) 어떤 전략으로 무결성을 지키나?
- warm resume인데도 화면이 하얗게 다시 그려진다면 어디를 의심해야 하나?
Q6. iOS는 어떤 기준으로 앱 Process를 종료하는가?
대부분은 메모리 압박 때문이고, 이를 처리하는 커널 메커니즘이 jetsam(memorystatus)이다. jetsam은 무작위가 아니라 우선순위 밴드로 죽인다: 포그라운드 앱은 맨 나중, Suspended 백그라운드 앱이 맨 먼저. 같은 밴드에선 메모리를 많이 먹는 놈과 오래 안 쓴 놈(LRU)이 먼저다. 메모리 외에도 (1) watchdog(메인 스레드가 너무 오래 멈춤 → 0x8badf00d), (2) 과열(0xc00010ff), (3) suspend 중 시스템 자원(파일/SQLite 락)을 쥐고 있음(0xdead10cc), (4) 크래시, (5) 사용자 스와이프 종료가 있다.
CS 원리
메모리는 유한한데 요구는 무한할 수 있다. 데스크톱 OS는 부족하면 디스크로 스왑해서 버틴다. 하지만 스왑은 플래시 수명·지연·전력 측면에서 모바일에 불리하다. 그래서 iOS는 (적어도 iPhone에선) 앱 메모리를 디스크로 페이징하지 않는다. 대신 먼저 잘 안 쓰는 페이지를 RAM 안에서 압축(memory compressor)해 자리를 만들고, 그래도 모자라면 덜 중요한 프로세스를 골라 통째로 종료해 물리 메모리를 확보한다. 이때 "누구를 죽일까"를 정하는 게 우선순위 기반 회수 정책이다.
iOS에서는
XNU의 memorystatus/jetsam 서브시스템이 각 프로세스에 우선순위 밴드를 부여한다. 포그라운드일수록 높은(=오래 살아남는) 밴드, Suspended 백그라운드일수록 낮은 밴드다. 시스템 메모리가 임계에 닿으면 낮은 밴드부터, 같은 밴드 안에선 메모리 사용량이 큰 프로세스부터 SIGKILL 유사 경로로 즉시 죽인다(정리 콜백 없이). 앱마다 개별 메모리 한도도 있어(기기·상황에 따라 다르며 확장앱은 훨씬 빡빡함), 이를 넘으면 그 앱만 콕 집어 죽인다. 종료 사유는 크래시 리포트의 Exception 코드로 남는다.
| 코드 | 별명 | 의미 |
|---|---|---|
0x8badf00d | "ate bad food" | Watchdog — 메인 스레드가 시간 안에 응답 못 함(런치/전환 hang) |
0xdead10cc | "dead lock" | Suspend 중 파일/SQLite 등 시스템 자원 락을 쥐고 있었음 |
0xc00010ff | "cool off" | 기기 과열로 열 관리 차원의 종료 |
0xbaaaaaad | — | 전체 메모리 stackshot(크래시 아님, 진단용 스냅샷) |
실험 · 도구
남은 메모리 여유는 코드에서 직접 조회한다.
import os
// 이 프로세스가 죽기 전까지 더 쓸 수 있는 대략적 바이트
let headroom = os_proc_available_memory()
print("남은 여유: \(headroom / 1024 / 1024) MB")
// 임계에 다가가면 UIKit이 이 콜백을 준다 → 캐시 비우기
override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
imageCache.removeAll() // 여기서 안 비우면 다음 차례는 jetsam
}실제 종료가 나면 설정 앱의 개인정보 보호 및 보안 → 분석 및 향상 → 분석 데이터에 JetsamEvent 리포트가 쌓인다. Xcode Organizer의 크래시 목록에서도 종료 사유(위 표의 코드)를 확인한다. WWDC18 iOS Memory Deep Dive가 footprint 측정과 최적화의 표준 참고다.
프로젝트 적용
- 메모리 한도를 넘기지 마라 — 대형 이미지는 원본 픽셀이 아니라 표시 크기로 다운샘플링해서 로드. 화면 밖 캐시는
didReceiveMemoryWarning에서 비운다. - 메인 스레드를 절대 오래 막지 마라(watchdog
0x8badf00d). 무거운 파싱·디스크 I/O는 백그라운드로. - 백그라운드 진입 전 파일·DB 트랜잭션·락을 정리하라(
0xdead10cc예방).
"메모리 부족하면 iOS가 아무 앱이나 랜덤으로 죽인다"는 오해다. jetsam은 우선순위 밴드 순서로 죽이며, 같은 밴드에선 메모리 사용량이 큰 쪽을 고른다 — 예측 가능한 정책이다. 또 "메모리 때문에 죽는 것만 조심하면 된다"도 반쪽 — watchdog(hang), 과열, suspend 중 락 유지 같은 비-메모리 종료가 크래시 리포트에선 오히려 흔하다.
꼬리 질문
- App Extension(위젯·공유 시트)의 메모리 한도가 본 앱보다 훨씬 작은 이유는 무엇인가?
- 같은 우선순위 밴드에 앱 둘이 있고 메모리 사용량도 비슷하면 무엇으로 종료 순서를 가르나?
- watchdog 타임아웃은 상황(런치 vs 백그라운드 전환)마다 다른데, 왜 하나의 고정 값이 아닌가?
Q7. App Sandbox는 운영체제 수준에서 무엇을 격리하는가?
App Sandbox는 각 앱을 자기만의 컨테이너 안에 가두는 커널 강제 격리다. 핵심은 파일 시스템 격리(앱마다 고유한 홈 디렉터리, 남의 컨테이너 접근 불가), IPC/네트워크·하드웨어 접근 제한(카메라·연락처 등은 entitlement + 사용자 동의 필요)이다. 놀라운 점: iOS의 서드파티 앱은 전부 같은 유닉스 사용자(mobile)로 실행되므로 격리가 유닉스 파일 권한(uid)으로 되는 게 아니라, TrustedBSD MAC 기반 Sandbox(프로필) + 앱별 컨테이너 + 코드 서명/entitlement로 이뤄진다.
CS 원리
보안 설계의 기본 원칙은 최소 권한(least privilege)이다 — 각 컴포넌트는 자기 일에 꼭 필요한 권한만 가져야 한다. 전통 유닉스는 임의 접근 제어(DAC), 즉 파일 소유자(uid/gid)가 권한을 정하는 방식인데, 이건 "같은 사용자면 서로의 파일에 접근 가능"이라 앱 격리엔 부족하다. 그래서 현대 OS는 강제 접근 제어(MAC)를 얹는다 — 사용자가 못 풀고 시스템이 강제하는 정책(프로필)으로, 프로세스가 무엇을 할 수 있는지를 커널이 매 접근마다 검사한다.
iOS에서는
iOS 샌드박스는 TrustedBSD MAC 프레임워크 위의 Sandbox 커널 확장(Seatbelt)으로 구현된다. 앱이 open 같은 syscall을 부르면, 커널은 DAC(파일 권한) 검사에 더해 MAC 훅에서 그 프로세스의 샌드박스 프로필에 이 동작이 허용되는지 물어본다. 하나라도 거부하면 접근 실패다. 서드파티 앱은 모두 mobile 사용자로 도는데도 서로를 못 건드리는 이유가 여기 있다 — 격리는 uid가 아니라 프로세스별로 적용된 프로필에서 온다. 무엇을 격리하나:
| 대상 | 앱이 접근 가능 | 격리·차단 |
|---|---|---|
자기 데이터 컨테이너Documents/Library/tmp | 자유롭게 읽고 씀 | |
| 다른 앱의 컨테이너 | 경로를 알아도 접근 불가 | |
앱 번들(.app) | 읽기 전용 실행 | 쓰기 불가(코드 서명 무결성) |
| 카메라·연락처·위치 | entitlement + 사용자 동의 시 | 기본 차단 |
| 다른 앱과 데이터 공유 | App Group·Keychain 그룹으로만 | 임의 IPC 불가 |
각 앱은 설치 시 무작위 UUID로 된 컨테이너를 받는다: 데이터는 /var/mobile/Containers/Data/Application/<UUID>/, 번들은 /var/containers/Bundle/Application/<UUID>/ 아래에 놓이고, 앱은 대개 NSHomeDirectory() 기준 상대 경로만 안다. 코드 서명·entitlement가 "이 앱이 어떤 프로필을 받을지"와 "어떤 특권 capability를 쓸 수 있는지"를 정하고, Data Protection은 이 위에 파일 단위 암호화 계층을 더한다.
실험 · 도구
격리를 코드로 체감해 보라. 자기 컨테이너 경로는 열리지만, 컨테이너 밖 경로는 파일이 실제로 존재해도 open이 실패한다.
// 내 컨테이너 홈 — 접근 OK
print(NSHomeDirectory())
// 예: /var/mobile/Containers/Data/Application/<UUID>
// 컨테이너 밖은 차단 — 존재해도 못 읽음
let outside = "/var/mobile/Library/Preferences/com.apple.springboard.plist"
do {
_ = try Data(contentsOf: URL(fileURLWithPath: outside))
} catch {
print("차단됨: \(error)") // MAC 정책이 open을 거부
}프로젝트 적용
- 파일 경로를 하드코딩하지 마라. 컨테이너 UUID는 앱 업데이트·재설치 때 바뀔 수 있으므로
FileManager.url(for:in:...)로 그때그때 얻어라. - 다른 앱·확장과 데이터를 나누려면 임의 IPC가 아니라 App Group 공유 컨테이너나 Keychain access group을 쓰고, entitlement에 명시하라.
- 필요 없는 권한(entitlement)은 요청하지 마라 — 최소 권한 원칙이자 심사·프라이버시 라벨에 직결된다.
"iOS 앱은 유닉스 파일 권한(uid/gid)으로 서로 격리된다"는 오해다. 서드파티 앱은 전부 같은 mobile 사용자로 돌기 때문에 uid만으로는 격리가 안 된다. 실제 격리는 커널 MAC(샌드박스 프로필) + 앱별 컨테이너 + 코드 서명의 조합이다. 또 "샌드박스는 앱을 실행 후에만 감시한다"도 오해 — 프로필은 프로세스 생성 시점부터 적용되어 정책 대상 접근(파일/IPC/하드웨어/네트워크)마다 MAC 훅에서 검사된다.
꼬리 질문
- 같은 개발팀의 두 앱이 App Group으로 파일을 공유할 때, 그 공유 컨테이너는 어느 앱의 샌드박스에 속하나?
- 탈옥(jailbreak)은 이 격리 모델의 어느 고리(MAC? 코드 서명? 커널 특권?)를 깨는 것인가?
- App Extension은 자기만의 샌드박스를 갖는가, 아니면 호스트 앱의 컨테이너를 공유하는가?